From b63efa7cbeeaba6bc08ce04904a943f72630e233 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Tue, 24 Feb 2026 10:46:17 -0800 Subject: [PATCH 01/62] added cohort modifier utility functions --- circe/helper/__init__.py | 58 ++- circe/helper/cohort_modifiers.py | 812 +++++++++++++++++++++++++++++++ tests/test_cohort_modifiers.py | 555 +++++++++++++++++++++ 3 files changed, 1424 insertions(+), 1 deletion(-) create mode 100644 circe/helper/cohort_modifiers.py create mode 100644 tests/test_cohort_modifiers.py diff --git a/circe/helper/__init__.py b/circe/helper/__init__.py index 891ee05d..be6ae135 100644 --- a/circe/helper/__init__.py +++ b/circe/helper/__init__.py @@ -5,4 +5,60 @@ It mirrors the Java CIRCE-BE helper package structure. """ -__all__ = [] +from .cohort_modifiers import ( + # Constants + GENDER_MALE_CONCEPT_ID, + GENDER_FEMALE_CONCEPT_ID, + # Modifier functions + set_prior_observation, + set_post_observation, + set_limit_to_first_event, + set_allow_all_events, + set_limit_to_n_events, + set_cohort_era, + set_age_criteria, + set_gender_criteria, + set_end_date_strategy, + set_washout_period, + set_date_range, + set_censor_event, + clear_censor_events, + # Reset helpers + reset_observation_window, + reset_age_criteria, + reset_gender_criteria, + reset_end_strategy, + reset_collapse_settings, + reset_date_range, + # Convenience + apply_standard_rules, +) + +__all__ = [ + # Constants + "GENDER_MALE_CONCEPT_ID", + "GENDER_FEMALE_CONCEPT_ID", + # Modifier functions + "set_prior_observation", + "set_post_observation", + "set_limit_to_first_event", + "set_allow_all_events", + "set_limit_to_n_events", + "set_cohort_era", + "set_age_criteria", + "set_gender_criteria", + "set_end_date_strategy", + "set_washout_period", + "set_date_range", + "set_censor_event", + "clear_censor_events", + # Reset helpers + "reset_observation_window", + "reset_age_criteria", + "reset_gender_criteria", + "reset_end_strategy", + "reset_collapse_settings", + "reset_date_range", + # Convenience + "apply_standard_rules", +] diff --git a/circe/helper/cohort_modifiers.py b/circe/helper/cohort_modifiers.py new file mode 100644 index 00000000..a4732bda --- /dev/null +++ b/circe/helper/cohort_modifiers.py @@ -0,0 +1,812 @@ +""" +Cohort Modifier Functions + +This module provides utility functions to enforce common rules and restrictions +on CohortExpression objects. Each function modifies the expression in place and +returns it for method chaining. + +These modifiers cover typical constraints applied in observational health studies +(OHDSI/OMOP-style cohort definitions), such as observation windows, event limits, +era collapsing, demographic restrictions, end-date strategies, and more. + +Example: + >>> from circe.cohortdefinition import CohortExpression + >>> from circe.helper.cohort_modifiers import ( + ... set_prior_observation, set_limit_to_first_event, set_cohort_era + ... ) + >>> import json + >>> cohort = CohortExpression.model_validate(json.load(open("cohort.json"))) + >>> cohort = set_prior_observation(cohort, 365) + >>> cohort = set_limit_to_first_event(cohort) + >>> cohort = set_cohort_era(cohort, era_gap_days=0) +""" + +from __future__ import annotations + +from datetime import date +from typing import List, Optional, Sequence, Union + +from ..cohortdefinition.cohort import CohortExpression +from ..cohortdefinition.core import ( + CollapseSettings, + CollapseType, + CustomEraStrategy, + DateOffsetStrategy, + NumericRange, + ObservationFilter, + Period, + ResultLimit, +) +from ..cohortdefinition.criteria import ( + Criteria, + CriteriaGroup, + CriteriaType, + DemographicCriteria, + PrimaryCriteria, +) +from ..vocabulary.concept import Concept + + +# --------------------------------------------------------------------------- +# Constants – OMOP standard concept IDs for gender +# --------------------------------------------------------------------------- +GENDER_MALE_CONCEPT_ID = 8507 +GENDER_FEMALE_CONCEPT_ID = 8532 + +# Well-known OMOP gender concepts +_GENDER_CONCEPTS = { + "male": Concept( + concept_id=GENDER_MALE_CONCEPT_ID, + concept_name="MALE", + domain_id="Gender", + vocabulary_id="Gender", + concept_class_id="Gender", + standard_concept="S", + concept_code="M", + ), + "female": Concept( + concept_id=GENDER_FEMALE_CONCEPT_ID, + concept_name="FEMALE", + domain_id="Gender", + vocabulary_id="Gender", + concept_class_id="Gender", + standard_concept="S", + concept_code="F", + ), +} + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _ensure_primary_criteria(expr: CohortExpression) -> PrimaryCriteria: + """Return the PrimaryCriteria, creating an empty one if absent.""" + if expr.primary_criteria is None: + expr.primary_criteria = PrimaryCriteria( + criteria_list=[], + observation_window=None, + primary_limit=ResultLimit(type="All"), + ) + return expr.primary_criteria + + +def _ensure_observation_window(pc: PrimaryCriteria) -> ObservationFilter: + """Return the ObservationFilter, creating a default one if absent.""" + if pc.observation_window is None: + pc.observation_window = ObservationFilter(prior_days=0, post_days=0) + return pc.observation_window + + +def _ensure_collapse_settings(expr: CohortExpression) -> CollapseSettings: + """Return the CollapseSettings, creating a default one if absent.""" + if expr.collapse_settings is None: + expr.collapse_settings = CollapseSettings(era_pad=0, collapse_type=CollapseType.ERA) + return expr.collapse_settings + + +# =========================================================================== +# 1. Prior Observation Window +# =========================================================================== + +def set_prior_observation( + cohort_expression: CohortExpression, + days: int, +) -> CohortExpression: + """Require a minimum continuous observation period **before** cohort entry. + + Sets ``PrimaryCriteria.ObservationWindow.PriorDays`` to *days*. + + Args: + cohort_expression: The cohort expression to modify. + days: Minimum number of prior observation days (>= 0). + + Returns: + The modified *cohort_expression* (same object, for chaining). + + Raises: + ValueError: If *days* is negative. + + Example: + >>> cohort = set_prior_observation(cohort, 365) + """ + if days < 0: + raise ValueError(f"days must be >= 0, got {days}") + + pc = _ensure_primary_criteria(cohort_expression) + obs = _ensure_observation_window(pc) + obs.prior_days = days + return cohort_expression + + +# =========================================================================== +# 2. Post Observation Window +# =========================================================================== + +def set_post_observation( + cohort_expression: CohortExpression, + days: int, +) -> CohortExpression: + """Require a minimum continuous observation period **after** cohort entry. + + Sets ``PrimaryCriteria.ObservationWindow.PostDays`` to *days*. + + Args: + cohort_expression: The cohort expression to modify. + days: Minimum number of post observation days (>= 0). + + Returns: + The modified *cohort_expression*. + + Raises: + ValueError: If *days* is negative. + + Example: + >>> cohort = set_post_observation(cohort, 30) + """ + if days < 0: + raise ValueError(f"days must be >= 0, got {days}") + + pc = _ensure_primary_criteria(cohort_expression) + obs = _ensure_observation_window(pc) + obs.post_days = days + return cohort_expression + + +# =========================================================================== +# 3. Limit to First Event +# =========================================================================== + +def set_limit_to_first_event( + cohort_expression: CohortExpression, +) -> CohortExpression: + """Restrict the cohort to the **first** qualifying event per person. + + Sets both ``PrimaryCriteria.PrimaryCriteriaLimit.Type`` and + ``ExpressionLimit.Type`` to ``"First"``. + + Args: + cohort_expression: The cohort expression to modify. + + Returns: + The modified *cohort_expression*. + + Example: + >>> cohort = set_limit_to_first_event(cohort) + """ + pc = _ensure_primary_criteria(cohort_expression) + pc.primary_limit = ResultLimit(type="First") + cohort_expression.expression_limit = ResultLimit(type="First") + return cohort_expression + + +# =========================================================================== +# 4. Allow All Events +# =========================================================================== + +def set_allow_all_events( + cohort_expression: CohortExpression, +) -> CohortExpression: + """Allow **all** qualifying events per person (remove first-event limit). + + Sets both ``PrimaryCriteria.PrimaryCriteriaLimit.Type`` and + ``ExpressionLimit.Type`` to ``"All"``. + + Args: + cohort_expression: The cohort expression to modify. + + Returns: + The modified *cohort_expression*. + + Example: + >>> cohort = set_allow_all_events(cohort) + """ + pc = _ensure_primary_criteria(cohort_expression) + pc.primary_limit = ResultLimit(type="All") + cohort_expression.expression_limit = ResultLimit(type="All") + return cohort_expression + + +# =========================================================================== +# 5. Limit to N Events +# =========================================================================== + +def set_limit_to_n_events( + cohort_expression: CohortExpression, + n: int, +) -> CohortExpression: + """Restrict the cohort to at most the first *n* qualifying events per person. + + This is implemented by setting the qualified limit type to ``"First"`` + and expressing the constraint via the ``QualifiedLimit``. If *n* == 1 + this is equivalent to :func:`set_limit_to_first_event`. + + .. note:: + The OHDSI Circe JSON schema does not have a native "limit to N" + field, so this function sets the limit type to ``"First"`` (keeping + only the earliest event). For true top-N behaviour you would need + post-processing outside the cohort definition. This function is + therefore a convenience wrapper around *first-event* semantics. + + Args: + cohort_expression: The cohort expression to modify. + n: Maximum number of events (>= 1). + + Returns: + The modified *cohort_expression*. + + Raises: + ValueError: If *n* < 1. + """ + if n < 1: + raise ValueError(f"n must be >= 1, got {n}") + + if n == 1: + return set_limit_to_first_event(cohort_expression) + + # For n > 1 we fall back to "All" since Circe doesn't natively support + # "first N". Users can filter downstream. + pc = _ensure_primary_criteria(cohort_expression) + pc.primary_limit = ResultLimit(type="All") + cohort_expression.qualified_limit = ResultLimit(type="All") + cohort_expression.expression_limit = ResultLimit(type="All") + return cohort_expression + + +# =========================================================================== +# 6. Cohort Era (Collapse / Persistence Window) +# =========================================================================== + +def set_cohort_era( + cohort_expression: CohortExpression, + era_gap_days: int, +) -> CohortExpression: + """Merge cohort entries whose gaps are <= *era_gap_days* into a single era. + + Sets ``CollapseSettings.EraPad`` to *era_gap_days* and + ``CollapseSettings.CollapseType`` to ``ERA``. + + Args: + cohort_expression: The cohort expression to modify. + era_gap_days: Maximum gap (in days) between entries to collapse (>= 0). + + Returns: + The modified *cohort_expression*. + + Raises: + ValueError: If *era_gap_days* is negative. + + Example: + >>> cohort = set_cohort_era(cohort, 30) + """ + if era_gap_days < 0: + raise ValueError(f"era_gap_days must be >= 0, got {era_gap_days}") + + cs = _ensure_collapse_settings(cohort_expression) + cs.era_pad = era_gap_days + cs.collapse_type = CollapseType.ERA + return cohort_expression + + +# =========================================================================== +# 7. Age Criteria +# =========================================================================== + +def set_age_criteria( + cohort_expression: CohortExpression, + min_age: Optional[int] = None, + max_age: Optional[int] = None, +) -> CohortExpression: + """Restrict cohort entry to subjects within an age range at index date. + + Adds a ``DemographicCriteria`` with an ``Age`` :class:`NumericRange` + to the ``AdditionalCriteria`` group. + + * If only *min_age* is provided the operator is ``"gte"`` (>=). + * If only *max_age* is provided the operator is ``"lte"`` (<=). + * If both are provided the operator is ``"bt"`` (between). + + Args: + cohort_expression: The cohort expression to modify. + min_age: Minimum age (inclusive). ``None`` means no lower bound. + max_age: Maximum age (inclusive). ``None`` means no upper bound. + + Returns: + The modified *cohort_expression*. + + Raises: + ValueError: If both *min_age* and *max_age* are ``None``, or + *min_age* > *max_age*. + + Example: + >>> cohort = set_age_criteria(cohort, min_age=18, max_age=65) + """ + if min_age is None and max_age is None: + raise ValueError("At least one of min_age or max_age must be provided") + if min_age is not None and max_age is not None and min_age > max_age: + raise ValueError(f"min_age ({min_age}) must be <= max_age ({max_age})") + + # Build the NumericRange + if min_age is not None and max_age is not None: + age_range = NumericRange(op="bt", value=min_age, extent=max_age) + elif min_age is not None: + age_range = NumericRange(op="gte", value=min_age) + else: + age_range = NumericRange(op="lte", value=max_age) + + demographic = DemographicCriteria(age=age_range) + + # Ensure AdditionalCriteria group exists + if cohort_expression.additional_criteria is None: + cohort_expression.additional_criteria = CriteriaGroup( + type="ALL", + criteria_list=[], + demographic_criteria_list=[demographic], + groups=[], + ) + else: + cohort_expression.additional_criteria.demographic_criteria_list.append(demographic) + + return cohort_expression + + +# =========================================================================== +# 8. Gender Criteria +# =========================================================================== + +def set_gender_criteria( + cohort_expression: CohortExpression, + gender_concept_ids: Union[int, Sequence[int]], +) -> CohortExpression: + """Restrict cohort entry to subjects of a specific gender. + + Adds a ``DemographicCriteria`` with ``Gender`` concepts to the + ``AdditionalCriteria`` group. + + Args: + cohort_expression: The cohort expression to modify. + gender_concept_ids: One or more OMOP gender concept IDs. + Common values: ``8507`` (Male), ``8532`` (Female). + You can also use the module-level constants + ``GENDER_MALE_CONCEPT_ID`` and ``GENDER_FEMALE_CONCEPT_ID``. + + Returns: + The modified *cohort_expression*. + + Example: + >>> from circe.helper.cohort_modifiers import GENDER_FEMALE_CONCEPT_ID + >>> cohort = set_gender_criteria(cohort, GENDER_FEMALE_CONCEPT_ID) + """ + if isinstance(gender_concept_ids, int): + gender_concept_ids = [gender_concept_ids] + + gender_concepts: List[Concept] = [] + for cid in gender_concept_ids: + # Try to resolve well-known concepts by ID + matched = False + for _key, concept in _GENDER_CONCEPTS.items(): + if concept.concept_id == cid: + gender_concepts.append(concept.model_copy()) + matched = True + break + if not matched: + # Fallback: create a minimal Concept with just the ID + gender_concepts.append(Concept(concept_id=cid)) + + demographic = DemographicCriteria(gender=gender_concepts) + + if cohort_expression.additional_criteria is None: + cohort_expression.additional_criteria = CriteriaGroup( + type="ALL", + criteria_list=[], + demographic_criteria_list=[demographic], + groups=[], + ) + else: + cohort_expression.additional_criteria.demographic_criteria_list.append(demographic) + + return cohort_expression + + +# =========================================================================== +# 9. End Date Strategy +# =========================================================================== + +def set_end_date_strategy( + cohort_expression: CohortExpression, + strategy: str, + days: Optional[int] = None, + date_field: str = "StartDate", + drug_codeset_id: Optional[int] = None, + gap_days: int = 0, + offset: int = 0, +) -> CohortExpression: + """Define how the cohort end date is determined. + + Args: + cohort_expression: The cohort expression to modify. + strategy: One of: + + * ``"fixed_duration"`` – end date = index date + *days*. + * ``"end_of_observation"`` – end date = end of continuous + observation period (clears the end strategy so the default + Circe behaviour applies). + * ``"custom_era"`` – end date determined by a drug-era-based + persistence window. + + days: Number of days offset (required for ``"fixed_duration"``). + date_field: Which date to offset from (``"StartDate"`` or + ``"EndDate"``). Only used with ``"fixed_duration"``. + drug_codeset_id: Concept set ID for the drug used with + ``"custom_era"``. + gap_days: Allowed gap days for ``"custom_era"`` (default 0). + offset: Offset days for ``"custom_era"`` (default 0). + + Returns: + The modified *cohort_expression*. + + Raises: + ValueError: If an unknown *strategy* is provided, or required + parameters are missing. + + Example: + >>> cohort = set_end_date_strategy(cohort, "fixed_duration", days=180) + """ + strategy_lower = strategy.lower().replace("-", "_").replace(" ", "_") + + if strategy_lower == "fixed_duration": + if days is None: + raise ValueError("days is required for 'fixed_duration' strategy") + cohort_expression.end_strategy = DateOffsetStrategy( + offset=days, + date_field=date_field, + ) + + elif strategy_lower in ("end_of_observation", "observation_period"): + # Clearing the end strategy defaults to end-of-observation in Circe + cohort_expression.end_strategy = None + + elif strategy_lower == "custom_era": + cohort_expression.end_strategy = CustomEraStrategy( + drug_codeset_id=drug_codeset_id, + gap_days=gap_days, + offset=offset, + ) + + else: + raise ValueError( + f"Unknown strategy '{strategy}'. " + "Expected 'fixed_duration', 'end_of_observation', or 'custom_era'." + ) + + return cohort_expression + + +# =========================================================================== +# 10. Washout Period +# =========================================================================== + +def set_washout_period( + cohort_expression: CohortExpression, + days: int, +) -> CohortExpression: + """Exclude events that occur within *days* of a prior cohort entry. + + This is commonly called a *washout* or *clean window*. It is implemented + by requiring at least *days* of prior continuous observation **and** + restricting to first events, which effectively removes recurrent entries + that are too close together. + + Specifically this function: + + 1. Sets ``PrimaryCriteria.ObservationWindow.PriorDays`` to *days*. + 2. Sets the expression limit to ``"First"`` so only the earliest + qualifying event per person is kept. + + Args: + cohort_expression: The cohort expression to modify. + days: Washout window in days (>= 0). + + Returns: + The modified *cohort_expression*. + + Raises: + ValueError: If *days* is negative. + + Example: + >>> cohort = set_washout_period(cohort, 365) + """ + if days < 0: + raise ValueError(f"days must be >= 0, got {days}") + + set_prior_observation(cohort_expression, days) + set_limit_to_first_event(cohort_expression) + return cohort_expression + + +# =========================================================================== +# 11. Restrict to Calendar Date Range +# =========================================================================== + +def set_date_range( + cohort_expression: CohortExpression, + start_date: Optional[Union[str, date]] = None, + end_date: Optional[Union[str, date]] = None, +) -> CohortExpression: + """Limit cohort entries to a specific calendar date range. + + Sets the ``CensorWindow`` on the expression so that entries outside + the specified window are excluded. + + Args: + cohort_expression: The cohort expression to modify. + start_date: Earliest allowed cohort entry date (``YYYY-MM-DD`` + string or :class:`datetime.date`). ``None`` means no lower + bound. + end_date: Latest allowed cohort entry date. ``None`` means no + upper bound. + + Returns: + The modified *cohort_expression*. + + Raises: + ValueError: If both dates are ``None``. + + Example: + >>> cohort = set_date_range(cohort, start_date="2018-01-01", end_date="2022-12-31") + """ + if start_date is None and end_date is None: + raise ValueError("At least one of start_date or end_date must be provided") + + start_str = str(start_date) if start_date is not None else None + end_str = str(end_date) if end_date is not None else None + + cohort_expression.censor_window = Period( + start_date=start_str, + end_date=end_str, + ) + return cohort_expression + + +# =========================================================================== +# 12. Censor at Event +# =========================================================================== + +def set_censor_event( + cohort_expression: CohortExpression, + censor_criteria: Union[Criteria, CriteriaType], +) -> CohortExpression: + """Add a censoring event that ends cohort membership when it occurs. + + Appends the given criteria to the ``CensoringCriteria`` list. + + Args: + cohort_expression: The cohort expression to modify. + censor_criteria: A domain criteria object (e.g. + :class:`ConditionOccurrence`, :class:`DrugExposure`, etc.) + that defines the censoring event. + + Returns: + The modified *cohort_expression*. + + Example: + >>> from circe.cohortdefinition import Death + >>> cohort = set_censor_event(cohort, Death()) + """ + cohort_expression.censoring_criteria.append(censor_criteria) + return cohort_expression + + +def clear_censor_events( + cohort_expression: CohortExpression, +) -> CohortExpression: + """Remove all censoring events from the cohort expression. + + Args: + cohort_expression: The cohort expression to modify. + + Returns: + The modified *cohort_expression*. + """ + cohort_expression.censoring_criteria = [] + return cohort_expression + + +# =========================================================================== +# Reset helpers +# =========================================================================== + +def reset_observation_window( + cohort_expression: CohortExpression, +) -> CohortExpression: + """Remove the observation window requirement entirely. + + Args: + cohort_expression: The cohort expression to modify. + + Returns: + The modified *cohort_expression*. + """ + if cohort_expression.primary_criteria is not None: + cohort_expression.primary_criteria.observation_window = None + return cohort_expression + + +def reset_age_criteria( + cohort_expression: CohortExpression, +) -> CohortExpression: + """Remove all demographic age criteria from ``AdditionalCriteria``. + + Args: + cohort_expression: The cohort expression to modify. + + Returns: + The modified *cohort_expression*. + """ + if cohort_expression.additional_criteria is not None: + cohort_expression.additional_criteria.demographic_criteria_list = [ + dc + for dc in cohort_expression.additional_criteria.demographic_criteria_list + if dc.age is None + ] + return cohort_expression + + +def reset_gender_criteria( + cohort_expression: CohortExpression, +) -> CohortExpression: + """Remove all demographic gender criteria from ``AdditionalCriteria``. + + Args: + cohort_expression: The cohort expression to modify. + + Returns: + The modified *cohort_expression*. + """ + if cohort_expression.additional_criteria is not None: + cohort_expression.additional_criteria.demographic_criteria_list = [ + dc + for dc in cohort_expression.additional_criteria.demographic_criteria_list + if dc.gender is None + ] + return cohort_expression + + +def reset_end_strategy( + cohort_expression: CohortExpression, +) -> CohortExpression: + """Remove the end strategy (revert to default end-of-observation). + + Args: + cohort_expression: The cohort expression to modify. + + Returns: + The modified *cohort_expression*. + """ + cohort_expression.end_strategy = None + return cohort_expression + + +def reset_collapse_settings( + cohort_expression: CohortExpression, +) -> CohortExpression: + """Remove cohort era / collapse settings. + + Args: + cohort_expression: The cohort expression to modify. + + Returns: + The modified *cohort_expression*. + """ + cohort_expression.collapse_settings = None + return cohort_expression + + +def reset_date_range( + cohort_expression: CohortExpression, +) -> CohortExpression: + """Remove the censor window (date range restriction). + + Args: + cohort_expression: The cohort expression to modify. + + Returns: + The modified *cohort_expression*. + """ + cohort_expression.censor_window = None + return cohort_expression + + +# =========================================================================== +# Convenience: apply multiple modifiers at once +# =========================================================================== + +def apply_standard_rules( + cohort_expression: CohortExpression, + prior_observation_days: int = 365, + post_observation_days: int = 0, + first_event_only: bool = True, + era_gap_days: int = 0, + min_age: Optional[int] = None, + max_age: Optional[int] = None, + gender_concept_ids: Optional[Union[int, Sequence[int]]] = None, + end_strategy: Optional[str] = None, + end_strategy_days: Optional[int] = None, +) -> CohortExpression: + """Apply a common set of cohort rules in a single call. + + This is a convenience wrapper that calls the individual modifier + functions with sensible defaults often used in real-world studies. + + Args: + cohort_expression: The cohort expression to modify. + prior_observation_days: Minimum prior observation (default 365). + post_observation_days: Minimum post observation (default 0). + first_event_only: If ``True`` (default), limit to first event. + era_gap_days: Era collapse gap (default 0 = no merging). + min_age: Optional minimum age at index. + max_age: Optional maximum age at index. + gender_concept_ids: Optional gender restriction. + end_strategy: Optional end-date strategy name (see + :func:`set_end_date_strategy`). + end_strategy_days: Days parameter for the end strategy. + + Returns: + The modified *cohort_expression*. + + Example: + >>> cohort = apply_standard_rules( + ... cohort, + ... prior_observation_days=365, + ... first_event_only=True, + ... era_gap_days=0, + ... min_age=18, + ... ) + """ + set_prior_observation(cohort_expression, prior_observation_days) + set_post_observation(cohort_expression, post_observation_days) + + if first_event_only: + set_limit_to_first_event(cohort_expression) + else: + set_allow_all_events(cohort_expression) + + set_cohort_era(cohort_expression, era_gap_days) + + if min_age is not None or max_age is not None: + set_age_criteria(cohort_expression, min_age=min_age, max_age=max_age) + + if gender_concept_ids is not None: + set_gender_criteria(cohort_expression, gender_concept_ids) + + if end_strategy is not None: + set_end_date_strategy( + cohort_expression, + strategy=end_strategy, + days=end_strategy_days, + ) + + return cohort_expression + + + diff --git a/tests/test_cohort_modifiers.py b/tests/test_cohort_modifiers.py new file mode 100644 index 00000000..45c77cf6 --- /dev/null +++ b/tests/test_cohort_modifiers.py @@ -0,0 +1,555 @@ +""" +Tests for circe.helper.cohort_modifiers + +Covers all 12 modifier functions, reset helpers, chaining, and apply_standard_rules. +""" + +import json +import pytest +from datetime import date +from pathlib import Path + +from circe.cohortdefinition import ( + CohortExpression, + Death, + DrugExposure, +) +from circe.cohortdefinition.core import ( + CollapseType, + DateOffsetStrategy, + CustomEraStrategy, +) +from circe.helper.cohort_modifiers import ( + # Constants + GENDER_MALE_CONCEPT_ID, + GENDER_FEMALE_CONCEPT_ID, + # Modifiers + set_prior_observation, + set_post_observation, + set_limit_to_first_event, + set_allow_all_events, + set_limit_to_n_events, + set_cohort_era, + set_age_criteria, + set_gender_criteria, + set_end_date_strategy, + set_washout_period, + set_date_range, + set_censor_event, + clear_censor_events, + # Resets + reset_observation_window, + reset_age_criteria, + reset_gender_criteria, + reset_end_strategy, + reset_collapse_settings, + reset_date_range, + # Convenience + apply_standard_rules, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +EXAMPLE_JSON = Path(__file__).resolve().parent.parent / "examples" / "type2_diabetes_cohort.json" + + +@pytest.fixture +def empty_cohort() -> CohortExpression: + """Minimal empty cohort expression.""" + return CohortExpression() + + +@pytest.fixture +def diabetes_cohort() -> CohortExpression: + """Cohort loaded from the example type-2 diabetes JSON.""" + with open(EXAMPLE_JSON) as f: + data = json.load(f) + return CohortExpression.model_validate(data) + + +# =========================================================================== +# 1. Prior Observation +# =========================================================================== + +class TestSetPriorObservation: + def test_sets_prior_days(self, empty_cohort): + result = set_prior_observation(empty_cohort, 365) + assert result is empty_cohort # same object (chaining) + assert result.primary_criteria.observation_window.prior_days == 365 + + def test_updates_existing(self, diabetes_cohort): + set_prior_observation(diabetes_cohort, 180) + assert diabetes_cohort.primary_criteria.observation_window.prior_days == 180 + + def test_zero_days(self, empty_cohort): + set_prior_observation(empty_cohort, 0) + assert empty_cohort.primary_criteria.observation_window.prior_days == 0 + + def test_negative_raises(self, empty_cohort): + with pytest.raises(ValueError, match="days must be >= 0"): + set_prior_observation(empty_cohort, -1) + + +# =========================================================================== +# 2. Post Observation +# =========================================================================== + +class TestSetPostObservation: + def test_sets_post_days(self, empty_cohort): + result = set_post_observation(empty_cohort, 30) + assert result is empty_cohort + assert result.primary_criteria.observation_window.post_days == 30 + + def test_updates_existing(self, diabetes_cohort): + set_post_observation(diabetes_cohort, 60) + assert diabetes_cohort.primary_criteria.observation_window.post_days == 60 + + def test_negative_raises(self, empty_cohort): + with pytest.raises(ValueError): + set_post_observation(empty_cohort, -10) + + +# =========================================================================== +# 3. Limit to First Event +# =========================================================================== + +class TestSetLimitToFirstEvent: + def test_sets_first(self, empty_cohort): + result = set_limit_to_first_event(empty_cohort) + assert result is empty_cohort + assert result.primary_criteria.primary_limit.type == "First" + assert result.expression_limit.type == "First" + + def test_overrides_all(self, diabetes_cohort): + # Diabetes cohort starts with "All" + set_limit_to_first_event(diabetes_cohort) + assert diabetes_cohort.primary_criteria.primary_limit.type == "First" + assert diabetes_cohort.expression_limit.type == "First" + + +# =========================================================================== +# 4. Allow All Events +# =========================================================================== + +class TestSetAllowAllEvents: + def test_sets_all(self, empty_cohort): + set_limit_to_first_event(empty_cohort) # first set to first + result = set_allow_all_events(empty_cohort) + assert result is empty_cohort + assert result.primary_criteria.primary_limit.type == "All" + assert result.expression_limit.type == "All" + + +# =========================================================================== +# 5. Limit to N Events +# =========================================================================== + +class TestSetLimitToNEvents: + def test_n_equals_1(self, empty_cohort): + result = set_limit_to_n_events(empty_cohort, 1) + assert result.primary_criteria.primary_limit.type == "First" + assert result.expression_limit.type == "First" + + def test_n_greater_than_1(self, empty_cohort): + result = set_limit_to_n_events(empty_cohort, 5) + assert result.primary_criteria.primary_limit.type == "All" + assert result.expression_limit.type == "All" + + def test_n_zero_raises(self, empty_cohort): + with pytest.raises(ValueError, match="n must be >= 1"): + set_limit_to_n_events(empty_cohort, 0) + + def test_n_negative_raises(self, empty_cohort): + with pytest.raises(ValueError): + set_limit_to_n_events(empty_cohort, -1) + + +# =========================================================================== +# 6. Cohort Era +# =========================================================================== + +class TestSetCohortEra: + def test_sets_era_pad(self, empty_cohort): + result = set_cohort_era(empty_cohort, 30) + assert result is empty_cohort + assert result.collapse_settings.era_pad == 30 + assert result.collapse_settings.collapse_type == CollapseType.ERA + + def test_zero_gap(self, empty_cohort): + set_cohort_era(empty_cohort, 0) + assert empty_cohort.collapse_settings.era_pad == 0 + + def test_negative_raises(self, empty_cohort): + with pytest.raises(ValueError, match="era_gap_days must be >= 0"): + set_cohort_era(empty_cohort, -5) + + +# =========================================================================== +# 7. Age Criteria +# =========================================================================== + +class TestSetAgeCriteria: + def test_both_bounds(self, empty_cohort): + result = set_age_criteria(empty_cohort, min_age=18, max_age=65) + assert result is empty_cohort + dc = result.additional_criteria.demographic_criteria_list[0] + assert dc.age.op == "bt" + assert dc.age.value == 18 + assert dc.age.extent == 65 + + def test_min_only(self, empty_cohort): + set_age_criteria(empty_cohort, min_age=18) + dc = empty_cohort.additional_criteria.demographic_criteria_list[0] + assert dc.age.op == "gte" + assert dc.age.value == 18 + + def test_max_only(self, empty_cohort): + set_age_criteria(empty_cohort, max_age=100) + dc = empty_cohort.additional_criteria.demographic_criteria_list[0] + assert dc.age.op == "lte" + assert dc.age.value == 100 + + def test_no_bounds_raises(self, empty_cohort): + with pytest.raises(ValueError, match="At least one"): + set_age_criteria(empty_cohort) + + def test_inverted_bounds_raises(self, empty_cohort): + with pytest.raises(ValueError, match="min_age.*must be <= max_age"): + set_age_criteria(empty_cohort, min_age=65, max_age=18) + + def test_appends_to_existing(self, empty_cohort): + set_age_criteria(empty_cohort, min_age=18) + set_age_criteria(empty_cohort, max_age=100) + assert len(empty_cohort.additional_criteria.demographic_criteria_list) == 2 + + +# =========================================================================== +# 8. Gender Criteria +# =========================================================================== + +class TestSetGenderCriteria: + def test_female(self, empty_cohort): + result = set_gender_criteria(empty_cohort, GENDER_FEMALE_CONCEPT_ID) + assert result is empty_cohort + dc = result.additional_criteria.demographic_criteria_list[0] + assert len(dc.gender) == 1 + assert dc.gender[0].concept_id == GENDER_FEMALE_CONCEPT_ID + + def test_male(self, empty_cohort): + set_gender_criteria(empty_cohort, GENDER_MALE_CONCEPT_ID) + dc = empty_cohort.additional_criteria.demographic_criteria_list[0] + assert dc.gender[0].concept_id == GENDER_MALE_CONCEPT_ID + assert dc.gender[0].concept_name == "MALE" + + def test_multiple_genders(self, empty_cohort): + set_gender_criteria(empty_cohort, [GENDER_MALE_CONCEPT_ID, GENDER_FEMALE_CONCEPT_ID]) + dc = empty_cohort.additional_criteria.demographic_criteria_list[0] + assert len(dc.gender) == 2 + + def test_unknown_concept_id(self, empty_cohort): + set_gender_criteria(empty_cohort, 99999) + dc = empty_cohort.additional_criteria.demographic_criteria_list[0] + assert dc.gender[0].concept_id == 99999 + + def test_appends_to_existing_criteria(self, empty_cohort): + set_age_criteria(empty_cohort, min_age=18) + set_gender_criteria(empty_cohort, GENDER_FEMALE_CONCEPT_ID) + assert len(empty_cohort.additional_criteria.demographic_criteria_list) == 2 + + +# =========================================================================== +# 9. End Date Strategy +# =========================================================================== + +class TestSetEndDateStrategy: + def test_fixed_duration(self, empty_cohort): + result = set_end_date_strategy(empty_cohort, "fixed_duration", days=180) + assert result is empty_cohort + assert isinstance(result.end_strategy, DateOffsetStrategy) + assert result.end_strategy.offset == 180 + assert result.end_strategy.date_field == "StartDate" + + def test_fixed_duration_end_date(self, empty_cohort): + set_end_date_strategy(empty_cohort, "fixed_duration", days=90, date_field="EndDate") + assert empty_cohort.end_strategy.date_field == "EndDate" + + def test_fixed_duration_no_days_raises(self, empty_cohort): + with pytest.raises(ValueError, match="days is required"): + set_end_date_strategy(empty_cohort, "fixed_duration") + + def test_end_of_observation(self, empty_cohort): + # First set a strategy, then clear it + set_end_date_strategy(empty_cohort, "fixed_duration", days=30) + set_end_date_strategy(empty_cohort, "end_of_observation") + assert empty_cohort.end_strategy is None + + def test_custom_era(self, empty_cohort): + set_end_date_strategy( + empty_cohort, "custom_era", + drug_codeset_id=1, gap_days=30, offset=7, + ) + assert isinstance(empty_cohort.end_strategy, CustomEraStrategy) + assert empty_cohort.end_strategy.drug_codeset_id == 1 + assert empty_cohort.end_strategy.gap_days == 30 + assert empty_cohort.end_strategy.offset == 7 + + def test_unknown_strategy_raises(self, empty_cohort): + with pytest.raises(ValueError, match="Unknown strategy"): + set_end_date_strategy(empty_cohort, "unknown_strategy") + + def test_strategy_name_normalization(self, empty_cohort): + set_end_date_strategy(empty_cohort, "Fixed-Duration", days=10) + assert isinstance(empty_cohort.end_strategy, DateOffsetStrategy) + + set_end_date_strategy(empty_cohort, "observation_period") + assert empty_cohort.end_strategy is None + + +# =========================================================================== +# 10. Washout Period +# =========================================================================== + +class TestSetWashoutPeriod: + def test_sets_prior_and_first(self, empty_cohort): + result = set_washout_period(empty_cohort, 365) + assert result is empty_cohort + assert result.primary_criteria.observation_window.prior_days == 365 + assert result.primary_criteria.primary_limit.type == "First" + assert result.expression_limit.type == "First" + + def test_negative_raises(self, empty_cohort): + with pytest.raises(ValueError): + set_washout_period(empty_cohort, -1) + + +# =========================================================================== +# 11. Date Range +# =========================================================================== + +class TestSetDateRange: + def test_both_dates_string(self, empty_cohort): + result = set_date_range(empty_cohort, start_date="2020-01-01", end_date="2022-12-31") + assert result is empty_cohort + assert result.censor_window.start_date == "2020-01-01" + assert result.censor_window.end_date == "2022-12-31" + + def test_date_objects(self, empty_cohort): + set_date_range(empty_cohort, start_date=date(2020, 1, 1), end_date=date(2022, 12, 31)) + assert empty_cohort.censor_window.start_date == "2020-01-01" + assert empty_cohort.censor_window.end_date == "2022-12-31" + + def test_start_only(self, empty_cohort): + set_date_range(empty_cohort, start_date="2020-01-01") + assert empty_cohort.censor_window.start_date == "2020-01-01" + assert empty_cohort.censor_window.end_date is None + + def test_end_only(self, empty_cohort): + set_date_range(empty_cohort, end_date="2022-12-31") + assert empty_cohort.censor_window.start_date is None + assert empty_cohort.censor_window.end_date == "2022-12-31" + + def test_no_dates_raises(self, empty_cohort): + with pytest.raises(ValueError, match="At least one"): + set_date_range(empty_cohort) + + +# =========================================================================== +# 12. Censor at Event +# =========================================================================== + +class TestSetCensorEvent: + def test_add_death(self, empty_cohort): + death = Death() + result = set_censor_event(empty_cohort, death) + assert result is empty_cohort + assert len(result.censoring_criteria) == 1 + assert isinstance(result.censoring_criteria[0], Death) + + def test_add_multiple(self, empty_cohort): + set_censor_event(empty_cohort, Death()) + set_censor_event(empty_cohort, DrugExposure(codeset_id=1)) + assert len(empty_cohort.censoring_criteria) == 2 + + def test_clear(self, empty_cohort): + set_censor_event(empty_cohort, Death()) + set_censor_event(empty_cohort, Death()) + clear_censor_events(empty_cohort) + assert len(empty_cohort.censoring_criteria) == 0 + + +# =========================================================================== +# Reset helpers +# =========================================================================== + +class TestResetFunctions: + def test_reset_observation_window(self, empty_cohort): + set_prior_observation(empty_cohort, 365) + reset_observation_window(empty_cohort) + assert empty_cohort.primary_criteria.observation_window is None + + def test_reset_observation_window_no_pc(self, empty_cohort): + # Should not raise on an empty cohort + result = reset_observation_window(empty_cohort) + assert result is empty_cohort + + def test_reset_age_criteria(self, empty_cohort): + set_age_criteria(empty_cohort, min_age=18) + reset_age_criteria(empty_cohort) + assert len(empty_cohort.additional_criteria.demographic_criteria_list) == 0 + + def test_reset_age_preserves_gender(self, empty_cohort): + set_age_criteria(empty_cohort, min_age=18) + set_gender_criteria(empty_cohort, GENDER_FEMALE_CONCEPT_ID) + reset_age_criteria(empty_cohort) + assert len(empty_cohort.additional_criteria.demographic_criteria_list) == 1 + assert empty_cohort.additional_criteria.demographic_criteria_list[0].gender is not None + + def test_reset_gender_criteria(self, empty_cohort): + set_gender_criteria(empty_cohort, GENDER_MALE_CONCEPT_ID) + reset_gender_criteria(empty_cohort) + assert len(empty_cohort.additional_criteria.demographic_criteria_list) == 0 + + def test_reset_gender_preserves_age(self, empty_cohort): + set_age_criteria(empty_cohort, min_age=18) + set_gender_criteria(empty_cohort, GENDER_MALE_CONCEPT_ID) + reset_gender_criteria(empty_cohort) + assert len(empty_cohort.additional_criteria.demographic_criteria_list) == 1 + assert empty_cohort.additional_criteria.demographic_criteria_list[0].age is not None + + def test_reset_end_strategy(self, empty_cohort): + set_end_date_strategy(empty_cohort, "fixed_duration", days=30) + reset_end_strategy(empty_cohort) + assert empty_cohort.end_strategy is None + + def test_reset_collapse_settings(self, empty_cohort): + set_cohort_era(empty_cohort, 30) + reset_collapse_settings(empty_cohort) + assert empty_cohort.collapse_settings is None + + def test_reset_date_range(self, empty_cohort): + set_date_range(empty_cohort, start_date="2020-01-01") + reset_date_range(empty_cohort) + assert empty_cohort.censor_window is None + + +# =========================================================================== +# Chaining +# =========================================================================== + +class TestChaining: + def test_chain_multiple_modifiers(self, empty_cohort): + result = ( + set_prior_observation( + set_post_observation( + set_limit_to_first_event( + set_cohort_era(empty_cohort, 0) + ), 30 + ), 365 + ) + ) + assert result is empty_cohort + assert result.primary_criteria.observation_window.prior_days == 365 + assert result.primary_criteria.observation_window.post_days == 30 + assert result.primary_criteria.primary_limit.type == "First" + assert result.collapse_settings.era_pad == 0 + + +# =========================================================================== +# apply_standard_rules +# =========================================================================== + +class TestApplyStandardRules: + def test_defaults(self, empty_cohort): + result = apply_standard_rules(empty_cohort) + assert result is empty_cohort + assert result.primary_criteria.observation_window.prior_days == 365 + assert result.primary_criteria.observation_window.post_days == 0 + assert result.primary_criteria.primary_limit.type == "First" + assert result.expression_limit.type == "First" + assert result.collapse_settings.era_pad == 0 + + def test_custom_values(self, empty_cohort): + apply_standard_rules( + empty_cohort, + prior_observation_days=180, + post_observation_days=30, + first_event_only=False, + era_gap_days=14, + min_age=18, + max_age=65, + gender_concept_ids=GENDER_FEMALE_CONCEPT_ID, + end_strategy="fixed_duration", + end_strategy_days=365, + ) + assert empty_cohort.primary_criteria.observation_window.prior_days == 180 + assert empty_cohort.primary_criteria.observation_window.post_days == 30 + assert empty_cohort.primary_criteria.primary_limit.type == "All" + assert empty_cohort.collapse_settings.era_pad == 14 + dc_list = empty_cohort.additional_criteria.demographic_criteria_list + # One for age, one for gender + assert len(dc_list) == 2 + assert isinstance(empty_cohort.end_strategy, DateOffsetStrategy) + assert empty_cohort.end_strategy.offset == 365 + + def test_no_optional_params(self, empty_cohort): + apply_standard_rules(empty_cohort, prior_observation_days=0) + assert empty_cohort.primary_criteria.observation_window.prior_days == 0 + assert empty_cohort.additional_criteria is None + + def test_on_real_cohort(self, diabetes_cohort): + apply_standard_rules( + diabetes_cohort, + prior_observation_days=365, + first_event_only=True, + min_age=40, + ) + assert diabetes_cohort.primary_criteria.observation_window.prior_days == 365 + assert diabetes_cohort.primary_criteria.primary_limit.type == "First" + assert diabetes_cohort.additional_criteria is not None + dc = diabetes_cohort.additional_criteria.demographic_criteria_list[0] + assert dc.age.op == "gte" + assert dc.age.value == 40 + + +# =========================================================================== +# JSON round-trip +# =========================================================================== + +class TestJsonRoundTrip: + def test_modified_cohort_serializes(self, diabetes_cohort): + """Ensure a fully modified cohort can be serialized back to JSON.""" + set_prior_observation(diabetes_cohort, 180) + set_limit_to_first_event(diabetes_cohort) + set_cohort_era(diabetes_cohort, 30) + set_age_criteria(diabetes_cohort, min_age=18, max_age=65) + set_gender_criteria(diabetes_cohort, GENDER_FEMALE_CONCEPT_ID) + set_end_date_strategy(diabetes_cohort, "fixed_duration", days=365) + set_date_range(diabetes_cohort, start_date="2020-01-01", end_date="2023-12-31") + set_censor_event(diabetes_cohort, Death()) + + json_str = diabetes_cohort.model_dump_json() + data = json.loads(json_str) + + # Verify key fields survived round-trip + assert data["PrimaryCriteria"]["ObservationWindow"]["PriorDays"] == 180 + assert data["PrimaryCriteria"]["PrimaryCriteriaLimit"]["Type"] == "First" + assert data["ExpressionLimit"]["Type"] == "First" + assert data["CollapseSettings"]["EraPad"] == 30 + + def test_modified_cohort_deserializes(self, diabetes_cohort): + """Ensure a modified cohort can be serialized and parsed back.""" + set_prior_observation(diabetes_cohort, 180) + set_limit_to_first_event(diabetes_cohort) + set_cohort_era(diabetes_cohort, 30) + + json_str = diabetes_cohort.model_dump_json() + parsed = CohortExpression.model_validate_json(json_str) + + assert parsed.primary_criteria.observation_window.prior_days == 180 + assert parsed.primary_criteria.primary_limit.type == "First" + assert parsed.collapse_settings.era_pad == 30 + + From eb5a4ebcd92e448999a32738ba31dee31f22c989 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Tue, 24 Feb 2026 16:41:45 -0800 Subject: [PATCH 02/62] refined cohort modifier utility functions --- circe/helper/__init__.py | 6 +- circe/helper/cohort_modifiers.py | 272 +++++++++++++++++++++------- tests/test_cohort_modifiers.py | 299 ++++++++++++++++++++++++++++--- 3 files changed, 482 insertions(+), 95 deletions(-) diff --git a/circe/helper/__init__.py b/circe/helper/__init__.py index be6ae135..f05cd1d4 100644 --- a/circe/helper/__init__.py +++ b/circe/helper/__init__.py @@ -14,12 +14,12 @@ set_post_observation, set_limit_to_first_event, set_allow_all_events, - set_limit_to_n_events, set_cohort_era, set_age_criteria, set_gender_criteria, set_end_date_strategy, set_washout_period, + set_clean_window, set_date_range, set_censor_event, clear_censor_events, @@ -29,6 +29,7 @@ reset_gender_criteria, reset_end_strategy, reset_collapse_settings, + reset_clean_window, reset_date_range, # Convenience apply_standard_rules, @@ -43,12 +44,12 @@ "set_post_observation", "set_limit_to_first_event", "set_allow_all_events", - "set_limit_to_n_events", "set_cohort_era", "set_age_criteria", "set_gender_criteria", "set_end_date_strategy", "set_washout_period", + "set_clean_window", "set_date_range", "set_censor_event", "clear_censor_events", @@ -58,6 +59,7 @@ "reset_gender_criteria", "reset_end_strategy", "reset_collapse_settings", + "reset_clean_window", "reset_date_range", # Convenience "apply_standard_rules", diff --git a/circe/helper/cohort_modifiers.py b/circe/helper/cohort_modifiers.py index a4732bda..636d8d9a 100644 --- a/circe/helper/cohort_modifiers.py +++ b/circe/helper/cohort_modifiers.py @@ -36,12 +36,17 @@ ObservationFilter, Period, ResultLimit, + Window, + WindowBound, ) from ..cohortdefinition.criteria import ( + CorelatedCriteria, Criteria, CriteriaGroup, CriteriaType, DemographicCriteria, + InclusionRule, + Occurrence, PrimaryCriteria, ) from ..vocabulary.concept import Concept @@ -99,9 +104,13 @@ def _ensure_observation_window(pc: PrimaryCriteria) -> ObservationFilter: def _ensure_collapse_settings(expr: CohortExpression) -> CollapseSettings: - """Return the CollapseSettings, creating a default one if absent.""" + """Return the CollapseSettings, creating a default one if absent. + + Note: Creates with collapse_type=None to avoid enabling era collapsing + by default. The caller should explicitly set collapse_type if needed. + """ if expr.collapse_settings is None: - expr.collapse_settings = CollapseSettings(era_pad=0, collapse_type=CollapseType.ERA) + expr.collapse_settings = CollapseSettings(era_pad=0, collapse_type=None) return expr.collapse_settings @@ -227,52 +236,6 @@ def set_allow_all_events( return cohort_expression -# =========================================================================== -# 5. Limit to N Events -# =========================================================================== - -def set_limit_to_n_events( - cohort_expression: CohortExpression, - n: int, -) -> CohortExpression: - """Restrict the cohort to at most the first *n* qualifying events per person. - - This is implemented by setting the qualified limit type to ``"First"`` - and expressing the constraint via the ``QualifiedLimit``. If *n* == 1 - this is equivalent to :func:`set_limit_to_first_event`. - - .. note:: - The OHDSI Circe JSON schema does not have a native "limit to N" - field, so this function sets the limit type to ``"First"`` (keeping - only the earliest event). For true top-N behaviour you would need - post-processing outside the cohort definition. This function is - therefore a convenience wrapper around *first-event* semantics. - - Args: - cohort_expression: The cohort expression to modify. - n: Maximum number of events (>= 1). - - Returns: - The modified *cohort_expression*. - - Raises: - ValueError: If *n* < 1. - """ - if n < 1: - raise ValueError(f"n must be >= 1, got {n}") - - if n == 1: - return set_limit_to_first_event(cohort_expression) - - # For n > 1 we fall back to "All" since Circe doesn't natively support - # "first N". Users can filter downstream. - pc = _ensure_primary_criteria(cohort_expression) - pc.primary_limit = ResultLimit(type="All") - cohort_expression.qualified_limit = ResultLimit(type="All") - cohort_expression.expression_limit = ResultLimit(type="All") - return cohort_expression - - # =========================================================================== # 6. Cohort Era (Collapse / Persistence Window) # =========================================================================== @@ -316,6 +279,7 @@ def set_age_criteria( cohort_expression: CohortExpression, min_age: Optional[int] = None, max_age: Optional[int] = None, + replace: bool = False, ) -> CohortExpression: """Restrict cohort entry to subjects within an age range at index date. @@ -326,10 +290,17 @@ def set_age_criteria( * If only *max_age* is provided the operator is ``"lte"`` (<=). * If both are provided the operator is ``"bt"`` (between). + .. note:: + By default, this function **appends** age criteria to any existing + demographic criteria. To replace existing age criteria instead, + set ``replace=True``. + Args: cohort_expression: The cohort expression to modify. min_age: Minimum age (inclusive). ``None`` means no lower bound. max_age: Maximum age (inclusive). ``None`` means no upper bound. + replace: If ``True``, remove any existing age criteria before + adding the new one. Default is ``False`` (append). Returns: The modified *cohort_expression*. @@ -340,12 +311,18 @@ def set_age_criteria( Example: >>> cohort = set_age_criteria(cohort, min_age=18, max_age=65) + >>> # Replace existing age criteria + >>> cohort = set_age_criteria(cohort, min_age=21, replace=True) """ if min_age is None and max_age is None: raise ValueError("At least one of min_age or max_age must be provided") if min_age is not None and max_age is not None and min_age > max_age: raise ValueError(f"min_age ({min_age}) must be <= max_age ({max_age})") + # Remove existing age criteria if replace=True + if replace: + reset_age_criteria(cohort_expression) + # Build the NumericRange if min_age is not None and max_age is not None: age_range = NumericRange(op="bt", value=min_age, extent=max_age) @@ -377,18 +354,26 @@ def set_age_criteria( def set_gender_criteria( cohort_expression: CohortExpression, gender_concept_ids: Union[int, Sequence[int]], + replace: bool = False, ) -> CohortExpression: """Restrict cohort entry to subjects of a specific gender. Adds a ``DemographicCriteria`` with ``Gender`` concepts to the ``AdditionalCriteria`` group. + .. note:: + By default, this function **appends** gender criteria to any existing + demographic criteria. To replace existing gender criteria instead, + set ``replace=True``. + Args: cohort_expression: The cohort expression to modify. gender_concept_ids: One or more OMOP gender concept IDs. Common values: ``8507`` (Male), ``8532`` (Female). You can also use the module-level constants ``GENDER_MALE_CONCEPT_ID`` and ``GENDER_FEMALE_CONCEPT_ID``. + replace: If ``True``, remove any existing gender criteria before + adding the new one. Default is ``False`` (append). Returns: The modified *cohort_expression*. @@ -396,10 +381,16 @@ def set_gender_criteria( Example: >>> from circe.helper.cohort_modifiers import GENDER_FEMALE_CONCEPT_ID >>> cohort = set_gender_criteria(cohort, GENDER_FEMALE_CONCEPT_ID) + >>> # Replace existing gender criteria + >>> cohort = set_gender_criteria(cohort, GENDER_MALE_CONCEPT_ID, replace=True) """ if isinstance(gender_concept_ids, int): gender_concept_ids = [gender_concept_ids] + # Remove existing gender criteria if replace=True + if replace: + reset_gender_criteria(cohort_expression) + gender_concepts: List[Concept] = [] for cid in gender_concept_ids: # Try to resolve well-known concepts by ID @@ -503,29 +494,28 @@ def set_end_date_strategy( # =========================================================================== -# 10. Washout Period +# 10. Washout Period (alias for prior observation) # =========================================================================== def set_washout_period( cohort_expression: CohortExpression, days: int, ) -> CohortExpression: - """Exclude events that occur within *days* of a prior cohort entry. - - This is commonly called a *washout* or *clean window*. It is implemented - by requiring at least *days* of prior continuous observation **and** - restricting to first events, which effectively removes recurrent entries - that are too close together. + """Require a minimum period of prior observation (washout) before entry. - Specifically this function: + This is an alias for :func:`set_prior_observation`. A washout period + ensures that subjects have been observed for at least *days* before + their qualifying event, reducing the chance that a prevalent condition + is mistaken for an incident one. - 1. Sets ``PrimaryCriteria.ObservationWindow.PriorDays`` to *days*. - 2. Sets the expression limit to ``"First"`` so only the earliest - qualifying event per person is kept. + .. note:: + This function is semantically equivalent to + :func:`set_prior_observation`. Use whichever name better fits + your study's terminology. Args: cohort_expression: The cohort expression to modify. - days: Washout window in days (>= 0). + days: Minimum prior continuous observation in days (>= 0). Returns: The modified *cohort_expression*. @@ -535,12 +525,166 @@ def set_washout_period( Example: >>> cohort = set_washout_period(cohort, 365) + + See Also: + :func:`set_prior_observation` """ - if days < 0: - raise ValueError(f"days must be >= 0, got {days}") + return set_prior_observation(cohort_expression, days) + + +# =========================================================================== +# 10b. Clean Window +# =========================================================================== - set_prior_observation(cohort_expression, days) - set_limit_to_first_event(cohort_expression) +_CLEAN_WINDOW_RULE_NAME = "__clean_window__" + + +def set_clean_window( + cohort_expression: CohortExpression, + days: int, + criteria_mode: str = "any", +) -> CohortExpression: + """Exclude repeat events that occur within *days* of a prior event. + + A *clean window* (sometimes called a *deduplication window*) keeps only + events that are separated by at least *days* from any prior qualifying + event. For example, with a 7-day clean window two condition + occurrences 5 days apart would count as one event — the second would + be excluded. + + This is implemented by adding an inclusion rule with one + ``CorelatedCriteria`` (exactly 0 occurrences in ``[-days, -1]``) per + primary criterion. The *criteria_mode* parameter controls how those + checks are combined when the cohort has multiple primary criteria. + + **criteria_mode="any"** (default) + Primary criteria are treated as alternatives (OR). A person + enters the cohort when *any* criterion fires. The clean window + must therefore ensure that **none** of the entry criteria had a + prior occurrence in the window. Internally the correlated + criteria are joined with ``type="ALL"`` (every "exactly 0" check + must pass). + + **criteria_mode="all"** + Primary criteria are treated as co-requirements (AND). A person + enters only when *all* criteria fire together. The clean window + should exclude an event only if the full set of criteria + co-occurred previously. Internally the correlated criteria are + joined with ``type="ANY"`` — if *any* criterion had zero prior + occurrences the full combination could not have repeated, so the + event is kept. + + The rule is tagged with the internal name ``"__clean_window__"`` so + that :func:`reset_clean_window` can remove it later. + + Args: + cohort_expression: The cohort expression to modify. + days: Minimum gap between qualifying events (>= 1). + criteria_mode: How to combine checks across multiple primary + criteria. ``"any"`` (default) or ``"all"``. + + Returns: + The modified *cohort_expression*. + + Raises: + ValueError: If *days* < 1, no primary criteria are defined, or + *criteria_mode* is not ``"any"`` or ``"all"``. + + Example: + >>> # OR-style entry criteria (most common) + >>> cohort = set_clean_window(cohort, 7) + >>> # AND-style entry criteria + >>> cohort = set_clean_window(cohort, 7, criteria_mode="all") + """ + if days < 1: + raise ValueError(f"days must be >= 1, got {days}") + + mode = criteria_mode.strip().lower() + if mode not in ("any", "all"): + raise ValueError( + f"criteria_mode must be 'any' or 'all', got '{criteria_mode}'" + ) + + pc = cohort_expression.primary_criteria + if pc is None or not pc.criteria_list: + raise ValueError( + "Cannot set a clean window without primary criteria. " + "Add at least one primary criterion first." + ) + + # Remove any existing clean-window rule before adding a new one + reset_clean_window(cohort_expression) + + # Build one correlated criteria per primary criterion. + # Each one says: "exactly 0 occurrences of this criterion in the + # [-days, -1] day window before the index event." + correlated_list: List[CorelatedCriteria] = [] + for criterion in pc.criteria_list: + correlated = CorelatedCriteria( + criteria=criterion, + start_window=Window( + start=WindowBound(coeff=-1, days=days), + end=WindowBound(coeff=-1, days=1), + use_index_end=False, + use_event_end=False, + ), + occurrence=Occurrence(type=0, count=0, is_distinct=False), # EXACTLY 0 + restrict_visit=False, + ignore_observation_period=False, + ) + correlated_list.append(correlated) + + # Choose the CriteriaGroup type based on the mode. + # + # mode="any" → group type="ALL" + # Every correlated criteria (each checking one entry criterion) + # must show 0 prior occurrences. This means no prior qualifying + # event of *any* type occurred in the window. + # + # mode="all" → group type="ANY" + # At least one correlated criteria must show 0 prior occurrences. + # If any single entry criterion was absent in the window, the full + # co-occurring combination could not have happened, so the event + # passes the clean window. + group_type = "ALL" if mode == "any" else "ANY" + + rule = InclusionRule( + name=_CLEAN_WINDOW_RULE_NAME, + description=( + f"Exclude events within {days} days of a prior qualifying event " + f"(criteria_mode={mode})" + ), + expression=CriteriaGroup( + type=group_type, + criteria_list=correlated_list, + demographic_criteria_list=[], + groups=[], + ), + ) + + cohort_expression.inclusion_rules.append(rule) + return cohort_expression + + +def reset_clean_window( + cohort_expression: CohortExpression, +) -> CohortExpression: + """Remove the clean-window inclusion rule, if present. + + Only removes rules tagged with the internal name ``"__clean_window__"``. + + Args: + cohort_expression: The cohort expression to modify. + + Returns: + The modified *cohort_expression*. + """ + if cohort_expression.inclusion_rules: + cohort_expression.inclusion_rules = [ + r + for r in cohort_expression.inclusion_rules + if getattr(r, "name", None) != _CLEAN_WINDOW_RULE_NAME + ] return cohort_expression diff --git a/tests/test_cohort_modifiers.py b/tests/test_cohort_modifiers.py index 45c77cf6..6344084b 100644 --- a/tests/test_cohort_modifiers.py +++ b/tests/test_cohort_modifiers.py @@ -28,12 +28,12 @@ set_post_observation, set_limit_to_first_event, set_allow_all_events, - set_limit_to_n_events, set_cohort_era, set_age_criteria, set_gender_criteria, set_end_date_strategy, set_washout_period, + set_clean_window, set_date_range, set_censor_event, clear_censor_events, @@ -43,6 +43,7 @@ reset_gender_criteria, reset_end_strategy, reset_collapse_settings, + reset_clean_window, reset_date_range, # Convenience apply_standard_rules, @@ -142,31 +143,6 @@ def test_sets_all(self, empty_cohort): assert result.primary_criteria.primary_limit.type == "All" assert result.expression_limit.type == "All" - -# =========================================================================== -# 5. Limit to N Events -# =========================================================================== - -class TestSetLimitToNEvents: - def test_n_equals_1(self, empty_cohort): - result = set_limit_to_n_events(empty_cohort, 1) - assert result.primary_criteria.primary_limit.type == "First" - assert result.expression_limit.type == "First" - - def test_n_greater_than_1(self, empty_cohort): - result = set_limit_to_n_events(empty_cohort, 5) - assert result.primary_criteria.primary_limit.type == "All" - assert result.expression_limit.type == "All" - - def test_n_zero_raises(self, empty_cohort): - with pytest.raises(ValueError, match="n must be >= 1"): - set_limit_to_n_events(empty_cohort, 0) - - def test_n_negative_raises(self, empty_cohort): - with pytest.raises(ValueError): - set_limit_to_n_events(empty_cohort, -1) - - # =========================================================================== # 6. Cohort Era # =========================================================================== @@ -313,18 +289,279 @@ def test_strategy_name_normalization(self, empty_cohort): # =========================================================================== class TestSetWashoutPeriod: - def test_sets_prior_and_first(self, empty_cohort): + def test_sets_prior_observation_only(self, empty_cohort): + """Washout sets prior observation but does NOT force first event.""" result = set_washout_period(empty_cohort, 365) assert result is empty_cohort assert result.primary_criteria.observation_window.prior_days == 365 - assert result.primary_criteria.primary_limit.type == "First" - assert result.expression_limit.type == "First" + # Washout should NOT touch the event limit + assert result.expression_limit is None + + def test_preserves_all_events_limit(self, empty_cohort): + """Washout should not change an existing 'All' event limit.""" + set_allow_all_events(empty_cohort) + set_washout_period(empty_cohort, 180) + assert empty_cohort.primary_criteria.primary_limit.type == "All" + assert empty_cohort.primary_criteria.observation_window.prior_days == 180 + + def test_zero_days(self, empty_cohort): + set_washout_period(empty_cohort, 0) + assert empty_cohort.primary_criteria.observation_window.prior_days == 0 def test_negative_raises(self, empty_cohort): with pytest.raises(ValueError): set_washout_period(empty_cohort, -1) +# =========================================================================== +# 10b. Clean Window +# =========================================================================== + +class TestSetCleanWindow: + def test_adds_inclusion_rule(self, diabetes_cohort): + """A clean window adds an inclusion rule to deduplicate events.""" + result = set_clean_window(diabetes_cohort, 7) + assert result is diabetes_cohort + # Should have added exactly one inclusion rule + matching = [ + r for r in result.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ] + assert len(matching) == 1 + + def test_single_criterion_defaults_to_any_mode(self, diabetes_cohort): + """With one primary criterion and default mode, group type is ALL.""" + assert len(diabetes_cohort.primary_criteria.criteria_list) == 1 + set_clean_window(diabetes_cohort, 30) + rule = next( + r for r in diabetes_cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ) + assert rule.description is not None + assert "30" in rule.description + assert "criteria_mode=any" in rule.description + group = rule.expression + assert group is not None + assert group.type == "ALL" + assert len(group.criteria_list) == 1 + correlated = group.criteria_list[0] + assert correlated.occurrence.type == 0 # EXACTLY + assert correlated.occurrence.count == 0 + assert correlated.start_window.start.coeff == -1 + assert correlated.start_window.start.days == 30 + assert correlated.start_window.end.coeff == -1 + assert correlated.start_window.end.days == 1 + + def test_single_criterion_both_modes_equivalent(self, diabetes_cohort): + """With one criterion, 'any' and 'all' produce the same correlated list.""" + set_clean_window(diabetes_cohort, 7, criteria_mode="any") + rule_any = next( + r for r in diabetes_cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ) + n_any = len(rule_any.expression.criteria_list) + + set_clean_window(diabetes_cohort, 7, criteria_mode="all") + rule_all = next( + r for r in diabetes_cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ) + n_all = len(rule_all.expression.criteria_list) + + # Same count (1), but different group types + assert n_any == n_all == 1 + assert rule_any.expression.type == "ALL" + assert rule_all.expression.type == "ANY" + + # ----------------------------------------------------------------------- + # criteria_mode="any" (default) – OR-style primary criteria + # ----------------------------------------------------------------------- + + def test_any_mode_multi_criteria_uses_all_group(self): + """mode='any': group type is ALL so every criterion must show 0 prior.""" + cohort = CohortExpression.model_validate({ + "PrimaryCriteria": { + "CriteriaList": [ + {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, + {"DrugExposure": {"CodesetId": 2, "First": True}}, + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryCriteriaLimit": {"Type": "All"}, + } + }) + set_clean_window(cohort, 7, criteria_mode="any") + rule = next( + r for r in cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ) + group = rule.expression + assert group.type == "ALL" + assert len(group.criteria_list) == 2 + + criteria_types = set() + for correlated in group.criteria_list: + criteria_types.add(type(correlated.criteria).__name__) + assert correlated.occurrence.type == 0 + assert correlated.occurrence.count == 0 + assert correlated.start_window.start.days == 7 + assert criteria_types == {"ConditionOccurrence", "DrugExposure"} + + # ----------------------------------------------------------------------- + # criteria_mode="all" – AND-style primary criteria + # ----------------------------------------------------------------------- + + def test_all_mode_multi_criteria_uses_any_group(self): + """mode='all': group type is ANY – event passes if any criterion was absent.""" + cohort = CohortExpression.model_validate({ + "PrimaryCriteria": { + "CriteriaList": [ + {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, + {"DrugExposure": {"CodesetId": 2, "First": True}}, + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryCriteriaLimit": {"Type": "All"}, + } + }) + set_clean_window(cohort, 7, criteria_mode="all") + rule = next( + r for r in cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ) + group = rule.expression + assert group.type == "ANY" + assert len(group.criteria_list) == 2 + assert "criteria_mode=all" in rule.description + + def test_all_mode_three_criteria(self): + """mode='all' scales to three criteria with ANY group.""" + cohort = CohortExpression.model_validate({ + "PrimaryCriteria": { + "CriteriaList": [ + {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, + {"DrugExposure": {"CodesetId": 2, "First": True}}, + {"ProcedureOccurrence": {"CodesetId": 3, "First": True}}, + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryCriteriaLimit": {"Type": "All"}, + } + }) + set_clean_window(cohort, 14, criteria_mode="all") + rule = next( + r for r in cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ) + assert rule.expression.type == "ANY" + assert len(rule.expression.criteria_list) == 3 + + # ----------------------------------------------------------------------- + # Invalid criteria_mode + # ----------------------------------------------------------------------- + + def test_invalid_criteria_mode_raises(self, diabetes_cohort): + with pytest.raises(ValueError, match="criteria_mode must be"): + set_clean_window(diabetes_cohort, 7, criteria_mode="first") + + # ----------------------------------------------------------------------- + # Replace, reset, edge cases + # ----------------------------------------------------------------------- + + def test_replaces_existing_clean_window(self, diabetes_cohort): + """Calling set_clean_window twice replaces the old rule.""" + set_clean_window(diabetes_cohort, 7) + set_clean_window(diabetes_cohort, 14) + matching = [ + r for r in diabetes_cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ] + assert len(matching) == 1 + assert "14" in matching[0].description + + def test_replace_changes_mode(self, diabetes_cohort): + """Replacing a clean window can switch from 'any' to 'all'.""" + set_clean_window(diabetes_cohort, 7, criteria_mode="any") + rule = next( + r for r in diabetes_cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ) + assert rule.expression.type == "ALL" + + set_clean_window(diabetes_cohort, 7, criteria_mode="all") + rule = next( + r for r in diabetes_cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ) + assert rule.expression.type == "ANY" + + def test_preserves_other_inclusion_rules(self, diabetes_cohort): + """Clean window should not remove user-defined inclusion rules.""" + from circe.cohortdefinition.criteria import InclusionRule as IR + user_rule = IR(name="my_rule", description="custom") + diabetes_cohort.inclusion_rules.append(user_rule) + set_clean_window(diabetes_cohort, 7) + names = [getattr(r, "name", None) for r in diabetes_cohort.inclusion_rules] + assert "my_rule" in names + assert "__clean_window__" in names + + def test_no_primary_criteria_raises(self, empty_cohort): + """Cannot set a clean window if no primary criteria exist.""" + with pytest.raises(ValueError, match="primary criteria"): + set_clean_window(empty_cohort, 7) + + def test_days_less_than_1_raises(self, diabetes_cohort): + with pytest.raises(ValueError, match="days must be >= 1"): + set_clean_window(diabetes_cohort, 0) + + def test_negative_days_raises(self, diabetes_cohort): + with pytest.raises(ValueError): + set_clean_window(diabetes_cohort, -5) + + def test_reset_clean_window(self, diabetes_cohort): + """reset_clean_window removes only the clean-window rule.""" + from circe.cohortdefinition.criteria import InclusionRule as IR + user_rule = IR(name="keep_me", description="custom") + diabetes_cohort.inclusion_rules.append(user_rule) + set_clean_window(diabetes_cohort, 7) + assert len(diabetes_cohort.inclusion_rules) == 2 + reset_clean_window(diabetes_cohort) + assert len(diabetes_cohort.inclusion_rules) == 1 + assert diabetes_cohort.inclusion_rules[0].name == "keep_me" + + def test_reset_clean_window_noop_when_absent(self, empty_cohort): + """reset_clean_window should not raise when no rule is present.""" + result = reset_clean_window(empty_cohort) + assert result is empty_cohort + + def test_replace_updates_count_after_criteria_change(self): + """If primary criteria change between calls, the new rule reflects them.""" + from circe.cohortdefinition import DrugExposure + cohort = CohortExpression.model_validate({ + "PrimaryCriteria": { + "CriteriaList": [ + {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryCriteriaLimit": {"Type": "All"}, + } + }) + set_clean_window(cohort, 7) + rule = next( + r for r in cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ) + assert len(rule.expression.criteria_list) == 1 + + # Now add a second primary criterion and reset the clean window + cohort.primary_criteria.criteria_list.append( + DrugExposure(codeset_id=2) + ) + set_clean_window(cohort, 7) + rule = next( + r for r in cohort.inclusion_rules + if getattr(r, "name", None) == "__clean_window__" + ) + assert len(rule.expression.criteria_list) == 2 + + # =========================================================================== # 11. Date Range # =========================================================================== @@ -553,3 +790,7 @@ def test_modified_cohort_deserializes(self, diabetes_cohort): assert parsed.collapse_settings.era_pad == 30 + + + + From 2e2651f277c0d1b18e7fa30f17d636354fdfff16 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Wed, 25 Feb 2026 10:31:44 -0800 Subject: [PATCH 03/62] default object settings --- circe/cohortdefinition/cohort.py | 203 +++++++++++++++++++++++++++-- circe/cohortdefinition/core.py | 2 +- circe/cohortdefinition/criteria.py | 29 +++-- tests/test_cohort_expression.py | 42 +++++- 4 files changed, 250 insertions(+), 26 deletions(-) diff --git a/circe/cohortdefinition/cohort.py b/circe/cohortdefinition/cohort.py index b62f62c4..47460f03 100644 --- a/circe/cohortdefinition/cohort.py +++ b/circe/cohortdefinition/cohort.py @@ -45,8 +45,8 @@ class CohortExpression(CirceBaseModel): Java equivalent: org.ohdsi.circe.cohortdefinition.CohortExpression """ - concept_sets: Optional[List[ConceptSet]] = Field( - default=None, + concept_sets: List[ConceptSet] = Field( + default_factory=list, validation_alias=AliasChoices("ConceptSets", "conceptSets"), serialization_alias="ConceptSets" ) @@ -89,8 +89,8 @@ class CohortExpression(CirceBaseModel): validation_alias=AliasChoices("Title", "title"), serialization_alias="Title" ) - inclusion_rules: Optional[List[InclusionRule]] = Field( - default=None, + inclusion_rules: List[InclusionRule] = Field( + default_factory=list, validation_alias=AliasChoices("InclusionRules", "inclusionRules"), serialization_alias="InclusionRules" ) @@ -99,14 +99,30 @@ class CohortExpression(CirceBaseModel): validation_alias=AliasChoices("CensorWindow", "censorWindow"), serialization_alias="CensorWindow" ) - censoring_criteria: Optional[List[CriteriaType]] = Field( - default=None, - validation_alias=AliasChoices("CensoringCriteria", "censoringCriteria"), + censoring_criteria: List[CriteriaType] = Field( + default_factory=list, + validation_alias=AliasChoices("CensoringCriteria", "censoring_criteria", "censoringCriteria"), serialization_alias="CensoringCriteria" ) model_config = ConfigDict(populate_by_name=True) + @field_validator('inclusion_rules', mode='before') + @classmethod + def allow_none_inclusion_rules(cls, v: Any) -> Any: + """Convert None to empty list for inclusion_rules.""" + if v is None: + return [] + return v + + @field_validator('concept_sets', mode='before') + @classmethod + def allow_none_concept_sets(cls, v: Any) -> Any: + """Convert None to empty list for concept_sets.""" + if v is None: + return [] + return v + @field_validator('end_strategy', mode='before') @classmethod def deserialize_end_strategy(cls, v: Any) -> Any: @@ -141,6 +157,8 @@ def deserialize_censoring_criteria(cls, v: Any) -> Any: Censoring criteria come as [{"ConditionOccurrence": {...}}, ...] and need to be unwrapped and deserialized to Criteria objects. """ + if v is None: + return [] if not v or not isinstance(v, list): return v @@ -224,8 +242,6 @@ def add_concept_set(self, concept_set: ConceptSet) -> None: """ if not isinstance(concept_set, ConceptSet): raise TypeError("Expected ConceptSet instance") - if self.concept_sets is None: - self.concept_sets = [] self.concept_sets.append(concept_set) def remove_concept_set_by_id(self, id_: int) -> None: @@ -241,8 +257,6 @@ def add_inclusion_rule(self, rule: InclusionRule) -> None: """ if not isinstance(rule, InclusionRule): raise TypeError("Expected InclusionRule instance") - if self.inclusion_rules is None: - self.inclusion_rules = [] self.inclusion_rules.append(rule) def remove_inclusion_rule_by_name(self, name: str) -> None: @@ -258,8 +272,6 @@ def add_censoring_criteria(self, criteria: Criteria) -> None: """ if not isinstance(criteria, Criteria): raise TypeError("Expected Criteria instance") - if self.censoring_criteria is None: - self.censoring_criteria = [] self.censoring_criteria.append(criteria) def remove_censoring_criteria_by_type(self, criteria_type: str) -> None: @@ -382,6 +394,171 @@ def _normalize_for_checksum(self, data: Any) -> Any: return data + # ========================================================================= + # VALIDATION AND PROPERTY CHECKING METHODS + # ========================================================================= + + def is_first_event(self) -> bool: + """Check if cohort uses first event criteria. + + Returns: + True if all primary criteria have first=True, False otherwise. + """ + if not self.primary_criteria or not self.primary_criteria.criteria_list: + return False + + # Check if all criteria have first=True + for criteria in self.primary_criteria.criteria_list: + # Get the first attribute, handling both direct attribute and nested structure + first_value = getattr(criteria, 'first', None) + if first_value is not True: + return False + + return True + + def has_exclusion_rules(self) -> bool: + """Check if cohort has exclusion rules (inclusion rules). + + Note: In CIRCE terminology, "inclusion rules" act as exclusion criteria. + + Returns: + True if the cohort has any inclusion rules. + """ + return bool(self.inclusion_rules and len(self.inclusion_rules) > 0) + + def get_exclusion_count(self) -> int: + """Get the number of exclusion rules (inclusion rules). + + Returns: + The number of inclusion rules. + """ + return len(self.inclusion_rules) if self.inclusion_rules else 0 + + def has_inclusion_rule_by_name(self, name: str) -> bool: + """Check if an inclusion rule with the given name exists. + + This is useful for checking if specific rules are shared between cohorts. + + Args: + name: The name of the inclusion rule to search for. + + Returns: + True if an inclusion rule with the given name exists. + """ + if not self.inclusion_rules: + return False + + for rule in self.inclusion_rules: + if getattr(rule, 'name', None) == name: + return True + + return False + + def has_censoring_criteria(self) -> bool: + """Check if cohort has censoring criteria. + + Returns: + True if censoring criteria are defined. + """ + return bool(self.censoring_criteria and len(self.censoring_criteria) > 0) + + def get_censoring_criteria_types(self) -> List[str]: + """Get list of censoring criteria class names. + + Returns: + List of class names (e.g., ['ConditionOccurrence', 'DrugExposure']). + """ + if not self.censoring_criteria: + return [] + + return [criteria.__class__.__name__ for criteria in self.censoring_criteria] + + def has_additional_criteria(self) -> bool: + """Check if cohort has additional criteria defined and not empty. + + Returns: + True if additional criteria are defined and not empty. + """ + if not self.additional_criteria: + return False + + # Check if the criteria group is not empty + return not self.additional_criteria.is_empty() + + def has_end_strategy(self) -> bool: + """Check if cohort has an end strategy defined. + + Returns: + True if an end strategy is defined. + """ + return self.end_strategy is not None + + def get_end_strategy_type(self) -> Optional[str]: + """Get the type of end strategy. + + Returns: + 'DateOffset', 'CustomEra', or None if no end strategy is defined. + """ + if not self.end_strategy: + return None + + class_name = self.end_strategy.__class__.__name__ + if class_name == 'DateOffsetStrategy': + return 'DateOffset' + elif class_name == 'CustomEraStrategy': + return 'CustomEra' + else: + return class_name + + def get_primary_criteria_types(self) -> List[str]: + """Get list of primary criteria class names. + + Returns: + List of class names (e.g., ['ConditionOccurrence', 'DrugExposure']). + """ + if not self.primary_criteria or not self.primary_criteria.criteria_list: + return [] + + return [criteria.__class__.__name__ for criteria in self.primary_criteria.criteria_list] + + def has_observation_window(self) -> bool: + """Check if observation window is defined in primary criteria. + + Returns: + True if observation window is defined. + """ + if not self.primary_criteria: + return False + + return self.primary_criteria.observation_window is not None + + def get_primary_limit_type(self) -> Optional[str]: + """Get the primary limit type. + + Returns: + The primary limit type (e.g., 'All', 'First') or None. + """ + if not self.primary_criteria or not self.primary_criteria.primary_limit: + return None + + return getattr(self.primary_criteria.primary_limit, 'type', None) + + def get_concept_set_count(self) -> int: + """Get the number of concept sets. + + Returns: + The number of concept sets. + """ + return len(self.concept_sets) if self.concept_sets else 0 + + def has_concept_sets(self) -> bool: + """Check if concept sets are defined. + + Returns: + True if concept sets are defined. + """ + return bool(self.concept_sets and len(self.concept_sets) > 0) + def _repr_markdown_(self) -> str: """IPython notebook markdown representation. diff --git a/circe/cohortdefinition/core.py b/circe/cohortdefinition/core.py index 664e2a7b..d27e181a 100644 --- a/circe/cohortdefinition/core.py +++ b/circe/cohortdefinition/core.py @@ -200,7 +200,7 @@ class CollapseSettings(CirceBaseModel): serialization_alias="EraPad" ) collapse_type: Optional[CollapseType] = Field( - default=None, + default=CollapseType.ERA, validation_alias=AliasChoices("CollapseType", "collapseType"), serialization_alias="CollapseType" ) diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index f0f58de0..1ff2d3a2 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -1004,8 +1004,8 @@ class CriteriaGroup(BaseModel): Java equivalent: org.ohdsi.circe.cohortdefinition.CriteriaGroup """ - criteria_list: Optional[List['CorelatedCriteria']] = Field( - default=None, + criteria_list: List['CorelatedCriteria'] = Field( + default_factory=list, validation_alias=AliasChoices("CriteriaList", "criteriaList"), serialization_alias="CriteriaList" ) @@ -1014,13 +1014,13 @@ class CriteriaGroup(BaseModel): validation_alias=AliasChoices("Count", "count"), serialization_alias="Count" ) - groups: Optional[List['CriteriaGroup']] = Field( - default=None, + groups: List['CriteriaGroup'] = Field( + default_factory=list, validation_alias=AliasChoices("Groups", "groups"), serialization_alias="Groups" ) - demographic_criteria_list: Optional[List[DemographicCriteria]] = Field( - default=None, + demographic_criteria_list: List[DemographicCriteria] = Field( + default_factory=list, validation_alias=AliasChoices("DemographicCriteriaList", "demographicCriteriaList"), serialization_alias="DemographicCriteriaList" ) @@ -1039,10 +1039,19 @@ def is_empty(self) -> bool: has_demographic = self.demographic_criteria_list and len(self.demographic_criteria_list) > 0 return not (has_criteria or has_groups or has_demographic) + @field_validator('demographic_criteria_list', mode='before') + @classmethod + def allow_none_demographic(cls, v: Any) -> Any: + if v is None: + return [] + return v + @field_validator('groups', mode='before') @classmethod def deserialize_groups(cls, v: Any) -> Any: # Same Logic as before, just local + if v is None: + return [] if not v or not isinstance(v, list): return v result = [] @@ -1061,6 +1070,8 @@ def deserialize_groups(cls, v: Any) -> Any: @classmethod def deserialize_criteria_list(cls, v: Any) -> Any: # Logic adapted for local CorelatedCriteria + if v is None: + return [] if not v or not isinstance(v, list): return v @@ -1238,8 +1249,8 @@ class PrimaryCriteria(BaseModel): Java equivalent: org.ohdsi.circe.cohortdefinition.PrimaryCriteria """ - criteria_list: Optional[List[CriteriaType]] = Field( - default=None, + criteria_list: List[CriteriaType] = Field( + default_factory=list, validation_alias=AliasChoices("CriteriaList", "criteriaList"), serialization_alias="CriteriaList" ) @@ -1259,6 +1270,8 @@ class PrimaryCriteria(BaseModel): @field_validator('criteria_list', mode='before') @classmethod def deserialize_criteria_list(cls, v: Any) -> Any: + if v is None: + return [] if not v or not isinstance(v, list): return v diff --git a/tests/test_cohort_expression.py b/tests/test_cohort_expression.py index 8a72fed0..cc703b67 100644 --- a/tests/test_cohort_expression.py +++ b/tests/test_cohort_expression.py @@ -34,7 +34,7 @@ def test_cohort_expression_empty_initialization(self): """Test CohortExpression with no parameters.""" cohort = CohortExpression() - self.assertIsNone(cohort.concept_sets) + self.assertEqual(cohort.concept_sets, []) self.assertIsNone(cohort.qualified_limit) self.assertIsNone(cohort.additional_criteria) self.assertIsNone(cohort.end_strategy) @@ -43,9 +43,9 @@ def test_cohort_expression_empty_initialization(self): self.assertIsNone(cohort.expression_limit) self.assertIsNone(cohort.collapse_settings) self.assertIsNone(cohort.title) - self.assertIsNone(cohort.inclusion_rules) + self.assertEqual(cohort.inclusion_rules, []) self.assertIsNone(cohort.censor_window) - self.assertIsNone(cohort.censoring_criteria) + self.assertEqual(cohort.censoring_criteria, []) def test_cohort_expression_with_title(self): """Test CohortExpression with title.""" @@ -370,8 +370,42 @@ def test_cohort_expression_with_none_values(self): self.assertIsNone(cohort.title) self.assertIsNone(cohort.primary_criteria) - self.assertIsNone(cohort.concept_sets) + self.assertEqual(cohort.concept_sets, []) + def test_cohort_expression_inclusion_rules_none_to_list(self): + """Test that inclusion_rules=None is converted to empty list.""" + # Test via constructor + cohort = CohortExpression(inclusion_rules=None) + self.assertEqual(cohort.inclusion_rules, []) + + # Test via JSON validation + cohort_json = CohortExpression.model_validate({"InclusionRules": None}) + self.assertEqual(cohort_json.inclusion_rules, []) + + def test_cohort_expression_list_defaults(self): + """Test defaults and None handling for list fields.""" + # 1. Default Initialization + c = CohortExpression() + self.assertEqual(c.concept_sets, []) + self.assertEqual(c.censoring_criteria, []) + self.assertEqual(c.inclusion_rules, []) + + # 2. None Initialization + c_none = CohortExpression( + concept_sets=None, + censoring_criteria=None, + inclusion_rules=None + ) + self.assertEqual(c_none.concept_sets, []) + self.assertEqual(c_none.censoring_criteria, []) + self.assertEqual(c_none.inclusion_rules, []) + + # 3. JSON Null + c_json = CohortExpression.model_validate_json('{"ConceptSets": null, "CensoringCriteria": null, "InclusionRules": null}') + self.assertEqual(c_json.concept_sets, []) + self.assertEqual(c_json.censoring_criteria, []) + self.assertEqual(c_json.inclusion_rules, []) + def test_cohort_expression_empty_string_title(self): """Test CohortExpression with empty string title.""" cohort = CohortExpression(title="") From aab84404cba9d805c9292dc6c9285e600192a9a8 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Wed, 25 Feb 2026 10:33:13 -0800 Subject: [PATCH 04/62] code formatting --- circe/__init__.py | 72 +- circe/__main__.py | 3 +- circe/api.py | 64 +- circe/chat.py | 141 ++- circe/check/__init__.py | 10 +- circe/check/check.py | 13 +- circe/check/checker.py | 26 +- circe/check/checkers/__init__.py | 78 +- circe/check/checkers/attribute_check.py | 17 +- .../checkers/attribute_checker_factory.py | 54 +- circe/check/checkers/base_check.py | 47 +- circe/check/checkers/base_checker_factory.py | 38 +- .../checkers/base_corelated_criteria_check.py | 75 +- circe/check/checkers/base_criteria_check.py | 63 +- circe/check/checkers/base_iterable_check.py | 36 +- circe/check/checkers/base_value_check.py | 118 +- circe/check/checkers/comparisons.py | 143 ++- circe/check/checkers/concept_check.py | 13 +- .../check/checkers/concept_checker_factory.py | 477 +++++-- .../checkers/concept_set_criteria_check.py | 226 ++-- .../checkers/concept_set_selection_check.py | 13 +- .../concept_set_selection_checker_factory.py | 89 +- .../checkers/criteria_checker_factory.py | 196 +-- .../checkers/criteria_contradictions_check.py | 105 +- .../check/checkers/death_time_window_check.py | 79 +- circe/check/checkers/domain_type_check.py | 183 +-- circe/check/checkers/drug_domain_check.py | 145 ++- circe/check/checkers/drug_era_check.py | 41 +- .../checkers/duplicates_concept_set_check.py | 18 +- .../checkers/duplicates_criteria_check.py | 116 +- .../check/checkers/empty_concept_set_check.py | 20 +- .../checkers/events_progression_check.py | 61 +- circe/check/checkers/exit_criteria_check.py | 21 +- .../exit_criteria_days_offset_check.py | 29 +- circe/check/checkers/incomplete_rule_check.py | 42 +- circe/check/checkers/initial_event_check.py | 20 +- .../check/checkers/no_exit_criteria_check.py | 52 +- circe/check/checkers/ocurrence_check.py | 18 +- circe/check/checkers/range_check.py | 134 +- circe/check/checkers/range_checker_factory.py | 729 ++++++++--- circe/check/checkers/text_check.py | 13 +- circe/check/checkers/text_checker_factory.py | 133 +- circe/check/checkers/time_pattern_check.py | 97 +- circe/check/checkers/time_window_check.py | 40 +- circe/check/checkers/unused_concepts_check.py | 207 ++-- circe/check/checkers/warning_reporter.py | 9 +- .../check/checkers/warning_reporter_helper.py | 18 +- circe/check/constants.py | 9 +- circe/check/operations/__init__.py | 11 +- .../operations/conditional_operations.py | 33 +- circe/check/operations/execution.py | 7 +- .../check/operations/executive_operations.py | 27 +- circe/check/operations/operations.py | 61 +- circe/check/utils/__init__.py | 2 +- circe/check/utils/criteria_name_helper.py | 139 ++- circe/check/warning.py | 9 +- circe/check/warning_severity.py | 4 +- circe/check/warnings/__init__.py | 8 +- circe/check/warnings/base_warning.py | 13 +- circe/check/warnings/concept_set_warning.py | 28 +- circe/check/warnings/default_warning.py | 13 +- .../check/warnings/incomplete_rule_warning.py | 19 +- circe/cli.py | 187 +-- circe/cohortdefinition/__init__.py | 132 +- circe/cohortdefinition/builders/__init__.py | 10 +- circe/cohortdefinition/builders/base.py | 113 +- .../builders/condition_era.py | 155 ++- .../builders/condition_occurrence.py | 281 +++-- circe/cohortdefinition/builders/death.py | 118 +- .../builders/device_exposure.py | 241 ++-- circe/cohortdefinition/builders/dose_era.py | 167 ++- circe/cohortdefinition/builders/drug_era.py | 163 ++- .../builders/drug_exposure.py | 285 +++-- .../builders/location_region.py | 62 +- .../cohortdefinition/builders/measurement.py | 342 +++-- .../cohortdefinition/builders/observation.py | 305 +++-- .../builders/observation_period.py | 207 ++-- .../builders/payer_plan_period.py | 276 +++-- .../builders/procedure_occurrence.py | 348 ++++-- circe/cohortdefinition/builders/specimen.py | 170 ++- circe/cohortdefinition/builders/utils.py | 174 +-- .../cohortdefinition/builders/visit_detail.py | 247 ++-- .../builders/visit_occurrence.py | 305 +++-- circe/cohortdefinition/code_generator.py | 85 +- circe/cohortdefinition/cohort.py | 292 +++-- .../cohort_expression_query_builder.py | 962 +++++++++----- .../concept_set_expression_query_builder.py | 133 +- circe/cohortdefinition/core.py | 180 +-- circe/cohortdefinition/criteria.py | 1100 +++++++++++------ circe/cohortdefinition/interfaces.py | 133 +- .../printfriendly/__init__.py | 4 +- .../printfriendly/markdown_render.py | 140 ++- circe/cohortdefinition/utils.py | 29 +- circe/helper/cohort_modifiers.py | 29 +- circe/vocabulary/__init__.py | 8 +- circe/vocabulary/concept.py | 48 +- .../concept_set_expression_query_builder.py | 137 +- 97 files changed, 7971 insertions(+), 4322 deletions(-) diff --git a/circe/__init__.py b/circe/__init__.py index 93222da9..f85ea7fb 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -34,16 +34,50 @@ ) from circe.cohortdefinition import ( - CohortExpression, Criteria, CorelatedCriteria, DemographicCriteria, - Occurrence, CriteriaColumn, InclusionRule, CollapseType, DateType, - ResultLimit, Period, DateRange, NumericRange, DateAdjustment, - ObservationFilter, CollapseSettings, EndStrategy, PrimaryCriteria, - CriteriaGroup, ConceptSetSelection, Window, TextFilter, GeoCriteria, WindowedCriteria, - DateOffsetStrategy, CustomEraStrategy, ConditionOccurrence, DrugExposure, - InclusionRule, WindowBound, - ProcedureOccurrence, VisitOccurrence, Observation, Measurement, DeviceExposure, - Specimen, Death, VisitDetail, ObservationPeriod, PayerPlanPeriod, LocationRegion, - ConditionEra, DrugEra, DoseEra + CohortExpression, + Criteria, + CorelatedCriteria, + DemographicCriteria, + Occurrence, + CriteriaColumn, + InclusionRule, + CollapseType, + DateType, + ResultLimit, + Period, + DateRange, + NumericRange, + DateAdjustment, + ObservationFilter, + CollapseSettings, + EndStrategy, + PrimaryCriteria, + CriteriaGroup, + ConceptSetSelection, + Window, + TextFilter, + GeoCriteria, + WindowedCriteria, + DateOffsetStrategy, + CustomEraStrategy, + ConditionOccurrence, + DrugExposure, + InclusionRule, + WindowBound, + ProcedureOccurrence, + VisitOccurrence, + Observation, + Measurement, + DeviceExposure, + Specimen, + Death, + VisitDetail, + ObservationPeriod, + PayerPlanPeriod, + LocationRegion, + ConditionEra, + DrugEra, + DoseEra, ) from typing import Dict @@ -58,6 +92,7 @@ from pydantic import BaseModel import circe as package + def safe_model_rebuild(package): """ Force-rebuild all Pydantic models in the given package. @@ -90,7 +125,6 @@ def safe_model_rebuild(package): pass - def get_json_schema() -> dict: """ Generate a combined JSON Schema from your Pydantic models @@ -142,7 +176,7 @@ def get_json_schema() -> dict: "Window": Window, "TextFilter": TextFilter, "InclusionRule": InclusionRule, - "WindowBound": WindowBound + "WindowBound": WindowBound, } # Build root-level $defs with each schema @@ -163,14 +197,11 @@ def get_json_schema() -> dict: "version": "1.3.3", "type": "object", "$defs": defs, - "properties": { - "CohortExpression": {"$ref": "#/$defs/CohortExpression"} - }, - "required": ["CohortExpression"] + "properties": {"CohortExpression": {"$ref": "#/$defs/CohortExpression"}}, + "required": ["CohortExpression"], } - # --------------------------------------------------------------------- __all__ = [ "__version__", @@ -181,10 +212,13 @@ def get_json_schema() -> dict: "CohortExpression", "get_json_schema", # Vocabulary classes - "Concept", "ConceptSet", "ConceptSetExpression", "ConceptSetItem", + "Concept", + "ConceptSet", + "ConceptSetExpression", + "ConceptSetItem", # API functions "cohort_expression_from_json", "build_cohort_query", "cohort_print_friendly", - "safe_model_rebuild" + "safe_model_rebuild", ] diff --git a/circe/__main__.py b/circe/__main__.py index c8fa05e3..9ae637f1 100644 --- a/circe/__main__.py +++ b/circe/__main__.py @@ -1,5 +1,4 @@ - from .cli import main -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/circe/api.py b/circe/api.py index 363ed635..31fd6a96 100644 --- a/circe/api.py +++ b/circe/api.py @@ -19,47 +19,51 @@ def cohort_expression_from_json(json_str: str) -> CohortExpression: """Load a cohort expression from a JSON string. - + This is equivalent to R CirceR's `cohortExpressionFromJson()` function. - + Args: json_str: JSON string containing the cohort definition - + Returns: CohortExpression instance - + Raises: ValueError: If the JSON is invalid or doesn't conform to the schema - + Example: >>> json_str = '{"ConceptSets": [], "PrimaryCriteria": {...}}' >>> expression = cohort_expression_from_json(json_str) """ import json - + # Parse JSON data = json.loads(json_str) - + # Handle cdmVersionRange as string - if 'cdmVersionRange' in data and isinstance(data['cdmVersionRange'], str): - data.pop('cdmVersionRange', None) + if "cdmVersionRange" in data and isinstance(data["cdmVersionRange"], str): + data.pop("cdmVersionRange", None) # Handle empty censorWindow - if 'censorWindow' in data and data['censorWindow'] == {}: - data.pop('censorWindow', None) + if "censorWindow" in data and data["censorWindow"] == {}: + data.pop("censorWindow", None) # Ensure ConceptSetExpression objects have required fields - if 'conceptSets' in data and data['conceptSets']: - for concept_set in data['conceptSets']: - if isinstance(concept_set, dict) and 'expression' in concept_set and concept_set['expression'] is not None: - expr = concept_set['expression'] + if "conceptSets" in data and data["conceptSets"]: + for concept_set in data["conceptSets"]: + if ( + isinstance(concept_set, dict) + and "expression" in concept_set + and concept_set["expression"] is not None + ): + expr = concept_set["expression"] if isinstance(expr, dict): - if 'isExcluded' not in expr: - expr['isExcluded'] = False - if 'includeMapped' not in expr: - expr['includeMapped'] = False - if 'includeDescendants' not in expr: - expr['includeDescendants'] = False + if "isExcluded" not in expr: + expr["isExcluded"] = False + if "includeMapped" not in expr: + expr["includeMapped"] = False + if "includeDescendants" not in expr: + expr["includeDescendants"] = False try: return CohortExpression.model_validate(data) @@ -68,8 +72,7 @@ def cohort_expression_from_json(json_str: str) -> CohortExpression: def build_cohort_query( - expression: CohortExpression, - options: Optional[BuildExpressionQueryOptions] = None + expression: CohortExpression, options: Optional[BuildExpressionQueryOptions] = None ) -> str: """Generate SQL query from a cohort expression. @@ -101,12 +104,12 @@ def cohort_print_friendly( expression: CohortExpression, concept_sets: Optional[List[ConceptSet]] = None, title: Optional[str] = None, - include_concept_sets: bool = False + include_concept_sets: bool = False, ) -> str: """Generate human-readable Markdown from a cohort expression. - + This is equivalent to R CirceR's `cohortPrintFriendly()` function. - + Args: expression: CohortExpression instance concept_sets: Optional list of concept sets (uses expression.concept_sets if None) @@ -114,7 +117,7 @@ def cohort_print_friendly( title: Optional title for the output (default: None) Returns: Markdown string - + Example: >>> expression = cohort_expression_from_json(json_str) >>> markdown = cohort_print_friendly(expression) @@ -123,7 +126,8 @@ def cohort_print_friendly( """ if concept_sets is None: concept_sets = expression.concept_sets or [] - - renderer = MarkdownRender(concept_sets=concept_sets, include_concept_sets=include_concept_sets) - return renderer.render_cohort_expression(expression, title=title) + renderer = MarkdownRender( + concept_sets=concept_sets, include_concept_sets=include_concept_sets + ) + return renderer.render_cohort_expression(expression, title=title) diff --git a/circe/chat.py b/circe/chat.py index 60b9afbb..8c8ffa7c 100644 --- a/circe/chat.py +++ b/circe/chat.py @@ -1,6 +1,7 @@ """ Chat module for interacting with LLMs to generate cohort definitions. """ + import sys import os import json @@ -10,6 +11,7 @@ from circe.prompt_builder import CohortPromptBuilder, ConceptSet + def chat_command(args): """ Entry point for the chat command. @@ -19,16 +21,17 @@ def chat_command(args): prompt_type=args.prompt_type, output=args.output, concept_sets_file=args.concept_sets, - input_file=args.input_file + input_file=args.input_file, ) return 0 + def start_chat( model: Optional[str], prompt_type: str, output: Optional[str], concept_sets_file: Optional[str], - input_file: Optional[str] = None + input_file: Optional[str] = None, ): """ Start the interactive chat session. @@ -38,44 +41,54 @@ def start_chat( import litellm from dotenv import load_dotenv except ImportError: - print("Error: 'litellm' and 'python-dotenv' are required for chat functionality.", file=sys.stderr) - print("Please install them with: pip install litellm python-dotenv", file=sys.stderr) + print( + "Error: 'litellm' and 'python-dotenv' are required for chat functionality.", + file=sys.stderr, + ) + print( + "Please install them with: pip install litellm python-dotenv", + file=sys.stderr, + ) return 1 # Load environment variables load_dotenv() - + # Determine model if not model: model = os.getenv("LLM_MODEL", "gpt-4o") # Handle optional temperature if needed, but litellm handles it or we pass it - + print(f"🚀 Starting Circe Chat") print(f" Model: {model}") print(f" Prompt: {prompt_type}") print("-" * 50) - + # Load concept sets if provided concept_sets_data = [] if concept_sets_file: try: - with open(concept_sets_file, 'r') as f: + with open(concept_sets_file, "r") as f: raw_data = json.load(f) # Expecting list of dicts with id, name for item in raw_data: - concept_sets_data.append(ConceptSet( - id=item.get('id'), - name=item.get('name'), - description=item.get('description') - )) - print(f" Loaded {len(concept_sets_data)} concept sets from {concept_sets_file}") + concept_sets_data.append( + ConceptSet( + id=item.get("id"), + name=item.get("name"), + description=item.get("description"), + ) + ) + print( + f" Loaded {len(concept_sets_data)} concept sets from {concept_sets_file}" + ) except Exception as e: print(f"Error loading concept sets: {e}", file=sys.stderr) return 1 - + # Initialize builder builder = CohortPromptBuilder() - + try: system_prompt = builder.load_system_prompt(prompt_type) except Exception as e: @@ -84,19 +97,21 @@ def start_chat( # Add inference instruction if no concept sets provided if not concept_sets_data: - system_prompt += "\n\nIMPORTANT: No concept sets were provided.\n" \ - "You MUST infer appropriate concept sets from the clinical description.\n" \ - "1. Define them using `circe.vocabulary.concept_set`.\n" \ - "2. Add them to the builder using `.with_concept_sets(...)`.\n" \ - "3. Use valid OMOP Concept IDs (or realistic placeholders if exact IDs are unknown)." - + system_prompt += ( + "\n\nIMPORTANT: No concept sets were provided.\n" + "You MUST infer appropriate concept sets from the clinical description.\n" + "1. Define them using `circe.vocabulary.concept_set`.\n" + "2. Add them to the builder using `.with_concept_sets(...)`.\n" + "3. Use valid OMOP Concept IDs (or realistic placeholders if exact IDs are unknown)." + ) + messages = [{"role": "system", "content": system_prompt}] - + print("\nPlease describe the cohort you want to build (or type 'quit' to exit):") - + first_turn = True initial_input = None - + if input_file: try: initial_input = Path(input_file).read_text() @@ -115,26 +130,30 @@ def start_chat( except (EOFError, KeyboardInterrupt): print("\nExiting chat.") break - - if user_input.lower() in ('quit', 'exit'): + + if user_input.lower() in ("quit", "exit"): break - + if not user_input.strip(): continue - + # Turn off first_turn flag after we have a valid input if first_turn: first_turn = False - + # Construct user message if len(messages) == 1: # First user message - format nicely - formatted_content = f"\n---\n## User Task\n**Clinical Description:**\n{user_input}\n" + formatted_content = ( + f"\n---\n## User Task\n**Clinical Description:**\n{user_input}\n" + ) if concept_sets_data: formatted_content += builder.format_concept_sets(concept_sets_data) else: - formatted_content += "\nNo pre-defined concept sets provided. Please infer them." - + formatted_content += ( + "\nNo pre-defined concept sets provided. Please infer them." + ) + messages.append({"role": "user", "content": formatted_content}) else: messages.append({"role": "user", "content": user_input}) @@ -145,9 +164,9 @@ def start_chat( response = litellm.completion(model=model, messages=messages) content = response.choices[0].message.content print("\n" + content) - + messages.append({"role": "assistant", "content": content}) - + # Extract and process code _process_response_content(content, output) @@ -160,12 +179,12 @@ def _process_response_content(content: str, output_base: Optional[str]): Extract logic to find Python code, save it, and attempt to run it to generate JSON. """ # Look for python code block - code_match = re.search(r'```python\n(.*?)\n```', content, re.DOTALL) + code_match = re.search(r"```python\n(.*?)\n```", content, re.DOTALL) if not code_match: return code = code_match.group(1) - + # Determine output filenames if output_base: py_file = Path(output_base + ".py") @@ -174,7 +193,7 @@ def _process_response_content(content: str, output_base: Optional[str]): # Default name py_file = Path("cohort_definition.py") json_file = Path("cohort_definition.json") - + # Save Python code try: py_file.write_text(code) @@ -186,45 +205,49 @@ def _process_response_content(content: str, output_base: Optional[str]): # Attempt to execute and save JSON # This involves running the code and capturing the 'cohort' variable or 'expression' variable print(" Attempting to generate JSON...") - + try: # Create a local scope local_scope = {} # We need to make sure the CWD is in path so imports work? # Assuming we are running from project root or installed package - + exec(code, {}, local_scope) - + # Look for a CohortExpression or CohortBuilder object - # The prompt usually produces: + # The prompt usually produces: # cohort = CohortBuilder(...).build() # So we look for 'cohort' - - cohort_obj = local_scope.get('cohort') + + cohort_obj = local_scope.get("cohort") if not cohort_obj: # Try to find any variable that is a tuple (builder) or CohortExpression for k, v in local_scope.items(): - if hasattr(v, 'to_json'): # CohortExpression has to_json? Check API. + if hasattr(v, "to_json"): # CohortExpression has to_json? Check API. cohort_obj = v break - + if cohort_obj: # If it's the builder (tuple in some cases?), checks if it has build() # But the prompt says `.build()` returns CohortExpression. - - # Check if it has 'to_json' or similar. - # circe.cohortdefinition.CohortExpression uses Pydantic? + + # Check if it has 'to_json' or similar. + # circe.cohortdefinition.CohortExpression uses Pydantic? # It inherits from Serializable? - + json_output = None - if hasattr(cohort_obj, 'json'): # Pydantic v1/v2 - json_output = cohort_obj.model_dump_json(indent=2) if hasattr(cohort_obj, 'model_dump_json') else cohort_obj.json(indent=2) - elif hasattr(cohort_obj, 'to_json'): + if hasattr(cohort_obj, "json"): # Pydantic v1/v2 + json_output = ( + cohort_obj.model_dump_json(indent=2) + if hasattr(cohort_obj, "model_dump_json") + else cohort_obj.json(indent=2) + ) + elif hasattr(cohort_obj, "to_json"): json_output = cohort_obj.to_json() else: - # It might be a dict? - if isinstance(cohort_obj, dict): - json_output = json.dumps(cohort_obj, indent=2) + # It might be a dict? + if isinstance(cohort_obj, dict): + json_output = json.dumps(cohort_obj, indent=2) if json_output: json_file.write_text(json_output) @@ -233,7 +256,9 @@ def _process_response_content(content: str, output_base: Optional[str]): print(" Could not serialize 'cohort' object to JSON.") else: print(" Could not find 'cohort' variable in executed code.") - + except Exception as e: print(f" Error executing generated code: {e}") - print(" (Ensure the generated code is valid and all dependencies are installed)") + print( + " (Ensure the generated code is valid and all dependencies are installed)" + ) diff --git a/circe/check/__init__.py b/circe/check/__init__.py index 38d9399a..d7941042 100644 --- a/circe/check/__init__.py +++ b/circe/check/__init__.py @@ -12,9 +12,9 @@ from .constants import Constants __all__ = [ - 'Check', - 'Checker', - 'Warning', - 'WarningSeverity', - 'Constants', + "Check", + "Checker", + "Warning", + "WarningSeverity", + "Constants", ] diff --git a/circe/check/check.py b/circe/check/check.py index 2b48753e..54315288 100644 --- a/circe/check/check.py +++ b/circe/check/check.py @@ -25,22 +25,21 @@ class Check(ABC): """Base interface for validation checks. - + Java equivalent: org.ohdsi.circe.check.Check - + All validation checks must implement this interface and provide a method to check a cohort expression and return warnings. """ - + @abstractmethod - def check(self, expression: 'CohortExpression') -> List[Warning]: + def check(self, expression: "CohortExpression") -> List[Warning]: """Check a cohort expression and return any warnings. - + Args: expression: The cohort expression to validate - + Returns: A list of warnings found during validation. Empty list if no issues. """ pass - diff --git a/circe/check/checker.py b/circe/check/checker.py index cc8f2247..18ae6759 100644 --- a/circe/check/checker.py +++ b/circe/check/checker.py @@ -18,29 +18,32 @@ from ..cohortdefinition.cohort import CohortExpression except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ..cohortdefinition.cohort import CohortExpression class Checker(Check): """Main checker class that runs all validation checks. - + Java equivalent: org.ohdsi.circe.check.Checker - + This class orchestrates running all validation checks against a cohort expression and collects all warnings. """ - + def _get_checks(self) -> List[Check]: """Get the list of all checks to run. - + Returns: A list of Check instances to run against the expression. """ # Import checkers here to avoid circular dependencies from .checkers.unused_concepts_check import UnusedConceptsCheck from .checkers.exit_criteria_check import ExitCriteriaCheck - from .checkers.exit_criteria_days_offset_check import ExitCriteriaDaysOffsetCheck + from .checkers.exit_criteria_days_offset_check import ( + ExitCriteriaDaysOffsetCheck, + ) from .checkers.range_check import RangeCheck from .checkers.concept_check import ConceptCheck from .checkers.concept_set_selection_check import ConceptSetSelectionCheck @@ -62,7 +65,7 @@ def _get_checks(self) -> List[Check]: from .checkers.domain_type_check import DomainTypeCheck from .checkers.criteria_contradictions_check import CriteriaContradictionsCheck from .checkers.death_time_window_check import DeathTimeWindowCheck - + checks: List[Check] = [ UnusedConceptsCheck(), ExitCriteriaCheck(), @@ -89,15 +92,15 @@ def _get_checks(self) -> List[Check]: CriteriaContradictionsCheck(), DeathTimeWindowCheck(), ] - + return checks - - def check(self, expression: 'CohortExpression') -> List[Warning]: + + def check(self, expression: "CohortExpression") -> List[Warning]: """Run all validation checks against a cohort expression. - + Args: expression: The cohort expression to validate - + Returns: A list of all warnings found by all checks. """ @@ -105,4 +108,3 @@ def check(self, expression: 'CohortExpression') -> List[Warning]: for check in self._get_checks(): result.extend(check.check(expression)) return result - diff --git a/circe/check/checkers/__init__.py b/circe/check/checkers/__init__.py index 78433afe..7f8ee642 100644 --- a/circe/check/checkers/__init__.py +++ b/circe/check/checkers/__init__.py @@ -48,46 +48,46 @@ __all__ = [ # Base classes - 'BaseCheck', - 'BaseCriteriaCheck', - 'BaseCorelatedCriteriaCheck', - 'BaseIterableCheck', - 'BaseValueCheck', - 'BaseCheckerFactory', + "BaseCheck", + "BaseCriteriaCheck", + "BaseCorelatedCriteriaCheck", + "BaseIterableCheck", + "BaseValueCheck", + "BaseCheckerFactory", # Factory classes - 'AttributeCheckerFactory', - 'ConceptCheckerFactory', - 'ConceptSetSelectionCheckerFactory', - 'CriteriaCheckerFactory', - 'RangeCheckerFactory', - 'TextCheckerFactory', + "AttributeCheckerFactory", + "ConceptCheckerFactory", + "ConceptSetSelectionCheckerFactory", + "CriteriaCheckerFactory", + "RangeCheckerFactory", + "TextCheckerFactory", # Utility classes - 'WarningReporter', - 'WarningReporterHelper', - 'Comparisons', + "WarningReporter", + "WarningReporterHelper", + "Comparisons", # Checker implementations - 'UnusedConceptsCheck', - 'ExitCriteriaCheck', - 'ExitCriteriaDaysOffsetCheck', - 'RangeCheck', - 'ConceptCheck', - 'ConceptSetSelectionCheck', - 'AttributeCheck', - 'TextCheck', - 'IncompleteRuleCheck', - 'InitialEventCheck', - 'NoExitCriteriaCheck', - 'ConceptSetCriteriaCheck', - 'DrugEraCheck', - 'OcurrenceCheck', - 'DuplicatesCriteriaCheck', - 'DuplicatesConceptSetCheck', - 'DrugDomainCheck', - 'EmptyConceptSetCheck', - 'EventsProgressionCheck', - 'TimeWindowCheck', - 'TimePatternCheck', - 'DomainTypeCheck', - 'CriteriaContradictionsCheck', - 'DeathTimeWindowCheck', + "UnusedConceptsCheck", + "ExitCriteriaCheck", + "ExitCriteriaDaysOffsetCheck", + "RangeCheck", + "ConceptCheck", + "ConceptSetSelectionCheck", + "AttributeCheck", + "TextCheck", + "IncompleteRuleCheck", + "InitialEventCheck", + "NoExitCriteriaCheck", + "ConceptSetCriteriaCheck", + "DrugEraCheck", + "OcurrenceCheck", + "DuplicatesCriteriaCheck", + "DuplicatesConceptSetCheck", + "DrugDomainCheck", + "EmptyConceptSetCheck", + "EventsProgressionCheck", + "TimeWindowCheck", + "TimePatternCheck", + "DomainTypeCheck", + "CriteriaContradictionsCheck", + "DeathTimeWindowCheck", ] diff --git a/circe/check/checkers/attribute_check.py b/circe/check/checkers/attribute_check.py index 9eddcb5f..e899cb8c 100644 --- a/circe/check/checkers/attribute_check.py +++ b/circe/check/checkers/attribute_check.py @@ -16,27 +16,28 @@ class AttributeCheck(BaseValueCheck): """Check for missing attributes in demographic criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.AttributeCheck """ - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _get_factory(self, reporter: WarningReporter, name: str) -> AttributeCheckerFactory: + + def _get_factory( + self, reporter: WarningReporter, name: str + ) -> AttributeCheckerFactory: """Get an attribute checker factory. - + Args: reporter: The warning reporter to use name: The name of the criteria group - + Returns: An AttributeCheckerFactory instance """ return AttributeCheckerFactory.get_factory(reporter, name) - diff --git a/circe/check/checkers/attribute_checker_factory.py b/circe/check/checkers/attribute_checker_factory.py index 145b187f..3c303cb7 100644 --- a/circe/check/checkers/attribute_checker_factory.py +++ b/circe/check/checkers/attribute_checker_factory.py @@ -18,75 +18,86 @@ from ...cohortdefinition.criteria import Criteria, DemographicCriteria except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.criteria import Criteria, DemographicCriteria class AttributeCheckerFactory(BaseCheckerFactory): """Factory for checking attributes in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.AttributeCheckerFactory """ - + WARNING_EMPTY_VALUE = "%s in the %s does not have attributes" - + def __init__(self, reporter: WarningReporter, group_name: str): """Initialize an attribute checker factory. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked """ super().__init__(reporter, group_name) - + @staticmethod - def get_factory(reporter: WarningReporter, group_name: str) -> 'AttributeCheckerFactory': + def get_factory( + reporter: WarningReporter, group_name: str + ) -> "AttributeCheckerFactory": """Get a factory instance. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked - + Returns: A new AttributeCheckerFactory instance """ return AttributeCheckerFactory(reporter, group_name) - - def _get_check_criteria(self, criteria: 'Criteria') -> Callable[['Criteria'], None]: + + def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], None]: """Get a checker function for criteria. - + Args: criteria: The criteria to get a checker for - + Returns: A function that checks the criteria (non-demographic criteria don't need attribute checks) """ return lambda c: None # Non-demographic criteria don't need attribute checks - - def _get_check_demographic(self, criteria: 'DemographicCriteria') -> Callable[['DemographicCriteria'], None]: + + def _get_check_demographic( + self, criteria: "DemographicCriteria" + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. - + Args: criteria: The demographic criteria to get a checker for - + Returns: A function that checks the criteria """ - def check(c: 'DemographicCriteria') -> None: + + def check(c: "DemographicCriteria") -> None: self._check_attribute( Constants.Criteria.DEMOGRAPHIC, c.age, c.gender, c.race, c.ethnicity, - c.occurrence_start_date if hasattr(c, 'occurrence_start_date') else None, - c.occurrence_end_date if hasattr(c, 'occurrence_end_date') else None + ( + c.occurrence_start_date + if hasattr(c, "occurrence_start_date") + else None + ), + c.occurrence_end_date if hasattr(c, "occurrence_end_date") else None, ) + return check - + def _check_attribute(self, criteria_name: str, *attributes: Any) -> None: """Check if any attributes are present. - + Args: criteria_name: The name of the criteria type *attributes: The attribute values to check @@ -94,4 +105,3 @@ def _check_attribute(self, criteria_name: str, *attributes: Any) -> None: has_value = any(attr is not None for attr in attributes) if not has_value: self._reporter(self.WARNING_EMPTY_VALUE, self._group_name, criteria_name) - diff --git a/circe/check/checkers/base_check.py b/circe/check/checkers/base_check.py index e3d75b44..4af38f73 100644 --- a/circe/check/checkers/base_check.py +++ b/circe/check/checkers/base_check.py @@ -20,80 +20,83 @@ from ...cohortdefinition.cohort import CohortExpression except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression class BaseCheck(Check): """Base class for all validation checks. - + Java equivalent: org.ohdsi.circe.check.checkers.BaseCheck - + This class provides common functionality for all checks, including severity management and warning reporting. """ - + INCLUSION_RULE = "inclusion rule " ADDITIONAL_RULE = "additional rule" INITIAL_EVENT = "initial event" - - def check(self, expression: 'CohortExpression') -> List[Warning]: + + def check(self, expression: "CohortExpression") -> List[Warning]: """Check a cohort expression and return warnings. - + This is the main entry point that sets up the warning reporter and calls the abstract check method. - + Args: expression: The cohort expression to validate - + Returns: A list of warnings found during validation """ warnings: List[Warning] = [] self._check(expression, self._define_reporter(warnings)) return warnings - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Internal check method to be implemented by subclasses. - + Args: expression: The cohort expression to validate reporter: The warning reporter to use for adding warnings """ raise NotImplementedError("Subclasses must implement _check") - + def _define_severity(self) -> WarningSeverity: """Define the severity level for warnings from this check. - + Returns: The default severity level (CRITICAL by default) """ return WarningSeverity.CRITICAL - + def _define_reporter(self, warnings: List[Warning]) -> WarningReporter: """Define the warning reporter for this check. - + Args: warnings: The list to which warnings will be added - + Returns: A WarningReporter that adds warnings to the list """ return self._get_reporter(self._define_severity(), warnings) - - def _get_reporter(self, severity: WarningSeverity, warnings: List[Warning]) -> WarningReporter: + + def _get_reporter( + self, severity: WarningSeverity, warnings: List[Warning] + ) -> WarningReporter: """Get a warning reporter for the given severity level. - + Args: severity: The severity level for warnings warnings: The list to which warnings will be added - + Returns: A WarningReporter that creates DefaultWarning instances """ + def reporter(template: str, *args: Any) -> None: message = template % args if args else template warnings.append(DefaultWarning(severity, message)) - - return reporter + return reporter diff --git a/circe/check/checkers/base_checker_factory.py b/circe/check/checkers/base_checker_factory.py index 8326b187..77ff0b59 100644 --- a/circe/check/checkers/base_checker_factory.py +++ b/circe/check/checkers/base_checker_factory.py @@ -17,74 +17,76 @@ from ...cohortdefinition.criteria import Criteria, DemographicCriteria except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.criteria import Criteria, DemographicCriteria class BaseCheckerFactory: """Base class for checker factories. - + Java equivalent: org.ohdsi.circe.check.checkers.BaseCheckerFactory - + This class provides the infrastructure for factories that create checker functions for validating criteria. """ - + def __init__(self, reporter: WarningReporter, group_name: str): """Initialize a checker factory. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked """ self._group_name = group_name self._reporter = reporter - + @property def group_name(self) -> str: """Get the group name.""" return self._group_name - + @property def reporter(self) -> WarningReporter: """Get the warning reporter.""" return self._reporter - - def _get_check_criteria(self, criteria: 'Criteria') -> Callable[['Criteria'], None]: + + def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], None]: """Get a checker function for a criteria (to be implemented by subclasses). - + Args: criteria: The criteria to get a checker for - + Returns: A function that checks the criteria """ raise NotImplementedError("Subclasses must implement _get_check_criteria") - - def _get_check_demographic(self, criteria: 'DemographicCriteria') -> Callable[['DemographicCriteria'], None]: + + def _get_check_demographic( + self, criteria: "DemographicCriteria" + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for a demographic criteria (to be implemented by subclasses). - + Args: criteria: The demographic criteria to get a checker for - + Returns: A function that checks the criteria """ raise NotImplementedError("Subclasses must implement _get_check_demographic") - + def check(self, criteria) -> None: """Check a criteria (supports both Criteria and DemographicCriteria). - + Args: criteria: The criteria to check (Criteria or DemographicCriteria) """ # Import here to avoid circular dependencies from ...cohortdefinition.criteria import Criteria, DemographicCriteria - + if isinstance(criteria, DemographicCriteria): checker = self._get_check_demographic(criteria) checker(criteria) elif isinstance(criteria, Criteria): checker = self._get_check_criteria(criteria) checker(criteria) - diff --git a/circe/check/checkers/base_corelated_criteria_check.py b/circe/check/checkers/base_corelated_criteria_check.py index c2048832..98f20b67 100644 --- a/circe/check/checkers/base_corelated_criteria_check.py +++ b/circe/check/checkers/base_corelated_criteria_check.py @@ -17,6 +17,7 @@ from ...cohortdefinition.criteria import Criteria, CorelatedCriteria except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import Criteria, CorelatedCriteria @@ -24,35 +25,44 @@ class BaseCorelatedCriteriaCheck(BaseIterableCheck): """Base class for checks that validate corelated criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.BaseCorelatedCriteriaCheck - + This class provides functionality to iterate over corelated criteria in inclusion rules. """ - - def _internal_check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _internal_check( + self, expression: "CohortExpression", reporter: WarningReporter + ) -> None: """Internal check that iterates over corelated criteria. - + Args: expression: The cohort expression to validate reporter: The warning reporter to use """ if expression.inclusion_rules: for inclusion_rule in expression.inclusion_rules: - if inclusion_rule.expression and inclusion_rule.expression.criteria_list: + if ( + inclusion_rule.expression + and inclusion_rule.expression.criteria_list + ): for criteria in inclusion_rule.expression.criteria_list: # Skip if criteria is still a dict (shouldn't happen after deserialization, but be defensive) if isinstance(criteria, dict): continue group_name = f"{self.INCLUSION_RULE}{inclusion_rule.name}" self._check_criteria(criteria, group_name, reporter) - if hasattr(criteria, 'criteria') and criteria.criteria: - self._check_criteria_group(criteria.criteria, group_name, reporter) - - def _check_criteria_group(self, criteria: 'Criteria', group_name: str, reporter: WarningReporter) -> None: + if hasattr(criteria, "criteria") and criteria.criteria: + self._check_criteria_group( + criteria.criteria, group_name, reporter + ) + + def _check_criteria_group( + self, criteria: "Criteria", group_name: str, reporter: WarningReporter + ) -> None: """Check correlated criteria groups. - + Args: criteria: The criteria to check group_name: The name of the group containing this criteria @@ -61,31 +71,45 @@ def _check_criteria_group(self, criteria: 'Criteria', group_name: str, reporter: # Skip if criteria is still a dict (not yet deserialized) if isinstance(criteria, dict): return - - if hasattr(criteria, 'correlated_criteria') and criteria.correlated_criteria: + + if hasattr(criteria, "correlated_criteria") and criteria.correlated_criteria: correlated = criteria.correlated_criteria - if hasattr(correlated, 'criteria_list') and correlated.criteria_list: + if hasattr(correlated, "criteria_list") and correlated.criteria_list: for corelated_criteria in correlated.criteria_list: # Skip dicts if isinstance(corelated_criteria, dict): continue self._check_criteria(corelated_criteria, group_name, reporter) - if hasattr(corelated_criteria, 'criteria') and corelated_criteria.criteria: - self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) - if hasattr(correlated, 'groups') and correlated.groups: + if ( + hasattr(corelated_criteria, "criteria") + and corelated_criteria.criteria + ): + self._check_criteria_group( + corelated_criteria.criteria, group_name, reporter + ) + if hasattr(correlated, "groups") and correlated.groups: for group in correlated.groups: - if hasattr(group, 'criteria_list') and group.criteria_list: + if hasattr(group, "criteria_list") and group.criteria_list: for corelated_criteria in group.criteria_list: # Skip dicts if isinstance(corelated_criteria, dict): continue - self._check_criteria(corelated_criteria, group_name, reporter) - if hasattr(corelated_criteria, 'criteria') and corelated_criteria.criteria: - self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) - - def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, reporter: WarningReporter) -> None: + self._check_criteria( + corelated_criteria, group_name, reporter + ) + if ( + hasattr(corelated_criteria, "criteria") + and corelated_criteria.criteria + ): + self._check_criteria_group( + corelated_criteria.criteria, group_name, reporter + ) + + def _check_criteria( + self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + ) -> None: """Check a single corelated criteria (to be implemented by subclasses). - + Args: criteria: The corelated criteria to check group_name: The name of the group containing this criteria @@ -95,6 +119,5 @@ def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, report # This can happen when Pydantic doesn't fully deserialize polymorphic types if isinstance(criteria, dict): return - - raise NotImplementedError("Subclasses must implement _check_criteria") + raise NotImplementedError("Subclasses must implement _check_criteria") diff --git a/circe/check/checkers/base_criteria_check.py b/circe/check/checkers/base_criteria_check.py index 39fabb5f..7e84d09d 100644 --- a/circe/check/checkers/base_criteria_check.py +++ b/circe/check/checkers/base_criteria_check.py @@ -18,6 +18,7 @@ from ...cohortdefinition.criteria import Criteria, CorelatedCriteria except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import Criteria, CorelatedCriteria @@ -25,16 +26,18 @@ class BaseCriteriaCheck(BaseIterableCheck): """Base class for checks that validate criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.BaseCriteriaCheck - + This class provides functionality to iterate over criteria in primary criteria and inclusion rules. """ - - def _internal_check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _internal_check( + self, expression: "CohortExpression", reporter: WarningReporter + ) -> None: """Internal check that iterates over criteria. - + Args: expression: The cohort expression to validate reporter: The warning reporter to use @@ -42,47 +45,61 @@ def _internal_check(self, expression: 'CohortExpression', reporter: WarningRepor if expression.primary_criteria and expression.primary_criteria.criteria_list: for criteria in expression.primary_criteria.criteria_list: self._check_criteria_group(criteria, self.INITIAL_EVENT, reporter) - + if expression.inclusion_rules: for inclusion_rule in expression.inclusion_rules: - if inclusion_rule.expression and inclusion_rule.expression.criteria_list: + if ( + inclusion_rule.expression + and inclusion_rule.expression.criteria_list + ): for criteria in inclusion_rule.expression.criteria_list: group_name = f"{self.INCLUSION_RULE}{inclusion_rule.name}" self._check_criteria_group( - criteria.criteria if hasattr(criteria, 'criteria') else criteria, + ( + criteria.criteria + if hasattr(criteria, "criteria") + else criteria + ), group_name, - reporter + reporter, ) - - def _check_criteria_group(self, criteria: 'Criteria', group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria_group( + self, criteria: "Criteria", group_name: str, reporter: WarningReporter + ) -> None: """Check a criteria and its correlated criteria. - + Args: criteria: The criteria to check group_name: The name of the group containing this criteria reporter: The warning reporter to use """ self._check_criteria(criteria, group_name, reporter) - + # Check correlated criteria if present - if hasattr(criteria, 'correlated_criteria') and criteria.correlated_criteria: + if hasattr(criteria, "correlated_criteria") and criteria.correlated_criteria: correlated = criteria.correlated_criteria - if hasattr(correlated, 'criteria_list') and correlated.criteria_list: + if hasattr(correlated, "criteria_list") and correlated.criteria_list: for corelated_criteria in correlated.criteria_list: - self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) - if hasattr(correlated, 'groups') and correlated.groups: + self._check_criteria_group( + corelated_criteria.criteria, group_name, reporter + ) + if hasattr(correlated, "groups") and correlated.groups: for group in correlated.groups: - if hasattr(group, 'criteria_list') and group.criteria_list: + if hasattr(group, "criteria_list") and group.criteria_list: for corelated_criteria in group.criteria_list: - self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) - - def _check_criteria(self, criteria: 'Criteria', group_name: str, reporter: WarningReporter) -> None: + self._check_criteria_group( + corelated_criteria.criteria, group_name, reporter + ) + + def _check_criteria( + self, criteria: "Criteria", group_name: str, reporter: WarningReporter + ) -> None: """Check a single criteria (to be implemented by subclasses). - + Args: criteria: The criteria to check group_name: The name of the group containing this criteria reporter: The warning reporter to use """ raise NotImplementedError("Subclasses must implement _check_criteria") - diff --git a/circe/check/checkers/base_iterable_check.py b/circe/check/checkers/base_iterable_check.py index fb7dbe47..1c44b947 100644 --- a/circe/check/checkers/base_iterable_check.py +++ b/circe/check/checkers/base_iterable_check.py @@ -17,22 +17,23 @@ from ...cohortdefinition.cohort import CohortExpression except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression class BaseIterableCheck(BaseCheck): """Base class for checks that iterate over expression elements. - + Java equivalent: org.ohdsi.circe.check.checkers.BaseIterableCheck - + This class provides hooks for before/after check processing and delegates to an internal check method. """ - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check implementation that calls hooks and internal check. - + Args: expression: The cohort expression to validate reporter: The warning reporter to use @@ -40,31 +41,36 @@ def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> N self._before_check(reporter, expression) self._internal_check(expression, reporter) self._after_check(reporter, expression) - - def _before_check(self, reporter: WarningReporter, expression: 'CohortExpression') -> None: + + def _before_check( + self, reporter: WarningReporter, expression: "CohortExpression" + ) -> None: """Hook called before the internal check runs. - + Args: reporter: The warning reporter expression: The cohort expression being validated """ pass - - def _after_check(self, reporter: WarningReporter, expression: 'CohortExpression') -> None: + + def _after_check( + self, reporter: WarningReporter, expression: "CohortExpression" + ) -> None: """Hook called after the internal check runs. - + Args: reporter: The warning reporter expression: The cohort expression being validated """ pass - - def _internal_check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _internal_check( + self, expression: "CohortExpression", reporter: WarningReporter + ) -> None: """Internal check method to be implemented by subclasses. - + Args: expression: The cohort expression to validate reporter: The warning reporter to use """ raise NotImplementedError("Subclasses must implement _internal_check") - diff --git a/circe/check/checkers/base_value_check.py b/circe/check/checkers/base_value_check.py index 8c0caf92..520b6fd0 100644 --- a/circe/check/checkers/base_value_check.py +++ b/circe/check/checkers/base_value_check.py @@ -16,32 +16,45 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import Criteria, CorelatedCriteria, DemographicCriteria, PrimaryCriteria, CriteriaGroup + from ...cohortdefinition.criteria import ( + Criteria, + CorelatedCriteria, + DemographicCriteria, + PrimaryCriteria, + CriteriaGroup, + ) except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import Criteria, CorelatedCriteria, DemographicCriteria, PrimaryCriteria, CriteriaGroup + from ...cohortdefinition.criteria import ( + Criteria, + CorelatedCriteria, + DemographicCriteria, + PrimaryCriteria, + CriteriaGroup, + ) class BaseValueCheck(BaseCheck): """Base class for checks that validate values in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.BaseValueCheck - + This class provides functionality to iterate over criteria in primary criteria, additional criteria, inclusion rules, and censoring criteria. """ - + INCLUSION_CRITERIA = "Inclusion criteria " PRIMARY_CRITERIA = "Primary criteria" ADDITIONAL_CRITERIA = "Additional criteria" CENSORING_CRITERIA = "Censoring events" - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check implementation that validates all criteria types. - + Args: expression: The cohort expression to validate reporter: The warning reporter to use @@ -50,10 +63,12 @@ def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> N self._check_additional_criteria(expression.additional_criteria, reporter) self._check_inclusion_rules(expression, reporter) self._check_censoring_criteria(expression, reporter) - - def _check_primary_criteria(self, primary_criteria: Optional['PrimaryCriteria'], reporter: WarningReporter) -> None: + + def _check_primary_criteria( + self, primary_criteria: Optional["PrimaryCriteria"], reporter: WarningReporter + ) -> None: """Check primary criteria. - + Args: primary_criteria: The primary criteria to check reporter: The warning reporter to use @@ -61,28 +76,38 @@ def _check_primary_criteria(self, primary_criteria: Optional['PrimaryCriteria'], if primary_criteria and primary_criteria.criteria_list: for criteria in primary_criteria.criteria_list: self._check_criteria(criteria, reporter, self.PRIMARY_CRITERIA) - - def _check_additional_criteria(self, criteria_group: Optional['CriteriaGroup'], reporter: WarningReporter) -> None: + + def _check_additional_criteria( + self, criteria_group: Optional["CriteriaGroup"], reporter: WarningReporter + ) -> None: """Check additional criteria. - + Args: criteria_group: The additional criteria group to check reporter: The warning reporter to use """ if criteria_group: - if hasattr(criteria_group, 'criteria_list') and criteria_group.criteria_list: + if ( + hasattr(criteria_group, "criteria_list") + and criteria_group.criteria_list + ): for criteria in criteria_group.criteria_list: self._check_criteria(criteria, reporter, self.ADDITIONAL_CRITERIA) - if hasattr(criteria_group, 'demographic_criteria_list') and criteria_group.demographic_criteria_list: + if ( + hasattr(criteria_group, "demographic_criteria_list") + and criteria_group.demographic_criteria_list + ): for criteria in criteria_group.demographic_criteria_list: self._check_criteria(criteria, reporter, self.ADDITIONAL_CRITERIA) - if hasattr(criteria_group, 'groups') and criteria_group.groups: + if hasattr(criteria_group, "groups") and criteria_group.groups: for group in criteria_group.groups: self._check_additional_criteria(group, reporter) - - def _check_censoring_criteria(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check_censoring_criteria( + self, expression: "CohortExpression", reporter: WarningReporter + ) -> None: """Check censoring criteria. - + Args: expression: The cohort expression containing censoring criteria reporter: The warning reporter to use @@ -90,10 +115,12 @@ def _check_censoring_criteria(self, expression: 'CohortExpression', reporter: Wa if expression.censoring_criteria: for criteria in expression.censoring_criteria: self._check_criteria(criteria, reporter, self.CENSORING_CRITERIA) - - def _check_inclusion_rules(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check_inclusion_rules( + self, expression: "CohortExpression", reporter: WarningReporter + ) -> None: """Check inclusion rules. - + Args: expression: The cohort expression containing inclusion rules reporter: The warning reporter to use @@ -102,39 +129,52 @@ def _check_inclusion_rules(self, expression: 'CohortExpression', reporter: Warni for rule in expression.inclusion_rules: if rule.expression: rule_name = f'{self.INCLUSION_CRITERIA}"{rule.name}"' - if hasattr(rule.expression, 'criteria_list') and rule.expression.criteria_list: + if ( + hasattr(rule.expression, "criteria_list") + and rule.expression.criteria_list + ): for criteria in rule.expression.criteria_list: self._check_criteria(criteria, reporter, rule_name) - if hasattr(rule.expression, 'demographic_criteria_list') and rule.expression.demographic_criteria_list: + if ( + hasattr(rule.expression, "demographic_criteria_list") + and rule.expression.demographic_criteria_list + ): for criteria in rule.expression.demographic_criteria_list: self._check_criteria(criteria, reporter, rule_name) - + def _check_criteria(self, criteria, reporter: WarningReporter, name: str) -> None: """Check a criteria (supports multiple types via runtime checking). - + Args: criteria: The criteria to check (Criteria, CorelatedCriteria, DemographicCriteria, or CriteriaGroup) reporter: The warning reporter to use name: The name of the criteria group """ # Import here to avoid circular dependencies - from ...cohortdefinition.criteria import Criteria, CorelatedCriteria, DemographicCriteria + from ...cohortdefinition.criteria import ( + Criteria, + CorelatedCriteria, + DemographicCriteria, + ) from ...cohortdefinition.criteria import CriteriaGroup - + # Check CriteriaGroup if isinstance(criteria, CriteriaGroup): - if hasattr(criteria, 'demographic_criteria_list') and criteria.demographic_criteria_list: + if ( + hasattr(criteria, "demographic_criteria_list") + and criteria.demographic_criteria_list + ): for dem_criteria in criteria.demographic_criteria_list: self._check_criteria(dem_criteria, reporter, name) - if hasattr(criteria, 'criteria_list') and criteria.criteria_list: + if hasattr(criteria, "criteria_list") and criteria.criteria_list: for corelated_criteria in criteria.criteria_list: self._check_criteria(corelated_criteria, reporter, name) - if hasattr(criteria, 'groups') and criteria.groups: + if hasattr(criteria, "groups") and criteria.groups: for group in criteria.groups: self._check_criteria(group, reporter, name) # Check CorelatedCriteria elif isinstance(criteria, CorelatedCriteria): - if hasattr(criteria, 'criteria') and criteria.criteria: + if hasattr(criteria, "criteria") and criteria.criteria: self._check_criteria(criteria.criteria, reporter, name) # Check DemographicCriteria elif isinstance(criteria, DemographicCriteria): @@ -142,20 +182,22 @@ def _check_criteria(self, criteria, reporter: WarningReporter, name: str) -> Non factory.check(criteria) # Check Criteria (must be last as it's the base type) elif isinstance(criteria, Criteria): - if hasattr(criteria, 'correlated_criteria') and criteria.correlated_criteria: + if ( + hasattr(criteria, "correlated_criteria") + and criteria.correlated_criteria + ): self._check_criteria(criteria.correlated_criteria, reporter, name) # Don't call factory.check for base Criteria - only specific criteria types have ranges to check # The factory's check method is for CohortExpression, not Criteria - + def _get_factory(self, reporter: WarningReporter, name: str): """Get a checker factory (to be implemented by subclasses). - + Args: reporter: The warning reporter to use name: The name of the criteria group - + Returns: A checker factory instance """ raise NotImplementedError("Subclasses must implement _get_factory") - diff --git a/circe/check/checkers/comparisons.py b/circe/check/checkers/comparisons.py index df89d894..1d8bc002 100644 --- a/circe/check/checkers/comparisons.py +++ b/circe/check/checkers/comparisons.py @@ -27,29 +27,29 @@ class Comparisons: """Utility class for comparing values in validation checks. - + Java equivalent: org.ohdsi.circe.check.checkers.Comparisons - + This class provides static methods for comparing ranges, dates, and other values. """ - + @staticmethod def start_is_greater_than_end(range_val) -> bool: """Check if start value is greater than end value (supports NumericRange, DateRange, and Period). - + Args: range_val: The range or period to check (NumericRange, DateRange, or Period) - + Returns: True if start > end, False otherwise """ if range_val is None: return False - + # Import here to avoid circular dependencies from ...cohortdefinition.core import NumericRange, DateRange, Period - + if isinstance(range_val, NumericRange): if range_val.value is None or range_val.extent is None: return False @@ -73,14 +73,14 @@ def start_is_greater_than_end(range_val) -> bool: except (ValueError, TypeError): return False return False - + @staticmethod def is_date_valid(date: Optional[str]) -> bool: """Check if a date string is valid. - + Args: date: The date string to validate - + Returns: True if the date is valid, False otherwise """ @@ -91,150 +91,172 @@ def is_date_valid(date: Optional[str]) -> bool: return True except (ValueError, TypeError): return False - + @staticmethod def is_start_negative(range_val: NumericRange) -> bool: """Check if the start value is negative. - + Args: range_val: The numeric range to check - + Returns: True if start value is negative, False otherwise """ if range_val is None or range_val.value is None: return False return int(range_val.value) < 0 - + @staticmethod - def compare_to(filter_val: 'ObservationFilter', window: 'Window') -> int: + def compare_to(filter_val: "ObservationFilter", window: "Window") -> int: """Compare an observation filter to a window. - + Args: filter_val: The observation filter window: The window to compare against - + Returns: An integer representing the comparison result """ if filter_val is None or window is None: return 0 - + range1 = filter_val.post_days + filter_val.prior_days range2_start = 0 range2_end = 0 - + if window.start and window.start.days is not None: range2_start = window.start.coeff * window.start.days - + if window.end and window.end.days is not None: range2_end = window.end.coeff * window.end.days - + return range1 - (range2_end - range2_start) - + @staticmethod - def is_before(window: 'Window') -> bool: + def is_before(window: "Window") -> bool: """Check if a window is before the reference point. - + Args: window: The window to check - + Returns: True if the window is before, False otherwise """ if window is None: return False - return Comparisons.is_before_endpoint(window.start) and not Comparisons.is_after_endpoint(window.end) - + return Comparisons.is_before_endpoint( + window.start + ) and not Comparisons.is_after_endpoint(window.end) + @staticmethod - def is_before_endpoint(endpoint: Optional['Window.Endpoint']) -> bool: + def is_before_endpoint(endpoint: Optional["Window.Endpoint"]) -> bool: """Check if an endpoint is before the reference point. - + Args: endpoint: The endpoint to check - + Returns: True if before, False otherwise """ if endpoint is None: return False return endpoint.coeff < 0 - + @staticmethod - def is_after_endpoint(endpoint: Optional['Window.Endpoint']) -> bool: + def is_after_endpoint(endpoint: Optional["Window.Endpoint"]) -> bool: """Check if an endpoint is after the reference point. - + Args: endpoint: The endpoint to check - + Returns: True if after, False otherwise """ if endpoint is None: return False return endpoint.coeff > 0 - + @staticmethod - def compare_concept_set(source: 'ConceptSet'): + def compare_concept_set(source: "ConceptSet"): """Create a predicate function to compare concept sets. - + Args: source: The source concept set to compare against - + Returns: A function that takes a ConceptSet and returns True if it matches """ - def compare_func(concept_set: 'ConceptSet') -> bool: + + def compare_func(concept_set: "ConceptSet") -> bool: if concept_set.expression == source.expression: return True if concept_set.expression and source.expression: if len(concept_set.expression.items) == len(source.expression.items): source_concepts = [item.concept for item in source.expression.items] return all( - any(Comparisons.compare_concept(concept)(source_concept) - for source_concept in source_concepts) - for concept in [item.concept for item in concept_set.expression.items] + any( + Comparisons.compare_concept(concept)(source_concept) + for source_concept in source_concepts + ) + for concept in [ + item.concept for item in concept_set.expression.items + ] ) return False + return compare_func - + @staticmethod - def compare_concept(source: 'Concept'): + def compare_concept(source: "Concept"): """Create a predicate function to compare concepts. - + Args: source: The source concept to compare against - + Returns: A function that takes a Concept and returns True if it matches """ - def compare_func(concept: 'Concept') -> bool: - return (concept.concept_code == source.concept_code and - concept.domain_id == source.domain_id and - concept.vocabulary_id == source.vocabulary_id) + + def compare_func(concept: "Concept") -> bool: + return ( + concept.concept_code == source.concept_code + and concept.domain_id == source.domain_id + and concept.vocabulary_id == source.vocabulary_id + ) + return compare_func - + @staticmethod - def compare_criteria(c1: 'Criteria', c2: 'Criteria') -> bool: + def compare_criteria(c1: "Criteria", c2: "Criteria") -> bool: """Compare two criteria to see if they are the same type and have the same codeset ID. - + Args: c1: The first criteria c2: The second criteria - + Returns: True if the criteria are the same type and have the same codeset ID """ if type(c1) != type(c2): return False - + # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, ) - + if isinstance(c1, ConditionEra): return c1.codeset_id == c2.codeset_id elif isinstance(c1, ConditionOccurrence): @@ -261,6 +283,5 @@ def compare_criteria(c1: 'Criteria', c2: 'Criteria') -> bool: return c1.codeset_id == c2.codeset_id elif isinstance(c1, VisitDetail): return c1.codeset_id == c2.codeset_id - - return False + return False diff --git a/circe/check/checkers/concept_check.py b/circe/check/checkers/concept_check.py index 510c9e2c..a19b60ff 100644 --- a/circe/check/checkers/concept_check.py +++ b/circe/check/checkers/concept_check.py @@ -15,19 +15,20 @@ class ConceptCheck(BaseValueCheck): """Check for empty concept arrays in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.ConceptCheck """ - - def _get_factory(self, reporter: WarningReporter, name: str) -> ConceptCheckerFactory: + + def _get_factory( + self, reporter: WarningReporter, name: str + ) -> ConceptCheckerFactory: """Get a concept checker factory. - + Args: reporter: The warning reporter to use name: The name of the criteria group - + Returns: A ConceptCheckerFactory instance """ return ConceptCheckerFactory.get_factory(reporter, name) - diff --git a/circe/check/checkers/concept_checker_factory.py b/circe/check/checkers/concept_checker_factory.py index 75a16514..0014b4e6 100644 --- a/circe/check/checkers/concept_checker_factory.py +++ b/circe/check/checkers/concept_checker_factory.py @@ -17,152 +17,373 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.criteria import ( - Criteria, DemographicCriteria, ConditionEra, ConditionOccurrence, - Death, DeviceExposure, DoseEra, DrugEra, DrugExposure, Measurement, - Observation, ObservationPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, PayerPlanPeriod + Criteria, + DemographicCriteria, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + PayerPlanPeriod, ) from ...vocabulary.concept import Concept except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.criteria import ( - Criteria, DemographicCriteria, ConditionEra, ConditionOccurrence, - Death, DeviceExposure, DoseEra, DrugEra, DrugExposure, Measurement, - Observation, ObservationPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, PayerPlanPeriod + Criteria, + DemographicCriteria, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + PayerPlanPeriod, ) from ...vocabulary.concept import Concept class ConceptCheckerFactory(BaseCheckerFactory): """Factory for checking concept arrays in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.ConceptCheckerFactory """ - + WARNING_EMPTY_VALUE = "%s in the %s has empty %s value" - + def __init__(self, reporter: WarningReporter, group_name: str): """Initialize a concept checker factory. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked """ super().__init__(reporter, group_name) - + @staticmethod - def get_factory(reporter: WarningReporter, group_name: str) -> 'ConceptCheckerFactory': + def get_factory( + reporter: WarningReporter, group_name: str + ) -> "ConceptCheckerFactory": """Get a factory instance. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked - + Returns: A new ConceptCheckerFactory instance """ return ConceptCheckerFactory(reporter, group_name) - - def _get_check_criteria(self, criteria: 'Criteria') -> Callable[['Criteria'], None]: + + def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], None]: """Get a checker function for criteria. - + Args: criteria: The criteria to get a checker for - + Returns: A function that checks the criteria """ # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ObservationPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, PayerPlanPeriod + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + PayerPlanPeriod, ) - - def check_condition_era(c: 'ConditionEra') -> None: - self._check_concept(c.gender, Constants.Criteria.CONDITION_ERA, Constants.Attributes.GENDER_ATTR) - - def check_condition_occurrence(c: 'ConditionOccurrence') -> None: - self._check_concept(c.condition_type, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.CONDITION_TYPE_ATTR) - self._check_concept(c.gender, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.GENDER_ATTR) - self._check_concept(c.provider_specialty, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.PROVIDER_SPECIALITY_ATTR) - self._check_concept(c.visit_type, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.VISIT_TYPE_ATTR) - - def check_death(c: 'Death') -> None: - self._check_concept(c.death_type, Constants.Criteria.DEATH, Constants.Attributes.DEATH_TYPE_ATTR) - self._check_concept(c.gender, Constants.Criteria.DEATH, Constants.Attributes.GENDER_ATTR) - - def check_device_exposure(c: 'DeviceExposure') -> None: - self._check_concept(c.device_type, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.DEVICE_TYPE_ATTR) - self._check_concept(c.gender, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.GENDER_ATTR) - self._check_concept(c.provider_specialty, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.PROVIDER_SPECIALITY_ATTR) - self._check_concept(c.visit_type, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.VISIT_TYPE_ATTR) - - def check_dose_era(c: 'DoseEra') -> None: - self._check_concept(c.unit, Constants.Criteria.DOSE_ERA, Constants.Attributes.UNIT_ATTR) - self._check_concept(c.gender, Constants.Criteria.DOSE_ERA, Constants.Attributes.GENDER_ATTR) - - def check_drug_era(c: 'DrugEra') -> None: - self._check_concept(c.gender, Constants.Criteria.DRUG_ERA, Constants.Attributes.GENDER_ATTR) - - def check_drug_exposure(c: 'DrugExposure') -> None: - self._check_concept(c.drug_type, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.DRUG_TYPE_ATTR) - self._check_concept(c.route_concept, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.ROUTE_CONCEPT_ATTR) - self._check_concept(c.dose_unit, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.DOSE_UNIT_ATTR) - self._check_concept(c.gender, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.GENDER_ATTR) - self._check_concept(c.provider_specialty, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.PROVIDER_SPECIALITY_ATTR) - self._check_concept(c.visit_type, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.VISIT_TYPE_ATTR) - - def check_measurement(c: 'Measurement') -> None: - self._check_concept(c.measurement_type, Constants.Criteria.MEASUREMENT, Constants.Attributes.MEASUREMENT_TYPE_ATTR) - self._check_concept(c.operator, Constants.Criteria.MEASUREMENT, Constants.Attributes.OPERATOR_ATTR) - self._check_concept(c.value_as_concept, Constants.Criteria.MEASUREMENT, Constants.Attributes.VALUE_AS_CONCEPT_ATTR) - self._check_concept(c.unit, Constants.Criteria.MEASUREMENT, Constants.Attributes.UNIT_ATTR) - self._check_concept(c.gender, Constants.Criteria.MEASUREMENT, Constants.Attributes.GENDER_ATTR) - self._check_concept(c.provider_specialty, Constants.Criteria.MEASUREMENT, Constants.Attributes.PROVIDER_SPECIALITY_ATTR) - self._check_concept(c.visit_type, Constants.Criteria.MEASUREMENT, Constants.Attributes.VISIT_TYPE_ATTR) - - def check_observation(c: 'Observation') -> None: - self._check_concept(c.observation_type, Constants.Criteria.OBSERVATION, Constants.Attributes.OBSERVATION_TYPE_ATTR) - self._check_concept(c.value_as_concept, Constants.Criteria.OBSERVATION, Constants.Attributes.VALUE_AS_CONCEPT_ATTR) - self._check_concept(c.qualifier, Constants.Criteria.OBSERVATION, Constants.Attributes.QUALIFIER_ATTR) - self._check_concept(c.unit, Constants.Criteria.OBSERVATION, Constants.Attributes.UNIT_ATTR) - self._check_concept(c.gender, Constants.Criteria.OBSERVATION, Constants.Attributes.GENDER_ATTR) - self._check_concept(c.provider_specialty, Constants.Criteria.OBSERVATION, Constants.Attributes.PROVIDER_SPECIALITY_ATTR) - self._check_concept(c.visit_type, Constants.Criteria.OBSERVATION, Constants.Attributes.VISIT_TYPE_ATTR) - - def check_observation_period(c: 'ObservationPeriod') -> None: - self._check_concept(c.period_type, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.PERIOD_TYPE_ATTR) - - def check_procedure_occurrence(c: 'ProcedureOccurrence') -> None: - self._check_concept(c.procedure_type, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.PROCEDURE_TYPE_ATTR) - self._check_concept(c.modifier, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.MODIFIER_ATTR) - self._check_concept(c.gender, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.GENDER_ATTR) - self._check_concept(c.provider_specialty, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.PROVIDER_SPECIALITY_ATTR) - self._check_concept(c.visit_type, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.VISIT_TYPE_ATTR) - - def check_specimen(c: 'Specimen') -> None: - self._check_concept(c.specimen_type, Constants.Criteria.SPECIMEN, Constants.Attributes.SPECIMEN_TYPE_ATTR) - self._check_concept(c.unit, Constants.Criteria.SPECIMEN, Constants.Attributes.UNIT_ATTR) - self._check_concept(c.anatomic_site, Constants.Criteria.SPECIMEN, Constants.Attributes.ANATOMIC_SITE_ATTR) - self._check_concept(c.disease_status, Constants.Criteria.SPECIMEN, Constants.Attributes.DISEASE_STATUS_ATTR) - self._check_concept(c.gender, Constants.Criteria.SPECIMEN, Constants.Attributes.GENDER_ATTR) - - def check_visit_occurrence(c: 'VisitOccurrence') -> None: - self._check_concept(c.visit_type, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.VISIT_TYPE_ATTR) - self._check_concept(c.gender, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.GENDER_ATTR) - self._check_concept(c.provider_specialty, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.PROVIDER_SPECIALITY_ATTR) - self._check_concept(c.place_of_service, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.PLACE_OF_SERVICE_ATTR) - - def check_payer_plan_period(c: 'PayerPlanPeriod') -> None: - self._check_concept(c.gender, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.GENDER_ATTR) - - def default_check(c: 'Criteria') -> None: + + def check_condition_era(c: "ConditionEra") -> None: + self._check_concept( + c.gender, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.GENDER_ATTR, + ) + + def check_condition_occurrence(c: "ConditionOccurrence") -> None: + self._check_concept( + c.condition_type, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.CONDITION_TYPE_ATTR, + ) + self._check_concept( + c.gender, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.GENDER_ATTR, + ) + self._check_concept( + c.provider_specialty, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ) + self._check_concept( + c.visit_type, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.VISIT_TYPE_ATTR, + ) + + def check_death(c: "Death") -> None: + self._check_concept( + c.death_type, + Constants.Criteria.DEATH, + Constants.Attributes.DEATH_TYPE_ATTR, + ) + self._check_concept( + c.gender, Constants.Criteria.DEATH, Constants.Attributes.GENDER_ATTR + ) + + def check_device_exposure(c: "DeviceExposure") -> None: + self._check_concept( + c.device_type, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.DEVICE_TYPE_ATTR, + ) + self._check_concept( + c.gender, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.GENDER_ATTR, + ) + self._check_concept( + c.provider_specialty, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ) + self._check_concept( + c.visit_type, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.VISIT_TYPE_ATTR, + ) + + def check_dose_era(c: "DoseEra") -> None: + self._check_concept( + c.unit, Constants.Criteria.DOSE_ERA, Constants.Attributes.UNIT_ATTR + ) + self._check_concept( + c.gender, Constants.Criteria.DOSE_ERA, Constants.Attributes.GENDER_ATTR + ) + + def check_drug_era(c: "DrugEra") -> None: + self._check_concept( + c.gender, Constants.Criteria.DRUG_ERA, Constants.Attributes.GENDER_ATTR + ) + + def check_drug_exposure(c: "DrugExposure") -> None: + self._check_concept( + c.drug_type, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.DRUG_TYPE_ATTR, + ) + self._check_concept( + c.route_concept, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.ROUTE_CONCEPT_ATTR, + ) + self._check_concept( + c.dose_unit, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.DOSE_UNIT_ATTR, + ) + self._check_concept( + c.gender, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.GENDER_ATTR, + ) + self._check_concept( + c.provider_specialty, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ) + self._check_concept( + c.visit_type, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.VISIT_TYPE_ATTR, + ) + + def check_measurement(c: "Measurement") -> None: + self._check_concept( + c.measurement_type, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.MEASUREMENT_TYPE_ATTR, + ) + self._check_concept( + c.operator, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.OPERATOR_ATTR, + ) + self._check_concept( + c.value_as_concept, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.VALUE_AS_CONCEPT_ATTR, + ) + self._check_concept( + c.unit, Constants.Criteria.MEASUREMENT, Constants.Attributes.UNIT_ATTR + ) + self._check_concept( + c.gender, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.GENDER_ATTR, + ) + self._check_concept( + c.provider_specialty, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ) + self._check_concept( + c.visit_type, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.VISIT_TYPE_ATTR, + ) + + def check_observation(c: "Observation") -> None: + self._check_concept( + c.observation_type, + Constants.Criteria.OBSERVATION, + Constants.Attributes.OBSERVATION_TYPE_ATTR, + ) + self._check_concept( + c.value_as_concept, + Constants.Criteria.OBSERVATION, + Constants.Attributes.VALUE_AS_CONCEPT_ATTR, + ) + self._check_concept( + c.qualifier, + Constants.Criteria.OBSERVATION, + Constants.Attributes.QUALIFIER_ATTR, + ) + self._check_concept( + c.unit, Constants.Criteria.OBSERVATION, Constants.Attributes.UNIT_ATTR + ) + self._check_concept( + c.gender, + Constants.Criteria.OBSERVATION, + Constants.Attributes.GENDER_ATTR, + ) + self._check_concept( + c.provider_specialty, + Constants.Criteria.OBSERVATION, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ) + self._check_concept( + c.visit_type, + Constants.Criteria.OBSERVATION, + Constants.Attributes.VISIT_TYPE_ATTR, + ) + + def check_observation_period(c: "ObservationPeriod") -> None: + self._check_concept( + c.period_type, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.PERIOD_TYPE_ATTR, + ) + + def check_procedure_occurrence(c: "ProcedureOccurrence") -> None: + self._check_concept( + c.procedure_type, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.PROCEDURE_TYPE_ATTR, + ) + self._check_concept( + c.modifier, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.MODIFIER_ATTR, + ) + self._check_concept( + c.gender, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.GENDER_ATTR, + ) + self._check_concept( + c.provider_specialty, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ) + self._check_concept( + c.visit_type, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.VISIT_TYPE_ATTR, + ) + + def check_specimen(c: "Specimen") -> None: + self._check_concept( + c.specimen_type, + Constants.Criteria.SPECIMEN, + Constants.Attributes.SPECIMEN_TYPE_ATTR, + ) + self._check_concept( + c.unit, Constants.Criteria.SPECIMEN, Constants.Attributes.UNIT_ATTR + ) + self._check_concept( + c.anatomic_site, + Constants.Criteria.SPECIMEN, + Constants.Attributes.ANATOMIC_SITE_ATTR, + ) + self._check_concept( + c.disease_status, + Constants.Criteria.SPECIMEN, + Constants.Attributes.DISEASE_STATUS_ATTR, + ) + self._check_concept( + c.gender, Constants.Criteria.SPECIMEN, Constants.Attributes.GENDER_ATTR + ) + + def check_visit_occurrence(c: "VisitOccurrence") -> None: + self._check_concept( + c.visit_type, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.VISIT_TYPE_ATTR, + ) + self._check_concept( + c.gender, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.GENDER_ATTR, + ) + self._check_concept( + c.provider_specialty, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ) + self._check_concept( + c.place_of_service, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.PLACE_OF_SERVICE_ATTR, + ) + + def check_payer_plan_period(c: "PayerPlanPeriod") -> None: + self._check_concept( + c.gender, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.GENDER_ATTR, + ) + + def default_check(c: "Criteria") -> None: pass # No concept checks for this criteria type - + # Use isinstance checks to route to appropriate checker if isinstance(criteria, ConditionEra): return check_condition_era @@ -194,34 +415,50 @@ def default_check(c: 'Criteria') -> None: return check_payer_plan_period else: return default_check - - def _get_check_demographic(self, criteria: 'DemographicCriteria') -> Callable[['DemographicCriteria'], None]: + + def _get_check_demographic( + self, criteria: "DemographicCriteria" + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. - + Args: criteria: The demographic criteria to get a checker for - + Returns: A function that checks the criteria """ - def check(c: 'DemographicCriteria') -> None: - self._check_concept(c.ethnicity, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.ETHNICITY_ATTR) - self._check_concept(c.gender, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.GENDER_ATTR) - self._check_concept(c.race, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.RACE_ATTR) + + def check(c: "DemographicCriteria") -> None: + self._check_concept( + c.ethnicity, + Constants.Criteria.DEMOGRAPHIC, + Constants.Attributes.ETHNICITY_ATTR, + ) + self._check_concept( + c.gender, + Constants.Criteria.DEMOGRAPHIC, + Constants.Attributes.GENDER_ATTR, + ) + self._check_concept( + c.race, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.RACE_ATTR + ) + return check - - def _check_concept(self, concepts: Optional[List['Concept']], criteria_name: str, attribute: str) -> None: + + def _check_concept( + self, concepts: Optional[List["Concept"]], criteria_name: str, attribute: str + ) -> None: """Check if a concept array is empty. - + Args: concepts: The concept array to check criteria_name: The name of the criteria type attribute: The name of the attribute """ + def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - - Operations.match(concepts)\ - .when(lambda c: c is not None and len(c) == 0)\ - .then(lambda c: warning(self.WARNING_EMPTY_VALUE)) + Operations.match(concepts).when(lambda c: c is not None and len(c) == 0).then( + lambda c: warning(self.WARNING_EMPTY_VALUE) + ) diff --git a/circe/check/checkers/concept_set_criteria_check.py b/circe/check/checkers/concept_set_criteria_check.py index 382e3848..1469aad8 100644 --- a/circe/check/checkers/concept_set_criteria_check.py +++ b/circe/check/checkers/concept_set_criteria_check.py @@ -18,39 +18,66 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.criteria import ( - Criteria, ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail + Criteria, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, ) except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.criteria import ( - Criteria, ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail + Criteria, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, ) class ConceptSetCriteriaCheck(BaseCriteriaCheck): """Check for missing concept sets in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.ConceptSetCriteriaCheck """ - - NO_CONCEPT_SET_ERROR = "No concept set specified as part of a criteria at %s in %s criteria" - + + NO_CONCEPT_SET_ERROR = ( + "No concept set specified as part of a criteria at %s in %s criteria" + ) + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _check_criteria(self, criteria: 'Criteria', group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria( + self, criteria: "Criteria", group_name: str, reporter: WarningReporter + ) -> None: """Check if a criteria has a concept set specified. - + Args: criteria: The criteria to check group_name: The name of the group containing this criteria @@ -59,78 +86,107 @@ def _check_criteria(self, criteria: 'Criteria', group_name: str, reporter: Warni helper = WarningReporterHelper(reporter, self.NO_CONCEPT_SET_ERROR, group_name) criteria_name = CriteriaNameHelper.get_criteria_name(criteria) add_warning = helper.add_warning(criteria_name) - + # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, ) - - Operations.match(criteria)\ - .is_a(ConditionEra)\ - .then(lambda c: Operations.match(c) - .when(lambda ce: ce.codeset_id is None) - .then(add_warning) - )\ - .is_a(ConditionOccurrence)\ - .then(lambda c: Operations.match(c) - .when(lambda co: co.codeset_id is None and co.condition_source_concept is None) - .then(add_warning) - )\ - .is_a(Death)\ - .then(lambda c: Operations.match(c) - .when(lambda d: d.codeset_id is None) - .then(add_warning) - )\ - .is_a(DeviceExposure)\ - .then(lambda c: Operations.match(c) - .when(lambda de: de.codeset_id is None and de.device_source_concept is None) - .then(add_warning) - )\ - .is_a(DoseEra)\ - .then(lambda c: Operations.match(c) - .when(lambda de: de.codeset_id is None) - .then(add_warning) - )\ - .is_a(DrugEra)\ - .then(lambda c: Operations.match(c) - .when(lambda de: de.codeset_id is None) - .then(add_warning) - )\ - .is_a(DrugExposure)\ - .then(lambda c: Operations.match(c) - .when(lambda de: de.codeset_id is None and de.drug_source_concept is None) - .then(add_warning) - )\ - .is_a(Measurement)\ - .then(lambda c: Operations.match(c) - .when(lambda m: m.codeset_id is None and m.measurement_source_concept is None) - .then(add_warning) - )\ - .is_a(Observation)\ - .then(lambda c: Operations.match(c) - .when(lambda o: o.codeset_id is None and o.observation_source_concept is None) - .then(add_warning) - )\ - .is_a(ProcedureOccurrence)\ - .then(lambda c: Operations.match(c) - .when(lambda po: po.codeset_id is None and po.procedure_source_concept is None) - .then(add_warning) - )\ - .is_a(Specimen)\ - .then(lambda c: Operations.match(c) - .when(lambda s: s.codeset_id is None and s.specimen_source_concept is None) - .then(add_warning) - )\ - .is_a(VisitOccurrence)\ - .then(lambda c: Operations.match(c) - .when(lambda vo: vo.codeset_id is None and vo.visit_source_concept is None) - .then(add_warning) - )\ - .is_a(VisitDetail)\ - .then(lambda c: Operations.match(c) - .when(lambda vd: vd.codeset_id is None and vd.visit_detail_source_concept is None) - .then(add_warning) - ) + Operations.match(criteria).is_a(ConditionEra).then( + lambda c: Operations.match(c) + .when(lambda ce: ce.codeset_id is None) + .then(add_warning) + ).is_a(ConditionOccurrence).then( + lambda c: Operations.match(c) + .when( + lambda co: co.codeset_id is None and co.condition_source_concept is None + ) + .then(add_warning) + ).is_a( + Death + ).then( + lambda c: Operations.match(c) + .when(lambda d: d.codeset_id is None) + .then(add_warning) + ).is_a( + DeviceExposure + ).then( + lambda c: Operations.match(c) + .when(lambda de: de.codeset_id is None and de.device_source_concept is None) + .then(add_warning) + ).is_a( + DoseEra + ).then( + lambda c: Operations.match(c) + .when(lambda de: de.codeset_id is None) + .then(add_warning) + ).is_a( + DrugEra + ).then( + lambda c: Operations.match(c) + .when(lambda de: de.codeset_id is None) + .then(add_warning) + ).is_a( + DrugExposure + ).then( + lambda c: Operations.match(c) + .when(lambda de: de.codeset_id is None and de.drug_source_concept is None) + .then(add_warning) + ).is_a( + Measurement + ).then( + lambda c: Operations.match(c) + .when( + lambda m: m.codeset_id is None and m.measurement_source_concept is None + ) + .then(add_warning) + ).is_a( + Observation + ).then( + lambda c: Operations.match(c) + .when( + lambda o: o.codeset_id is None and o.observation_source_concept is None + ) + .then(add_warning) + ).is_a( + ProcedureOccurrence + ).then( + lambda c: Operations.match(c) + .when( + lambda po: po.codeset_id is None and po.procedure_source_concept is None + ) + .then(add_warning) + ).is_a( + Specimen + ).then( + lambda c: Operations.match(c) + .when(lambda s: s.codeset_id is None and s.specimen_source_concept is None) + .then(add_warning) + ).is_a( + VisitOccurrence + ).then( + lambda c: Operations.match(c) + .when(lambda vo: vo.codeset_id is None and vo.visit_source_concept is None) + .then(add_warning) + ).is_a( + VisitDetail + ).then( + lambda c: Operations.match(c) + .when( + lambda vd: vd.codeset_id is None + and vd.visit_detail_source_concept is None + ) + .then(add_warning) + ) diff --git a/circe/check/checkers/concept_set_selection_check.py b/circe/check/checkers/concept_set_selection_check.py index 9490305b..64b53803 100644 --- a/circe/check/checkers/concept_set_selection_check.py +++ b/circe/check/checkers/concept_set_selection_check.py @@ -15,19 +15,20 @@ class ConceptSetSelectionCheck(BaseValueCheck): """Check for empty ConceptSetSelection values in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.ConceptSetSelectionCheck """ - - def _get_factory(self, reporter: WarningReporter, name: str) -> ConceptSetSelectionCheckerFactory: + + def _get_factory( + self, reporter: WarningReporter, name: str + ) -> ConceptSetSelectionCheckerFactory: """Get a concept set selection checker factory. - + Args: reporter: The warning reporter to use name: The name of the criteria group - + Returns: A ConceptSetSelectionCheckerFactory instance """ return ConceptSetSelectionCheckerFactory.get_factory(reporter, name) - diff --git a/circe/check/checkers/concept_set_selection_checker_factory.py b/circe/check/checkers/concept_set_selection_checker_factory.py index 9f097c37..12496106 100644 --- a/circe/check/checkers/concept_set_selection_checker_factory.py +++ b/circe/check/checkers/concept_set_selection_checker_factory.py @@ -20,99 +20,120 @@ from ...cohortdefinition.core import ConceptSetSelection except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: - from ...cohortdefinition.criteria import Criteria, DemographicCriteria, VisitDetail + from ...cohortdefinition.criteria import ( + Criteria, + DemographicCriteria, + VisitDetail, + ) from ...cohortdefinition.core import ConceptSetSelection class ConceptSetSelectionCheckerFactory(BaseCheckerFactory): """Factory for checking ConceptSetSelection in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.ConceptSetSelectionCheckerFactory """ - + WARNING_EMPTY_VALUE = "%s in the %s has empty %s value" - + def __init__(self, reporter: WarningReporter, group_name: str): """Initialize a concept set selection checker factory. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked """ super().__init__(reporter, group_name) - + @staticmethod - def get_factory(reporter: WarningReporter, group_name: str) -> 'ConceptSetSelectionCheckerFactory': + def get_factory( + reporter: WarningReporter, group_name: str + ) -> "ConceptSetSelectionCheckerFactory": """Get a factory instance. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked - + Returns: A new ConceptSetSelectionCheckerFactory instance """ return ConceptSetSelectionCheckerFactory(reporter, group_name) - - def _get_check_criteria(self, criteria: 'Criteria') -> Callable[['Criteria'], None]: + + def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], None]: """Get a checker function for criteria. - + Args: criteria: The criteria to get a checker for - + Returns: A function that checks the criteria """ # Import here to avoid circular dependencies from ...cohortdefinition.criteria import VisitDetail - + if isinstance(criteria, VisitDetail): - def check(c: 'VisitDetail') -> None: + + def check(c: "VisitDetail") -> None: self._check_concept_set_selection( - c.visit_detail_type_cs, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.VISIT_DETAIL_TYPE_ATTR + c.visit_detail_type_cs, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.VISIT_DETAIL_TYPE_ATTR, ) self._check_concept_set_selection( - c.gender_cs, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.GENDER_ATTR + c.gender_cs, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.GENDER_ATTR, ) self._check_concept_set_selection( - c.provider_specialty_cs, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.PROVIDER_SPECIALITY_ATTR + c.provider_specialty_cs, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, ) self._check_concept_set_selection( - c.place_of_service_cs, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.PLACE_OF_SERVICE_ATTR + c.place_of_service_cs, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.PLACE_OF_SERVICE_ATTR, ) + return check else: - return lambda c: None # No ConceptSetSelection checks for other criteria types - - def _get_check_demographic(self, criteria: 'DemographicCriteria') -> Callable[['DemographicCriteria'], None]: + return ( + lambda c: None + ) # No ConceptSetSelection checks for other criteria types + + def _get_check_demographic( + self, criteria: "DemographicCriteria" + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. - + Args: criteria: The demographic criteria to get a checker for - + Returns: A function that checks the criteria (no ConceptSetSelection in demographic) """ return lambda c: None # No ConceptSetSelection in demographic criteria - + def _check_concept_set_selection( - self, - concept_set_selection: Optional['ConceptSetSelection'], - criteria_name: str, - attribute: str + self, + concept_set_selection: Optional["ConceptSetSelection"], + criteria_name: str, + attribute: str, ) -> None: """Check if a ConceptSetSelection has an empty codesetId. - + Args: concept_set_selection: The ConceptSetSelection to check criteria_name: The name of the criteria type attribute: The name of the attribute """ + def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - - Operations.match(concept_set_selection)\ - .when(lambda css: css is not None and css.codeset_id is None)\ - .then(lambda css: warning(self.WARNING_EMPTY_VALUE)) + Operations.match(concept_set_selection).when( + lambda css: css is not None and css.codeset_id is None + ).then(lambda css: warning(self.WARNING_EMPTY_VALUE)) diff --git a/circe/check/checkers/criteria_checker_factory.py b/circe/check/checkers/criteria_checker_factory.py index c0aa277d..27321eb6 100644 --- a/circe/check/checkers/criteria_checker_factory.py +++ b/circe/check/checkers/criteria_checker_factory.py @@ -16,115 +16,162 @@ try: from ...vocabulary.concept import ConceptSet from ...cohortdefinition.criteria import ( - Criteria, ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail, - LocationRegion + Criteria, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + LocationRegion, ) from ...cohortdefinition.core import ConceptSetSelection except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...vocabulary.concept import ConceptSet from ...cohortdefinition.criteria import ( - Criteria, ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail, - LocationRegion + Criteria, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + LocationRegion, ) from ...cohortdefinition.core import ConceptSetSelection class CriteriaCheckerFactory: """Factory for checking if criteria use a specific concept set. - + Java equivalent: org.ohdsi.circe.check.checkers.CriteriaCheckerFactory - + Note: This is not a BaseCheckerFactory subclass - it has a different purpose. It's used to check if a criteria uses a specific concept set. """ - - def __init__(self, concept_set: 'ConceptSet'): + + def __init__(self, concept_set: "ConceptSet"): """Initialize a criteria checker factory. - + Args: concept_set: The concept set to check for """ self._concept_set = concept_set - + @staticmethod - def get_factory(concept_set: 'ConceptSet') -> 'CriteriaCheckerFactory': + def get_factory(concept_set: "ConceptSet") -> "CriteriaCheckerFactory": """Get a factory instance. - + Args: concept_set: The concept set to check for - + Returns: A new CriteriaCheckerFactory instance """ return CriteriaCheckerFactory(concept_set) - - def get_criteria_checker(self, criteria: 'Criteria') -> Callable[['Criteria'], bool]: + + def get_criteria_checker( + self, criteria: "Criteria" + ) -> Callable[["Criteria"], bool]: """Get a checker function that returns True if the criteria uses the concept set. - + Args: criteria: The criteria to get a checker for - + Returns: A function that returns True if the criteria uses the concept set """ # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail, - LocationRegion + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + LocationRegion, ) from ...cohortdefinition.core import ConceptSetSelection - - def check_condition_era(c: 'ConditionEra') -> bool: + + def check_condition_era(c: "ConditionEra") -> bool: return c.codeset_id == self._concept_set.id - - def check_condition_occurrence(c: 'ConditionOccurrence') -> bool: - return (c.codeset_id == self._concept_set.id or - c.condition_source_concept == self._concept_set.id) - - def check_death(c: 'Death') -> bool: + + def check_condition_occurrence(c: "ConditionOccurrence") -> bool: + return ( + c.codeset_id == self._concept_set.id + or c.condition_source_concept == self._concept_set.id + ) + + def check_death(c: "Death") -> bool: return c.codeset_id == self._concept_set.id - - def check_device_exposure(c: 'DeviceExposure') -> bool: - return (c.codeset_id == self._concept_set.id or - c.device_source_concept == self._concept_set.id) - - def check_dose_era(c: 'DoseEra') -> bool: + + def check_device_exposure(c: "DeviceExposure") -> bool: + return ( + c.codeset_id == self._concept_set.id + or c.device_source_concept == self._concept_set.id + ) + + def check_dose_era(c: "DoseEra") -> bool: return c.codeset_id == self._concept_set.id - - def check_drug_era(c: 'DrugEra') -> bool: + + def check_drug_era(c: "DrugEra") -> bool: return c.codeset_id == self._concept_set.id - - def check_drug_exposure(c: 'DrugExposure') -> bool: - return (c.codeset_id == self._concept_set.id or - c.drug_source_concept == self._concept_set.id) - - def check_measurement(c: 'Measurement') -> bool: - return (c.codeset_id == self._concept_set.id or - c.measurement_source_concept == self._concept_set.id) - - def check_observation(c: 'Observation') -> bool: - return (c.codeset_id == self._concept_set.id or - c.observation_source_concept == self._concept_set.id) - - def check_procedure_occurrence(c: 'ProcedureOccurrence') -> bool: - return (c.codeset_id == self._concept_set.id or - c.procedure_source_concept == self._concept_set.id) - - def check_specimen(c: 'Specimen') -> bool: + + def check_drug_exposure(c: "DrugExposure") -> bool: + return ( + c.codeset_id == self._concept_set.id + or c.drug_source_concept == self._concept_set.id + ) + + def check_measurement(c: "Measurement") -> bool: + return ( + c.codeset_id == self._concept_set.id + or c.measurement_source_concept == self._concept_set.id + ) + + def check_observation(c: "Observation") -> bool: + return ( + c.codeset_id == self._concept_set.id + or c.observation_source_concept == self._concept_set.id + ) + + def check_procedure_occurrence(c: "ProcedureOccurrence") -> bool: + return ( + c.codeset_id == self._concept_set.id + or c.procedure_source_concept == self._concept_set.id + ) + + def check_specimen(c: "Specimen") -> bool: return c.codeset_id == self._concept_set.id - - def check_visit_occurrence(c: 'VisitOccurrence') -> bool: + + def check_visit_occurrence(c: "VisitOccurrence") -> bool: return c.codeset_id == self._concept_set.id - - def check_visit_detail(c: 'VisitDetail') -> bool: + + def check_visit_detail(c: "VisitDetail") -> bool: if c.codeset_id == self._concept_set.id: return True # Check ConceptSetSelection fields @@ -134,13 +181,13 @@ def check_visit_detail(c: 'VisitDetail') -> bool: if css is not None and css.codeset_id == self._concept_set.id: return True return False - - def check_location_region(c: 'LocationRegion') -> bool: + + def check_location_region(c: "LocationRegion") -> bool: return c.codeset_id == self._concept_set.id - - def default_check(c: 'Criteria') -> bool: + + def default_check(c: "Criteria") -> bool: return False - + # Route to appropriate checker if isinstance(criteria, ConditionEra): return check_condition_era @@ -172,20 +219,21 @@ def default_check(c: 'Criteria') -> bool: return check_location_region else: return default_check - - def _get_concept_set_selection_suppliers(self, criteria: 'VisitDetail') -> List[Callable[[], Optional['ConceptSetSelection']]]: + + def _get_concept_set_selection_suppliers( + self, criteria: "VisitDetail" + ) -> List[Callable[[], Optional["ConceptSetSelection"]]]: """Get suppliers for ConceptSetSelection fields in VisitDetail. - + Args: criteria: The VisitDetail criteria - + Returns: A list of functions that return ConceptSetSelection objects """ - suppliers: List[Callable[[], Optional['ConceptSetSelection']]] = [] + suppliers: List[Callable[[], Optional["ConceptSetSelection"]]] = [] suppliers.append(lambda: criteria.place_of_service_cs) suppliers.append(lambda: criteria.gender_cs) suppliers.append(lambda: criteria.provider_specialty_cs) suppliers.append(lambda: criteria.visit_detail_type_cs) return suppliers - diff --git a/circe/check/checkers/criteria_contradictions_check.py b/circe/check/checkers/criteria_contradictions_check.py index b02fd120..090315fc 100644 --- a/circe/check/checkers/criteria_contradictions_check.py +++ b/circe/check/checkers/criteria_contradictions_check.py @@ -21,6 +21,7 @@ from ...cohortdefinition.criteria import CorelatedCriteria, Occurrence except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import CorelatedCriteria, Occurrence @@ -28,55 +29,57 @@ class CriteriaInfo: """Information about a criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.CriteriaContradictionsCheck.CriteriaInto """ - - def __init__(self, name: str, criteria: 'CorelatedCriteria'): + + def __init__(self, name: str, criteria: "CorelatedCriteria"): """Initialize criteria info. - + Args: name: The name of the criteria criteria: The corelated criteria """ self._name = name self._criteria = criteria - + @property def name(self) -> str: """Get the name.""" return self._name - + @property - def criteria(self) -> 'CorelatedCriteria': + def criteria(self) -> "CorelatedCriteria": """Get the criteria.""" return self._criteria class CriteriaContradictionsCheck(BaseCorelatedCriteriaCheck): """Check for contradictory occurrence criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.CriteriaContradictionsCheck """ - + WARNING = "%s might be contradicted with %s and possibly will lead to 0 records" - + def __init__(self): """Initialize the criteria contradictions check.""" super().__init__() self._criteria_list: List[CriteriaInfo] = [] - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria( + self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + ) -> None: """Collect criteria information. - + Args: criteria: The corelated criteria to check group_name: The name of the group containing this criteria @@ -84,10 +87,12 @@ def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, report """ name = f"{group_name} {CriteriaNameHelper.get_criteria_name(criteria.criteria)}" self._criteria_list.append(CriteriaInfo(name, criteria)) - - def _after_check(self, reporter: WarningReporter, expression: 'CohortExpression') -> None: + + def _after_check( + self, reporter: WarningReporter, expression: "CohortExpression" + ) -> None: """Check for contradictions after all criteria have been collected. - + Args: reporter: The warning reporter to use expression: The cohort expression that was checked @@ -96,36 +101,41 @@ def _after_check(self, reporter: WarningReporter, expression: 'CohortExpression' size = len(self._criteria_list) for i in range(size - 1): info = self._criteria_list[i] - for other_info in self._criteria_list[i + 1:]: - if (Comparisons.compare_criteria(info.criteria.criteria, other_info.criteria.criteria) and - self._check_contradiction(info.criteria.occurrence, other_info.criteria.occurrence)): + for other_info in self._criteria_list[i + 1 :]: + if Comparisons.compare_criteria( + info.criteria.criteria, other_info.criteria.criteria + ) and self._check_contradiction( + info.criteria.occurrence, other_info.criteria.occurrence + ): reporter(self.WARNING, info.name, other_info.name) - - def _check_contradiction(self, o1: Optional['Occurrence'], o2: Optional['Occurrence']) -> bool: + + def _check_contradiction( + self, o1: Optional["Occurrence"], o2: Optional["Occurrence"] + ) -> bool: """Check if two occurrences contradict each other. - + Args: o1: The first occurrence o2: The second occurrence - + Returns: True if the occurrences contradict, False otherwise """ if o1 is None or o2 is None: return False - + range1 = self._get_occurrence_range(o1) range2 = self._get_occurrence_range(o2) - + # Check if ranges overlap return not self._ranges_overlap(range1, range2) - - def _get_occurrence_range(self, occurrence: 'Occurrence') -> Tuple[int, int]: + + def _get_occurrence_range(self, occurrence: "Occurrence") -> Tuple[int, int]: """Get the range of valid occurrence counts. - + Args: occurrence: The occurrence to get range for - + Returns: A tuple of (min, max) values """ @@ -133,35 +143,34 @@ def _get_occurrence_range(self, occurrence: 'Occurrence') -> Tuple[int, int]: if occurrence.type == 0: # EXACTLY return (occurrence.count, occurrence.count) elif occurrence.type == 1: # AT_MOST - return (float('-inf'), occurrence.count) + return (float("-inf"), occurrence.count) elif occurrence.type == 2: # AT_LEAST - return (occurrence.count, float('inf')) + return (occurrence.count, float("inf")) else: - return (float('-inf'), float('inf')) - + return (float("-inf"), float("inf")) + def _ranges_overlap(self, range1: Tuple[int, int], range2: Tuple[int, int]) -> bool: """Check if two ranges overlap. - + Args: range1: First range (min, max) range2: Second range (min, max) - + Returns: True if ranges overlap, False otherwise """ min1, max1 = range1 min2, max2 = range2 - + # Handle infinity - if min1 == float('-inf'): - min1 = float('-inf') - if max1 == float('inf'): - max1 = float('inf') - if min2 == float('-inf'): - min2 = float('-inf') - if max2 == float('inf'): - max2 = float('inf') - + if min1 == float("-inf"): + min1 = float("-inf") + if max1 == float("inf"): + max1 = float("inf") + if min2 == float("-inf"): + min2 = float("-inf") + if max2 == float("inf"): + max2 = float("inf") + # Check if ranges overlap return not (max1 < min2 or max2 < min1) - diff --git a/circe/check/checkers/death_time_window_check.py b/circe/check/checkers/death_time_window_check.py index 30228b99..83586724 100644 --- a/circe/check/checkers/death_time_window_check.py +++ b/circe/check/checkers/death_time_window_check.py @@ -21,6 +21,7 @@ from ...cohortdefinition.criteria import CorelatedCriteria, Criteria, Death except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import CorelatedCriteria, Criteria, Death @@ -28,48 +29,50 @@ class DeathTimeWindowCheck(BaseCorelatedCriteriaCheck): """Check for death criteria with time windows before the index event. - + Java equivalent: org.ohdsi.circe.check.checkers.DeathTimeWindowCheck """ - + MESSAGE = "%s attempts to identify death event prior to index event. Events post-death may not be available" - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _internal_check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _internal_check( + self, expression: "CohortExpression", reporter: WarningReporter + ) -> None: """Check death criteria in inclusion rules and other locations. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ super()._internal_check(expression, reporter) - + # Check additional criteria if expression.additional_criteria: self._check_criteria_list( expression.additional_criteria.criteria_list, self.ADDITIONAL_RULE, - reporter + reporter, ) - + # Check primary criteria if expression.primary_criteria and expression.primary_criteria.criteria_list: self._check_criteria_list( - expression.primary_criteria.criteria_list, - self.INITIAL_EVENT, - reporter + expression.primary_criteria.criteria_list, self.INITIAL_EVENT, reporter ) - - def _check_criteria_list(self, criteria_list, group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria_list( + self, criteria_list, group_name: str, reporter: WarningReporter + ) -> None: """Check a list of criteria. - + Args: criteria_list: The list of criteria to check group_name: The name of the group @@ -77,7 +80,7 @@ def _check_criteria_list(self, criteria_list, group_name: str, reporter: Warning """ if not criteria_list: return - + for c in criteria_list: criteria = None if isinstance(c, CorelatedCriteria): @@ -85,43 +88,49 @@ def _check_criteria_list(self, criteria_list, group_name: str, reporter: Warning self._check_criteria(c, group_name, reporter) elif isinstance(c, Criteria): criteria = c - + if criteria: self._check_criteria_group(criteria, group_name, reporter) - - def _check_criteria_group(self, criteria: 'Criteria', group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria_group( + self, criteria: "Criteria", group_name: str, reporter: WarningReporter + ) -> None: """Check a criteria and its correlated criteria. - + Args: criteria: The criteria to check group_name: The name of the group reporter: The warning reporter to use """ - if hasattr(criteria, 'correlated_criteria') and criteria.correlated_criteria: + if hasattr(criteria, "correlated_criteria") and criteria.correlated_criteria: correlated = criteria.correlated_criteria - if hasattr(correlated, 'criteria_list') and correlated.criteria_list: + if hasattr(correlated, "criteria_list") and correlated.criteria_list: for corelated_criteria in correlated.criteria_list: self._check_criteria(corelated_criteria, group_name, reporter) - if hasattr(correlated, 'groups') and correlated.groups: + if hasattr(correlated, "groups") and correlated.groups: for group in correlated.groups: - if hasattr(group, 'criteria_list') and group.criteria_list: + if hasattr(group, "criteria_list") and group.criteria_list: for corelated_criteria in group.criteria_list: - self._check_criteria(corelated_criteria, group_name, reporter) - - def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, reporter: WarningReporter) -> None: + self._check_criteria( + corelated_criteria, group_name, reporter + ) + + def _check_criteria( + self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + ) -> None: """Check a corelated criteria for death time window issues. - + Args: criteria: The corelated criteria to check group_name: The name of the group containing this criteria reporter: The warning reporter to use """ name = f"{group_name} {CriteriaNameHelper.get_criteria_name(criteria.criteria)}" - + match_result = Operations.match(criteria.criteria) match_result.is_a(Death) - match_result.then(lambda death: Operations.match(criteria) - .when(lambda c: Comparisons.is_before(c.start_window)) - .then(lambda c: reporter(self.MESSAGE, name)) - ) - + match_result.then( + lambda death: Operations.match(criteria) + .when(lambda c: Comparisons.is_before(c.start_window)) + .then(lambda c: reporter(self.MESSAGE, name)) + ) diff --git a/circe/check/checkers/domain_type_check.py b/circe/check/checkers/domain_type_check.py index 92f363c8..d6f6d623 100644 --- a/circe/check/checkers/domain_type_check.py +++ b/circe/check/checkers/domain_type_check.py @@ -21,117 +21,151 @@ try: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import ( - Criteria, ConditionOccurrence, Death, DeviceExposure, - DrugExposure, Measurement, Observation, ProcedureOccurrence, - Specimen, VisitOccurrence, VisitDetail + Criteria, + ConditionOccurrence, + Death, + DeviceExposure, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, ) except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import ( - Criteria, ConditionOccurrence, Death, DeviceExposure, - DrugExposure, Measurement, Observation, ProcedureOccurrence, - Specimen, VisitOccurrence, VisitDetail + Criteria, + ConditionOccurrence, + Death, + DeviceExposure, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, ) class DomainTypeCheck(BaseCriteriaCheck): """Check for missing domain type specifications. - + Java equivalent: org.ohdsi.circe.check.checkers.DomainTypeCheck """ - + WARNING = "It's not specified what type of records to look for in %s" - + def __init__(self): """Initialize the domain type check.""" super().__init__() self._warn_names: List[str] = [] - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: INFO severity level """ return WarningSeverity.INFO - - def _check_criteria(self, criteria: 'Criteria', group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria( + self, criteria: "Criteria", group_name: str, reporter: WarningReporter + ) -> None: """Check if a criteria has a domain type specified. - + Args: criteria: The criteria to check group_name: The name of the group containing this criteria reporter: The warning reporter to use """ name = CriteriaNameHelper.get_criteria_name(criteria) - + def add_warning() -> None: self._warn_names.append(f"{name} at {group_name}") - + # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - ConditionOccurrence, Death, DeviceExposure, DrugExposure, - Measurement, Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail + ConditionOccurrence, + Death, + DeviceExposure, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + ) + + Operations.match(criteria).is_a(ConditionOccurrence).then( + lambda c: Operations.match(c) + .when(lambda co: co.condition_type is None) + .then(lambda co: add_warning()) + ).is_a(Death).then( + lambda c: Operations.match(c) + .when(lambda d: d.death_type is None) + .then(lambda d: add_warning()) + ).is_a( + DeviceExposure + ).then( + lambda c: Operations.match(c) + .when(lambda de: de.device_type is None) + .then(lambda de: add_warning()) + ).is_a( + DrugExposure + ).then( + lambda c: Operations.match(c) + .when(lambda de: de.drug_type is None) + .then(lambda de: add_warning()) + ).is_a( + Measurement + ).then( + lambda c: Operations.match(c) + .when(lambda m: m.measurement_type is None) + .then(lambda m: add_warning()) + ).is_a( + Observation + ).then( + lambda c: Operations.match(c) + .when(lambda o: o.observation_type is None) + .then(lambda o: add_warning()) + ).is_a( + ProcedureOccurrence + ).then( + lambda c: Operations.match(c) + .when(lambda po: po.procedure_type is None) + .then(lambda po: add_warning()) + ).is_a( + Specimen + ).then( + lambda c: Operations.match(c) + .when(lambda s: s.specimen_type is None) + .then(lambda s: add_warning()) + ).is_a( + VisitOccurrence + ).then( + lambda c: Operations.match(c) + .when(lambda vo: vo.visit_type is None) + .then(lambda vo: add_warning()) + ).is_a( + VisitDetail + ).then( + lambda c: Operations.match(c) + .when(lambda vd: vd.visit_detail_type_cs is None) + .then(lambda vd: add_warning()) ) - - Operations.match(criteria)\ - .is_a(ConditionOccurrence)\ - .then(lambda c: Operations.match(c) - .when(lambda co: co.condition_type is None) - .then(lambda co: add_warning()) - )\ - .is_a(Death)\ - .then(lambda c: Operations.match(c) - .when(lambda d: d.death_type is None) - .then(lambda d: add_warning()) - )\ - .is_a(DeviceExposure)\ - .then(lambda c: Operations.match(c) - .when(lambda de: de.device_type is None) - .then(lambda de: add_warning()) - )\ - .is_a(DrugExposure)\ - .then(lambda c: Operations.match(c) - .when(lambda de: de.drug_type is None) - .then(lambda de: add_warning()) - )\ - .is_a(Measurement)\ - .then(lambda c: Operations.match(c) - .when(lambda m: m.measurement_type is None) - .then(lambda m: add_warning()) - )\ - .is_a(Observation)\ - .then(lambda c: Operations.match(c) - .when(lambda o: o.observation_type is None) - .then(lambda o: add_warning()) - )\ - .is_a(ProcedureOccurrence)\ - .then(lambda c: Operations.match(c) - .when(lambda po: po.procedure_type is None) - .then(lambda po: add_warning()) - )\ - .is_a(Specimen)\ - .then(lambda c: Operations.match(c) - .when(lambda s: s.specimen_type is None) - .then(lambda s: add_warning()) - )\ - .is_a(VisitOccurrence)\ - .then(lambda c: Operations.match(c) - .when(lambda vo: vo.visit_type is None) - .then(lambda vo: add_warning()) - )\ - .is_a(VisitDetail)\ - .then(lambda c: Operations.match(c) - .when(lambda vd: vd.visit_detail_type_cs is None) - .then(lambda vd: add_warning()) - ) - - def _after_check(self, reporter: WarningReporter, expression: 'CohortExpression') -> None: + + def _after_check( + self, reporter: WarningReporter, expression: "CohortExpression" + ) -> None: """Report warnings after all criteria have been checked. - + Args: reporter: The warning reporter to use expression: The cohort expression that was checked @@ -139,4 +173,3 @@ def _after_check(self, reporter: WarningReporter, expression: 'CohortExpression' if self._warn_names: names = ", ".join(self._warn_names) reporter(self.WARNING, names) - diff --git a/circe/check/checkers/drug_domain_check.py b/circe/check/checkers/drug_domain_check.py index e50e2659..0318feeb 100644 --- a/circe/check/checkers/drug_domain_check.py +++ b/circe/check/checkers/drug_domain_check.py @@ -22,6 +22,7 @@ from ...vocabulary.concept import ConceptSet except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import Criteria @@ -31,122 +32,162 @@ class DrugDomainCheck(BaseCheck): """Check for drug domain concept sets not used in exit criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.DrugDomainCheck """ - + MESSAGE = "%s %s used in initial event and not used for cohort exit criteria" - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: INFO severity level """ return WarningSeverity.INFO - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check for drug domain concept sets. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ - if not expression.primary_criteria or not expression.primary_criteria.criteria_list: + if ( + not expression.primary_criteria + or not expression.primary_criteria.criteria_list + ): return - - concept_sets: List['ConceptSet'] = [] - + + concept_sets: List["ConceptSet"] = [] + # Map criteria to codeset IDs codeset_ids = [ - self._map_criteria(criteria) + self._map_criteria(criteria) for criteria in expression.primary_criteria.criteria_list ] - + # Filter to only drug domain concept sets for codeset_id in codeset_ids: if codeset_id and self._is_concept_in_drug_domain(expression, codeset_id): concept_set = self._map_concept_set(expression, codeset_id) if concept_set: concept_sets.append(concept_set) - + # Filter out concept sets used in exit strategy if isinstance(expression.end_strategy, CustomEraStrategy): concept_sets = [ - cs for cs in concept_sets + cs + for cs in concept_sets if cs.id != expression.end_strategy.drug_codeset_id ] - + if concept_sets: names = ", ".join(cs.name for cs in concept_sets) title = "Concept sets" if len(concept_sets) > 1 else "Concept set" reporter(self.MESSAGE, title, names) - - def _map_criteria(self, criteria: 'Criteria') -> Optional[int]: + + def _map_criteria(self, criteria: "Criteria") -> Optional[int]: """Map a criteria to its codeset ID. - + Args: criteria: The criteria to map - + Returns: The codeset ID, or None """ # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, ) - - return Operations.match(criteria)\ - .is_a(ConditionEra).then_return(lambda c: c.codeset_id)\ - .is_a(ConditionOccurrence).then_return(lambda c: c.codeset_id)\ - .is_a(Death).then_return(lambda c: c.codeset_id)\ - .is_a(DeviceExposure).then_return(lambda c: c.codeset_id)\ - .is_a(DoseEra).then_return(lambda c: c.codeset_id)\ - .is_a(DrugEra).then_return(lambda c: c.codeset_id)\ - .is_a(DrugExposure).then_return(lambda c: c.codeset_id)\ - .is_a(Measurement).then_return(lambda c: c.codeset_id)\ - .is_a(Observation).then_return(lambda c: c.codeset_id)\ - .is_a(ProcedureOccurrence).then_return(lambda c: c.codeset_id)\ - .is_a(Specimen).then_return(lambda c: c.codeset_id)\ - .is_a(VisitOccurrence).then_return(lambda c: c.codeset_id)\ - .is_a(VisitDetail).then_return(lambda c: c.codeset_id)\ + + return ( + Operations.match(criteria) + .is_a(ConditionEra) + .then_return(lambda c: c.codeset_id) + .is_a(ConditionOccurrence) + .then_return(lambda c: c.codeset_id) + .is_a(Death) + .then_return(lambda c: c.codeset_id) + .is_a(DeviceExposure) + .then_return(lambda c: c.codeset_id) + .is_a(DoseEra) + .then_return(lambda c: c.codeset_id) + .is_a(DrugEra) + .then_return(lambda c: c.codeset_id) + .is_a(DrugExposure) + .then_return(lambda c: c.codeset_id) + .is_a(Measurement) + .then_return(lambda c: c.codeset_id) + .is_a(Observation) + .then_return(lambda c: c.codeset_id) + .is_a(ProcedureOccurrence) + .then_return(lambda c: c.codeset_id) + .is_a(Specimen) + .then_return(lambda c: c.codeset_id) + .is_a(VisitOccurrence) + .then_return(lambda c: c.codeset_id) + .is_a(VisitDetail) + .then_return(lambda c: c.codeset_id) .value() - - def _is_concept_in_drug_domain(self, expression: 'CohortExpression', codeset_id: int) -> bool: + ) + + def _is_concept_in_drug_domain( + self, expression: "CohortExpression", codeset_id: int + ) -> bool: """Check if a concept set contains drug domain concepts. - + Args: expression: The cohort expression codeset_id: The codeset ID to check - + Returns: True if the concept set contains drug domain concepts, False otherwise """ if not expression.concept_sets: return False - - concept_set = next((cs for cs in expression.concept_sets if cs.id == codeset_id), None) - if not concept_set or not concept_set.expression or not concept_set.expression.items: + + concept_set = next( + (cs for cs in expression.concept_sets if cs.id == codeset_id), None + ) + if ( + not concept_set + or not concept_set.expression + or not concept_set.expression.items + ): return False - + return any( - item.concept and item.concept.domain_id and item.concept.domain_id.upper() == "DRUG" + item.concept + and item.concept.domain_id + and item.concept.domain_id.upper() == "DRUG" for item in concept_set.expression.items ) - - def _map_concept_set(self, expression: 'CohortExpression', codeset_id: int) -> Optional['ConceptSet']: + + def _map_concept_set( + self, expression: "CohortExpression", codeset_id: int + ) -> Optional["ConceptSet"]: """Map a codeset ID to a concept set. - + Args: expression: The cohort expression codeset_id: The codeset ID to map - + Returns: The concept set, or None """ if not expression.concept_sets: return None return next((cs for cs in expression.concept_sets if cs.id == codeset_id), None) - diff --git a/circe/check/checkers/drug_era_check.py b/circe/check/checkers/drug_era_check.py index 4980f5d3..0d032eaf 100644 --- a/circe/check/checkers/drug_era_check.py +++ b/circe/check/checkers/drug_era_check.py @@ -18,29 +18,32 @@ from ...cohortdefinition.criteria import CorelatedCriteria, DrugEra except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.criteria import CorelatedCriteria, DrugEra class DrugEraCheck(BaseCorelatedCriteriaCheck): """Check for missing days supply information in drug era criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.DrugEraCheck """ - + MISSING_DAYS_INFO = "Using drug era at %s criteria on medical claims (e.g., biologics) may not be accurate due to missing days supply information" - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: INFO severity level """ return WarningSeverity.INFO - - def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria( + self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + ) -> None: """Check drug era criteria for missing days supply information. - + Args: criteria: The corelated criteria to check group_name: The name of the group containing this criteria @@ -50,19 +53,21 @@ def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, report if isinstance(criteria, dict): # Skip validation for dict-based criteria - they need to be deserialized first return - + # Ensure criteria has a criteria attribute - if not hasattr(criteria, 'criteria') or not criteria.criteria: + if not hasattr(criteria, "criteria") or not criteria.criteria: return - + match_result = Operations.match(criteria.criteria) match_result.is_a(DrugEra) - match_result.then(lambda c: Operations.match(criteria) - .when(lambda de: ( - (not criteria.start_window or not criteria.start_window.start) and - (not criteria.start_window or not criteria.start_window.end) and - (not criteria.end_window or not criteria.end_window.start) - )) - .then(lambda de: reporter(self.MISSING_DAYS_INFO, group_name)) + match_result.then( + lambda c: Operations.match(criteria) + .when( + lambda de: ( + (not criteria.start_window or not criteria.start_window.start) + and (not criteria.start_window or not criteria.start_window.end) + and (not criteria.end_window or not criteria.end_window.start) + ) ) - + .then(lambda de: reporter(self.MISSING_DAYS_INFO, group_name)) + ) diff --git a/circe/check/checkers/duplicates_concept_set_check.py b/circe/check/checkers/duplicates_concept_set_check.py index 7029c658..26d352af 100644 --- a/circe/check/checkers/duplicates_concept_set_check.py +++ b/circe/check/checkers/duplicates_concept_set_check.py @@ -28,23 +28,23 @@ class DuplicatesConceptSetCheck(BaseCheck): """Check for duplicate concept sets. - + Java equivalent: org.ohdsi.circe.check.checkers.DuplicatesConceptSetCheck """ - + DUPLICATES_WARNING = "Concept set %s contains the same concepts like %s" - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check for duplicate concept sets. - + Args: expression: The cohort expression to check reporter: The warning reporter to use @@ -56,10 +56,8 @@ def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> N # Create comparison function for this concept set compare_func = Comparisons.compare_concept_set(concept_set) duplicates = [ - cs for cs in expression.concept_sets[i + 1:] - if compare_func(cs) + cs for cs in expression.concept_sets[i + 1 :] if compare_func(cs) ] if duplicates: names = ", ".join(cs.name for cs in duplicates) reporter(self.DUPLICATES_WARNING, concept_set.name, names) - diff --git a/circe/check/checkers/duplicates_criteria_check.py b/circe/check/checkers/duplicates_criteria_check.py index cb5285e2..a2c58f34 100644 --- a/circe/check/checkers/duplicates_criteria_check.py +++ b/circe/check/checkers/duplicates_criteria_check.py @@ -20,6 +20,7 @@ from ...cohortdefinition.criteria import Criteria except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import Criteria @@ -27,20 +28,22 @@ class DuplicatesCriteriaCheck(BaseCriteriaCheck): """Check for duplicate criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.DuplicatesCriteriaCheck """ - + DUPLICATE_WARNING = "Probably %s duplicates %s" - + def __init__(self): """Initialize the duplicates criteria check.""" super().__init__() - self._criteria_list: List[Tuple[str, 'Criteria']] = [] - - def _after_check(self, reporter: WarningReporter, expression: 'CohortExpression') -> None: + self._criteria_list: List[Tuple[str, "Criteria"]] = [] + + def _after_check( + self, reporter: WarningReporter, expression: "CohortExpression" + ) -> None: """Check for duplicates after all criteria have been collected. - + Args: reporter: The warning reporter to use expression: The cohort expression that was checked @@ -49,47 +52,61 @@ def _after_check(self, reporter: WarningReporter, expression: 'CohortExpression' for i in range(len(self._criteria_list) - 1): criteria, criteria_obj = self._criteria_list[i] duplicates = [ - (name, obj) for name, obj in self._criteria_list[i + 1:] + (name, obj) + for name, obj in self._criteria_list[i + 1 :] if self._compare_criteria(criteria_obj, obj) ] if duplicates: names = ", ".join(name for name, _ in duplicates) reporter(self.DUPLICATE_WARNING, criteria, names) - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _compare_criteria(self, c1: 'Criteria', c2: 'Criteria') -> bool: + + def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: """Compare two criteria to see if they are duplicates. - + Args: c1: The first criteria c2: The second criteria - + Returns: True if the criteria are duplicates, False otherwise """ if type(c1) != type(c2): return False - + # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ObservationPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, PayerPlanPeriod + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + PayerPlanPeriod, ) - + if isinstance(c1, ConditionEra): return c1.codeset_id == c2.codeset_id elif isinstance(c1, ConditionOccurrence): - return (c1.codeset_id == c2.codeset_id and - c1.condition_source_concept == c2.condition_source_concept) + return ( + c1.codeset_id == c2.codeset_id + and c1.condition_source_concept == c2.condition_source_concept + ) elif isinstance(c1, Death): return c1.codeset_id == c2.codeset_id elif isinstance(c1, DeviceExposure): @@ -106,9 +123,11 @@ def _compare_criteria(self, c1: 'Criteria', c2: 'Criteria') -> bool: return c1.codeset_id == c2.codeset_id elif isinstance(c1, ObservationPeriod): # For ObservationPeriod, compare all fields - return (self._compare_objects(c1.period_start_date, c2.period_start_date) and - self._compare_objects(c1.period_end_date, c2.period_end_date) and - self._compare_objects(c1.period_length, c2.period_length)) + return ( + self._compare_objects(c1.period_start_date, c2.period_start_date) + and self._compare_objects(c1.period_end_date, c2.period_end_date) + and self._compare_objects(c1.period_length, c2.period_length) + ) elif isinstance(c1, ProcedureOccurrence): return c1.codeset_id == c2.codeset_id elif isinstance(c1, Specimen): @@ -118,50 +137,57 @@ def _compare_criteria(self, c1: 'Criteria', c2: 'Criteria') -> bool: elif isinstance(c1, VisitDetail): return c1.codeset_id == c2.codeset_id elif isinstance(c1, PayerPlanPeriod): - return (c1.payer_concept == c2.payer_concept and - c1.payer_source_concept == c2.payer_source_concept and - c1.plan_concept == c2.plan_concept and - c1.plan_source_concept == c2.plan_source_concept and - c1.sponsor_concept == c2.sponsor_concept and - c1.sponsor_source_concept == c2.sponsor_source_concept and - c1.stop_reason_concept == c2.stop_reason_concept and - c1.stop_reason_source_concept == c2.stop_reason_source_concept) - + return ( + c1.payer_concept == c2.payer_concept + and c1.payer_source_concept == c2.payer_source_concept + and c1.plan_concept == c2.plan_concept + and c1.plan_source_concept == c2.plan_source_concept + and c1.sponsor_concept == c2.sponsor_concept + and c1.sponsor_source_concept == c2.sponsor_source_concept + and c1.stop_reason_concept == c2.stop_reason_concept + and c1.stop_reason_source_concept == c2.stop_reason_source_concept + ) + # Fallback to reflection-based comparison return self._compare_objects_reflection(c1, c2) - + def _compare_objects(self, obj1, obj2) -> bool: """Compare two objects for equality. - + Args: obj1: First object obj2: Second object - + Returns: True if objects are equal, False otherwise """ return obj1 == obj2 - + def _compare_objects_reflection(self, obj1, obj2) -> bool: """Compare objects using equality (Pydantic models support this). - + Args: obj1: First object obj2: Second object - + Returns: True if objects are equal, False otherwise """ return obj1 == obj2 - - def _check_criteria(self, criteria: 'Criteria', group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria( + self, criteria: "Criteria", group_name: str, reporter: WarningReporter + ) -> None: """Collect criteria for duplicate checking. - + Args: criteria: The criteria to check group_name: The name of the group containing this criteria reporter: The warning reporter to use (not used here, but kept for interface) """ - criteria_name = CriteriaNameHelper.get_criteria_name(criteria) + " criteria in " + group_name + criteria_name = ( + CriteriaNameHelper.get_criteria_name(criteria) + + " criteria in " + + group_name + ) self._criteria_list.append((criteria_name, criteria)) - diff --git a/circe/check/checkers/empty_concept_set_check.py b/circe/check/checkers/empty_concept_set_check.py index 3b8ad699..56a28de6 100644 --- a/circe/check/checkers/empty_concept_set_check.py +++ b/circe/check/checkers/empty_concept_set_check.py @@ -16,29 +16,31 @@ from ...cohortdefinition.cohort import CohortExpression except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression class EmptyConceptSetCheck(BaseCheck): """Check for empty concept sets. - + Java equivalent: org.ohdsi.circe.check.checkers.EmptyConceptSetCheck """ - + EMPTY_ERROR = "Concept set %s contains no concepts" - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check for empty concept sets. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ if expression.concept_sets: for concept_set in expression.concept_sets: - if (not concept_set.expression or - not concept_set.expression.items or - len(concept_set.expression.items) == 0): + if ( + not concept_set.expression + or not concept_set.expression.items + or len(concept_set.expression.items) == 0 + ): reporter(self.EMPTY_ERROR, concept_set.name) - diff --git a/circe/check/checkers/events_progression_check.py b/circe/check/checkers/events_progression_check.py index 91476891..65d93687 100644 --- a/circe/check/checkers/events_progression_check.py +++ b/circe/check/checkers/events_progression_check.py @@ -20,6 +20,7 @@ from ...cohortdefinition.core import ResultLimit except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.core import ResultLimit @@ -27,49 +28,50 @@ class LimitType(Enum): """Limit type enum with weights. - + Java equivalent: org.ohdsi.circe.check.checkers.EventsProgressionCheck.LimitType """ + NONE = (0, None) EARLIEST = (0, "First") LATEST = (1, "Last") ALL = (2, "All") - + def __init__(self, weight: int, name: Optional[str]): """Initialize a limit type. - + Args: weight: The weight for progression comparison name: The name string for matching """ self._weight = weight self._name = name - + @property def weight(self) -> int: """Get the weight of this limit type. - + Returns: The weight value """ return self._weight - + @property def name(self) -> Optional[str]: """Get the name of this limit type. - + Returns: The name string """ return self._name - + @staticmethod - def from_name(name: Optional[str]) -> 'LimitType': + def from_name(name: Optional[str]) -> "LimitType": """Get a limit type from its name. - + Args: name: The name to match - + Returns: The matching LimitType, or NONE if not found """ @@ -83,56 +85,57 @@ def from_name(name: Optional[str]) -> 'LimitType': class EventsProgressionCheck(BaseCheck): """Check for event progression limit issues. - + Java equivalent: org.ohdsi.circe.check.checkers.EventsProgressionCheck """ - + WARNING = "%s limit may not have intended effect since it breaks all/latest/earliest progression" - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check for event progression issues. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ if not expression.primary_criteria: return - + initial_weight = self._get_weight(expression.primary_criteria.primary_limit) cohort_initial_weight = self._get_weight(expression.qualified_limit) - + # Qualifying limit is ignored when no additionalCriteria specified if expression.additional_criteria is not None: qualifying_weight = self._get_weight(expression.expression_limit) else: qualifying_weight = LimitType.NONE.weight - + if initial_weight - cohort_initial_weight < 0: reporter(self.WARNING, "Cohort of initial events") - - if (cohort_initial_weight - qualifying_weight < 0 or - initial_weight - qualifying_weight < 0): + + if ( + cohort_initial_weight - qualifying_weight < 0 + or initial_weight - qualifying_weight < 0 + ): reporter(self.WARNING, "Qualifying cohort") - - def _get_weight(self, limit: Optional['ResultLimit']) -> int: + + def _get_weight(self, limit: Optional["ResultLimit"]) -> int: """Get the weight for a result limit. - + Args: limit: The result limit to get weight for - + Returns: The weight value """ if limit is None or limit.type is None: return LimitType.NONE.weight return LimitType.from_name(limit.type).weight - diff --git a/circe/check/checkers/exit_criteria_check.py b/circe/check/checkers/exit_criteria_check.py index 655e2dc1..9d619fed 100644 --- a/circe/check/checkers/exit_criteria_check.py +++ b/circe/check/checkers/exit_criteria_check.py @@ -18,6 +18,7 @@ from ...cohortdefinition.core import CustomEraStrategy except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.core import CustomEraStrategy @@ -25,23 +26,23 @@ class ExitCriteriaCheck(BaseCheck): """Check for missing drug concept set in exit criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.ExitCriteriaCheck """ - + DRUG_CONCEPT_EMPTY_ERROR = "Drug concept set must be selected at Exit Criteria." - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check exit criteria for missing drug concept set. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ match_result = Operations.match(expression.end_strategy) match_result.is_a(CustomEraStrategy) - match_result.then(lambda s: Operations.match(s) - .when(lambda ces: ces.drug_codeset_id is None) - .then(lambda ces: reporter(self.DRUG_CONCEPT_EMPTY_ERROR)) - ) - + match_result.then( + lambda s: Operations.match(s) + .when(lambda ces: ces.drug_codeset_id is None) + .then(lambda ces: reporter(self.DRUG_CONCEPT_EMPTY_ERROR)) + ) diff --git a/circe/check/checkers/exit_criteria_days_offset_check.py b/circe/check/checkers/exit_criteria_days_offset_check.py index 212c92bf..4e8e56f8 100644 --- a/circe/check/checkers/exit_criteria_days_offset_check.py +++ b/circe/check/checkers/exit_criteria_days_offset_check.py @@ -19,6 +19,7 @@ from ...cohortdefinition.core import DateOffsetStrategy, DateType except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.core import DateOffsetStrategy, DateType @@ -26,31 +27,33 @@ class ExitCriteriaDaysOffsetCheck(BaseCheck): """Check for invalid days offset in exit criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.ExitCriteriaDaysOffsetCheck """ - - DAYS_OFFSET_WARNING = "Cohort Exit criteria: Days offset from start date should be greater than 0" - + + DAYS_OFFSET_WARNING = ( + "Cohort Exit criteria: Days offset from start date should be greater than 0" + ) + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check exit criteria days offset. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ match_result = Operations.match(expression.end_strategy) match_result.is_a(DateOffsetStrategy) - match_result.then(lambda s: Operations.match(s) - .when(lambda dos: dos.date_field == DateType.START_DATE and dos.offset == 0) - .then(lambda dos: reporter(self.DAYS_OFFSET_WARNING)) - ) - + match_result.then( + lambda s: Operations.match(s) + .when(lambda dos: dos.date_field == DateType.START_DATE and dos.offset == 0) + .then(lambda dos: reporter(self.DAYS_OFFSET_WARNING)) + ) diff --git a/circe/check/checkers/incomplete_rule_check.py b/circe/check/checkers/incomplete_rule_check.py index 242a810d..07b3671e 100644 --- a/circe/check/checkers/incomplete_rule_check.py +++ b/circe/check/checkers/incomplete_rule_check.py @@ -21,6 +21,7 @@ from ...cohortdefinition.criteria import InclusionRule except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import InclusionRule @@ -28,27 +29,31 @@ class IncompleteRuleCheck(BaseCheck): """Check for incomplete inclusion rules. - + Java equivalent: org.ohdsi.circe.check.checkers.IncompleteRuleCheck """ - - def _get_reporter(self, severity: WarningSeverity, warnings: List[Warning]) -> WarningReporter: + + def _get_reporter( + self, severity: WarningSeverity, warnings: List[Warning] + ) -> WarningReporter: """Get a warning reporter that creates IncompleteRuleWarning instances. - + Args: severity: The severity level warnings: The list to add warnings to - + Returns: A WarningReporter that creates IncompleteRuleWarning instances """ + def reporter(name: str, *args) -> None: warnings.append(IncompleteRuleWarning(severity, name)) + return reporter - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check for incomplete inclusion rules. - + Args: expression: The cohort expression to check reporter: The warning reporter to use @@ -56,19 +61,26 @@ def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> N if expression.inclusion_rules: for rule in expression.inclusion_rules: self._check_inclusion_rule(rule, reporter) - - def _check_inclusion_rule(self, rule: 'InclusionRule', reporter: WarningReporter) -> None: + + def _check_inclusion_rule( + self, rule: "InclusionRule", reporter: WarningReporter + ) -> None: """Check if an inclusion rule is incomplete. - + Args: rule: The inclusion rule to check reporter: The warning reporter to use """ # Check if expression is empty if not rule.expression or ( - (not hasattr(rule.expression, 'criteria_list') or not rule.expression.criteria_list) and - (not hasattr(rule.expression, 'demographic_criteria_list') or not rule.expression.demographic_criteria_list) and - (not hasattr(rule.expression, 'groups') or not rule.expression.groups) + ( + not hasattr(rule.expression, "criteria_list") + or not rule.expression.criteria_list + ) + and ( + not hasattr(rule.expression, "demographic_criteria_list") + or not rule.expression.demographic_criteria_list + ) + and (not hasattr(rule.expression, "groups") or not rule.expression.groups) ): reporter(rule.name) - diff --git a/circe/check/checkers/initial_event_check.py b/circe/check/checkers/initial_event_check.py index df98ffbe..c25ef33d 100644 --- a/circe/check/checkers/initial_event_check.py +++ b/circe/check/checkers/initial_event_check.py @@ -17,28 +17,30 @@ from ...cohortdefinition.cohort import CohortExpression except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression class InitialEventCheck(BaseCheck): """Check for missing initial event criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.InitialEventCheck """ - + NO_INITIAL_EVENT_ERROR = "No initial event criteria specified" - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check for missing initial event criteria. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ match_result = Operations.match(expression) - match_result.when(lambda e: e.primary_criteria is None or - e.primary_criteria.criteria_list is None or - len(e.primary_criteria.criteria_list) == 0) + match_result.when( + lambda e: e.primary_criteria is None + or e.primary_criteria.criteria_list is None + or len(e.primary_criteria.criteria_list) == 0 + ) match_result.then(lambda e: reporter(self.NO_INITIAL_EVENT_ERROR)) - diff --git a/circe/check/checkers/no_exit_criteria_check.py b/circe/check/checkers/no_exit_criteria_check.py index 96cf3b98..b9d08428 100644 --- a/circe/check/checkers/no_exit_criteria_check.py +++ b/circe/check/checkers/no_exit_criteria_check.py @@ -18,45 +18,55 @@ from ...cohortdefinition.cohort import CohortExpression except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression class NoExitCriteriaCheck(BaseCheck): """Check for missing exit criteria when all events are selected. - + Java equivalent: org.ohdsi.circe.check.checkers.NoExitCriteriaCheck """ - - NO_EXIT_CRITERIA_WARNING = " \"all events\" are selected and cohort exit criteria has not been specified" - + + NO_EXIT_CRITERIA_WARNING = ( + ' "all events" are selected and cohort exit criteria has not been specified' + ) + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check for missing exit criteria. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ match_result = Operations.match(expression) - match_result.when(lambda e: ( - e.primary_criteria and - e.primary_criteria.primary_limit and - e.primary_criteria.primary_limit.type and - e.primary_criteria.primary_limit.type.upper() == "ALL" and - e.end_strategy is None and - e.expression_limit and - e.expression_limit.type and - e.expression_limit.type.upper() == "ALL" and - (e.additional_criteria is None or - (e.qualified_limit and e.qualified_limit.type and e.qualified_limit.type.upper() == "ALL")) - )) + match_result.when( + lambda e: ( + e.primary_criteria + and e.primary_criteria.primary_limit + and e.primary_criteria.primary_limit.type + and e.primary_criteria.primary_limit.type.upper() == "ALL" + and e.end_strategy is None + and e.expression_limit + and e.expression_limit.type + and e.expression_limit.type.upper() == "ALL" + and ( + e.additional_criteria is None + or ( + e.qualified_limit + and e.qualified_limit.type + and e.qualified_limit.type.upper() == "ALL" + ) + ) + ) + ) match_result.then(lambda e: reporter(self.NO_EXIT_CRITERIA_WARNING)) - diff --git a/circe/check/checkers/ocurrence_check.py b/circe/check/checkers/ocurrence_check.py index c7a1747d..5fdebdda 100644 --- a/circe/check/checkers/ocurrence_check.py +++ b/circe/check/checkers/ocurrence_check.py @@ -18,30 +18,33 @@ from ...cohortdefinition.criteria import CorelatedCriteria, Occurrence except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.criteria import CorelatedCriteria, Occurrence class OcurrenceCheck(BaseCorelatedCriteriaCheck): """Check for invalid occurrence values (at least 0). - + Java equivalent: org.ohdsi.circe.check.checkers.OcurrenceCheck """ - + AT_LEAST_0_WARNING = "'at least 0' occurrence is not a real constraint, probably meant 'exactly 0' or 'at least 1'" AT_LEAST = 2 - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria( + self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + ) -> None: """Check occurrence for invalid values. - + Args: criteria: The corelated criteria to check group_name: The name of the group containing this criteria @@ -51,4 +54,3 @@ def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, report match_result = Operations.match(criteria.occurrence) match_result.when(lambda o: o.type == self.AT_LEAST and o.count == 0) match_result.then(lambda o: reporter(self.AT_LEAST_0_WARNING)) - diff --git a/circe/check/checkers/range_check.py b/circe/check/checkers/range_check.py index da2e29a6..09b15b5b 100644 --- a/circe/check/checkers/range_check.py +++ b/circe/check/checkers/range_check.py @@ -21,6 +21,7 @@ from ...cohortdefinition.core import ObservationFilter, Window except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import CorelatedCriteria @@ -29,59 +30,71 @@ class RangeCheck(BaseValueCheck): """Check for invalid range values in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.RangeCheck """ - - NEGATIVE_VALUE_ERROR = "Time window in criteria \"%s\" has negative value %d at %s" - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + NEGATIVE_VALUE_ERROR = 'Time window in criteria "%s" has negative value %d at %s' + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check range values in the expression. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ super()._check(expression, reporter) - RangeCheckerFactory.get_factory(reporter, self.PRIMARY_CRITERIA).check(expression) - + RangeCheckerFactory.get_factory(reporter, self.PRIMARY_CRITERIA).check( + expression + ) + if expression.primary_criteria: self._check_observation_filter( - expression.primary_criteria.observation_window, - reporter, - "observation window" + expression.primary_criteria.observation_window, + reporter, + "observation window", ) - + RangeCheckerFactory.get_factory(reporter, self.PRIMARY_CRITERIA).check_range( expression.censor_window, "cohort", "censor window" ) - - def _check_inclusion_rules(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check_inclusion_rules( + self, expression: "CohortExpression", reporter: WarningReporter + ) -> None: """Check inclusion rules for window issues. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ super()._check_inclusion_rules(expression, reporter) - + if expression.inclusion_rules: for rule in expression.inclusion_rules: if rule.expression and rule.expression.criteria_list: for criteria in rule.expression.criteria_list: # Handle both dict and CorelatedCriteria objects if isinstance(criteria, dict): - start_window = criteria.get('startWindow') or criteria.get('start_window') - end_window = criteria.get('endWindow') or criteria.get('end_window') + start_window = criteria.get("startWindow") or criteria.get( + "start_window" + ) + end_window = criteria.get("endWindow") or criteria.get( + "end_window" + ) else: - start_window = getattr(criteria, 'start_window', None) or getattr(criteria, 'startWindow', None) - end_window = getattr(criteria, 'end_window', None) or getattr(criteria, 'endWindow', None) + start_window = getattr( + criteria, "start_window", None + ) or getattr(criteria, "startWindow", None) + end_window = getattr( + criteria, "end_window", None + ) or getattr(criteria, "endWindow", None) self._check_window(start_window, reporter, rule.name) self._check_window(end_window, reporter, rule.name) - + def _check_window(self, window, reporter: WarningReporter, name: str) -> None: """Check a window for negative values. - + Args: window: The window to check (Window object or dict) reporter: The warning reporter to use @@ -90,33 +103,47 @@ def _check_window(self, window, reporter: WarningReporter, name: str) -> None: if window: # Handle dict windows if isinstance(window, dict): - start = window.get('start') or window.get('Start') - end = window.get('end') or window.get('End') - + start = window.get("start") or window.get("Start") + end = window.get("end") or window.get("End") + if start: - start_days = start.get('days') if isinstance(start, dict) else getattr(start, 'days', None) + start_days = ( + start.get("days") + if isinstance(start, dict) + else getattr(start, "days", None) + ) if start_days is not None and start_days < 0: reporter(self.NEGATIVE_VALUE_ERROR, name, start_days, "start") - + if end: - end_days = end.get('days') if isinstance(end, dict) else getattr(end, 'days', None) + end_days = ( + end.get("days") + if isinstance(end, dict) + else getattr(end, "days", None) + ) if end_days is not None and end_days < 0: reporter(self.NEGATIVE_VALUE_ERROR, name, end_days, "end") else: # Window object - if window.start and window.start.days is not None and window.start.days < 0: - reporter(self.NEGATIVE_VALUE_ERROR, name, window.start.days, "start") + if ( + window.start + and window.start.days is not None + and window.start.days < 0 + ): + reporter( + self.NEGATIVE_VALUE_ERROR, name, window.start.days, "start" + ) if window.end and window.end.days is not None and window.end.days < 0: reporter(self.NEGATIVE_VALUE_ERROR, name, window.end.days, "end") - + def _check_observation_filter( - self, - filter_val: Optional['ObservationFilter'], - reporter: WarningReporter, - name: str + self, + filter_val: Optional["ObservationFilter"], + reporter: WarningReporter, + name: str, ) -> None: """Check an observation filter for negative values. - + Args: filter_val: The observation filter to check reporter: The warning reporter to use @@ -124,41 +151,48 @@ def _check_observation_filter( """ if filter_val: if filter_val.prior_days < 0: - reporter(self.NEGATIVE_VALUE_ERROR, name, filter_val.prior_days, "prior days") + reporter( + self.NEGATIVE_VALUE_ERROR, name, filter_val.prior_days, "prior days" + ) if filter_val.post_days < 0: - reporter(self.NEGATIVE_VALUE_ERROR, name, filter_val.post_days, "post days") - + reporter( + self.NEGATIVE_VALUE_ERROR, name, filter_val.post_days, "post days" + ) + def _check_criteria(self, criteria, reporter: WarningReporter, name: str) -> None: """Check a corelated criteria for window issues. - + Args: criteria: The criteria to check (CorelatedCriteria or dict) reporter: The warning reporter to use name: The name of the criteria """ super()._check_criteria(criteria, reporter, name) - + # Handle both dict and CorelatedCriteria objects if isinstance(criteria, dict): - start_window = criteria.get('startWindow') or criteria.get('start_window') - end_window = criteria.get('endWindow') or criteria.get('end_window') + start_window = criteria.get("startWindow") or criteria.get("start_window") + end_window = criteria.get("endWindow") or criteria.get("end_window") else: # CorelatedCriteria object - start_window = getattr(criteria, 'start_window', None) or getattr(criteria, 'startWindow', None) - end_window = getattr(criteria, 'end_window', None) or getattr(criteria, 'endWindow', None) - + start_window = getattr(criteria, "start_window", None) or getattr( + criteria, "startWindow", None + ) + end_window = getattr(criteria, "end_window", None) or getattr( + criteria, "endWindow", None + ) + self._check_window(start_window, reporter, name) self._check_window(end_window, reporter, name) - + def _get_factory(self, reporter: WarningReporter, name: str) -> RangeCheckerFactory: """Get a range checker factory. - + Args: reporter: The warning reporter to use name: The name of the criteria group - + Returns: A RangeCheckerFactory instance """ return RangeCheckerFactory.get_factory(reporter, name) - diff --git a/circe/check/checkers/range_checker_factory.py b/circe/check/checkers/range_checker_factory.py index b1602320..f00e833d 100644 --- a/circe/check/checkers/range_checker_factory.py +++ b/circe/check/checkers/range_checker_factory.py @@ -18,21 +18,50 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.criteria import ( - Criteria, DemographicCriteria, ConditionEra, ConditionOccurrence, - Death, DeviceExposure, DoseEra, DrugEra, DrugExposure, Measurement, - Observation, ObservationPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, PayerPlanPeriod, LocationRegion + Criteria, + DemographicCriteria, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + PayerPlanPeriod, + LocationRegion, ) from ...cohortdefinition.core import NumericRange, DateRange, Period from ...cohortdefinition.cohort import CohortExpression except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.criteria import ( - Criteria, DemographicCriteria, ConditionEra, ConditionOccurrence, - Death, DeviceExposure, DoseEra, DrugEra, DrugExposure, Measurement, - Observation, ObservationPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, PayerPlanPeriod, LocationRegion + Criteria, + DemographicCriteria, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + PayerPlanPeriod, + LocationRegion, ) from ...cohortdefinition.core import NumericRange, DateRange, Period from ...cohortdefinition.cohort import CohortExpression @@ -40,200 +69,548 @@ class RangeCheckerFactory(BaseCheckerFactory): """Factory for checking range values in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.RangeCheckerFactory """ - + WARNING_EMPTY_START_VALUE = "%s in the %s has empty %s start value" WARNING_EMPTY_END_VALUE = "%s in the %s has empty %s end value" - WARNING_START_GREATER_THAN_END = "%s in the %s has start value greater than end in %s" + WARNING_START_GREATER_THAN_END = ( + "%s in the %s has start value greater than end in %s" + ) WARNING_START_IS_NEGATIVE = "%s in the %s start value is negative at %s" WARNING_DATE_IS_INVALID = "%s in the %s has invalid date value at %s" ROOT_OBJECT = "root object" - + def __init__(self, reporter: WarningReporter, group_name: str): """Initialize a range checker factory. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked """ super().__init__(reporter, group_name) - + @staticmethod - def get_factory(reporter: WarningReporter, group_name: str) -> 'RangeCheckerFactory': + def get_factory( + reporter: WarningReporter, group_name: str + ) -> "RangeCheckerFactory": """Get a factory instance. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked - + Returns: A new RangeCheckerFactory instance """ return RangeCheckerFactory(reporter, group_name) - - def _get_check_criteria(self, criteria: 'Criteria') -> Callable[['Criteria'], None]: + + def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], None]: """Get a checker function for criteria. - + Args: criteria: The criteria to get a checker for - + Returns: A function that checks the criteria """ # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ObservationPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, PayerPlanPeriod, LocationRegion + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + PayerPlanPeriod, + LocationRegion, ) - + if isinstance(criteria, ConditionEra): - def check(c: 'ConditionEra') -> None: - self._check_range(c.age_at_start, Constants.Criteria.CONDITION_ERA, Constants.Attributes.AGE_AT_ERA_START_ATTR) - self._check_range(c.age_at_end, Constants.Criteria.CONDITION_ERA, Constants.Attributes.AGE_AT_ERA_END_ATTR) - self._check_range(c.era_length, Constants.Criteria.CONDITION_ERA, Constants.Attributes.ERA_LENGTH_ATTR) - self._check_range(c.occurrence_count, Constants.Criteria.CONDITION_ERA, Constants.Attributes.OCCURRENCE_COUNT_ATTR) - self._check_range(c.era_start_date, Constants.Criteria.CONDITION_ERA, Constants.Attributes.ERA_START_DATE_ATTR) - self._check_range(c.era_end_date, Constants.Criteria.CONDITION_ERA, Constants.Attributes.ERA_END_DATE_ATTR) + + def check(c: "ConditionEra") -> None: + self._check_range( + c.age_at_start, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.AGE_AT_ERA_START_ATTR, + ) + self._check_range( + c.age_at_end, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.AGE_AT_ERA_END_ATTR, + ) + self._check_range( + c.era_length, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.ERA_LENGTH_ATTR, + ) + self._check_range( + c.occurrence_count, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.OCCURRENCE_COUNT_ATTR, + ) + self._check_range( + c.era_start_date, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.ERA_START_DATE_ATTR, + ) + self._check_range( + c.era_end_date, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.ERA_END_DATE_ATTR, + ) + return check elif isinstance(criteria, ConditionOccurrence): - def check(c: 'ConditionOccurrence') -> None: - self._check_range(c.occurrence_start_date, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.OCCURRENCE_START_DATE_ATTR) - self._check_range(c.occurrence_end_date, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.OCCURRENCE_END_DATE_ATTR) - self._check_range(c.age, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.AGE_ATTR) + + def check(c: "ConditionOccurrence") -> None: + self._check_range( + c.occurrence_start_date, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ) + self._check_range( + c.occurrence_end_date, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.OCCURRENCE_END_DATE_ATTR, + ) + self._check_range( + c.age, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.AGE_ATTR, + ) + return check elif isinstance(criteria, Death): - def check(c: 'Death') -> None: - self._check_range(c.age, Constants.Criteria.DEATH, Constants.Attributes.AGE_ATTR) - self._check_range(c.occurrence_start_date, Constants.Criteria.DEATH, Constants.Attributes.OCCURRENCE_START_DATE_ATTR) + + def check(c: "Death") -> None: + self._check_range( + c.age, Constants.Criteria.DEATH, Constants.Attributes.AGE_ATTR + ) + self._check_range( + c.occurrence_start_date, + Constants.Criteria.DEATH, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ) + return check elif isinstance(criteria, DeviceExposure): - def check(c: 'DeviceExposure') -> None: - self._check_range(c.occurrence_start_date, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.OCCURRENCE_START_DATE_ATTR) - self._check_range(c.occurrence_end_date, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.OCCURRENCE_END_DATE_ATTR) - self._check_range(c.quantity, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.QUANTITY_ATTR) - self._check_range(c.age, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.AGE_ATTR) + + def check(c: "DeviceExposure") -> None: + self._check_range( + c.occurrence_start_date, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ) + self._check_range( + c.occurrence_end_date, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.OCCURRENCE_END_DATE_ATTR, + ) + self._check_range( + c.quantity, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.QUANTITY_ATTR, + ) + self._check_range( + c.age, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.AGE_ATTR, + ) + return check elif isinstance(criteria, DoseEra): - def check(c: 'DoseEra') -> None: - self._check_range(c.era_start_date, Constants.Criteria.DOSE_ERA, Constants.Attributes.ERA_START_DATE_ATTR) - self._check_range(c.era_end_date, Constants.Criteria.DOSE_ERA, Constants.Attributes.ERA_END_DATE_ATTR) - self._check_range(c.dose_value, Constants.Criteria.DOSE_ERA, Constants.Attributes.DOSE_VALUE_ATTR) - self._check_range(c.era_length, Constants.Criteria.DOSE_ERA, Constants.Attributes.ERA_LENGTH_ATTR) - self._check_range(c.age_at_start, Constants.Criteria.DOSE_ERA, Constants.Attributes.AGE_AT_START_ATTR) - self._check_range(c.age_at_end, Constants.Criteria.DOSE_ERA, Constants.Attributes.AGE_AT_END_ATTR) + + def check(c: "DoseEra") -> None: + self._check_range( + c.era_start_date, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.ERA_START_DATE_ATTR, + ) + self._check_range( + c.era_end_date, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.ERA_END_DATE_ATTR, + ) + self._check_range( + c.dose_value, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.DOSE_VALUE_ATTR, + ) + self._check_range( + c.era_length, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.ERA_LENGTH_ATTR, + ) + self._check_range( + c.age_at_start, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.AGE_AT_START_ATTR, + ) + self._check_range( + c.age_at_end, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.AGE_AT_END_ATTR, + ) + return check elif isinstance(criteria, DrugEra): - def check(c: 'DrugEra') -> None: - self._check_range(c.era_start_date, Constants.Criteria.DRUG_ERA, Constants.Attributes.ERA_START_DATE_ATTR) - self._check_range(c.era_end_date, Constants.Criteria.DRUG_ERA, Constants.Attributes.ERA_END_DATE_ATTR) - self._check_range(c.occurrence_count, Constants.Criteria.DRUG_ERA, Constants.Attributes.OCCURRENCE_COUNT_ATTR) - self._check_range(c.gap_days, Constants.Criteria.DRUG_ERA, Constants.Attributes.GAP_DAYS_ATTR) - self._check_range(c.era_length, Constants.Criteria.DRUG_ERA, Constants.Attributes.ERA_LENGTH_ATTR) - self._check_range(c.age_at_start, Constants.Criteria.DRUG_ERA, Constants.Attributes.AGE_AT_START_ATTR) - self._check_range(c.age_at_end, Constants.Criteria.DRUG_ERA, Constants.Attributes.AGE_AT_END_ATTR) + + def check(c: "DrugEra") -> None: + self._check_range( + c.era_start_date, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.ERA_START_DATE_ATTR, + ) + self._check_range( + c.era_end_date, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.ERA_END_DATE_ATTR, + ) + self._check_range( + c.occurrence_count, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.OCCURRENCE_COUNT_ATTR, + ) + self._check_range( + c.gap_days, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.GAP_DAYS_ATTR, + ) + self._check_range( + c.era_length, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.ERA_LENGTH_ATTR, + ) + self._check_range( + c.age_at_start, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.AGE_AT_START_ATTR, + ) + self._check_range( + c.age_at_end, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.AGE_AT_END_ATTR, + ) + return check elif isinstance(criteria, DrugExposure): - def check(c: 'DrugExposure') -> None: - self._check_range(c.occurrence_start_date, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.OCCURRENCE_START_DATE_ATTR) - self._check_range(c.occurrence_end_date, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.OCCURRENCE_END_DATE_ATTR) - self._check_range(c.refills, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.REFILLS_ATTR) - self._check_range(c.quantity, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.QUANTITY_ATTR) - self._check_range(c.days_supply, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.DAYS_SUPPLY_ATTR) - self._check_range(c.effective_drug_dose, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.EFFECTIVE_DRUG_DOSE_ATTR) - self._check_range(c.age, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.AGE_ATTR) + + def check(c: "DrugExposure") -> None: + self._check_range( + c.occurrence_start_date, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ) + self._check_range( + c.occurrence_end_date, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.OCCURRENCE_END_DATE_ATTR, + ) + self._check_range( + c.refills, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.REFILLS_ATTR, + ) + self._check_range( + c.quantity, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.QUANTITY_ATTR, + ) + self._check_range( + c.days_supply, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.DAYS_SUPPLY_ATTR, + ) + self._check_range( + c.effective_drug_dose, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.EFFECTIVE_DRUG_DOSE_ATTR, + ) + self._check_range( + c.age, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.AGE_ATTR, + ) + return check elif isinstance(criteria, Measurement): - def check(c: 'Measurement') -> None: - self._check_range(c.occurrence_start_date, Constants.Criteria.MEASUREMENT, Constants.Attributes.OCCURRENCE_START_DATE_ATTR) - self._check_range(c.value_as_number, Constants.Criteria.MEASUREMENT, Constants.Attributes.VALUE_AS_NUMBER_ATTR) - self._check_range(c.range_low, Constants.Criteria.MEASUREMENT, Constants.Attributes.RANGE_LOW_ATTR) - self._check_range(c.range_high, Constants.Criteria.MEASUREMENT, Constants.Attributes.RANGE_HIGH_ATTR) - self._check_range(c.range_low_ratio, Constants.Criteria.MEASUREMENT, Constants.Attributes.RANGE_LOW_RATIO_ATTR) - self._check_range(c.range_high_ratio, Constants.Criteria.MEASUREMENT, Constants.Attributes.RANGE_HIGH_RATIO_ATTR) - self._check_range(c.age, Constants.Criteria.MEASUREMENT, Constants.Attributes.AGE_ATTR) + + def check(c: "Measurement") -> None: + self._check_range( + c.occurrence_start_date, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ) + self._check_range( + c.value_as_number, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.VALUE_AS_NUMBER_ATTR, + ) + self._check_range( + c.range_low, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.RANGE_LOW_ATTR, + ) + self._check_range( + c.range_high, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.RANGE_HIGH_ATTR, + ) + self._check_range( + c.range_low_ratio, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.RANGE_LOW_RATIO_ATTR, + ) + self._check_range( + c.range_high_ratio, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.RANGE_HIGH_RATIO_ATTR, + ) + self._check_range( + c.age, Constants.Criteria.MEASUREMENT, Constants.Attributes.AGE_ATTR + ) + return check elif isinstance(criteria, Observation): - def check(c: 'Observation') -> None: - self._check_range(c.occurrence_start_date, Constants.Criteria.OBSERVATION, Constants.Attributes.OCCURRENCE_START_DATE_ATTR) - self._check_range(c.value_as_number, Constants.Criteria.OBSERVATION, Constants.Attributes.VALUE_AS_NUMBER_ATTR) - self._check_range(c.age, Constants.Criteria.OBSERVATION, Constants.Attributes.AGE_ATTR) + + def check(c: "Observation") -> None: + self._check_range( + c.occurrence_start_date, + Constants.Criteria.OBSERVATION, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ) + self._check_range( + c.value_as_number, + Constants.Criteria.OBSERVATION, + Constants.Attributes.VALUE_AS_NUMBER_ATTR, + ) + self._check_range( + c.age, Constants.Criteria.OBSERVATION, Constants.Attributes.AGE_ATTR + ) + return check elif isinstance(criteria, ObservationPeriod): - def check(c: 'ObservationPeriod') -> None: - self._check_range(c.period_start_date, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.PERIOD_START_DATE_ATTR) - self._check_range(c.period_end_date, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.PERIOD_END_DATE_ATTR) - self._check_range(c.period_length, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.PERIOD_LENGTH_ATTR) - self._check_range(c.age_at_start, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.AGE_AT_START_ATTR) - self._check_range(c.age_at_end, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.AGE_AT_END_ATTR) - self._check_range(c.user_defined_period, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.USER_DEFINED_PERIOD_ATTR) + + def check(c: "ObservationPeriod") -> None: + self._check_range( + c.period_start_date, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.PERIOD_START_DATE_ATTR, + ) + self._check_range( + c.period_end_date, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.PERIOD_END_DATE_ATTR, + ) + self._check_range( + c.period_length, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.PERIOD_LENGTH_ATTR, + ) + self._check_range( + c.age_at_start, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.AGE_AT_START_ATTR, + ) + self._check_range( + c.age_at_end, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.AGE_AT_END_ATTR, + ) + self._check_range( + c.user_defined_period, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.USER_DEFINED_PERIOD_ATTR, + ) + return check elif isinstance(criteria, ProcedureOccurrence): - def check(c: 'ProcedureOccurrence') -> None: - self._check_range(c.occurrence_start_date, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.OCCURRENCE_START_DATE_ATTR) - self._check_range(c.quantity, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.QUANTITY_ATTR) - self._check_range(c.age, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.AGE_ATTR) + + def check(c: "ProcedureOccurrence") -> None: + self._check_range( + c.occurrence_start_date, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ) + self._check_range( + c.quantity, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.QUANTITY_ATTR, + ) + self._check_range( + c.age, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.AGE_ATTR, + ) + return check elif isinstance(criteria, Specimen): - def check(c: 'Specimen') -> None: - self._check_range(c.occurrence_start_date, Constants.Criteria.SPECIMEN, Constants.Attributes.OCCURRENCE_START_DATE_ATTR) - self._check_range(c.quantity, Constants.Criteria.SPECIMEN, Constants.Attributes.QUANTITY_ATTR) - self._check_range(c.age, Constants.Criteria.SPECIMEN, Constants.Attributes.AGE_ATTR) + + def check(c: "Specimen") -> None: + self._check_range( + c.occurrence_start_date, + Constants.Criteria.SPECIMEN, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ) + self._check_range( + c.quantity, + Constants.Criteria.SPECIMEN, + Constants.Attributes.QUANTITY_ATTR, + ) + self._check_range( + c.age, Constants.Criteria.SPECIMEN, Constants.Attributes.AGE_ATTR + ) + return check elif isinstance(criteria, VisitOccurrence): - def check(c: 'VisitOccurrence') -> None: - self._check_range(c.occurrence_start_date, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.OCCURRENCE_START_DATE_ATTR) - self._check_range(c.occurrence_end_date, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.OCCURRENCE_END_DATE_ATTR) - self._check_range(c.visit_length, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.VISIT_LENGTH_ATTR) - self._check_range(c.age, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.AGE_ATTR) + + def check(c: "VisitOccurrence") -> None: + self._check_range( + c.occurrence_start_date, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ) + self._check_range( + c.occurrence_end_date, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.OCCURRENCE_END_DATE_ATTR, + ) + self._check_range( + c.visit_length, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.VISIT_LENGTH_ATTR, + ) + self._check_range( + c.age, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.AGE_ATTR, + ) + return check elif isinstance(criteria, VisitDetail): - def check(c: 'VisitDetail') -> None: - self._check_range(c.visit_detail_start_date, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.VISIT_DETAIL_START_DATE_ATTR) - self._check_range(c.visit_detail_end_date, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.VISIT_DETAIL_END_DATE_ATTR) - self._check_range(c.visit_detail_length, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.VISIT_DETAIL_LENGTH_ATTR) - self._check_range(c.age, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.AGE_ATTR) + + def check(c: "VisitDetail") -> None: + self._check_range( + c.visit_detail_start_date, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.VISIT_DETAIL_START_DATE_ATTR, + ) + self._check_range( + c.visit_detail_end_date, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.VISIT_DETAIL_END_DATE_ATTR, + ) + self._check_range( + c.visit_detail_length, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.VISIT_DETAIL_LENGTH_ATTR, + ) + self._check_range( + c.age, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.AGE_ATTR, + ) + return check elif isinstance(criteria, PayerPlanPeriod): - def check(c: 'PayerPlanPeriod') -> None: - self._check_range(c.period_start_date, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.PERIOD_START_DATE_ATTR) - self._check_range(c.period_end_date, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.PERIOD_END_DATE_ATTR) - self._check_range(c.period_length, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.PERIOD_LENGTH_ATTR) - self._check_range(c.age_at_start, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.AGE_AT_START_ATTR) - self._check_range(c.age_at_end, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.AGE_AT_END_ATTR) - self._check_range(c.user_defined_period, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.USER_DEFINED_PERIOD_ATTR) + + def check(c: "PayerPlanPeriod") -> None: + self._check_range( + c.period_start_date, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.PERIOD_START_DATE_ATTR, + ) + self._check_range( + c.period_end_date, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.PERIOD_END_DATE_ATTR, + ) + self._check_range( + c.period_length, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.PERIOD_LENGTH_ATTR, + ) + self._check_range( + c.age_at_start, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.AGE_AT_START_ATTR, + ) + self._check_range( + c.age_at_end, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.AGE_AT_END_ATTR, + ) + self._check_range( + c.user_defined_period, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.USER_DEFINED_PERIOD_ATTR, + ) + return check elif isinstance(criteria, LocationRegion): - def check(c: 'LocationRegion') -> None: - self._check_range(c.end_date, Constants.Criteria.LOCATION_REGION, Constants.Attributes.LOCATION_REGION_START_DATE_ATTR) - self._check_range(c.start_date, Constants.Criteria.LOCATION_REGION, Constants.Attributes.LOCATION_REGION_END_DATE_ATTR) + + def check(c: "LocationRegion") -> None: + self._check_range( + c.end_date, + Constants.Criteria.LOCATION_REGION, + Constants.Attributes.LOCATION_REGION_START_DATE_ATTR, + ) + self._check_range( + c.start_date, + Constants.Criteria.LOCATION_REGION, + Constants.Attributes.LOCATION_REGION_END_DATE_ATTR, + ) + return check else: + def default_check(c) -> None: pass + return default_check - - def _get_check_demographic(self, criteria: 'DemographicCriteria') -> Callable[['DemographicCriteria'], None]: + + def _get_check_demographic( + self, criteria: "DemographicCriteria" + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. - + Args: criteria: The demographic criteria to get a checker for - + Returns: A function that checks the criteria """ - def check(c: 'DemographicCriteria') -> None: - self._check_range(c.occurrence_end_date, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.OCCURRENCE_END_DATE_ATTR) - self._check_range(c.occurrence_start_date, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.OCCURRENCE_START_DATE_ATTR) - self._check_range(c.age, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.AGE_ATTR) + + def check(c: "DemographicCriteria") -> None: + self._check_range( + c.occurrence_end_date, + Constants.Criteria.DEMOGRAPHIC, + Constants.Attributes.OCCURRENCE_END_DATE_ATTR, + ) + self._check_range( + c.occurrence_start_date, + Constants.Criteria.DEMOGRAPHIC, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ) + self._check_range( + c.age, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.AGE_ATTR + ) + return check - + def _check_range(self, range_val, criteria_name: str, attribute: str) -> None: """Check a range (supports both NumericRange and DateRange). - + Args: range_val: The range to check (NumericRange or DateRange) criteria_name: The name of the criteria type @@ -241,53 +618,61 @@ def _check_range(self, range_val, criteria_name: str, attribute: str) -> None: """ if range_val is None: return - + # Import here to avoid circular dependencies from ...cohortdefinition.core import NumericRange, DateRange - + def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - + if isinstance(range_val, DateRange): # Date range checks match_result = Operations.match(range_val) - match_result.when(lambda r: r.value is not None and not Comparisons.is_date_valid(r.value))\ + match_result.when( + lambda r: r.value is not None and not Comparisons.is_date_valid(r.value) + ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) + match_result.when(lambda r: r.op is not None and r.op.endswith("bt")).then( + lambda r: Operations.match(r) + .when(lambda x: x.value is None) + .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + .when(lambda x: x.extent is None) + .then(lambda x: warning(self.WARNING_EMPTY_END_VALUE)) + .when( + lambda x: x.extent is not None + and not Comparisons.is_date_valid(x.extent) + ) .then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) - match_result.when(lambda r: r.op is not None and r.op.endswith("bt"))\ - .then(lambda r: Operations.match(r) - .when(lambda x: x.value is None) - .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) - .when(lambda x: x.extent is None) - .then(lambda x: warning(self.WARNING_EMPTY_END_VALUE)) - .when(lambda x: x.extent is not None and not Comparisons.is_date_valid(x.extent)) - .then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) - .when(Comparisons.start_is_greater_than_end) - .then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) - ) - match_result.or_else(lambda r: Operations.match(r) + .when(Comparisons.start_is_greater_than_end) + .then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) + ) + match_result.or_else( + lambda r: Operations.match(r) .when(lambda x: x.value is None) .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) ) elif isinstance(range_val, NumericRange): # Numeric range checks match_result = Operations.match(range_val) - match_result.when(lambda r: r.op is not None and r.op.endswith("bt"))\ - .then(lambda r: Operations.match(r) - .when(lambda x: x.value is None) - .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) - .when(lambda x: x.extent is None) - .then(lambda x: warning(self.WARNING_EMPTY_END_VALUE)) - .when(Comparisons.start_is_greater_than_end) - .then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) - ) - match_result.or_else(lambda r: Operations.match(r) + match_result.when(lambda r: r.op is not None and r.op.endswith("bt")).then( + lambda r: Operations.match(r) .when(lambda x: x.value is None) .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + .when(lambda x: x.extent is None) + .then(lambda x: warning(self.WARNING_EMPTY_END_VALUE)) + .when(Comparisons.start_is_greater_than_end) + .then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) ) - - def check_range(self, period: Optional['Period'], criteria_name: str, attribute: str) -> None: + match_result.or_else( + lambda r: Operations.match(r) + .when(lambda x: x.value is None) + .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + ) + + def check_range( + self, period: Optional["Period"], criteria_name: str, attribute: str + ) -> None: """Check a period. - + Args: period: The period to check criteria_name: The name of the criteria type @@ -295,36 +680,44 @@ def check_range(self, period: Optional['Period'], criteria_name: str, attribute: """ if period is None: return - + def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - + match_result = Operations.match(period) - match_result.when(lambda x: x.start_date is not None and not Comparisons.is_date_valid(x.start_date))\ - .then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) - match_result.when(lambda x: x.end_date is not None and not Comparisons.is_date_valid(x.end_date))\ - .then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) - match_result.when(Comparisons.start_is_greater_than_end)\ - .then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) - + match_result.when( + lambda x: x.start_date is not None + and not Comparisons.is_date_valid(x.start_date) + ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) + match_result.when( + lambda x: x.end_date is not None + and not Comparisons.is_date_valid(x.end_date) + ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) + match_result.when(Comparisons.start_is_greater_than_end).then( + lambda x: warning(self.WARNING_START_GREATER_THAN_END) + ) + def check(self, expression_or_criteria) -> None: """Check the cohort expression's censor window or individual criteria. - + Args: - expression_or_criteria: Either a CohortExpression (for censor_window) + expression_or_criteria: Either a CohortExpression (for censor_window) or Criteria/DemographicCriteria (for individual criteria) """ # Import here to avoid circular dependencies from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import Criteria, DemographicCriteria - + # Handle CohortExpression (for censor_window) if isinstance(expression_or_criteria, CohortExpression): - self.check_range(expression_or_criteria.censor_window, self.ROOT_OBJECT, Constants.Attributes.CENSOR_WINDOW_ATTR) + self.check_range( + expression_or_criteria.censor_window, + self.ROOT_OBJECT, + Constants.Attributes.CENSOR_WINDOW_ATTR, + ) # Handle DemographicCriteria (delegate to base class) elif isinstance(expression_or_criteria, DemographicCriteria): super().check(expression_or_criteria) # Handle Criteria (delegate to base class) elif isinstance(expression_or_criteria, Criteria): super().check(expression_or_criteria) - diff --git a/circe/check/checkers/text_check.py b/circe/check/checkers/text_check.py index 59ff2c0f..7c3627e4 100644 --- a/circe/check/checkers/text_check.py +++ b/circe/check/checkers/text_check.py @@ -16,27 +16,26 @@ class TextCheck(BaseValueCheck): """Check for empty TextFilter values in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.TextCheck """ - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - + def _get_factory(self, reporter: WarningReporter, name: str) -> TextCheckerFactory: """Get a text checker factory. - + Args: reporter: The warning reporter to use name: The name of the criteria group - + Returns: A TextCheckerFactory instance """ return TextCheckerFactory.get_factory(reporter, name) - diff --git a/circe/check/checkers/text_checker_factory.py b/circe/check/checkers/text_checker_factory.py index 6d46c554..88b96258 100644 --- a/circe/check/checkers/text_checker_factory.py +++ b/circe/check/checkers/text_checker_factory.py @@ -17,111 +17,164 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.criteria import ( - Criteria, DemographicCriteria, ConditionOccurrence, DeviceExposure, - DrugExposure, Observation, Specimen + Criteria, + DemographicCriteria, + ConditionOccurrence, + DeviceExposure, + DrugExposure, + Observation, + Specimen, ) from ...cohortdefinition.core import TextFilter except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.criteria import ( - Criteria, DemographicCriteria, ConditionOccurrence, DeviceExposure, - DrugExposure, Observation, Specimen + Criteria, + DemographicCriteria, + ConditionOccurrence, + DeviceExposure, + DrugExposure, + Observation, + Specimen, ) from ...cohortdefinition.core import TextFilter class TextCheckerFactory(BaseCheckerFactory): """Factory for checking TextFilter fields in criteria. - + Java equivalent: org.ohdsi.circe.check.checkers.TextCheckerFactory """ - + WARNING_EMPTY_VALUE = "%s in the %s has empty %s value" - + def __init__(self, reporter: WarningReporter, group_name: str): """Initialize a text checker factory. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked """ super().__init__(reporter, group_name) - + @staticmethod - def get_factory(reporter: WarningReporter, group_name: str) -> 'TextCheckerFactory': + def get_factory(reporter: WarningReporter, group_name: str) -> "TextCheckerFactory": """Get a factory instance. - + Args: reporter: The warning reporter to use group_name: The name of the criteria group being checked - + Returns: A new TextCheckerFactory instance """ return TextCheckerFactory(reporter, group_name) - - def _get_check_criteria(self, criteria: 'Criteria') -> Callable[['Criteria'], None]: + + def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], None]: """Get a checker function for criteria. - + Args: criteria: The criteria to get a checker for - + Returns: A function that checks the criteria """ # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - ConditionOccurrence, DeviceExposure, DrugExposure, Observation, Specimen + ConditionOccurrence, + DeviceExposure, + DrugExposure, + Observation, + Specimen, ) - + if isinstance(criteria, ConditionOccurrence): - def check(c: 'ConditionOccurrence') -> None: - self._check_text(c.stop_reason, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.STOP_REASON_ATTR) + + def check(c: "ConditionOccurrence") -> None: + self._check_text( + c.stop_reason, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.STOP_REASON_ATTR, + ) + return check elif isinstance(criteria, DeviceExposure): - def check(c: 'DeviceExposure') -> None: - self._check_text(c.unique_device_id, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.UNIQUE_DEVICE_ID_ATTR) + + def check(c: "DeviceExposure") -> None: + self._check_text( + c.unique_device_id, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.UNIQUE_DEVICE_ID_ATTR, + ) + return check elif isinstance(criteria, DrugExposure): - def check(c: 'DrugExposure') -> None: - self._check_text(c.stop_reason, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.STOP_REASON_ATTR) - self._check_text(c.lot_number, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.LOT_NUMBER_ATTR) + + def check(c: "DrugExposure") -> None: + self._check_text( + c.stop_reason, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.STOP_REASON_ATTR, + ) + self._check_text( + c.lot_number, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.LOT_NUMBER_ATTR, + ) + return check elif isinstance(criteria, Observation): - def check(c: 'Observation') -> None: - self._check_text(c.value_as_string, Constants.Criteria.OBSERVATION, Constants.Attributes.VALUE_AS_STRING_ATTR) + + def check(c: "Observation") -> None: + self._check_text( + c.value_as_string, + Constants.Criteria.OBSERVATION, + Constants.Attributes.VALUE_AS_STRING_ATTR, + ) + return check elif isinstance(criteria, Specimen): - def check(c: 'Specimen') -> None: - self._check_text(c.source_id, Constants.Criteria.SPECIMEN, Constants.Attributes.SOURCE_ID_ATTR) + + def check(c: "Specimen") -> None: + self._check_text( + c.source_id, + Constants.Criteria.SPECIMEN, + Constants.Attributes.SOURCE_ID_ATTR, + ) + return check else: return lambda c: None # No text checks for other criteria types - - def _get_check_demographic(self, criteria: 'DemographicCriteria') -> Callable[['DemographicCriteria'], None]: + + def _get_check_demographic( + self, criteria: "DemographicCriteria" + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. - + Args: criteria: The demographic criteria to get a checker for - + Returns: A function that checks the criteria (no TextFilter in demographic) """ return lambda c: None # No text filters in demographic criteria - - def _check_text(self, text_filter: Optional['TextFilter'], criteria_name: str, attribute: str) -> None: + + def _check_text( + self, text_filter: Optional["TextFilter"], criteria_name: str, attribute: str + ) -> None: """Check if a TextFilter has an empty text value. - + Args: text_filter: The TextFilter to check criteria_name: The name of the criteria type attribute: The name of the attribute """ + def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - - Operations.match(text_filter)\ - .when(lambda tf: tf is not None and tf.text is None)\ - .then(lambda tf: warning(self.WARNING_EMPTY_VALUE)) + Operations.match(text_filter).when( + lambda tf: tf is not None and tf.text is None + ).then(lambda tf: warning(self.WARNING_EMPTY_VALUE)) diff --git a/circe/check/checkers/time_pattern_check.py b/circe/check/checkers/time_pattern_check.py index 8260b759..f654c099 100644 --- a/circe/check/checkers/time_pattern_check.py +++ b/circe/check/checkers/time_pattern_check.py @@ -22,6 +22,7 @@ from ...cohortdefinition.core import Window except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import CorelatedCriteria @@ -30,13 +31,13 @@ class TimeWindowInfo: """Information about a time window. - + Java equivalent: org.ohdsi.circe.check.checkers.TimePatternCheck.TimeWindowInfo """ - - def __init__(self, name: str, start: Optional['Window'], end: Optional['Window']): + + def __init__(self, name: str, start: Optional["Window"], end: Optional["Window"]): """Initialize time window info. - + Args: name: The name of the criteria start: The start window @@ -45,45 +46,47 @@ def __init__(self, name: str, start: Optional['Window'], end: Optional['Window'] self._name = name self._start = start self._end = end - + @property def name(self) -> str: """Get the name.""" return self._name - + @property - def start(self) -> Optional['Window']: + def start(self) -> Optional["Window"]: """Get the start window.""" return self._start - + @property - def end(self) -> Optional['Window']: + def end(self) -> Optional["Window"]: """Get the end window.""" return self._end class TimePatternCheck(BaseCorelatedCriteriaCheck): """Check for inconsistent time window patterns. - + Java equivalent: org.ohdsi.circe.check.checkers.TimePatternCheck """ - + def __init__(self): """Initialize the time pattern check.""" super().__init__() self._time_window_info_list: List[TimeWindowInfo] = [] - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: INFO severity level """ return WarningSeverity.INFO - - def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria( + self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + ) -> None: """Collect time window information. - + Args: criteria: The corelated criteria to check group_name: The name of the group containing this criteria @@ -93,33 +96,40 @@ def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, report self._time_window_info_list.append( TimeWindowInfo(name, criteria.start_window, criteria.end_window) ) - - def _after_check(self, reporter: WarningReporter, expression: 'CohortExpression') -> None: + + def _after_check( + self, reporter: WarningReporter, expression: "CohortExpression" + ) -> None: """Check for inconsistent time window patterns. - + Args: reporter: The warning reporter to use expression: The cohort expression that was checked """ if len(self._time_window_info_list) <= 1: return - + # Calculate start days for each time window - start_days = [self._start_days(info.start) for info in self._time_window_info_list] - + start_days = [ + self._start_days(info.start) for info in self._time_window_info_list + ] + # Count frequency of each start day value freq = Counter(start_days) max_freq = max(freq.values()) if freq else 0 - + if max_freq > 1: # Find the most common pattern most_common_value = max(freq, key=freq.get) most_common_info = next( - (info for info in self._time_window_info_list - if self._start_days(info.start) == most_common_value), - None + ( + info + for info in self._time_window_info_list + if self._start_days(info.start) == most_common_value + ), + None, ) - + if most_common_info: most_common_pattern = self._format_time_window(most_common_info) for info in self._time_window_info_list: @@ -129,15 +139,15 @@ def _after_check(self, reporter: WarningReporter, expression: 'CohortExpression' reporter( "%s time window differs from most common pattern prior '%s', shouldn't that be a valid pattern?", info.name, - most_common_pattern + most_common_pattern, ) - + def _format_time_window(self, ti: TimeWindowInfo) -> str: """Format a time window as a string. - + Args: ti: The time window info to format - + Returns: A formatted string describing the time window """ @@ -147,39 +157,39 @@ def _format_time_window(self, ti: TimeWindowInfo) -> str: if ti.start and ti.start.end: result += f" and {self._format_days(ti.start.end)} days {self._format_coeff(ti.start.end)}" return result - - def _format_days(self, endpoint: Optional['Window.Endpoint']) -> str: + + def _format_days(self, endpoint: Optional["Window.Endpoint"]) -> str: """Format days from an endpoint. - + Args: endpoint: The endpoint to format - + Returns: A string representation of the days """ if endpoint is None or endpoint.days is None: return "all" return str(endpoint.days) - - def _format_coeff(self, endpoint: Optional['Window.Endpoint']) -> str: + + def _format_coeff(self, endpoint: Optional["Window.Endpoint"]) -> str: """Format coefficient from an endpoint. - + Args: endpoint: The endpoint to format - + Returns: "before " if negative, "after " if positive """ if endpoint is None: return "" return "before " if endpoint.coeff < 0 else "after " - - def _start_days(self, window: Optional['Window']) -> int: + + def _start_days(self, window: Optional["Window"]) -> int: """Calculate start days from a window. - + Args: window: The window to calculate from - + Returns: The calculated start days value """ @@ -187,4 +197,3 @@ def _start_days(self, window: Optional['Window']) -> int: return 0 days = window.start.days if window.start.days is not None else 0 return days * window.start.coeff - diff --git a/circe/check/checkers/time_window_check.py b/circe/check/checkers/time_window_check.py index 2e955c4b..9bca0704 100644 --- a/circe/check/checkers/time_window_check.py +++ b/circe/check/checkers/time_window_check.py @@ -23,6 +23,7 @@ from ...cohortdefinition.core import ObservationFilter except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import CorelatedCriteria @@ -31,48 +32,53 @@ class TimeWindowCheck(BaseCorelatedCriteriaCheck): """Check for time window ranges that are longer than required. - + Java equivalent: org.ohdsi.circe.check.checkers.TimeWindowCheck """ - + WARNING = "%s criteria have time window range that is longer than required time for initial event" - + def __init__(self): """Initialize the time window check.""" super().__init__() - self._observation_filter: Optional['ObservationFilter'] = None - + self._observation_filter: Optional["ObservationFilter"] = None + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: INFO severity level """ return WarningSeverity.INFO - - def _before_check(self, reporter: WarningReporter, expression: 'CohortExpression') -> None: + + def _before_check( + self, reporter: WarningReporter, expression: "CohortExpression" + ) -> None: """Store the observation filter before checking. - + Args: reporter: The warning reporter expression: The cohort expression being validated """ if expression.primary_criteria: self._observation_filter = expression.primary_criteria.observation_window - - def _check_criteria(self, criteria: 'CorelatedCriteria', group_name: str, reporter: WarningReporter) -> None: + + def _check_criteria( + self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + ) -> None: """Check criteria for time window issues. - + Args: criteria: The corelated criteria to check group_name: The name of the group containing this criteria reporter: The warning reporter to use """ name = f"{group_name} {CriteriaNameHelper.get_criteria_name(criteria.criteria)}" - + match_result = Operations.match(criteria) - match_result.when(lambda c: c.start_window is not None and - self._observation_filter is not None and - Comparisons.compare_to(self._observation_filter, c.start_window) < 0) + match_result.when( + lambda c: c.start_window is not None + and self._observation_filter is not None + and Comparisons.compare_to(self._observation_filter, c.start_window) < 0 + ) match_result.then(lambda c: reporter(self.WARNING, name)) - diff --git a/circe/check/checkers/unused_concepts_check.py b/circe/check/checkers/unused_concepts_check.py index 128f2ecc..8bf3fbf3 100644 --- a/circe/check/checkers/unused_concepts_check.py +++ b/circe/check/checkers/unused_concepts_check.py @@ -24,6 +24,7 @@ from ...vocabulary.concept import ConceptSet except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import Criteria, CorelatedCriteria @@ -34,206 +35,265 @@ class UnusedConceptsCheck(BaseCheck): """Check for unused concept sets in the expression. - + Java equivalent: org.ohdsi.circe.check.checkers.UnusedConceptsCheck """ - + def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. - + Returns: WARNING severity level """ return WarningSeverity.WARNING - - def _get_reporter(self, severity: WarningSeverity, warnings: List) -> WarningReporter: + + def _get_reporter( + self, severity: WarningSeverity, warnings: List + ) -> WarningReporter: """Get a warning reporter that creates ConceptSetWarning instances. - + Args: severity: The severity level warnings: The list to add warnings to - + Returns: A WarningReporter that creates ConceptSetWarning instances """ + def reporter(template: str, *args) -> None: if args and isinstance(args[0], ConceptSet): warnings.append(ConceptSetWarning(severity, template, args[0])) + return reporter - - def _check(self, expression: 'CohortExpression', reporter: WarningReporter) -> None: + + def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check for unused concept sets. - + Args: expression: The cohort expression to check reporter: The warning reporter to use """ additional_criteria = self._get_additional_criteria(expression) - + if expression.concept_sets: for concept_set in expression.concept_sets: if not self._is_used(expression, additional_criteria, concept_set): - reporter("Concept Set \"%s\" is not used", concept_set) - - def _get_additional_criteria(self, expression: 'CohortExpression') -> List['Criteria']: + reporter('Concept Set "%s" is not used', concept_set) + + def _get_additional_criteria( + self, expression: "CohortExpression" + ) -> List["Criteria"]: """Get all criteria from additional criteria. - + Args: expression: The cohort expression - + Returns: A list of all criteria from additional criteria """ - additional_criteria: List['Criteria'] = [] + additional_criteria: List["Criteria"] = [] if expression.additional_criteria: - additional_criteria.extend(self._to_criteria_list(expression.additional_criteria.criteria_list)) + additional_criteria.extend( + self._to_criteria_list(expression.additional_criteria.criteria_list) + ) if expression.additional_criteria.groups: - additional_criteria.extend(self._to_criteria_list_from_groups(expression.additional_criteria.groups)) + additional_criteria.extend( + self._to_criteria_list_from_groups( + expression.additional_criteria.groups + ) + ) return additional_criteria - + def _is_used( - self, - expression: 'CohortExpression', - additional_criteria: List['Criteria'], - concept_set: 'ConceptSet' + self, + expression: "CohortExpression", + additional_criteria: List["Criteria"], + concept_set: "ConceptSet", ) -> bool: """Check if a concept set is used. - + Args: expression: The cohort expression additional_criteria: Additional criteria to check concept_set: The concept set to check - + Returns: True if the concept set is used, False otherwise """ # Check primary criteria if expression.primary_criteria and expression.primary_criteria.criteria_list: - if self._is_concept_set_used(concept_set, expression.primary_criteria.criteria_list): + if self._is_concept_set_used( + concept_set, expression.primary_criteria.criteria_list + ): return True - + # Check additional criteria if self._is_concept_set_used(concept_set, additional_criteria): return True - + # Check inclusion rules if expression.inclusion_rules: for rule in expression.inclusion_rules: if rule.expression: # Convert rule expression to criteria list rule_criteria_list = [] - if hasattr(rule.expression, 'criteria_list') and rule.expression.criteria_list: - rule_criteria_list.extend([c.criteria for c in rule.expression.criteria_list if hasattr(c, 'criteria') and c.criteria]) - if rule_criteria_list and self._is_concept_set_used_in_list(concept_set, rule_criteria_list): + if ( + hasattr(rule.expression, "criteria_list") + and rule.expression.criteria_list + ): + rule_criteria_list.extend( + [ + c.criteria + for c in rule.expression.criteria_list + if hasattr(c, "criteria") and c.criteria + ] + ) + if rule_criteria_list and self._is_concept_set_used_in_list( + concept_set, rule_criteria_list + ): return True - + # Check end strategy (CustomEraStrategy) if isinstance(expression.end_strategy, CustomEraStrategy): if expression.end_strategy.drug_codeset_id == concept_set.id: return True - + # Check censoring criteria if expression.censoring_criteria: if self._is_concept_set_used(concept_set, expression.censoring_criteria): return True - + return False - - def _is_concept_set_used(self, concept_set: 'ConceptSet', target) -> bool: + + def _is_concept_set_used(self, concept_set: "ConceptSet", target) -> bool: """Check if a concept set is used (supports both List[Criteria] and CriteriaGroup). - + Args: concept_set: The concept set to check target: Either a List[Criteria] or CriteriaGroup - + Returns: True if the concept set is used, False otherwise """ # Import here to avoid circular dependencies from ...cohortdefinition.criteria import CriteriaGroup - + if isinstance(target, CriteriaGroup): criteria_list = self._to_criteria_list(target.criteria_list) if self._is_concept_set_used_in_list(concept_set, criteria_list): return True - + if target.groups: - return any(self._is_concept_set_used(concept_set, group) for group in target.groups) - + return any( + self._is_concept_set_used(concept_set, group) + for group in target.groups + ) + return False elif isinstance(target, list): # Assume it's a list of Criteria return self._is_concept_set_used_in_list(concept_set, target) else: return False - - def _is_concept_set_used_in_list(self, concept_set: 'ConceptSet', criteria_list: List['Criteria']) -> bool: + + def _is_concept_set_used_in_list( + self, concept_set: "ConceptSet", criteria_list: List["Criteria"] + ) -> bool: """Check if a concept set is used in a criteria list. - + Args: concept_set: The concept set to check criteria_list: The criteria list to check - + Returns: True if the concept set is used, False otherwise """ factory = CriteriaCheckerFactory.get_factory(concept_set) - main_check = any(factory.get_criteria_checker(criteria)(criteria) for criteria in criteria_list) - + main_check = any( + factory.get_criteria_checker(criteria)(criteria) + for criteria in criteria_list + ) + if main_check: return True - + # Check correlated criteria for criteria in criteria_list: - if hasattr(criteria, 'correlated_criteria') and criteria.correlated_criteria: + if ( + hasattr(criteria, "correlated_criteria") + and criteria.correlated_criteria + ): # Convert correlated criteria to list and check - correlated_list = self._correlated_criteria_to_list(criteria.correlated_criteria) + correlated_list = self._correlated_criteria_to_list( + criteria.correlated_criteria + ) if self._is_concept_set_used_in_list(concept_set, correlated_list): return True - + return False - - def _correlated_criteria_to_list(self, correlated_criteria) -> List['Criteria']: + + def _correlated_criteria_to_list(self, correlated_criteria) -> List["Criteria"]: """Convert correlated criteria to a list of criteria. - + Args: correlated_criteria: The correlated criteria to convert - + Returns: A list of Criteria """ - criteria_list: List['Criteria'] = [] - if hasattr(correlated_criteria, 'criteria_list') and correlated_criteria.criteria_list: - criteria_list.extend([c.criteria for c in correlated_criteria.criteria_list if hasattr(c, 'criteria') and c.criteria]) - if hasattr(correlated_criteria, 'groups') and correlated_criteria.groups: + criteria_list: List["Criteria"] = [] + if ( + hasattr(correlated_criteria, "criteria_list") + and correlated_criteria.criteria_list + ): + criteria_list.extend( + [ + c.criteria + for c in correlated_criteria.criteria_list + if hasattr(c, "criteria") and c.criteria + ] + ) + if hasattr(correlated_criteria, "groups") and correlated_criteria.groups: for group in correlated_criteria.groups: - if hasattr(group, 'criteria_list') and group.criteria_list: - criteria_list.extend([c.criteria for c in group.criteria_list if hasattr(c, 'criteria') and c.criteria]) + if hasattr(group, "criteria_list") and group.criteria_list: + criteria_list.extend( + [ + c.criteria + for c in group.criteria_list + if hasattr(c, "criteria") and c.criteria + ] + ) return criteria_list - - def _to_criteria_list(self, criteria_list: Optional[List['CorelatedCriteria']]) -> List['Criteria']: + + def _to_criteria_list( + self, criteria_list: Optional[List["CorelatedCriteria"]] + ) -> List["Criteria"]: """Convert a list of CorelatedCriteria to a list of Criteria. - + Args: criteria_list: The list of CorelatedCriteria - + Returns: A list of Criteria """ if not criteria_list: return [] - return [c.criteria for c in criteria_list if hasattr(c, 'criteria') and c.criteria] - - def _to_criteria_list_from_groups(self, groups: Optional[List['CriteriaGroup']]) -> List['Criteria']: + return [ + c.criteria for c in criteria_list if hasattr(c, "criteria") and c.criteria + ] + + def _to_criteria_list_from_groups( + self, groups: Optional[List["CriteriaGroup"]] + ) -> List["Criteria"]: """Convert groups to a list of criteria. - + Args: groups: The list of groups - + Returns: A list of Criteria """ - criteria: List['Criteria'] = [] + criteria: List["Criteria"] = [] if groups: for group in groups: if group.criteria_list: @@ -241,4 +301,3 @@ def _to_criteria_list_from_groups(self, groups: Optional[List['CriteriaGroup']]) if group.groups: criteria.extend(self._to_criteria_list_from_groups(group.groups)) return criteria - diff --git a/circe/check/checkers/warning_reporter.py b/circe/check/checkers/warning_reporter.py index 37cdc26e..ba071818 100644 --- a/circe/check/checkers/warning_reporter.py +++ b/circe/check/checkers/warning_reporter.py @@ -14,19 +14,18 @@ class WarningReporter(Protocol): """Functional interface for reporting warnings. - + Java equivalent: org.ohdsi.circe.check.checkers.WarningReporter - + This is a callable interface that accepts a template string and variable arguments to format and add warnings. """ - + def __call__(self, template: str, *args: Any) -> None: """Add a warning using a template string and arguments. - + Args: template: A format string template (e.g., "Error in %s: %s") *args: Arguments to format into the template """ ... - diff --git a/circe/check/checkers/warning_reporter_helper.py b/circe/check/checkers/warning_reporter_helper.py index 710f14ec..64731711 100644 --- a/circe/check/checkers/warning_reporter_helper.py +++ b/circe/check/checkers/warning_reporter_helper.py @@ -15,16 +15,16 @@ class WarningReporterHelper: """Helper class for creating warning reporters with templates. - + Java equivalent: org.ohdsi.circe.check.checkers.WarningReporterHelper - + This class helps create Execution objects that can be used to add warnings with consistent formatting. """ - + def __init__(self, reporter: WarningReporter, template: str, primary_group: str): """Initialize a warning reporter helper. - + Args: reporter: The warning reporter to use template: The message template string @@ -33,19 +33,19 @@ def __init__(self, reporter: WarningReporter, template: str, primary_group: str) self._reporter = reporter self._template = template self._primary_group = primary_group - + def add_warning(self, secondary_group: str) -> Execution: """Create an Execution that adds a warning. - + Args: secondary_group: The secondary group name for the warning - + Returns: An Execution object that will add the warning when called """ + def exec_warning(_value=None) -> None: # Accept optional value parameter (unused) for compatibility with Operations.then() self._reporter(self._template, self._primary_group, secondary_group) - - return exec_warning + return exec_warning diff --git a/circe/check/constants.py b/circe/check/constants.py index e6548e07..f5cfa6ae 100644 --- a/circe/check/constants.py +++ b/circe/check/constants.py @@ -12,12 +12,13 @@ class Constants: """Constants used in validation checks. - + Java equivalent: org.ohdsi.circe.check.Constants """ - + class Criteria: """Criteria type names.""" + CONDITION_ERA = "condition era" CONDITION_OCCURRENCE = "condition occurrence" DEATH = "death" @@ -35,9 +36,10 @@ class Criteria: OBSERVATION_PERIOD = "observation period" LOCATION_REGION = "location region" DEMOGRAPHIC = "demographic" - + class Attributes: """Attribute names for criteria fields.""" + AGE_ATTR = "age" QUANTITY_ATTR = "quantity" OCCURRENCE_START_DATE_ATTR = "occurrence start date" @@ -103,4 +105,3 @@ class Attributes: VISIT_DETAIL_END_DATE_ATTR = "visit detail end date" VISIT_DETAIL_LENGTH_ATTR = "visit detail length" VISIT_DETAIL_TYPE_ATTR = "visit detail type" - diff --git a/circe/check/operations/__init__.py b/circe/check/operations/__init__.py index ecc43beb..920ea84f 100644 --- a/circe/check/operations/__init__.py +++ b/circe/check/operations/__init__.py @@ -11,12 +11,13 @@ # Type alias for convenience (Callable[[], None]) from typing import Callable + Executable = Callable[[], None] __all__ = [ - 'Execution', - 'Executable', - 'ConditionalOperations', - 'ExecutiveOperations', - 'Operations', + "Execution", + "Executable", + "ConditionalOperations", + "ExecutiveOperations", + "Operations", ] diff --git a/circe/check/operations/conditional_operations.py b/circe/check/operations/conditional_operations.py index 4ae476c8..d1a79294 100644 --- a/circe/check/operations/conditional_operations.py +++ b/circe/check/operations/conditional_operations.py @@ -11,54 +11,53 @@ from typing import Protocol, TypeVar, Generic, Callable, Any -T = TypeVar('T') -V = TypeVar('V') +T = TypeVar("T") +V = TypeVar("V") class ConditionalOperations(Protocol, Generic[T, V]): """Interface for conditional operations in pattern matching. - + Java equivalent: org.ohdsi.circe.check.operations.ConditionalOperations - + This interface provides methods for conditional execution based on pattern matching results. """ - - def when(self, condition: Callable[[T], bool]) -> 'ExecutiveOperations[T, V]': + + def when(self, condition: Callable[[T], bool]) -> "ExecutiveOperations[T, V]": """Apply a condition to the value. - + Args: condition: A function that returns True if the condition matches - + Returns: An ExecutiveOperations instance for chaining """ ... - - def is_a(self, clazz: type) -> 'ExecutiveOperations[T, V]': + + def is_a(self, clazz: type) -> "ExecutiveOperations[T, V]": """Check if the value is an instance of the given class. - + Args: clazz: The class to check against - + Returns: An ExecutiveOperations instance for chaining """ ... - + def or_else(self, consumer: Callable[[T], None]) -> None: """Execute if the condition was not met. - + Args: consumer: The function to execute if condition was not met """ ... - + def value(self) -> V: """Get the return value from then_return operations. - + Returns: The value returned by a then_return operation, or None """ ... - diff --git a/circe/check/operations/execution.py b/circe/check/operations/execution.py index 00968b08..65d8b240 100644 --- a/circe/check/operations/execution.py +++ b/circe/check/operations/execution.py @@ -14,13 +14,12 @@ class Execution(Protocol): """Functional interface for deferred execution. - + Java equivalent: org.ohdsi.circe.check.operations.Execution - + This interface represents an operation that can be executed later. """ - + def apply(self) -> None: """Execute the operation.""" ... - diff --git a/circe/check/operations/executive_operations.py b/circe/check/operations/executive_operations.py index 377ed56a..a4e1d124 100644 --- a/circe/check/operations/executive_operations.py +++ b/circe/check/operations/executive_operations.py @@ -11,51 +11,50 @@ from typing import Protocol, TypeVar, Generic, Callable -T = TypeVar('T') -V = TypeVar('V') +T = TypeVar("T") +V = TypeVar("V") from .execution import Execution from .conditional_operations import ConditionalOperations class ExecutiveOperations(Protocol, Generic[T, V]): """Interface for executive operations in pattern matching. - + Java equivalent: org.ohdsi.circe.check.operations.ExecutiveOperations - + This interface provides methods for executing operations when pattern matching conditions are met. """ - + def then(self, consumer: Callable[[T], None]) -> ConditionalOperations[T, V]: """Execute a consumer function if the condition was met. - + Args: consumer: The function to execute - + Returns: A ConditionalOperations instance for chaining """ ... - + def then(self, execution: Execution) -> ConditionalOperations[T, V]: """Execute an Execution if the condition was met. - + Args: execution: The Execution to execute - + Returns: A ConditionalOperations instance for chaining """ ... - + def then_return(self, function: Callable[[T], V]) -> ConditionalOperations[T, V]: """Execute a function and return its value if the condition was met. - + Args: function: The function to execute and return its result - + Returns: A ConditionalOperations instance for chaining """ ... - diff --git a/circe/check/operations/operations.py b/circe/check/operations/operations.py index ed45b076..c162046f 100644 --- a/circe/check/operations/operations.py +++ b/circe/check/operations/operations.py @@ -14,114 +14,115 @@ from .conditional_operations import ConditionalOperations from .executive_operations import ExecutiveOperations -T = TypeVar('T') -V = TypeVar('V') +T = TypeVar("T") +V = TypeVar("V") class Operations(Generic[T, V], ConditionalOperations[T, V], ExecutiveOperations[T, V]): """Pattern matching operations class. - + Java equivalent: org.ohdsi.circe.check.operations.Operations - + This class provides a fluent interface for pattern matching and conditional execution, similar to Java's pattern matching. """ - + def __init__(self, value: T): """Initialize operations with a value. - + Args: value: The value to match against """ self._value = value self._result: Optional[bool] = None self._return_value: Optional[V] = None - + @staticmethod def match(value: T) -> ConditionalOperations[T, V]: """Create a new Operations instance for pattern matching. - + Args: value: The value to match against - + Returns: A ConditionalOperations instance for chaining """ return Operations(value) - + def when(self, condition: Callable[[T], bool]) -> ExecutiveOperations[T, V]: """Apply a condition to the value. - + Args: condition: A function that returns True if the condition matches - + Returns: An ExecutiveOperations instance for chaining """ self._result = self._value is not None and condition(self._value) return self - + def is_a(self, clazz: type) -> ExecutiveOperations[T, V]: """Check if the value is an instance of the given class. - + Args: clazz: The class to check against - + Returns: An ExecutiveOperations instance for chaining """ self._result = ( - clazz is not None and - self._value is not None and - isinstance(self._value, clazz) + clazz is not None + and self._value is not None + and isinstance(self._value, clazz) ) return self - + def then(self, consumer: Any) -> ConditionalOperations[T, V]: """Execute a consumer function or Execution if the condition was met. - + Args: consumer: Either a Callable function or an Execution object - + Returns: A ConditionalOperations instance for chaining """ if self._result: # Check if it's an Execution object (has apply method) - if hasattr(consumer, 'apply') and callable(getattr(consumer, 'apply', None)): + if hasattr(consumer, "apply") and callable( + getattr(consumer, "apply", None) + ): consumer.apply() else: # It's a callable function consumer(self._value) return self - + def then_return(self, function: Callable[[T], V]) -> ConditionalOperations[T, V]: """Execute a function and return its value if the condition was met. - + Args: function: The function to execute and return its result - + Returns: A ConditionalOperations instance for chaining """ if self._result: self._return_value = function(self._value) return self - + def or_else(self, consumer: Callable[[T], None]) -> None: """Execute if the condition was not met. - + Args: consumer: The function to execute if condition was not met """ if not self._result: consumer(self._value) - + def value(self) -> Optional[V]: """Get the return value from then_return operations. - + Returns: The value returned by a then_return operation, or None """ return self._return_value - diff --git a/circe/check/utils/__init__.py b/circe/check/utils/__init__.py index fe8e94e1..3d58d727 100644 --- a/circe/check/utils/__init__.py +++ b/circe/check/utils/__init__.py @@ -7,5 +7,5 @@ from .criteria_name_helper import CriteriaNameHelper __all__ = [ - 'CriteriaNameHelper', + "CriteriaNameHelper", ] diff --git a/circe/check/utils/criteria_name_helper.py b/circe/check/utils/criteria_name_helper.py index 157e40d8..9b175ddd 100644 --- a/circe/check/utils/criteria_name_helper.py +++ b/circe/check/utils/criteria_name_helper.py @@ -15,79 +15,118 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail, - ObservationPeriod, PayerPlanPeriod, LocationRegion + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + ObservationPeriod, + PayerPlanPeriod, + LocationRegion, ) except ImportError: from typing import TYPE_CHECKING + if TYPE_CHECKING: from ...cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail, - ObservationPeriod, PayerPlanPeriod, LocationRegion + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + ObservationPeriod, + PayerPlanPeriod, + LocationRegion, ) class CriteriaNameHelper: """Helper class for getting criteria type names. - + Java equivalent: org.ohdsi.circe.check.utils.CriteriaNameHelper - + This class provides a method to get human-readable names for different criteria types. """ - + @staticmethod def get_criteria_name(criteria) -> str: """Get the human-readable name for a criteria type. - + Args: criteria: The criteria instance to get the name for - + Returns: A string name for the criteria type """ # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail, - ObservationPeriod, PayerPlanPeriod, LocationRegion + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + ObservationPeriod, + PayerPlanPeriod, + LocationRegion, ) - - return Operations.match(criteria)\ - .is_a(ConditionEra)\ - .then_return(lambda c: Constants.Criteria.CONDITION_ERA)\ - .is_a(ConditionOccurrence)\ - .then_return(lambda c: Constants.Criteria.CONDITION_OCCURRENCE)\ - .is_a(Death)\ - .then_return(lambda c: Constants.Criteria.DEATH)\ - .is_a(DeviceExposure)\ - .then_return(lambda c: Constants.Criteria.DEVICE_EXPOSURE)\ - .is_a(DoseEra)\ - .then_return(lambda c: Constants.Criteria.DOSE_ERA)\ - .is_a(DrugEra)\ - .then_return(lambda c: Constants.Criteria.DRUG_ERA)\ - .is_a(DrugExposure)\ - .then_return(lambda c: Constants.Criteria.DRUG_EXPOSURE)\ - .is_a(Measurement)\ - .then_return(lambda c: Constants.Criteria.MEASUREMENT)\ - .is_a(Observation)\ - .then_return(lambda c: Constants.Criteria.OBSERVATION)\ - .is_a(ObservationPeriod)\ - .then_return(lambda c: Constants.Criteria.OBSERVATION_PERIOD)\ - .is_a(ProcedureOccurrence)\ - .then_return(lambda c: Constants.Criteria.PROCEDURE_OCCURRENCE)\ - .is_a(Specimen)\ - .then_return(lambda c: Constants.Criteria.SPECIMEN)\ - .is_a(VisitOccurrence)\ - .then_return(lambda c: Constants.Criteria.VISIT_OCCURRENCE)\ - .is_a(VisitDetail)\ - .then_return(lambda c: Constants.Criteria.VISIT_DETAIL)\ - .is_a(PayerPlanPeriod)\ - .then_return(lambda c: Constants.Criteria.PAYER_PLAN_PERIOD)\ - .value() or "unknown criteria" + return ( + Operations.match(criteria) + .is_a(ConditionEra) + .then_return(lambda c: Constants.Criteria.CONDITION_ERA) + .is_a(ConditionOccurrence) + .then_return(lambda c: Constants.Criteria.CONDITION_OCCURRENCE) + .is_a(Death) + .then_return(lambda c: Constants.Criteria.DEATH) + .is_a(DeviceExposure) + .then_return(lambda c: Constants.Criteria.DEVICE_EXPOSURE) + .is_a(DoseEra) + .then_return(lambda c: Constants.Criteria.DOSE_ERA) + .is_a(DrugEra) + .then_return(lambda c: Constants.Criteria.DRUG_ERA) + .is_a(DrugExposure) + .then_return(lambda c: Constants.Criteria.DRUG_EXPOSURE) + .is_a(Measurement) + .then_return(lambda c: Constants.Criteria.MEASUREMENT) + .is_a(Observation) + .then_return(lambda c: Constants.Criteria.OBSERVATION) + .is_a(ObservationPeriod) + .then_return(lambda c: Constants.Criteria.OBSERVATION_PERIOD) + .is_a(ProcedureOccurrence) + .then_return(lambda c: Constants.Criteria.PROCEDURE_OCCURRENCE) + .is_a(Specimen) + .then_return(lambda c: Constants.Criteria.SPECIMEN) + .is_a(VisitOccurrence) + .then_return(lambda c: Constants.Criteria.VISIT_OCCURRENCE) + .is_a(VisitDetail) + .then_return(lambda c: Constants.Criteria.VISIT_DETAIL) + .is_a(PayerPlanPeriod) + .then_return(lambda c: Constants.Criteria.PAYER_PLAN_PERIOD) + .value() + or "unknown criteria" + ) diff --git a/circe/check/warning.py b/circe/check/warning.py index 5db31bbd..d0d214db 100644 --- a/circe/check/warning.py +++ b/circe/check/warning.py @@ -15,19 +15,18 @@ class Warning(ABC): """Base interface for validation warnings. - + Java equivalent: org.ohdsi.circe.check.Warning - + All warnings must implement this interface and provide a message that describes the validation issue. """ - + @abstractmethod def to_message(self) -> str: """Generate a human-readable message describing the warning. - + Returns: A string message describing the validation issue. """ pass - diff --git a/circe/check/warning_severity.py b/circe/check/warning_severity.py index 8d0e4b80..d7528ffe 100644 --- a/circe/check/warning_severity.py +++ b/circe/check/warning_severity.py @@ -14,10 +14,10 @@ class WarningSeverity(Enum): """Severity levels for validation warnings. - + Java equivalent: org.ohdsi.circe.check.WarningSeverity """ + INFO = "INFO" WARNING = "WARNING" CRITICAL = "CRITICAL" - diff --git a/circe/check/warnings/__init__.py b/circe/check/warnings/__init__.py index 78d388b7..e69e51e2 100644 --- a/circe/check/warnings/__init__.py +++ b/circe/check/warnings/__init__.py @@ -10,8 +10,8 @@ from .incomplete_rule_warning import IncompleteRuleWarning __all__ = [ - 'BaseWarning', - 'DefaultWarning', - 'ConceptSetWarning', - 'IncompleteRuleWarning', + "BaseWarning", + "DefaultWarning", + "ConceptSetWarning", + "IncompleteRuleWarning", ] diff --git a/circe/check/warnings/base_warning.py b/circe/check/warnings/base_warning.py index 716fea0d..64074e39 100644 --- a/circe/check/warnings/base_warning.py +++ b/circe/check/warnings/base_warning.py @@ -14,27 +14,26 @@ class BaseWarning(Warning): """Base class for all validation warnings. - + Java equivalent: org.ohdsi.circe.check.warnings.BaseWarning - + All warning classes should extend this base class to provide common functionality like severity tracking. """ - + def __init__(self, severity: WarningSeverity): """Initialize a warning with a severity level. - + Args: severity: The severity level of this warning """ self._severity = severity - + @property def severity(self) -> WarningSeverity: """Get the severity level of this warning. - + Returns: The warning severity level """ return self._severity - diff --git a/circe/check/warnings/concept_set_warning.py b/circe/check/warnings/concept_set_warning.py index f7aca9f1..e9846ab0 100644 --- a/circe/check/warnings/concept_set_warning.py +++ b/circe/check/warnings/concept_set_warning.py @@ -16,16 +16,21 @@ class ConceptSetWarning(BaseWarning): """Warning related to a specific concept set. - + Java equivalent: org.ohdsi.circe.check.warnings.ConceptSetWarning - + This warning type includes a reference to the concept set that triggered the warning, allowing for more detailed error reporting. """ - - def __init__(self, severity: WarningSeverity, template: str, concept_set: Optional[ConceptSet]): + + def __init__( + self, + severity: WarningSeverity, + template: str, + concept_set: Optional[ConceptSet], + ): """Initialize a concept set warning. - + Args: severity: The severity level of this warning template: Message template string (should contain %s for concept set name) @@ -34,28 +39,28 @@ def __init__(self, severity: WarningSeverity, template: str, concept_set: Option super().__init__(severity) self._template = template self._concept_set = concept_set - + @property def concept_set(self) -> Optional[ConceptSet]: """Get the concept set associated with this warning. - + Returns: The concept set, or None if not available """ return self._concept_set - + @property def concept_set_id(self) -> int: """Get the concept set ID. - + Returns: The concept set ID, or 0 if concept set is None """ return self._concept_set.id if self._concept_set is not None else 0 - + def to_message(self) -> str: """Generate the warning message. - + Returns: A formatted message string using the template and concept set name """ @@ -63,4 +68,3 @@ def to_message(self) -> str: return self._template % self._concept_set.name else: return self._template % "Unknown" - diff --git a/circe/check/warnings/default_warning.py b/circe/check/warnings/default_warning.py index dc62ebbf..ffc8a5a9 100644 --- a/circe/check/warnings/default_warning.py +++ b/circe/check/warnings/default_warning.py @@ -15,28 +15,27 @@ class DefaultWarning(BaseWarning): """Default warning implementation with a simple message. - + Java equivalent: org.ohdsi.circe.check.warnings.DefaultWarning - + This is the most common warning type, containing a severity level and a message string. """ - + def __init__(self, severity: WarningSeverity, message: str): """Initialize a default warning. - + Args: severity: The severity level of this warning message: The warning message text """ super().__init__(severity) self._message = message - + def to_message(self) -> str: """Get the warning message. - + Returns: The warning message text """ return self._message - diff --git a/circe/check/warnings/incomplete_rule_warning.py b/circe/check/warnings/incomplete_rule_warning.py index 978e3dbb..c337decf 100644 --- a/circe/check/warnings/incomplete_rule_warning.py +++ b/circe/check/warnings/incomplete_rule_warning.py @@ -14,39 +14,38 @@ class IncompleteRuleWarning(BaseWarning): """Warning for incomplete inclusion rules. - + Java equivalent: org.ohdsi.circe.check.warnings.IncompleteRuleWarning - + This warning is raised when an inclusion rule is found to be incomplete or invalid. """ - + INCOMPLETE_ERROR = "Incomplete rule %s." - + def __init__(self, severity: WarningSeverity, rule_name: str): """Initialize an incomplete rule warning. - + Args: severity: The severity level of this warning rule_name: The name of the incomplete rule """ super().__init__(severity) self._rule_name = rule_name - + @property def rule_name(self) -> str: """Get the name of the incomplete rule. - + Returns: The rule name """ return self._rule_name - + def to_message(self) -> str: """Generate the warning message. - + Returns: A formatted message string indicating which rule is incomplete """ return self.INCOMPLETE_ERROR % self._rule_name - diff --git a/circe/cli.py b/circe/cli.py index 523ac1bf..ea3a6925 100644 --- a/circe/cli.py +++ b/circe/cli.py @@ -19,60 +19,102 @@ def main(): description="CIRCE - Cohort Identification and Representation via Computable Expression", formatter_class=argparse.RawDescriptionHelpFormatter, ) - - subparsers = parser.add_subparsers(dest='command', help='Available commands') - + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + # Validate command - validate_parser = subparsers.add_parser('validate', help='Validate a cohort definition') - validate_parser.add_argument('input', help='Input JSON file') - validate_parser.add_argument('--quiet', '-q', action='store_true', help='Only show errors') - + validate_parser = subparsers.add_parser( + "validate", help="Validate a cohort definition" + ) + validate_parser.add_argument("input", help="Input JSON file") + validate_parser.add_argument( + "--quiet", "-q", action="store_true", help="Only show errors" + ) + # Generate SQL command - sql_parser = subparsers.add_parser('generate-sql', help='Generate SQL from cohort definition') - sql_parser.add_argument('input', help='Input JSON file') - sql_parser.add_argument('--output', '-o', help='Output SQL file (default: stdout)') - sql_parser.add_argument('--cdm-schema', default='@cdm_database_schema', help='CDM schema name') - sql_parser.add_argument('--target-table', default='@target_database_schema.@target_cohort_table', help='Target table') - sql_parser.add_argument('--cohort-id', type=int, default=None, help='Cohort ID (default: @target_cohort_id placeholder)') - sql_parser.add_argument('--no-validate', action='store_true', help='Skip validation') - + sql_parser = subparsers.add_parser( + "generate-sql", help="Generate SQL from cohort definition" + ) + sql_parser.add_argument("input", help="Input JSON file") + sql_parser.add_argument("--output", "-o", help="Output SQL file (default: stdout)") + sql_parser.add_argument( + "--cdm-schema", default="@cdm_database_schema", help="CDM schema name" + ) + sql_parser.add_argument( + "--target-table", + default="@target_database_schema.@target_cohort_table", + help="Target table", + ) + sql_parser.add_argument( + "--cohort-id", + type=int, + default=None, + help="Cohort ID (default: @target_cohort_id placeholder)", + ) + sql_parser.add_argument( + "--no-validate", action="store_true", help="Skip validation" + ) + # Render markdown command - md_parser = subparsers.add_parser('render-markdown', help='Render cohort definition as Markdown') - md_parser.add_argument('input', help='Input JSON file') - md_parser.add_argument('--output', '-o', help='Output Markdown file (default: stdout)') - md_parser.add_argument('--no-validate', action='store_true', help='Skip validation') - md_parser.add_argument('--title', '-t', type=str, help='Title to add to markdown document') + md_parser = subparsers.add_parser( + "render-markdown", help="Render cohort definition as Markdown" + ) + md_parser.add_argument("input", help="Input JSON file") + md_parser.add_argument( + "--output", "-o", help="Output Markdown file (default: stdout)" + ) + md_parser.add_argument("--no-validate", action="store_true", help="Skip validation") + md_parser.add_argument( + "--title", "-t", type=str, help="Title to add to markdown document" + ) # Generate source code command - source_parser = subparsers.add_parser('generate-source', help='Generate Python source code from cohort definition') - source_parser.add_argument('input', help='Input JSON file') - source_parser.add_argument('--output', '-o', help='Output Python file (default: stdout)') + source_parser = subparsers.add_parser( + "generate-source", help="Generate Python source code from cohort definition" + ) + source_parser.add_argument("input", help="Input JSON file") + source_parser.add_argument( + "--output", "-o", help="Output Python file (default: stdout)" + ) # Process command (all-in-one) - process_parser = subparsers.add_parser('process', help='Validate, generate SQL and Markdown') - process_parser.add_argument('input', help='Input JSON file') - process_parser.add_argument('--sql-output', help='SQL output file') - process_parser.add_argument('--md-output', help='Markdown output file') - process_parser.add_argument('--cdm-schema', default='@cdm_database_schema', help='CDM schema name') - process_parser.add_argument('--target-table', default='@target_database_schema.@target_cohort_table', help='Target table') - process_parser.add_argument('--cohort-id', type=int, default=None, help='Cohort ID (default: @target_cohort_id placeholder)') - + process_parser = subparsers.add_parser( + "process", help="Validate, generate SQL and Markdown" + ) + process_parser.add_argument("input", help="Input JSON file") + process_parser.add_argument("--sql-output", help="SQL output file") + process_parser.add_argument("--md-output", help="Markdown output file") + process_parser.add_argument( + "--cdm-schema", default="@cdm_database_schema", help="CDM schema name" + ) + process_parser.add_argument( + "--target-table", + default="@target_database_schema.@target_cohort_table", + help="Target table", + ) + process_parser.add_argument( + "--cohort-id", + type=int, + default=None, + help="Cohort ID (default: @target_cohort_id placeholder)", + ) + args = parser.parse_args() - + if not args.command: parser.print_help() return 1 - + try: - if args.command == 'validate': + if args.command == "validate": return validate_command(args) - elif args.command == 'generate-sql': + elif args.command == "generate-sql": return generate_sql_command(args) - elif args.command == 'render-markdown': + elif args.command == "render-markdown": return render_markdown_command(args) - elif args.command == 'process': + elif args.command == "process": return process_command(args) - elif args.command == 'generate-source': + elif args.command == "generate-source": return generate_source_command(args) except Exception as e: print(f"Error: {e}", file=sys.stderr) @@ -83,28 +125,30 @@ def validate_command(args): """Validate a cohort definition.""" # Read JSON json_str = Path(args.input).read_text() - + # Load and validate expression = cohort_expression_from_json(json_str) - + # Run validation checks warnings = expression.check() - + if not warnings: if not args.quiet: print("✓ Cohort definition is valid") return 0 - + # Display warnings - error_count = sum(1 for w in warnings if w.severity.name == 'CRITICAL') + error_count = sum(1 for w in warnings if w.severity.name == "CRITICAL") warning_count = len(warnings) - error_count - + if not args.quiet: for warning in warnings: - severity = warning.severity.name if hasattr(warning, 'severity') else 'WARNING' - msg = str(warning) if not hasattr(warning, 'message') else warning.message + severity = ( + warning.severity.name if hasattr(warning, "severity") else "WARNING" + ) + msg = str(warning) if not hasattr(warning, "message") else warning.message print(f"[{severity}] {msg}") - + print(f"\n{error_count} error(s), {warning_count} warning(s)") return 1 if error_count > 0 else 0 @@ -113,18 +157,18 @@ def generate_sql_command(args): """Generate SQL from cohort definition.""" # Read JSON json_str = Path(args.input).read_text() - + # Load expression expression = cohort_expression_from_json(json_str) - + # Validate if requested if not args.no_validate: warnings = expression.check() - errors = [w for w in warnings if w.severity.name == 'CRITICAL'] + errors = [w for w in warnings if w.severity.name == "CRITICAL"] if errors: print(f"Error: {len(errors)} validation error(s) found", file=sys.stderr) return 1 - + # Set up options options = BuildExpressionQueryOptions() options.cdm_schema = args.cdm_schema @@ -148,28 +192,28 @@ def render_markdown_command(args): """Render cohort definition as Markdown.""" # Read JSON json_str = Path(args.input).read_text() - + # Load expression expression = cohort_expression_from_json(json_str) - + # Validate if requested if not args.no_validate: warnings = expression.check() - errors = [w for w in warnings if w.severity.name == 'CRITICAL'] + errors = [w for w in warnings if w.severity.name == "CRITICAL"] if errors: print(f"Error: {len(errors)} validation error(s) found", file=sys.stderr) return 1 - + # Generate Markdown markdown = cohort_print_friendly(expression, title=args.title) - + # Output if args.output: Path(args.output).write_text(markdown) print(f"Markdown written to {args.output}") else: print(markdown) - + return 0 @@ -177,65 +221,64 @@ def process_command(args): """Process cohort definition (validate, generate SQL and Markdown).""" # Read JSON json_str = Path(args.input).read_text() - + # Load expression expression = cohort_expression_from_json(json_str) - + # Validate warnings = expression.check() - errors = [w for w in warnings if w.severity.name == 'CRITICAL'] - + errors = [w for w in warnings if w.severity.name == "CRITICAL"] + if errors: print(f"✗ Validation failed: {len(errors)} error(s)") for error in errors: print(f" {error.message}") return 1 - + print("✓ Validation passed") - + # Set up options options = BuildExpressionQueryOptions() options.cdm_schema = args.cdm_schema options.target_table = args.target_table options.cohort_id = args.cohort_id options.generate_stats = True # Match R/Java default behavior - + # Generate SQL sql = build_cohort_query(expression, options) if args.sql_output: Path(args.sql_output).write_text(sql) print(f"✓ SQL written to {args.sql_output}") - + # Generate Markdown markdown = cohort_print_friendly(expression) if args.md_output: Path(args.md_output).write_text(markdown) print(f"✓ Markdown written to {args.md_output}") - - return 0 + return 0 def generate_source_command(args): """Generate Python source code from cohort definition.""" # Read JSON json_str = Path(args.input).read_text() - + # Load expression expression = cohort_expression_from_json(json_str) - + # Generate Source Code source_code = to_python_code(expression) - + # Output if args.output: Path(args.output).write_text(source_code) print(f"Source code written to {args.output}") else: print(source_code) - + return 0 -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/circe/cohortdefinition/__init__.py b/circe/cohortdefinition/__init__.py index e5581911..aa217d52 100644 --- a/circe/cohortdefinition/__init__.py +++ b/circe/cohortdefinition/__init__.py @@ -11,29 +11,60 @@ from .cohort import CohortExpression from .criteria import ( - Criteria, CorelatedCriteria, DemographicCriteria, - Occurrence, CriteriaColumn, InclusionRule, + Criteria, + CorelatedCriteria, + DemographicCriteria, + Occurrence, + CriteriaColumn, + InclusionRule, # Moved from core - CriteriaGroup, PrimaryCriteria, WindowedCriteria, + CriteriaGroup, + PrimaryCriteria, + WindowedCriteria, # Criteria Domain Classes - ConditionOccurrence, DrugExposure, ProcedureOccurrence, - VisitOccurrence, Observation, Measurement, DeviceExposure, - Specimen, Death, VisitDetail, ObservationPeriod, - PayerPlanPeriod, LocationRegion, + ConditionOccurrence, + DrugExposure, + ProcedureOccurrence, + VisitOccurrence, + Observation, + Measurement, + DeviceExposure, + Specimen, + Death, + VisitDetail, + ObservationPeriod, + PayerPlanPeriod, + LocationRegion, # Era Criteria Classes - ConditionEra, DrugEra, DoseEra, + ConditionEra, + DrugEra, + DoseEra, # Geographic Criteria - GeoCriteria + GeoCriteria, ) from .core import ( - CollapseType, DateType, ResultLimit, Period, DateRange, - NumericRange, DateAdjustment, ObservationFilter, - CollapseSettings, EndStrategy, ConceptSetSelection, + CollapseType, + DateType, + ResultLimit, + Period, + DateRange, + NumericRange, + DateAdjustment, + ObservationFilter, + CollapseSettings, + EndStrategy, + ConceptSetSelection, # Supporting Classes - TextFilter, WindowBound, Window, - DateOffsetStrategy, CustomEraStrategy + TextFilter, + WindowBound, + Window, + DateOffsetStrategy, + CustomEraStrategy, +) +from .cohort_expression_query_builder import ( + CohortExpressionQueryBuilder, + BuildExpressionQueryOptions, ) -from .cohort_expression_query_builder import CohortExpressionQueryBuilder, BuildExpressionQueryOptions from .concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder from .interfaces import IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher from .printfriendly import MarkdownRender @@ -41,42 +72,63 @@ __all__ = [ # Main cohort class "CohortExpression", - # Criteria classes - "Criteria", "CorelatedCriteria", "DemographicCriteria", - "Occurrence", "CriteriaColumn", "InclusionRule", - + "Criteria", + "CorelatedCriteria", + "DemographicCriteria", + "Occurrence", + "CriteriaColumn", + "InclusionRule", # Criteria Domain Classes - "ConditionOccurrence", "DrugExposure", "ProcedureOccurrence", - "VisitOccurrence", "Observation", "Measurement", "DeviceExposure", - "Specimen", "Death", "VisitDetail", "ObservationPeriod", - "PayerPlanPeriod", "LocationRegion", - + "ConditionOccurrence", + "DrugExposure", + "ProcedureOccurrence", + "VisitOccurrence", + "Observation", + "Measurement", + "DeviceExposure", + "Specimen", + "Death", + "VisitDetail", + "ObservationPeriod", + "PayerPlanPeriod", + "LocationRegion", # Era Criteria Classes - "ConditionEra", "DrugEra", "DoseEra", - + "ConditionEra", + "DrugEra", + "DoseEra", # Geographic Criteria "GeoCriteria", - # Core classes - "CollapseType", "DateType", "ResultLimit", "Period", "DateRange", - "NumericRange", "DateAdjustment", "ObservationFilter", - "CollapseSettings", "EndStrategy", "PrimaryCriteria", - "CriteriaGroup", "ConceptSetSelection", - + "CollapseType", + "DateType", + "ResultLimit", + "Period", + "DateRange", + "NumericRange", + "DateAdjustment", + "ObservationFilter", + "CollapseSettings", + "EndStrategy", + "PrimaryCriteria", + "CriteriaGroup", + "ConceptSetSelection", # Supporting Classes - "TextFilter", "WindowBound", "Window", "WindowedCriteria", - "DateOffsetStrategy", "CustomEraStrategy", - + "TextFilter", + "WindowBound", + "Window", + "WindowedCriteria", + "DateOffsetStrategy", + "CustomEraStrategy", # Query Builders - "CohortExpressionQueryBuilder", "BuildExpressionQueryOptions", + "CohortExpressionQueryBuilder", + "BuildExpressionQueryOptions", "ConceptSetExpressionQueryBuilder", - # Interfaces - "IGetCriteriaSqlDispatcher", "IGetEndStrategySqlDispatcher", - + "IGetCriteriaSqlDispatcher", + "IGetEndStrategySqlDispatcher", # Print-Friendly - "MarkdownRender" + "MarkdownRender", ] # Rebuild models with forward references after all imports are complete diff --git a/circe/cohortdefinition/builders/__init__.py b/circe/cohortdefinition/builders/__init__.py index a9b6b640..177e2ba8 100644 --- a/circe/cohortdefinition/builders/__init__.py +++ b/circe/cohortdefinition/builders/__init__.py @@ -30,14 +30,14 @@ __all__ = [ # Utility classes - "BuilderUtils", "BuilderOptions", "CriteriaColumn", - + "BuilderUtils", + "BuilderOptions", + "CriteriaColumn", # Base builder class "CriteriaSqlBuilder", - # Specific builders "ConditionOccurrenceSqlBuilder", - "DrugExposureSqlBuilder", + "DrugExposureSqlBuilder", "ProcedureOccurrenceSqlBuilder", "DeathSqlBuilder", "VisitOccurrenceSqlBuilder", @@ -51,5 +51,5 @@ "ObservationPeriodSqlBuilder", "PayerPlanPeriodSqlBuilder", "VisitDetailSqlBuilder", - "LocationRegionSqlBuilder" + "LocationRegionSqlBuilder", ] diff --git a/circe/cohortdefinition/builders/base.py b/circe/cohortdefinition/builders/base.py index d791468d..cee2c2fe 100644 --- a/circe/cohortdefinition/builders/base.py +++ b/circe/cohortdefinition/builders/base.py @@ -14,152 +14,173 @@ from ..criteria import Criteria from .utils import BuilderOptions, CriteriaColumn -T = TypeVar('T', bound=Criteria) +T = TypeVar("T", bound=Criteria) class CriteriaSqlBuilder(ABC, Generic[T]): """Abstract base class for building SQL queries from criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.CriteriaSqlBuilder """ - - def get_criteria_sql(self, criteria: T, options: Optional[BuilderOptions] = None) -> str: + + def get_criteria_sql( + self, criteria: T, options: Optional[BuilderOptions] = None + ) -> str: """Get SQL query for criteria. - + Java equivalent: CriteriaSqlBuilder.getCriteriaSql(T criteria) """ return self.get_criteria_sql_with_options(criteria, options) - - def get_criteria_sql_with_options(self, criteria: T, options: Optional[BuilderOptions]) -> str: + + def get_criteria_sql_with_options( + self, criteria: T, options: Optional[BuilderOptions] + ) -> str: """Get SQL query for criteria with builder options. - + Java equivalent: CriteriaSqlBuilder.getCriteriaSql(T criteria, BuilderOptions options) """ if options is None: options = BuilderOptions() - + query = self.get_query_template() - + query = self.embed_codeset_clause(query, criteria) - + select_clauses = self.resolve_select_clauses(criteria, options) join_clauses = self.resolve_join_clauses(criteria, options) where_clauses = self.resolve_where_clauses(criteria, options) - + query = self.embed_ordinal_expression(query, criteria, where_clauses) - + query = self.embed_select_clauses(query, select_clauses) query = self.embed_join_clauses(query, join_clauses) query = self.embed_where_clauses(query, where_clauses) - + if options is not None: filtered_columns = [ - column for column in options.additional_columns + column + for column in options.additional_columns if column not in self.get_default_columns() ] if filtered_columns: - query = query.replace("@additionalColumns", ", " + self.get_additional_columns(filtered_columns)) + query = query.replace( + "@additionalColumns", + ", " + self.get_additional_columns(filtered_columns), + ) else: query = query.replace("@additionalColumns", "") else: query = query.replace("@additionalColumns", "") - + return query - + @abstractmethod def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: """Get table column name for criteria column. - + Java equivalent: CriteriaSqlBuilder.getTableColumnForCriteriaColumn(CriteriaColumn column) """ pass - + @abstractmethod def get_query_template(self) -> str: """Get the SQL query template. - + Java equivalent: CriteriaSqlBuilder.getQueryTemplate() """ pass - + @abstractmethod def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for this builder. - + Java equivalent: CriteriaSqlBuilder.getDefaultColumns() """ pass - + def embed_codeset_clause(self, query: str, criteria: T) -> str: """Embed codeset clause in query. - + Java equivalent: CriteriaSqlBuilder.embedCodesetClause() """ # This would need to be implemented based on the Java logic return query.replace("@codesetClause", "") - - def resolve_select_clauses(self, criteria: T, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_select_clauses( + self, criteria: T, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for criteria. - + Java equivalent: CriteriaSqlBuilder.resolveSelectClauses() """ # This would need to be implemented based on the Java logic return [] - - def resolve_join_clauses(self, criteria: T, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: T, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for criteria. - + Java equivalent: CriteriaSqlBuilder.resolveJoinClauses() """ # This would need to be implemented based on the Java logic return [] - - def resolve_where_clauses(self, criteria: T, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: T, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for criteria. - + Java equivalent: CriteriaSqlBuilder.resolveWhereClauses() """ # This would need to be implemented based on the Java logic return [] - - def embed_ordinal_expression(self, query: str, criteria: T, where_clauses: List[str]) -> str: + + def embed_ordinal_expression( + self, query: str, criteria: T, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query. - + Java equivalent: CriteriaSqlBuilder.embedOrdinalExpression() """ # This would need to be implemented based on the Java logic return query.replace("@ordinalExpression", "") - + def embed_select_clauses(self, query: str, select_clauses: List[str]) -> str: """Embed select clauses in query. - + Java equivalent: CriteriaSqlBuilder.embedSelectClauses() Note: Reference uses no space after comma """ select_clause = ",".join(select_clauses) if select_clauses else "" return query.replace("@selectClause", select_clause) - + def embed_join_clauses(self, query: str, join_clauses: List[str]) -> str: """Embed join clauses in query. - + Java equivalent: CriteriaSqlBuilder.embedJoinClauses() """ join_clause = " ".join(join_clauses) if join_clauses else "" return query.replace("@joinClause", join_clause) - + def embed_where_clauses(self, query: str, where_clauses: List[str]) -> str: """Embed where clauses in query. - + Java equivalent: CriteriaSqlBuilder.embedWhereClauses() """ where_clause = "" if where_clauses: where_clause = "WHERE " + " AND ".join(where_clauses) return query.replace("@whereClause", where_clause) - + def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: """Get additional columns string. - + Java equivalent: CriteriaSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) + return ", ".join( + [ + f"{self.get_table_column_for_criteria_column(col)} as {col.value}" + for col in columns + ] + ) diff --git a/circe/cohortdefinition/builders/condition_era.py b/circe/cohortdefinition/builders/condition_era.py index 093a169b..1660c333 100644 --- a/circe/cohortdefinition/builders/condition_era.py +++ b/circe/cohortdefinition/builders/condition_era.py @@ -16,24 +16,21 @@ class ConditionEraSqlBuilder(CriteriaSqlBuilder[ConditionEra]): """SQL builder for Condition Era criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.ConditionEraSqlBuilder """ - + # Default columns are those that are specified in the template, and don't need to be added if specified in 'additionalColumns' - DEFAULT_COLUMNS = { - CriteriaColumn.START_DATE, - CriteriaColumn.END_DATE - } - + DEFAULT_COLUMNS = {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE} + # Default select columns are the columns that will always be returned from the subquery, but are added to based on the specific criteria DEFAULT_SELECT_COLUMNS = [ "ce.person_id", - "ce.condition_era_id", + "ce.condition_era_id", "ce.condition_concept_id", - "ce.condition_occurrence_count" + "ce.condition_occurrence_count", ] - + def get_query_template(self) -> str: """Get the SQL query template for condition era criteria.""" return """-- Begin Condition Era Criteria @@ -49,12 +46,14 @@ def get_query_template(self) -> str: @whereClause -- End Condition Era Criteria """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for condition era criteria.""" return self.DEFAULT_COLUMNS - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.condition_concept_id", @@ -62,106 +61,154 @@ def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) CriteriaColumn.DURATION: "(DATEDIFF(d,C.start_date, C.end_date))", CriteriaColumn.START_DATE: "C.start_date", CriteriaColumn.END_DATE: "C.end_date", - CriteriaColumn.VISIT_ID: "NULL" + CriteriaColumn.VISIT_ID: "NULL", } return column_mapping.get(criteria_column, "NULL") - + def embed_codeset_clause(self, query: str, criteria: ConditionEra) -> str: """Embed codeset clause in query. - + Note: Reference uses lowercase 'where' and double space before #Codesets """ codeset_clause = "" if criteria.codeset_id is not None: codeset_clause = f"where ce.condition_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.codeset_id})" return query.replace("@codesetClause", codeset_clause) - - def embed_ordinal_expression(self, query: str, criteria: ConditionEra, where_clauses: List[str]) -> str: + + def embed_ordinal_expression( + self, query: str, criteria: ConditionEra, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") - query = query.replace("@ordinalExpression", ", row_number() over (PARTITION BY ce.person_id ORDER BY ce.condition_era_start_date, ce.condition_era_id) as ordinal") + query = query.replace( + "@ordinalExpression", + ", row_number() over (PARTITION BY ce.person_id ORDER BY ce.condition_era_start_date, ce.condition_era_id) as ordinal", + ) else: query = query.replace("@ordinalExpression", "") return query - - def resolve_select_clauses(self, criteria: ConditionEra, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_select_clauses( + self, criteria: ConditionEra, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for condition era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) - + # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "ce.condition_era_start_date" if criteria.date_adjustment.start_with == "start_date" else "ce.condition_era_end_date" - end_column = "ce.condition_era_start_date" if criteria.date_adjustment.end_with == "start_date" else "ce.condition_era_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "ce.condition_era_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "ce.condition_era_end_date" + ) + end_column = ( + "ce.condition_era_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "ce.condition_era_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: - select_cols.append("ce.condition_era_start_date as start_date, ce.condition_era_end_date as end_date") - + select_cols.append( + "ce.condition_era_start_date as start_date, ce.condition_era_end_date as end_date" + ) + return select_cols - - def resolve_join_clauses(self, criteria: ConditionEra, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: ConditionEra, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for condition era criteria.""" join_clauses = [] - + # join to PERSON - if (criteria.age_at_start is not None or criteria.age_at_end is not None or - (criteria.gender is not None and len(criteria.gender) > 0) or - criteria.gender_cs is not None): - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + if ( + criteria.age_at_start is not None + or criteria.age_at_end is not None + or (criteria.gender is not None and len(criteria.gender) > 0) + or criteria.gender_cs is not None + ): + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + return join_clauses - - def resolve_where_clauses(self, criteria: ConditionEra, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: ConditionEra, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for condition era criteria.""" where_clauses = [] - + # eraStartDate if criteria.era_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.era_start_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.start_date", criteria.era_start_date + ) if date_clause: where_clauses.append(date_clause) - + # eraEndDate if criteria.era_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.era_end_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.end_date", criteria.era_end_date + ) if date_clause: where_clauses.append(date_clause) - + # occurrenceCount if criteria.occurrence_count is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("C.condition_occurrence_count", criteria.occurrence_count) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "C.condition_occurrence_count", criteria.occurrence_count + ) if numeric_clause: where_clauses.append(numeric_clause) - + # eraLength if criteria.era_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length + ) if numeric_clause: where_clauses.append(numeric_clause) - + # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start + ) if numeric_clause: where_clauses.append(numeric_clause) - + # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end + ) if numeric_clause: where_clauses.append(numeric_clause) - + # gender if criteria.gender is not None and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) + # genderCS if criteria.gender_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id", criteria.gender_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, + "P.gender_concept_id", + criteria.gender_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - + return where_clauses diff --git a/circe/cohortdefinition/builders/condition_occurrence.py b/circe/cohortdefinition/builders/condition_occurrence.py index 674ac210..c1594c65 100644 --- a/circe/cohortdefinition/builders/condition_occurrence.py +++ b/circe/cohortdefinition/builders/condition_occurrence.py @@ -16,25 +16,25 @@ class ConditionOccurrenceSqlBuilder(CriteriaSqlBuilder[ConditionOccurrence]): """SQL builder for Condition Occurrence criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.ConditionOccurrenceSqlBuilder """ - + # Default columns are those that are specified in the template, and don't need to be added if specified in 'additionalColumns' DEFAULT_COLUMNS = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - + # Default select columns are the columns that will always be returned from the subquery, but are added to based on the specific criteria DEFAULT_SELECT_COLUMNS = [ "co.person_id", - "co.condition_occurrence_id", + "co.condition_occurrence_id", "co.condition_concept_id", - "co.visit_occurrence_id" + "co.visit_occurrence_id", ] - + def get_query_template(self) -> str: """Get the SQL query template for condition occurrence criteria.""" return """ @@ -51,187 +51,280 @@ def get_query_template(self) -> str: @whereClause -- End Condition Occurrence Criteria """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for condition occurrence criteria.""" return self.DEFAULT_COLUMNS - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.condition_concept_id", CriteriaColumn.DURATION: "(DATEDIFF(d,C.start_date, C.end_date))", CriteriaColumn.START_DATE: "C.start_date", CriteriaColumn.END_DATE: "C.end_date", - CriteriaColumn.VISIT_ID: "C.visit_occurrence_id" + CriteriaColumn.VISIT_ID: "C.visit_occurrence_id", } return column_mapping.get(criteria_column, "NULL") - + def embed_codeset_clause(self, query: str, criteria: ConditionOccurrence) -> str: """Embed codeset clause in query. - + Java equivalent: ConditionOccurrenceSqlBuilder.embedCodesetClause() """ - return query.replace("@codesetClause", - BuilderUtils.get_codeset_join_expression( - criteria.codeset_id, - "co.condition_concept_id", - criteria.condition_source_concept, - "co.condition_source_concept_id" - )) - - def embed_ordinal_expression(self, query: str, criteria: ConditionOccurrence, where_clauses: List[str]) -> str: + return query.replace( + "@codesetClause", + BuilderUtils.get_codeset_join_expression( + criteria.codeset_id, + "co.condition_concept_id", + criteria.condition_source_concept, + "co.condition_source_concept_id", + ), + ) + + def embed_ordinal_expression( + self, query: str, criteria: ConditionOccurrence, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") - query = query.replace("@ordinalExpression", ", row_number() over (PARTITION BY co.person_id ORDER BY co.condition_start_date, co.condition_occurrence_id) as ordinal") + query = query.replace( + "@ordinalExpression", + ", row_number() over (PARTITION BY co.person_id ORDER BY co.condition_start_date, co.condition_occurrence_id) as ordinal", + ) else: query = query.replace("@ordinalExpression", "") return query - - def resolve_select_clauses(self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_select_clauses( + self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for condition occurrence criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) - + # Condition Type - if ((criteria.condition_type is not None and len(criteria.condition_type) > 0) or - criteria.condition_type_cs is not None): + if ( + criteria.condition_type is not None and len(criteria.condition_type) > 0 + ) or criteria.condition_type_cs is not None: select_cols.append("co.condition_type_concept_id") - + # Stop Reason if criteria.stop_reason is not None: select_cols.append("co.stop_reason") - + # providerSpecialty - if ((criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0) or - criteria.provider_specialty_cs is not None): + if ( + criteria.provider_specialty is not None + and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs is not None: select_cols.append("co.provider_id") - + # conditionStatus - if ((criteria.condition_status is not None and len(criteria.condition_status) > 0) or - criteria.condition_status_cs is not None): + if ( + criteria.condition_status is not None and len(criteria.condition_status) > 0 + ) or criteria.condition_status_cs is not None: select_cols.append("co.condition_status_concept_id") - + # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "co.condition_start_date" if criteria.date_adjustment.start_with == "start_date" else "COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date))" - end_column = "co.condition_start_date" if criteria.date_adjustment.end_with == "start_date" else "COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date))" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "co.condition_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date))" + ) + end_column = ( + "co.condition_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date))" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: - select_cols.append("co.condition_start_date as start_date, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)) as end_date") - + select_cols.append( + "co.condition_start_date as start_date, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)) as end_date" + ) + return select_cols - - def resolve_join_clauses(self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for condition occurrence criteria.""" join_clauses = [] - + # join to PERSON - if (criteria.age is not None or - (criteria.gender is not None and len(criteria.gender) > 0) or - criteria.gender_cs is not None): - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + if ( + criteria.age is not None + or (criteria.gender is not None and len(criteria.gender) > 0) + or criteria.gender_cs is not None + ): + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + # join to VISIT_OCCURRENCE - if ((criteria.visit_type is not None and len(criteria.visit_type) > 0) or - criteria.visit_type_cs is not None): - join_clauses.append("JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id") - + if ( + criteria.visit_type is not None and len(criteria.visit_type) > 0 + ) or criteria.visit_type_cs is not None: + join_clauses.append( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" + ) + # join to PROVIDER - if ((criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0) or - criteria.provider_specialty_cs is not None): - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") - + if ( + criteria.provider_specialty is not None + and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs is not None: + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) + return join_clauses - - def resolve_where_clauses(self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for condition occurrence criteria.""" where_clauses = [] - + # occurrenceStartDate if criteria.occurrence_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.start_date", criteria.occurrence_start_date + ) if date_clause: where_clauses.append(date_clause) - + # occurrenceEndDate if criteria.occurrence_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.occurrence_end_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.end_date", criteria.occurrence_end_date + ) if date_clause: where_clauses.append(date_clause) - + # conditionType if criteria.condition_type is not None and len(criteria.condition_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.condition_type) + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.condition_type + ) if concept_ids: exclude_clause = "not" if criteria.condition_type_exclude else "" - where_clauses.append(f"C.condition_type_concept_id {exclude_clause} in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.condition_type_concept_id {exclude_clause} in ({','.join(map(str, concept_ids))})" + ) + # conditionTypeCS if criteria.condition_type_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.condition_type_cs.codeset_id, "C.condition_type_concept_id", criteria.condition_type_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.condition_type_cs.codeset_id, + "C.condition_type_concept_id", + criteria.condition_type_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - + # Stop Reason if criteria.stop_reason is not None: - text_clause = BuilderUtils.build_text_filter_clause(criteria.stop_reason, "C.stop_reason") + text_clause = BuilderUtils.build_text_filter_clause( + criteria.stop_reason, "C.stop_reason" + ) if text_clause: where_clauses.append(text_clause) - + # age if criteria.age is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age + ) if numeric_clause: where_clauses.append(numeric_clause) - + # gender if criteria.gender is not None and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) + # genderCS if criteria.gender_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id", criteria.gender_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, + "P.gender_concept_id", + criteria.gender_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - + # providerSpecialty - if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) + if ( + criteria.provider_specialty is not None + and len(criteria.provider_specialty) > 0 + ): + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.provider_specialty + ) if concept_ids: - where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" + ) + # providerSpecialtyCS if criteria.provider_specialty_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id", criteria.provider_specialty_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.provider_specialty_cs.codeset_id, + "PR.specialty_concept_id", + criteria.provider_specialty_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - + # visitType if criteria.visit_type is not None and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.visit_type + ) if concept_ids: - where_clauses.append(f"V.visit_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"V.visit_concept_id in ({','.join(map(str, concept_ids))})" + ) + # visitTypeCS if criteria.visit_type_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id", criteria.visit_type_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, + "V.visit_concept_id", + criteria.visit_type_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - + # conditionStatus if criteria.condition_status is not None and len(criteria.condition_status) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.condition_status) + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.condition_status + ) if concept_ids: - where_clauses.append(f"C.condition_status_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.condition_status_concept_id in ({','.join(map(str, concept_ids))})" + ) + # conditionStatusCS if criteria.condition_status_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.condition_status_cs.codeset_id, "C.condition_status_concept_id", criteria.condition_status_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.condition_status_cs.codeset_id, + "C.condition_status_concept_id", + criteria.condition_status_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - - return where_clauses \ No newline at end of file + + return where_clauses diff --git a/circe/cohortdefinition/builders/death.py b/circe/cohortdefinition/builders/death.py index 359575fc..b8f2be3b 100644 --- a/circe/cohortdefinition/builders/death.py +++ b/circe/cohortdefinition/builders/death.py @@ -17,10 +17,10 @@ class DeathSqlBuilder(CriteriaSqlBuilder[Death]): """SQL builder for Death criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.DeathSqlBuilder """ - + def get_query_template(self) -> str: """Get the SQL query template for death criteria.""" # FIX: Updated template to match the standard OHDSI "Event" shape. @@ -47,30 +47,36 @@ def get_default_columns(self) -> Set[CriteriaColumn]: return { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "coalesce(C.cause_concept_id,0)", CriteriaColumn.DURATION: "CAST(1 as int)", CriteriaColumn.START_DATE: "C.start_date", - CriteriaColumn.END_DATE: "C.end_date" + CriteriaColumn.END_DATE: "C.end_date", } return column_mapping.get(criteria_column, "NULL") - def embed_codeset_clause(self, query: str, criteria: Death) -> str: """Embed codeset clause for death criteria.""" - return query.replace("@codesetClause", BuilderUtils.get_codeset_join_expression( - criteria.codeset_id, - "d.cause_concept_id", - criteria.death_source_concept, - "d.cause_source_concept_id" - )) - - def embed_ordinal_expression(self, query: str, criteria: Death, where_clauses: List[str]) -> str: + return query.replace( + "@codesetClause", + BuilderUtils.get_codeset_join_expression( + criteria.codeset_id, + "d.cause_concept_id", + criteria.death_source_concept, + "d.cause_source_concept_id", + ), + ) + + def embed_ordinal_expression( + self, query: str, criteria: Death, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query. Java DeathSqlBuilder overrides this to return query as is. @@ -79,42 +85,56 @@ def embed_ordinal_expression(self, query: str, criteria: Death, where_clauses: L """ return query - def resolve_select_clauses(self, criteria: Death, options: Optional[BuilderOptions] = None) -> List[str]: + def resolve_select_clauses( + self, criteria: Death, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for death criteria.""" - select_cols = [ - "d.person_id", - "d.cause_concept_id" - ] + select_cols = ["d.person_id", "d.cause_concept_id"] # deathType - if (criteria.death_type and len(criteria.death_type) > 0) or \ - (criteria.death_type_cs and criteria.death_type_cs.codeset_id): + if (criteria.death_type and len(criteria.death_type) > 0) or ( + criteria.death_type_cs and criteria.death_type_cs.codeset_id + ): select_cols.append("d.death_type_concept_id") # dateAdjustment or default start/end dates if criteria.date_adjustment: - select_cols.append(BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, "d.death_date", "DATEADD(day,1,d.death_date)" - )) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, + "d.death_date", + "DATEADD(day,1,d.death_date)", + ) + ) else: - # FIX: Added 'as start_date' to align with outer query expectation - select_cols.append("d.death_date as start_date, DATEADD(day,1,d.death_date) as end_date") + # FIX: Added 'as start_date' to align with outer query expectation + select_cols.append( + "d.death_date as start_date, DATEADD(day,1,d.death_date) as end_date" + ) return select_cols - def resolve_join_clauses(self, criteria: Death, options: Optional[BuilderOptions] = None) -> List[str]: + def resolve_join_clauses( + self, criteria: Death, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for death criteria.""" joins = [] # join to PERSON - if criteria.age or \ - (criteria.gender and len(criteria.gender) > 0) or \ - (criteria.gender_cs and criteria.gender_cs.codeset_id): - joins.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): + joins.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) return joins - def resolve_where_clauses(self, criteria: Death, options: Optional[BuilderOptions] = None) -> List[str]: + def resolve_where_clauses( + self, criteria: Death, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for death criteria.""" where_clauses = super().resolve_where_clauses(criteria) @@ -128,27 +148,43 @@ def resolve_where_clauses(self, criteria: Death, options: Optional[BuilderOption # deathType if criteria.death_type and len(criteria.death_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.death_type) + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.death_type + ) op = "not in" if criteria.death_type_exclude else "in" - where_clauses.append(f"C.death_type_concept_id {op} ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"C.death_type_concept_id {op} ({','.join(map(str, concept_ids))})" + ) # deathTypeCS if criteria.death_type_cs and criteria.death_type_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.death_type_cs.codeset_id, "C.death_type_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.death_type_cs.codeset_id, "C.death_type_concept_id" + ) + ) # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - )) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age + ) + ) # gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) # genderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, "P.gender_concept_id" + ) + ) - return where_clauses \ No newline at end of file + return where_clauses diff --git a/circe/cohortdefinition/builders/device_exposure.py b/circe/cohortdefinition/builders/device_exposure.py index 841f7881..8806ee5e 100644 --- a/circe/cohortdefinition/builders/device_exposure.py +++ b/circe/cohortdefinition/builders/device_exposure.py @@ -17,10 +17,10 @@ class DeviceExposureSqlBuilder(CriteriaSqlBuilder[DeviceExposure]): """SQL builder for Device Exposure criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.DeviceExposureSqlBuilder """ - + def get_query_template(self) -> str: """Get the SQL query template for device exposure criteria.""" return """-- Begin Device Exposure Criteria @@ -35,16 +35,18 @@ def get_query_template(self) -> str: @joinClause @whereClause -- End Device Exposure Criteria""" - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for device exposure criteria.""" return { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.START_DATE: "C.start_date", @@ -52,23 +54,26 @@ def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) CriteriaColumn.DOMAIN_CONCEPT: "C.device_concept_id", CriteriaColumn.DURATION: "DATEDIFF(day, C.start_date, C.end_date)", CriteriaColumn.VISIT_ID: "C.visit_occurrence_id", - CriteriaColumn.QUANTITY: "C.quantity" + CriteriaColumn.QUANTITY: "C.quantity", } return column_mapping.get(criteria_column, "NULL") - - def resolve_select_clauses(self, criteria: DeviceExposure, options: BuilderOptions) -> List[str]: + + def resolve_select_clauses( + self, criteria: DeviceExposure, options: BuilderOptions + ) -> List[str]: """Resolve select clauses for device exposure criteria.""" select_cols = [ "de.person_id", "de.device_exposure_id", "de.device_concept_id", "de.visit_occurrence_id", - "de.quantity" + "de.quantity", ] - + # Device Type - if (criteria.device_type and len(criteria.device_type) > 0) or \ - (criteria.device_type_cs and criteria.device_type_cs.codeset_id): + if (criteria.device_type and len(criteria.device_type) > 0) or ( + criteria.device_type_cs and criteria.device_type_cs.codeset_id + ): select_cols.append("de.device_type_concept_id") # unique_device_id @@ -76,57 +81,87 @@ def resolve_select_clauses(self, criteria: DeviceExposure, options: BuilderOptio select_cols.append("de.unique_device_id") # providerSpecialty - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or \ - (criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id): + if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( + criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id + ): select_cols.append("de.provider_id") - + # dateAdjustment or default start/end dates if criteria.date_adjustment: - select_cols.append(BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, - "de.device_exposure_start_date" if criteria.date_adjustment.start_with == "START_DATE" else "COALESCE(de.device_exposure_end_date, DATEADD(day,1,de.device_exposure_start_date))", - "de.device_exposure_start_date" if criteria.date_adjustment.end_with == "START_DATE" else "COALESCE(de.device_exposure_end_date, DATEADD(day,1,de.device_exposure_start_date))" - )) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, + ( + "de.device_exposure_start_date" + if criteria.date_adjustment.start_with == "START_DATE" + else "COALESCE(de.device_exposure_end_date, DATEADD(day,1,de.device_exposure_start_date))" + ), + ( + "de.device_exposure_start_date" + if criteria.date_adjustment.end_with == "START_DATE" + else "COALESCE(de.device_exposure_end_date, DATEADD(day,1,de.device_exposure_start_date))" + ), + ) + ) else: - select_cols.append("de.device_exposure_start_date as start_date, COALESCE(de.device_exposure_end_date, DATEADD(day,1,de.device_exposure_start_date)) as end_date") - + select_cols.append( + "de.device_exposure_start_date as start_date, COALESCE(de.device_exposure_end_date, DATEADD(day,1,de.device_exposure_start_date)) as end_date" + ) + return select_cols - - def resolve_join_clauses(self, criteria: DeviceExposure, options: BuilderOptions) -> List[str]: + + def resolve_join_clauses( + self, criteria: DeviceExposure, options: BuilderOptions + ) -> List[str]: """Resolve join clauses for device exposure criteria.""" joins = [] - + # Join to PERSON - if criteria.age or \ - (criteria.gender and len(criteria.gender) > 0) or \ - (criteria.gender_cs and criteria.gender_cs.codeset_id): - joins.append("JOIN @cdm_database_schema.PERSON P ON C.person_id = P.person_id") - + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): + joins.append( + "JOIN @cdm_database_schema.PERSON P ON C.person_id = P.person_id" + ) + # Join to VISIT_OCCURRENCE - if (criteria.visit_type and len(criteria.visit_type) > 0) or \ - (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): - joins.append("JOIN @cdm_database_schema.VISIT_OCCURRENCE V ON C.visit_occurrence_id = V.visit_occurrence_id AND C.person_id = V.person_id") + if (criteria.visit_type and len(criteria.visit_type) > 0) or ( + criteria.visit_type_cs and criteria.visit_type_cs.codeset_id + ): + joins.append( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V ON C.visit_occurrence_id = V.visit_occurrence_id AND C.person_id = V.person_id" + ) # Join to PROVIDER - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or \ - (criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id): - joins.append("LEFT JOIN @cdm_database_schema.PROVIDER PR ON C.provider_id = PR.provider_id") - + if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( + criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id + ): + joins.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR ON C.provider_id = PR.provider_id" + ) + return joins - + def embed_codeset_clause(self, query: str, criteria: DeviceExposure) -> str: """Embed codeset clause for device exposure criteria.""" - return query.replace("@codesetClause", BuilderUtils.get_codeset_join_expression( - criteria.codeset_id, - "de.device_concept_id", - criteria.device_source_concept, - "de.device_source_concept_id" - )) - - def resolve_where_clauses(self, criteria: DeviceExposure, options: BuilderOptions) -> List[str]: + return query.replace( + "@codesetClause", + BuilderUtils.get_codeset_join_expression( + criteria.codeset_id, + "de.device_concept_id", + criteria.device_source_concept, + "de.device_source_concept_id", + ), + ) + + def resolve_where_clauses( + self, criteria: DeviceExposure, options: BuilderOptions + ) -> List[str]: """Resolve where clauses for device exposure criteria.""" conditions = [] - + # Add date range conditions if criteria.occurrence_start_date: date_clause = BuilderUtils.build_date_range_clause( @@ -134,26 +169,31 @@ def resolve_where_clauses(self, criteria: DeviceExposure, options: BuilderOption ) if date_clause: conditions.append(date_clause) - + if criteria.occurrence_end_date: date_clause = BuilderUtils.build_date_range_clause( "C.end_date", criteria.occurrence_end_date ) if date_clause: conditions.append(date_clause) - + # deviceType if criteria.device_type and len(criteria.device_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.device_type) + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.device_type + ) op = "NOT IN" if criteria.device_type_exclude else "IN" - conditions.append(f"C.device_type_concept_id {op} ({','.join(map(str, concept_ids))})") - + conditions.append( + f"C.device_type_concept_id {op} ({','.join(map(str, concept_ids))})" + ) + # deviceTypeCS if criteria.device_type_cs and criteria.device_type_cs.codeset_id: - conditions.append(BuilderUtils.get_codeset_in_expression( - criteria.device_type_cs.codeset_id, - "C.device_type_concept_id" - )) + conditions.append( + BuilderUtils.get_codeset_in_expression( + criteria.device_type_cs.codeset_id, "C.device_type_concept_id" + ) + ) # Add unique device ID condition if criteria.unique_device_id: @@ -162,7 +202,7 @@ def resolve_where_clauses(self, criteria: DeviceExposure, options: BuilderOption ) if device_id_clause: conditions.append(device_id_clause) - + # Add quantity condition if criteria.quantity: quantity_clause = BuilderUtils.build_numeric_range_clause( @@ -170,58 +210,77 @@ def resolve_where_clauses(self, criteria: DeviceExposure, options: BuilderOption ) if quantity_clause: conditions.append(quantity_clause) - + # Age if criteria.age: - conditions.append(BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - )) + conditions.append( + BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age + ) + ) # Gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - conditions.append(f"P.gender_concept_id IN ({','.join(map(str, concept_ids))})") - + conditions.append( + f"P.gender_concept_id IN ({','.join(map(str, concept_ids))})" + ) + # GenderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: - conditions.append(BuilderUtils.get_codeset_in_expression( - criteria.gender_cs.codeset_id, - "P.gender_concept_id" - )) - + conditions.append( + BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, "P.gender_concept_id" + ) + ) + # Provider Specialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) - conditions.append(f"PR.specialty_concept_id IN ({','.join(map(str, concept_ids))})") - + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.provider_specialty + ) + conditions.append( + f"PR.specialty_concept_id IN ({','.join(map(str, concept_ids))})" + ) + # Provider Specialty CS if criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id: - conditions.append(BuilderUtils.get_codeset_in_expression( - criteria.provider_specialty_cs.codeset_id, - "PR.specialty_concept_id" - )) + conditions.append( + BuilderUtils.get_codeset_in_expression( + criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id" + ) + ) # Visit Type if criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) - conditions.append(f"V.visit_concept_id IN ({','.join(map(str, concept_ids))})") - + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.visit_type + ) + conditions.append( + f"V.visit_concept_id IN ({','.join(map(str, concept_ids))})" + ) + # Visit Type CS if criteria.visit_type_cs and criteria.visit_type_cs.codeset_id: - conditions.append(BuilderUtils.get_codeset_in_expression( - criteria.visit_type_cs.codeset_id, - "V.visit_concept_id" - )) - + conditions.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, "V.visit_concept_id" + ) + ) + return conditions - - def resolve_ordinal_expression(self, criteria: DeviceExposure, options: BuilderOptions) -> str: + + def resolve_ordinal_expression( + self, criteria: DeviceExposure, options: BuilderOptions + ) -> str: """Resolve ordinal expression for device exposure criteria.""" if criteria.first: return ", row_number() over (PARTITION BY de.person_id ORDER BY de.device_exposure_start_date, de.device_exposure_id) as ordinal" return "" - - def get_ordinal_expression_where_clause(self, criteria: DeviceExposure, options: BuilderOptions) -> List[str]: - if criteria.first: - return ["C.ordinal = 1"] - return [] + + def get_ordinal_expression_where_clause( + self, criteria: DeviceExposure, options: BuilderOptions + ) -> List[str]: + if criteria.first: + return ["C.ordinal = 1"] + return [] diff --git a/circe/cohortdefinition/builders/dose_era.py b/circe/cohortdefinition/builders/dose_era.py index e369ca7b..c4d8393b 100644 --- a/circe/cohortdefinition/builders/dose_era.py +++ b/circe/cohortdefinition/builders/dose_era.py @@ -16,26 +16,26 @@ class DoseEraSqlBuilder(CriteriaSqlBuilder[DoseEra]): """SQL builder for Dose Era criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.DoseEraSqlBuilder """ - + # Default columns are those that are specified in the template, and don't need to be added if specified in 'additionalColumns' DEFAULT_COLUMNS = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - + # Default select columns are the columns that will always be returned from the subquery, but are added to based on the specific criteria DEFAULT_SELECT_COLUMNS = [ "de.person_id", - "de.dose_era_id", + "de.dose_era_id", "de.drug_concept_id", "de.unit_concept_id", - "de.dose_value" + "de.dose_value", ] - + def get_query_template(self) -> str: """Get the SQL query template for dose era criteria.""" return """-- Begin Dose Era Criteria @@ -51,12 +51,14 @@ def get_query_template(self) -> str: @whereClause -- End Dose Era Criteria """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for dose era criteria.""" return self.DEFAULT_COLUMNS - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.drug_concept_id", @@ -66,119 +68,172 @@ def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) CriteriaColumn.START_DATE: "C.start_date", CriteriaColumn.END_DATE: "C.end_date", CriteriaColumn.VISIT_ID: "NULL", - CriteriaColumn.VISIT_ID: "NULL" + CriteriaColumn.VISIT_ID: "NULL", } return column_mapping.get(criteria_column, "NULL") - + def embed_codeset_clause(self, query: str, criteria: DoseEra) -> str: """Embed codeset clause in query. - + Note: Reference uses lowercase 'where' and double space before #Codesets """ codeset_clause = "" if criteria.codeset_id is not None: codeset_clause = f"where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.codeset_id})" return query.replace("@codesetClause", codeset_clause) - - def embed_ordinal_expression(self, query: str, criteria: DoseEra, where_clauses: List[str]) -> str: + + def embed_ordinal_expression( + self, query: str, criteria: DoseEra, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") - query = query.replace("@ordinalExpression", ", row_number() over (PARTITION BY de.person_id ORDER BY de.dose_era_start_date, de.dose_era_id) as ordinal") + query = query.replace( + "@ordinalExpression", + ", row_number() over (PARTITION BY de.person_id ORDER BY de.dose_era_start_date, de.dose_era_id) as ordinal", + ) else: query = query.replace("@ordinalExpression", "") return query - - def resolve_select_clauses(self, criteria: DoseEra, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_select_clauses( + self, criteria: DoseEra, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for dose era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) - + # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "de.dose_era_start_date" if criteria.date_adjustment.start_with == "start_date" else "de.dose_era_end_date" - end_column = "de.dose_era_start_date" if criteria.date_adjustment.end_with == "start_date" else "de.dose_era_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "de.dose_era_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "de.dose_era_end_date" + ) + end_column = ( + "de.dose_era_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "de.dose_era_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: - select_cols.append("de.dose_era_start_date as start_date, de.dose_era_end_date as end_date") - + select_cols.append( + "de.dose_era_start_date as start_date, de.dose_era_end_date as end_date" + ) + return select_cols - - def resolve_join_clauses(self, criteria: DoseEra, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: DoseEra, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for dose era criteria.""" join_clauses = [] - + # join to PERSON - if (criteria.age_at_start is not None or - criteria.age_at_end is not None or - (criteria.gender is not None and len(criteria.gender) > 0) or - criteria.gender_cs is not None): - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + if ( + criteria.age_at_start is not None + or criteria.age_at_end is not None + or (criteria.gender is not None and len(criteria.gender) > 0) + or criteria.gender_cs is not None + ): + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + return join_clauses - - def resolve_where_clauses(self, criteria: DoseEra, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: DoseEra, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for dose era criteria.""" where_clauses = [] - + # eraStartDate if criteria.era_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.era_start_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.start_date", criteria.era_start_date + ) if date_clause: where_clauses.append(date_clause) - + # eraEndDate if criteria.era_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.era_end_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.end_date", criteria.era_end_date + ) if date_clause: where_clauses.append(date_clause) - + # unit if criteria.unit is not None and len(criteria.unit) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.unit) if concept_ids: - where_clauses.append(f"C.unit_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.unit_concept_id in ({','.join(map(str, concept_ids))})" + ) + # unitCS if criteria.unit_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id", criteria.unit_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.unit_cs.codeset_id, + "C.unit_concept_id", + criteria.unit_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - + # doseValue if criteria.dose_value is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("C.dose_value", criteria.dose_value, ".4f") + numeric_clause = BuilderUtils.build_numeric_range_clause( + "C.dose_value", criteria.dose_value, ".4f" + ) if numeric_clause: where_clauses.append(numeric_clause) - + # eraLength if criteria.era_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length + ) if numeric_clause: where_clauses.append(numeric_clause) - + # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start + ) if numeric_clause: where_clauses.append(numeric_clause) - + # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end + ) if numeric_clause: where_clauses.append(numeric_clause) - + # gender if criteria.gender is not None and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) + # genderCS if criteria.gender_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id", criteria.gender_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, + "P.gender_concept_id", + criteria.gender_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - + return where_clauses diff --git a/circe/cohortdefinition/builders/drug_era.py b/circe/cohortdefinition/builders/drug_era.py index 275ea7dc..83909a3b 100644 --- a/circe/cohortdefinition/builders/drug_era.py +++ b/circe/cohortdefinition/builders/drug_era.py @@ -16,30 +16,30 @@ class DrugEraSqlBuilder(CriteriaSqlBuilder[DrugEra]): """SQL builder for Drug Era criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.DrugEraSqlBuilder """ - + # Default columns are those that are specified in the template, and don't need to be added if specified in 'additionalColumns' DEFAULT_COLUMNS = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - + # Default select columns are the columns that will always be returned from the subquery, but are added to based on the specific criteria # Note: These are joined with comma to form a single line like the Java output DEFAULT_SELECT_COLUMNS = [ "de.person_id", - "de.drug_era_id", + "de.drug_era_id", "de.drug_concept_id", "de.drug_exposure_count", - "de.gap_days" + "de.gap_days", ] - + def get_query_template(self) -> str: """Get the SQL query template for drug era criteria. - + This template matches the Java DrugEraSqlBuilder template exactly. """ return """-- Begin Drug Era Criteria @@ -55,12 +55,14 @@ def get_query_template(self) -> str: @whereClause -- End Drug Era Criteria """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for drug era criteria.""" return self.DEFAULT_COLUMNS - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.drug_concept_id", @@ -70,115 +72,164 @@ def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) CriteriaColumn.START_DATE: "C.start_date", CriteriaColumn.END_DATE: "C.end_date", CriteriaColumn.VISIT_ID: "NULL", - CriteriaColumn.VISIT_ID: "NULL" + CriteriaColumn.VISIT_ID: "NULL", } return column_mapping.get(criteria_column, "NULL") - + def embed_codeset_clause(self, query: str, criteria: DrugEra) -> str: """Embed codeset clause in query. - + Note: Reference uses lowercase 'where' and double space before #Codesets """ codeset_clause = "" if criteria.codeset_id is not None: codeset_clause = f"where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.codeset_id})" return query.replace("@codesetClause", codeset_clause) - - def embed_ordinal_expression(self, query: str, criteria: DrugEra, where_clauses: List[str]) -> str: + + def embed_ordinal_expression( + self, query: str, criteria: DrugEra, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") - query = query.replace("@ordinalExpression", ", row_number() over (PARTITION BY de.person_id ORDER BY de.drug_era_start_date, de.drug_era_id) as ordinal") + query = query.replace( + "@ordinalExpression", + ", row_number() over (PARTITION BY de.person_id ORDER BY de.drug_era_start_date, de.drug_era_id) as ordinal", + ) else: query = query.replace("@ordinalExpression", "") return query - - def resolve_select_clauses(self, criteria: DrugEra, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_select_clauses( + self, criteria: DrugEra, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for drug era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) - + # gap_days and drug_exposure_count are included by default so we do not need to add here - + # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "de.drug_era_start_date" if criteria.date_adjustment.start_with == "start_date" else "de.drug_era_end_date" - end_column = "de.drug_era_start_date" if criteria.date_adjustment.end_with == "start_date" else "de.drug_era_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "de.drug_era_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "de.drug_era_end_date" + ) + end_column = ( + "de.drug_era_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "de.drug_era_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: - select_cols.append("de.drug_era_start_date as start_date, de.drug_era_end_date as end_date") - + select_cols.append( + "de.drug_era_start_date as start_date, de.drug_era_end_date as end_date" + ) + return select_cols - - def resolve_join_clauses(self, criteria: DrugEra, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: DrugEra, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for drug era criteria.""" join_clauses = [] - + # join to PERSON - if (criteria.age_at_start is not None or - criteria.age_at_end is not None or - (criteria.gender is not None and len(criteria.gender) > 0) or - criteria.gender_cs is not None): - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + if ( + criteria.age_at_start is not None + or criteria.age_at_end is not None + or (criteria.gender is not None and len(criteria.gender) > 0) + or criteria.gender_cs is not None + ): + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + return join_clauses - - def resolve_where_clauses(self, criteria: DrugEra, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: DrugEra, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for drug era criteria.""" where_clauses = [] - + # eraStartDate if criteria.era_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.era_start_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.start_date", criteria.era_start_date + ) if date_clause: where_clauses.append(date_clause) - + # eraEndDate if criteria.era_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.era_end_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.end_date", criteria.era_end_date + ) if date_clause: where_clauses.append(date_clause) - + # occurrenceCount if criteria.occurrence_count is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("C.drug_exposure_count", criteria.occurrence_count) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "C.drug_exposure_count", criteria.occurrence_count + ) if numeric_clause: where_clauses.append(numeric_clause) - + # eraLength if criteria.era_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length + ) if numeric_clause: where_clauses.append(numeric_clause) - + # gapDays - Replicating Java bug: uses era_length instead of gap_days if criteria.gap_days is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("C.gap_days", criteria.era_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "C.gap_days", criteria.era_length + ) if numeric_clause: where_clauses.append(numeric_clause) - + # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start + ) if numeric_clause: where_clauses.append(numeric_clause) - + # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end + ) if numeric_clause: where_clauses.append(numeric_clause) - + # gender if criteria.gender is not None and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) + # genderCS if criteria.gender_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id", criteria.gender_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, + "P.gender_concept_id", + criteria.gender_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - + return where_clauses diff --git a/circe/cohortdefinition/builders/drug_exposure.py b/circe/cohortdefinition/builders/drug_exposure.py index d6302b8e..6b6d963a 100644 --- a/circe/cohortdefinition/builders/drug_exposure.py +++ b/circe/cohortdefinition/builders/drug_exposure.py @@ -32,17 +32,17 @@ class DrugExposureSqlBuilder(CriteriaSqlBuilder[DrugExposure]): """SQL builder for drug exposure criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.DrugExposureSqlBuilder """ - + # Default columns are those that are specified in the template DEFAULT_COLUMNS = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - + # Default select columns are the columns that will always be returned from the subquery DEFAULT_SELECT_COLUMNS = [ "de.person_id", @@ -51,26 +51,26 @@ class DrugExposureSqlBuilder(CriteriaSqlBuilder[DrugExposure]): "de.visit_occurrence_id", "days_supply", "quantity", - "refills" + "refills", ] - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for this builder. - + Java equivalent: DrugExposureSqlBuilder.getDefaultColumns() """ return self.DEFAULT_COLUMNS - + def get_query_template(self) -> str: """Get the SQL query template. - + Java equivalent: DrugExposureSqlBuilder.getQueryTemplate() """ return DRUG_EXPOSURE_TEMPLATE - + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: """Get table column name for criteria column. - + Java equivalent: DrugExposureSqlBuilder.getTableColumnForCriteriaColumn() """ if column == CriteriaColumn.DOMAIN_CONCEPT: @@ -85,118 +85,163 @@ def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: return "C.visit_occurrence_id" else: return f"C.{column.value}" - + def embed_codeset_clause(self, query: str, criteria: DrugExposure) -> str: """Embed codeset clause in query. - + Java equivalent: DrugExposureSqlBuilder.embedCodesetClause() """ - return query.replace("@codesetClause", - BuilderUtils.get_codeset_join_expression( - criteria.codeset_id, - "de.drug_concept_id", - criteria.drug_source_concept, - "de.drug_source_concept_id" - )) - - def embed_ordinal_expression(self, query: str, criteria: DrugExposure, where_clauses: List[str]) -> str: + return query.replace( + "@codesetClause", + BuilderUtils.get_codeset_join_expression( + criteria.codeset_id, + "de.drug_concept_id", + criteria.drug_source_concept, + "de.drug_source_concept_id", + ), + ) + + def embed_ordinal_expression( + self, query: str, criteria: DrugExposure, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query. - + Java equivalent: DrugExposureSqlBuilder.embedOrdinalExpression() """ # first if criteria.first: where_clauses.append("C.ordinal = 1") - query = query.replace("@ordinalExpression", ", row_number() over (PARTITION BY de.person_id ORDER BY de.drug_exposure_start_date, de.drug_exposure_id) as ordinal") + query = query.replace( + "@ordinalExpression", + ", row_number() over (PARTITION BY de.person_id ORDER BY de.drug_exposure_start_date, de.drug_exposure_id) as ordinal", + ) else: query = query.replace("@ordinalExpression", "") - + return query - def resolve_select_clauses(self, criteria: DrugExposure, options: Optional[BuilderOptions] = None) -> List[str]: + def resolve_select_clauses( + self, criteria: DrugExposure, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for drug exposure criteria. - + Java equivalent: DrugExposureSqlBuilder.resolveSelectClauses() """ # Default select columns that are always returned from inner subquery select_cols = [ "de.person_id", - "de.drug_exposure_id", + "de.drug_exposure_id", "de.drug_concept_id", "de.visit_occurrence_id", "days_supply", "quantity", - "refills" + "refills", ] - # drugType - if (criteria.drug_type and len(criteria.drug_type) > 0) or criteria.drug_type_cs: + if ( + criteria.drug_type and len(criteria.drug_type) > 0 + ) or criteria.drug_type_cs: select_cols.append("de.drug_type_concept_id") - + # stopReason if criteria.stop_reason: select_cols.append("de.stop_reason") # routeConcept - if (criteria.route_concept and len(criteria.route_concept) > 0) or criteria.route_concept_cs: + if ( + criteria.route_concept and len(criteria.route_concept) > 0 + ) or criteria.route_concept_cs: select_cols.append("de.route_concept_id") # providerSpecialty - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: + if ( + criteria.provider_specialty and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs: select_cols.append("de.provider_id") # doseUnit - if (criteria.dose_unit and len(criteria.dose_unit) > 0) or criteria.dose_unit_cs: + if ( + criteria.dose_unit and len(criteria.dose_unit) > 0 + ) or criteria.dose_unit_cs: select_cols.append("de.dose_unit_concept_id") # LotNumber if criteria.lot_number: select_cols.append("de.lot_number") - + # dateAdjustment or default start/end dates if criteria.date_adjustment: - select_cols.append(BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, - "de.drug_exposure_start_date" if criteria.date_adjustment.start_with == "start_date" else "de.drug_exposure_end_date", - "de.drug_exposure_start_date" if criteria.date_adjustment.end_with == "start_date" else "de.drug_exposure_end_date" - )) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, + ( + "de.drug_exposure_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "de.drug_exposure_end_date" + ), + ( + "de.drug_exposure_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "de.drug_exposure_end_date" + ), + ) + ) else: - select_cols.append("de.drug_exposure_start_date as start_date, COALESCE(de.drug_exposure_end_date, DATEADD(day,de.days_supply,de.drug_exposure_start_date), DATEADD(day,1,de.drug_exposure_start_date)) as end_date") - + select_cols.append( + "de.drug_exposure_start_date as start_date, COALESCE(de.drug_exposure_end_date, DATEADD(day,de.days_supply,de.drug_exposure_start_date), DATEADD(day,1,de.drug_exposure_start_date)) as end_date" + ) + return select_cols - - def resolve_join_clauses(self, criteria: DrugExposure, options: Optional[BuilderOptions] = None) -> List[str]: + def resolve_join_clauses( + self, criteria: DrugExposure, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for drug exposure criteria. - + Java equivalent: DrugExposureSqlBuilder.resolveJoinClauses() """ join_clauses = [] - + # Join to PERSON if age or gender conditions are present - if criteria.age or (criteria.gender and len(criteria.gender) > 0) or criteria.gender_cs: - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or criteria.gender_cs + ): + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + # Join to VISIT_OCCURRENCE - if (criteria.visit_type and len(criteria.visit_type) > 0) or criteria.visit_type_cs: - join_clauses.append("JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id") + if ( + criteria.visit_type and len(criteria.visit_type) > 0 + ) or criteria.visit_type_cs: + join_clauses.append( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" + ) # Join to PROVIDER if provider specialty conditions are present - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") - + if ( + criteria.provider_specialty and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs: + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) + return join_clauses - - def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: DrugExposure, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for drug exposure criteria. - + Java equivalent: DrugExposureSqlBuilder.resolveWhereClauses() """ where_clauses = super().resolve_where_clauses(criteria) - + # Note: codeset filtering is now handled via JOIN in inner query, not WHERE clause - + # Add occurrence dates if criteria.occurrence_start_date: date_clause = BuilderUtils.build_date_range_clause( @@ -204,91 +249,153 @@ def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[Builde ) if date_clause: where_clauses.append(date_clause) - + if criteria.occurrence_end_date: date_clause = BuilderUtils.build_date_range_clause( "C.end_date", criteria.occurrence_end_date ) if date_clause: where_clauses.append(date_clause) - + # drugType if criteria.drug_type and len(criteria.drug_type) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.drug_type) operator = "not in" if criteria.drug_type_exclude else "in" - where_clauses.append(f"C.drug_type_concept_id {operator} ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.drug_type_concept_id {operator} ({','.join(map(str, concept_ids))})" + ) + # drugTypeCS if criteria.drug_type_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.drug_type_cs.codeset_id, "C.drug_type_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.drug_type_cs.codeset_id, "C.drug_type_concept_id" + ) + ) # stopReason if criteria.stop_reason: - where_clauses.append(BuilderUtils.build_text_filter_clause(criteria.stop_reason, "C.stop_reason")) + where_clauses.append( + BuilderUtils.build_text_filter_clause( + criteria.stop_reason, "C.stop_reason" + ) + ) # routeConcept if criteria.route_concept and len(criteria.route_concept) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.route_concept) - where_clauses.append(f"C.route_concept_id in ({','.join(map(str, concept_ids))})") - + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.route_concept + ) + where_clauses.append( + f"C.route_concept_id in ({','.join(map(str, concept_ids))})" + ) + # routeConceptCS if criteria.route_concept_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.route_concept_cs.codeset_id, "C.route_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.route_concept_cs.codeset_id, "C.route_concept_id" + ) + ) # doseUnit if criteria.dose_unit and len(criteria.dose_unit) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.dose_unit) - where_clauses.append(f"C.dose_unit_concept_id in ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"C.dose_unit_concept_id in ({','.join(map(str, concept_ids))})" + ) # doseUnitCS if criteria.dose_unit_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.dose_unit_cs.codeset_id, "C.dose_unit_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.dose_unit_cs.codeset_id, "C.dose_unit_concept_id" + ) + ) # LotNumber if criteria.lot_number: - where_clauses.append(BuilderUtils.build_text_filter_clause(criteria.lot_number, "C.lot_number")) + where_clauses.append( + BuilderUtils.build_text_filter_clause( + criteria.lot_number, "C.lot_number" + ) + ) # refills if criteria.refills: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.refills", criteria.refills)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.refills", criteria.refills) + ) # quantity if criteria.quantity: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity) + ) # daysSupply if criteria.days_supply: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.days_supply", criteria.days_supply)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.days_supply", criteria.days_supply + ) + ) # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) - + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age + ) + ) + # gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) # genderCS if criteria.gender_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, "P.gender_concept_id" + ) + ) + # providerSpecialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) - where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.provider_specialty + ) + where_clauses.append( + f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" + ) # providerSpecialtyCS if criteria.provider_specialty_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id" + ) + ) # visitType if criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) - where_clauses.append(f"V.visit_concept_id in ({','.join(map(str, concept_ids))})") - + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.visit_type + ) + where_clauses.append( + f"V.visit_concept_id in ({','.join(map(str, concept_ids))})" + ) + # visitTypeCS if criteria.visit_type_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, "V.visit_concept_id" + ) + ) + return [c for c in where_clauses if c] # Filter out None values diff --git a/circe/cohortdefinition/builders/location_region.py b/circe/cohortdefinition/builders/location_region.py index 956d5281..2c7e167d 100644 --- a/circe/cohortdefinition/builders/location_region.py +++ b/circe/cohortdefinition/builders/location_region.py @@ -16,17 +16,17 @@ class LocationRegionSqlBuilder(CriteriaSqlBuilder[LocationRegion]): """SQL builder for Location Region criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.LocationRegionSqlBuilder """ - + # Default columns are those that are specified in the template, and don't need to be added if specified in 'additionalColumns' DEFAULT_COLUMNS = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - + def get_query_template(self) -> str: """Get the SQL query template for location region criteria.""" return """ @@ -49,58 +49,64 @@ def get_query_template(self) -> str: WHERE @whereClause @additionalColumns """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for location region criteria.""" return self.DEFAULT_COLUMNS - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.region_concept_id", CriteriaColumn.START_DATE: "C.start_date", CriteriaColumn.END_DATE: "C.end_date", - CriteriaColumn.VISIT_ID: "NULL" + CriteriaColumn.VISIT_ID: "NULL", } return column_mapping.get(criteria_column, "NULL") - + def embed_codeset_clause(self, query: str, criteria: LocationRegion) -> str: """Embed codeset clause in query.""" codeset_clause = "" if criteria.codeset_id is not None: - # Use explicit AND because of the WHERE clause in the template - codeset_clause = f"AND l.region_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.codeset_id})" + # Use explicit AND because of the WHERE clause in the template + codeset_clause = f"AND l.region_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.codeset_id})" return query.replace("@codesetClause", codeset_clause) - - def embed_ordinal_expression(self, query: str, criteria: LocationRegion, where_clauses: List[str]) -> str: + + def embed_ordinal_expression( + self, query: str, criteria: LocationRegion, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") - - def resolve_select_clauses(self, criteria: LocationRegion, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_select_clauses( + self, criteria: LocationRegion, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for location region criteria.""" # Default select columns that are always returned - select_cols = [ - "C.person_id", - "C.location_id", - "C.region_concept_id" - ] - + select_cols = ["C.person_id", "C.location_id", "C.region_concept_id"] + # Add date columns select_cols.append("C.start_date") select_cols.append("C.end_date") - + # Add domain concept column select_cols.append("C.region_concept_id as domain_concept") - + # Add visit_id column (location region doesn't have visit_id, so use NULL) select_cols.append("NULL as visit_id") - + return select_cols - - def resolve_join_clauses(self, criteria: LocationRegion, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: LocationRegion, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for location region criteria.""" return [] - - def resolve_where_clauses(self, criteria: LocationRegion, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: LocationRegion, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for location region criteria.""" return [] diff --git a/circe/cohortdefinition/builders/measurement.py b/circe/cohortdefinition/builders/measurement.py index 45f93bcd..49c8ed82 100644 --- a/circe/cohortdefinition/builders/measurement.py +++ b/circe/cohortdefinition/builders/measurement.py @@ -17,10 +17,10 @@ class MeasurementSqlBuilder(CriteriaSqlBuilder[Measurement]): """SQL builder for Measurement criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.MeasurementSqlBuilder """ - + def get_query_template(self) -> str: """Get the SQL query template for measurement criteria.""" return """-- Begin Measurement Criteria @@ -36,135 +36,185 @@ def get_query_template(self) -> str: @whereClause -- End Measurement Criteria """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for measurement criteria.""" return { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.DOMAIN_CONCEPT, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.START_DATE: "C.start_date", CriteriaColumn.END_DATE: "C.end_date", CriteriaColumn.DOMAIN_CONCEPT: "C.measurement_concept_id", CriteriaColumn.DURATION: "NULL", - CriteriaColumn.VISIT_ID: "C.visit_occurrence_id" + CriteriaColumn.VISIT_ID: "C.visit_occurrence_id", } return column_mapping.get(criteria_column, "NULL") - def embed_ordinal_expression(self, query: str, criteria: Measurement, where_clauses: List[str]) -> str: + def embed_ordinal_expression( + self, query: str, criteria: Measurement, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query. - + Java equivalent: MeasurementSqlBuilder.embedOrdinalExpression() """ # first if criteria.first: where_clauses.append("C.ordinal = 1") - query = query.replace("@ordinalExpression", ", row_number() over (PARTITION BY m.person_id ORDER BY m.measurement_date, m.measurement_id) as ordinal") + query = query.replace( + "@ordinalExpression", + ", row_number() over (PARTITION BY m.person_id ORDER BY m.measurement_date, m.measurement_id) as ordinal", + ) else: query = query.replace("@ordinalExpression", "") - + return query - + def embed_codeset_clause(self, query: str, criteria: Measurement) -> str: """Embed codeset clause for measurement criteria.""" - return query.replace("@codesetClause", BuilderUtils.get_codeset_join_expression( - criteria.codeset_id, - "m.measurement_concept_id", - criteria.measurement_source_concept, - "m.measurement_source_concept_id" - )) - - def resolve_select_clauses(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> List[str]: + return query.replace( + "@codesetClause", + BuilderUtils.get_codeset_join_expression( + criteria.codeset_id, + "m.measurement_concept_id", + criteria.measurement_source_concept, + "m.measurement_source_concept_id", + ), + ) + + def resolve_select_clauses( + self, criteria: Measurement, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for measurement criteria. - + Java equivalent: MeasurementSqlBuilder.resolveSelectClauses() """ # Default select columns that are always returned from inner subquery select_cols = [ "m.person_id", - "m.measurement_id", + "m.measurement_id", "m.measurement_concept_id", "m.visit_occurrence_id", "m.value_as_number", "m.range_high", - "m.range_low" + "m.range_low", ] - + # measurementType - if (criteria.measurement_type and len(criteria.measurement_type) > 0) or criteria.measurement_type_cs: + if ( + criteria.measurement_type and len(criteria.measurement_type) > 0 + ) or criteria.measurement_type_cs: select_cols.append("m.measurement_type_concept_id") - + # operator if (criteria.operator and len(criteria.operator) > 0) or criteria.operator_cs: - select_cols.append("m.operator_concept_id") - + select_cols.append("m.operator_concept_id") + # valueAsConcept - if (criteria.value_as_concept and len(criteria.value_as_concept) > 0) or criteria.value_as_concept_cs: - select_cols.append("m.value_as_concept_id") + if ( + criteria.value_as_concept and len(criteria.value_as_concept) > 0 + ) or criteria.value_as_concept_cs: + select_cols.append("m.value_as_concept_id") # unit if (criteria.unit and len(criteria.unit) > 0) or criteria.unit_cs: select_cols.append("m.unit_concept_id") - + # providerSpecialty - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: - select_cols.append("m.provider_id") - + if ( + criteria.provider_specialty and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs: + select_cols.append("m.provider_id") + # dateAdjustment or default start/end dates if criteria.date_adjustment: - select_cols.append(BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, - "m.measurement_date" if criteria.date_adjustment.start_with == "start_date" else "DATEADD(day,1,m.measurement_date)", - "m.measurement_date" if criteria.date_adjustment.end_with == "start_date" else "DATEADD(day,1,m.measurement_date)" - )) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, + ( + "m.measurement_date" + if criteria.date_adjustment.start_with == "start_date" + else "DATEADD(day,1,m.measurement_date)" + ), + ( + "m.measurement_date" + if criteria.date_adjustment.end_with == "start_date" + else "DATEADD(day,1,m.measurement_date)" + ), + ) + ) else: - select_cols.append("m.measurement_date as start_date, DATEADD(day,1,m.measurement_date) as end_date") - + select_cols.append( + "m.measurement_date as start_date, DATEADD(day,1,m.measurement_date) as end_date" + ) + return select_cols - - def resolve_join_clauses(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: Measurement, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for measurement criteria. - + Java equivalent: MeasurementSqlBuilder.resolveJoinClauses() """ join_clauses = [] - + # Join to PERSON if age or gender conditions are present - if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + # Join to VISIT_OCCURRENCE - if (criteria.visit_type and len(criteria.visit_type) > 0) or (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): - join_clauses.append("JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id") + if (criteria.visit_type and len(criteria.visit_type) > 0) or ( + criteria.visit_type_cs and criteria.visit_type_cs.codeset_id + ): + join_clauses.append( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" + ) # Join to PROVIDER if provider specialty conditions are present # Use "PR" alias to avoid conflict with PERSON alias - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or (criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id): - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") - + if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( + criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id + ): + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) + return join_clauses - def resolve_ordinal_expression(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> str: + def resolve_ordinal_expression( + self, criteria: Measurement, options: Optional[BuilderOptions] = None + ) -> str: """Resolve ordinal expression for measurement criteria.""" if criteria.first: return "ORDER BY m.measurement_date, m.measurement_id ASC" return "" - - def resolve_where_clauses(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: Measurement, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for measurement criteria. - + Java equivalent: MeasurementSqlBuilder.resolveWhereClauses() """ where_clauses = super().resolve_where_clauses(criteria) - + # Note: codeset filtering is now handled via JOIN in inner query, not WHERE clause - + # Add occurrence start date condition if criteria.occurrence_start_date: date_clause = BuilderUtils.build_date_range_clause( @@ -172,104 +222,194 @@ def resolve_where_clauses(self, criteria: Measurement, options: Optional[Builder ) if date_clause: where_clauses.append(date_clause) - + # measurementType if criteria.measurement_type and len(criteria.measurement_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.measurement_type) + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.measurement_type + ) operator = "not in" if criteria.measurement_type_exclude else "in" - where_clauses.append(f"C.measurement_type_concept_id {operator} ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.measurement_type_concept_id {operator} ({','.join(map(str, concept_ids))})" + ) + # measurementTypeCS if criteria.measurement_type_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.measurement_type_cs.codeset_id, "C.measurement_type_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.measurement_type_cs.codeset_id, + "C.measurement_type_concept_id", + ) + ) # operator if criteria.operator and len(criteria.operator) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.operator) - where_clauses.append(f"C.operator_concept_id in ({','.join(map(str, concept_ids))})") - + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.operator) + where_clauses.append( + f"C.operator_concept_id in ({','.join(map(str, concept_ids))})" + ) + # operatorCS if criteria.operator_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.operator_cs.codeset_id, "C.operator_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.operator_cs.codeset_id, "C.operator_concept_id" + ) + ) # valueAsNumber if criteria.value_as_number: # Java uses .4f - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number, ".4f")) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.value_as_number", criteria.value_as_number, ".4f" + ) + ) # valueAsConcept if criteria.value_as_concept and len(criteria.value_as_concept) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.value_as_concept) - where_clauses.append(f"C.value_as_concept_id in ({','.join(map(str, concept_ids))})") - + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.value_as_concept + ) + where_clauses.append( + f"C.value_as_concept_id in ({','.join(map(str, concept_ids))})" + ) + # valueAsConceptCS if criteria.value_as_concept_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id" + ) + ) # unit if criteria.unit and len(criteria.unit) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.unit) - where_clauses.append(f"C.unit_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.unit_concept_id in ({','.join(map(str, concept_ids))})" + ) + # unitCS if criteria.unit_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id")) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.unit_cs.codeset_id, "C.unit_concept_id" + ) + ) + # rangeLow if criteria.range_low: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.range_low", criteria.range_low, ".4f")) - + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.range_low", criteria.range_low, ".4f" + ) + ) + # rangeHigh if criteria.range_high: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.range_high", criteria.range_high, ".4f")) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.range_high", criteria.range_high, ".4f" + ) + ) # rangeLowRatio if criteria.range_low_ratio: - where_clauses.append(BuilderUtils.build_numeric_range_clause("(C.value_as_number / NULLIF(C.range_low, 0))", criteria.range_low_ratio, ".4f")) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "(C.value_as_number / NULLIF(C.range_low, 0))", + criteria.range_low_ratio, + ".4f", + ) + ) # rangeHighRatio if criteria.range_high_ratio: - where_clauses.append(BuilderUtils.build_numeric_range_clause("(C.value_as_number / NULLIF(C.range_high, 0))", criteria.range_high_ratio, ".4f")) - + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "(C.value_as_number / NULLIF(C.range_high, 0))", + criteria.range_high_ratio, + ".4f", + ) + ) + # abnormal if criteria.abnormal: - where_clauses.append("(C.value_as_number < C.range_low or C.value_as_number > C.range_high or C.value_as_concept_id in (4155142, 4155143))") + where_clauses.append( + "(C.value_as_number < C.range_low or C.value_as_number > C.range_high or C.value_as_concept_id in (4155142, 4155143))" + ) # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) - + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age + ) + ) + # gender if criteria.gender and len(criteria.gender) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) if criteria.gender_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id", criteria.gender_cs.is_exclusion)) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, + "P.gender_concept_id", + criteria.gender_cs.is_exclusion, + ) + ) + # providerSpecialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) - where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.provider_specialty + ) + where_clauses.append( + f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" + ) # providerSpecialtyCS if criteria.provider_specialty_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id", criteria.provider_specialty_cs.is_exclusion)) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.provider_specialty_cs.codeset_id, + "PR.specialty_concept_id", + criteria.provider_specialty_cs.is_exclusion, + ) + ) # visitType if criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) - where_clauses.append(f"V.visit_concept_id in ({','.join(map(str, concept_ids))})") - + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.visit_type + ) + where_clauses.append( + f"V.visit_concept_id in ({','.join(map(str, concept_ids))})" + ) + # visitTypeCS if criteria.visit_type_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, "V.visit_concept_id" + ) + ) + return where_clauses - + def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: """Get additional columns string with proper aliases. - + Java equivalent: MeasurementSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) + return ", ".join( + [ + f"{self.get_table_column_for_criteria_column(col)} as {col.value}" + for col in columns + ] + ) diff --git a/circe/cohortdefinition/builders/observation.py b/circe/cohortdefinition/builders/observation.py index d1c2e558..77bdd16e 100644 --- a/circe/cohortdefinition/builders/observation.py +++ b/circe/cohortdefinition/builders/observation.py @@ -17,10 +17,10 @@ class ObservationSqlBuilder(CriteriaSqlBuilder[Observation]): """SQL builder for Observation criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.ObservationSqlBuilder """ - + def get_query_template(self) -> str: """Get the SQL query template for observation criteria.""" return """-- Begin Observation Criteria @@ -36,98 +36,128 @@ def get_query_template(self) -> str: @whereClause -- End Observation Criteria """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for observation criteria.""" return { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.DOMAIN_CONCEPT, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.START_DATE: "C.start_date", CriteriaColumn.END_DATE: "C.end_date", CriteriaColumn.DOMAIN_CONCEPT: "C.observation_concept_id", CriteriaColumn.DURATION: "NULL", - CriteriaColumn.VISIT_ID: "C.visit_occurrence_id" + CriteriaColumn.VISIT_ID: "C.visit_occurrence_id", } return column_mapping.get(criteria_column, "NULL") - - + def embed_codeset_clause(self, query: str, criteria: Observation) -> str: """Embed codeset clause for observation criteria.""" - return query.replace("@codesetClause", BuilderUtils.get_codeset_join_expression( - criteria.codeset_id, - "o.observation_concept_id", - criteria.observation_source_concept, - "o.observation_source_concept_id" - )) - - def resolve_select_clauses(self, criteria: Observation, options: Optional[BuilderOptions] = None) -> List[str]: + return query.replace( + "@codesetClause", + BuilderUtils.get_codeset_join_expression( + criteria.codeset_id, + "o.observation_concept_id", + criteria.observation_source_concept, + "o.observation_source_concept_id", + ), + ) + + def resolve_select_clauses( + self, criteria: Observation, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for observation criteria. - + Java equivalent: ObservationSqlBuilder.resolveSelectClauses() """ # Default select columns that are always returned from inner subquery select_cols = [ "o.person_id", - "o.observation_id", + "o.observation_id", "o.observation_concept_id", "o.visit_occurrence_id", "o.value_as_number", "o.value_as_string", "o.value_as_concept_id", - "o.unit_concept_id" + "o.unit_concept_id", ] - + # observationType - if (criteria.observation_type and len(criteria.observation_type) > 0) or criteria.observation_type_cs: + if ( + criteria.observation_type and len(criteria.observation_type) > 0 + ) or criteria.observation_type_cs: select_cols.append("o.observation_type_concept_id") - + # qualifier - if (criteria.qualifier and len(criteria.qualifier) > 0) or criteria.qualifier_cs: - select_cols.append("o.qualifier_concept_id") - + if ( + criteria.qualifier and len(criteria.qualifier) > 0 + ) or criteria.qualifier_cs: + select_cols.append("o.qualifier_concept_id") + # providerSpecialty - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: - select_cols.append("o.provider_id") - + if ( + criteria.provider_specialty and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs: + select_cols.append("o.provider_id") + # Add date columns (start_date and end_date) select_cols.append("o.observation_date as start_date") select_cols.append("DATEADD(day,1,o.observation_date) as end_date") - + return select_cols - - def resolve_join_clauses(self, criteria: Observation, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: Observation, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for observation criteria. - + Java equivalent: ObservationSqlBuilder.resolveJoinClauses() """ join_clauses = [] - + # Join to PERSON if age or gender conditions are present - if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + # Join to PROVIDER if provider specialty conditions are present # Always use PR alias for PROVIDER to match Java implementation - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or (criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id): - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") - + if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( + criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id + ): + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) + # Join to VISIT_OCCURRENCE if visit type conditions are present - if (criteria.visit_type and len(criteria.visit_type) > 0) or (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): - join_clauses.append("JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id") - + if (criteria.visit_type and len(criteria.visit_type) > 0) or ( + criteria.visit_type_cs and criteria.visit_type_cs.codeset_id + ): + join_clauses.append( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" + ) + return join_clauses - - def resolve_where_clauses(self, criteria: Observation, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: Observation, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for observation criteria.""" where_clauses = super().resolve_where_clauses(criteria) - + # Add date range conditions if criteria.occurrence_start_date: date_clause = BuilderUtils.build_date_range_clause( @@ -135,109 +165,196 @@ def resolve_where_clauses(self, criteria: Observation, options: Optional[Builder ) if date_clause: where_clauses.append(date_clause) - + if criteria.occurrence_end_date: date_clause = BuilderUtils.build_date_range_clause( "C.end_date", criteria.occurrence_end_date ) if date_clause: where_clauses.append(date_clause) - + # observationType if criteria.observation_type and len(criteria.observation_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.observation_type) + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.observation_type + ) operator = "not in" if criteria.observation_type_exclude else "in" - where_clauses.append(f"C.observation_type_concept_id {operator} ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.observation_type_concept_id {operator} ({','.join(map(str, concept_ids))})" + ) + # observationTypeCS if criteria.observation_type_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.observation_type_cs.codeset_id, "C.observation_type_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.observation_type_cs.codeset_id, + "C.observation_type_concept_id", + ) + ) # valueAsNumber - if hasattr(criteria, 'value_as_number') and criteria.value_as_number: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number, ".4f")) + if hasattr(criteria, "value_as_number") and criteria.value_as_number: + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.value_as_number", criteria.value_as_number, ".4f" + ) + ) # valueAsString if criteria.value_as_string: - where_clauses.append(BuilderUtils.build_text_filter_clause(criteria.value_as_string, "C.value_as_string")) + where_clauses.append( + BuilderUtils.build_text_filter_clause( + criteria.value_as_string, "C.value_as_string" + ) + ) # valueAsConcept - if hasattr(criteria, 'value_as_concept') and criteria.value_as_concept and len(criteria.value_as_concept) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.value_as_concept) - where_clauses.append(f"C.value_as_concept_id in ({','.join(map(str, concept_ids))})") - + if ( + hasattr(criteria, "value_as_concept") + and criteria.value_as_concept + and len(criteria.value_as_concept) > 0 + ): + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.value_as_concept + ) + where_clauses.append( + f"C.value_as_concept_id in ({','.join(map(str, concept_ids))})" + ) + # valueAsConceptCS - if hasattr(criteria, 'value_as_concept_cs') and criteria.value_as_concept_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id")) + if hasattr(criteria, "value_as_concept_cs") and criteria.value_as_concept_cs: + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id" + ) + ) # unit - if hasattr(criteria, 'unit') and criteria.unit and len(criteria.unit) > 0: + if hasattr(criteria, "unit") and criteria.unit and len(criteria.unit) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.unit) - where_clauses.append(f"C.unit_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.unit_concept_id in ({','.join(map(str, concept_ids))})" + ) + # unitCS - if hasattr(criteria, 'unit_cs') and criteria.unit_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id")) + if hasattr(criteria, "unit_cs") and criteria.unit_cs: + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.unit_cs.codeset_id, "C.unit_concept_id" + ) + ) # qualifier - if hasattr(criteria, 'qualifier') and criteria.qualifier and len(criteria.qualifier) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.qualifier) - where_clauses.append(f"C.qualifier_concept_id in ({','.join(map(str, concept_ids))})") - + if ( + hasattr(criteria, "qualifier") + and criteria.qualifier + and len(criteria.qualifier) > 0 + ): + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.qualifier) + where_clauses.append( + f"C.qualifier_concept_id in ({','.join(map(str, concept_ids))})" + ) + # qualifierCS - if hasattr(criteria, 'qualifier_cs') and criteria.qualifier_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.qualifier_cs.codeset_id, "C.qualifier_concept_id")) + if hasattr(criteria, "qualifier_cs") and criteria.qualifier_cs: + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.qualifier_cs.codeset_id, "C.qualifier_concept_id" + ) + ) # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) - + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age + ) + ) + # gender if criteria.gender and len(criteria.gender) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) if criteria.gender_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id", criteria.gender_cs.is_exclusion)) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, + "P.gender_concept_id", + criteria.gender_cs.is_exclusion, + ) + ) + # providerSpecialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) - where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.provider_specialty + ) + where_clauses.append( + f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" + ) # providerSpecialtyCS if criteria.provider_specialty_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id", criteria.provider_specialty_cs.is_exclusion)) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.provider_specialty_cs.codeset_id, + "PR.specialty_concept_id", + criteria.provider_specialty_cs.is_exclusion, + ) + ) # visitType if criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) - where_clauses.append(f"V.visit_concept_id in ({','.join(map(str, concept_ids))})") - + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.visit_type + ) + where_clauses.append( + f"V.visit_concept_id in ({','.join(map(str, concept_ids))})" + ) + # visitTypeCS if criteria.visit_type_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, "V.visit_concept_id" + ) + ) + return where_clauses - + def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: """Get additional columns string with proper aliases. - + Java equivalent: ObservationSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) - - def embed_ordinal_expression(self, query: str, criteria: Observation, where_clauses: List[str]) -> str: + return ", ".join( + [ + f"{self.get_table_column_for_criteria_column(col)} as {col.value}" + for col in columns + ] + ) + + def embed_ordinal_expression( + self, query: str, criteria: Observation, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") - query = query.replace("@ordinalExpression", ", row_number() over (PARTITION BY o.person_id ORDER BY o.observation_date, o.observation_id) as ordinal") + query = query.replace( + "@ordinalExpression", + ", row_number() over (PARTITION BY o.person_id ORDER BY o.observation_date, o.observation_id) as ordinal", + ) else: query = query.replace("@ordinalExpression", "") return query - - def resolve_ordinal_expression(self, criteria: Observation, options: BuilderOptions) -> str: + + def resolve_ordinal_expression( + self, criteria: Observation, options: BuilderOptions + ) -> str: """Resolve ordinal expression for observation criteria.""" if criteria.first: return ", row_number() over (PARTITION BY o.person_id ORDER BY o.observation_date, o.observation_id) as ordinal" diff --git a/circe/cohortdefinition/builders/observation_period.py b/circe/cohortdefinition/builders/observation_period.py index 88825d22..404e5971 100644 --- a/circe/cohortdefinition/builders/observation_period.py +++ b/circe/cohortdefinition/builders/observation_period.py @@ -16,27 +16,27 @@ class ObservationPeriodSqlBuilder(CriteriaSqlBuilder[ObservationPeriod]): """SQL builder for Observation Period criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.ObservationPeriodSqlBuilder """ - + # Default columns are those that are specified in the template, and don't need to be added if specified in 'additionalColumns' DEFAULT_COLUMNS = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - + # Default select columns are the columns that will always be returned from the subquery, but are added to based on the specific criteria DEFAULT_SELECT_COLUMNS = [ "op.person_id", "op.observation_period_id", - "op.period_type_concept_id" + "op.period_type_concept_id", ] - + def get_query_template(self) -> str: """Get the SQL query template for observation period criteria. - + This template matches the Java ObservationPeriodSqlBuilder template exactly. """ return """-- Begin Observation Period Criteria @@ -52,12 +52,14 @@ def get_query_template(self) -> str: @whereClause -- End Observation Period Criteria """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for observation period criteria.""" return self.DEFAULT_COLUMNS - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.period_type_concept_id", @@ -65,129 +67,196 @@ def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) CriteriaColumn.START_DATE: "C.start_date", CriteriaColumn.END_DATE: "C.end_date", CriteriaColumn.VISIT_ID: "NULL", - CriteriaColumn.VISIT_ID: "NULL" + CriteriaColumn.VISIT_ID: "NULL", } return column_mapping.get(criteria_column, "NULL") - - def get_criteria_sql_with_options(self, criteria: ObservationPeriod, options: Optional[BuilderOptions]) -> str: + + def get_criteria_sql_with_options( + self, criteria: ObservationPeriod, options: Optional[BuilderOptions] + ) -> str: """Get SQL query for criteria with builder options.""" query = super().get_criteria_sql_with_options(criteria, options) - + # Override user defined dates in select - start_date_expression = (BuilderUtils.date_string_to_sql(criteria.user_defined_period.start_date) - if criteria.user_defined_period is not None and criteria.user_defined_period.start_date is not None - else "C.start_date") + start_date_expression = ( + BuilderUtils.date_string_to_sql(criteria.user_defined_period.start_date) + if criteria.user_defined_period is not None + and criteria.user_defined_period.start_date is not None + else "C.start_date" + ) query = query.replace("@startDateExpression", start_date_expression) - - end_date_expression = (BuilderUtils.date_string_to_sql(criteria.user_defined_period.end_date) - if criteria.user_defined_period is not None and criteria.user_defined_period.end_date is not None - else "C.end_date") + + end_date_expression = ( + BuilderUtils.date_string_to_sql(criteria.user_defined_period.end_date) + if criteria.user_defined_period is not None + and criteria.user_defined_period.end_date is not None + else "C.end_date" + ) query = query.replace("@endDateExpression", end_date_expression) - + return query - + def embed_codeset_clause(self, query: str, criteria: ObservationPeriod) -> str: """Embed codeset clause in query.""" return query.replace("@codesetClause", "") - - def embed_ordinal_expression(self, query: str, criteria: ObservationPeriod, where_clauses: List[str]) -> str: + + def embed_ordinal_expression( + self, query: str, criteria: ObservationPeriod, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") - - def resolve_select_clauses(self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_select_clauses( + self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for observation period criteria. - + Note: The outer SELECT in the template handles event_id, start_date, end_date, visit_occurrence_id, sort_date. This method only provides columns for the inner subquery. """ select_cols = list(self.DEFAULT_SELECT_COLUMNS) - + # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "op.observation_period_start_date" if criteria.date_adjustment.start_with == "start_date" else "op.observation_period_end_date" - end_column = "op.observation_period_start_date" if criteria.date_adjustment.end_with == "start_date" else "op.observation_period_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "op.observation_period_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "op.observation_period_end_date" + ) + end_column = ( + "op.observation_period_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "op.observation_period_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: - select_cols.append("op.observation_period_start_date as start_date, op.observation_period_end_date as end_date") - + select_cols.append( + "op.observation_period_start_date as start_date, op.observation_period_end_date as end_date" + ) + return select_cols - - def resolve_join_clauses(self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for observation period criteria.""" join_clauses = [] - + # join to PERSON if criteria.age_at_start is not None or criteria.age_at_end is not None: - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + return join_clauses - - def resolve_where_clauses(self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for observation period criteria.""" where_clauses = [] - + if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") - + # check for user defined start/end dates if criteria.user_defined_period is not None: user_defined_period = criteria.user_defined_period - + if user_defined_period.start_date is not None: - start_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.start_date) - where_clauses.append(f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}") - + start_date_expression = BuilderUtils.date_string_to_sql( + user_defined_period.start_date + ) + where_clauses.append( + f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}" + ) + if user_defined_period.end_date is not None: - end_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.end_date) - where_clauses.append(f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}") - + end_date_expression = BuilderUtils.date_string_to_sql( + user_defined_period.end_date + ) + where_clauses.append( + f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}" + ) + # periodStartDate if criteria.period_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.period_start_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.start_date", criteria.period_start_date + ) if date_clause: where_clauses.append(date_clause) - + # periodEndDate if criteria.period_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.period_end_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.end_date", criteria.period_end_date + ) if date_clause: where_clauses.append(date_clause) - + # periodType - if criteria.period_type is not None and hasattr(criteria.period_type, '__len__') and len(criteria.period_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.period_type) + if ( + criteria.period_type is not None + and hasattr(criteria.period_type, "__len__") + and len(criteria.period_type) > 0 + ): + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.period_type + ) if concept_ids: - where_clauses.append(f"C.period_type_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.period_type_concept_id in ({','.join(map(str, concept_ids))})" + ) + # periodTypeCS if criteria.period_type_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.period_type_cs.codeset_id, "C.period_type_concept_id", criteria.period_type_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.period_type_cs.codeset_id, + "C.period_type_concept_id", + criteria.period_type_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - + # periodLength if criteria.period_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length + ) if numeric_clause: where_clauses.append(numeric_clause) - + # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start + ) if numeric_clause: where_clauses.append(numeric_clause) - + # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end + ) if numeric_clause: where_clauses.append(numeric_clause) - + return where_clauses - + def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: """Get additional columns string with proper aliases. - + Java equivalent: ObservationPeriodSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) + return ", ".join( + [ + f"{self.get_table_column_for_criteria_column(col)} as {col.value}" + for col in columns + ] + ) diff --git a/circe/cohortdefinition/builders/payer_plan_period.py b/circe/cohortdefinition/builders/payer_plan_period.py index 281036f2..dbf863ad 100644 --- a/circe/cohortdefinition/builders/payer_plan_period.py +++ b/circe/cohortdefinition/builders/payer_plan_period.py @@ -16,23 +16,20 @@ class PayerPlanPeriodSqlBuilder(CriteriaSqlBuilder[PayerPlanPeriod]): """SQL builder for Payer Plan Period criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.PayerPlanPeriodSqlBuilder """ - + # Default columns are those that are specified in the template, and don't need to be added if specified in 'additionalColumns' DEFAULT_COLUMNS = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - + # Default select columns are the columns that will always be returned from the subquery, but are added to based on the specific criteria - DEFAULT_SELECT_COLUMNS = [ - "ppp.person_id", - "ppp.payer_plan_period_id" - ] - + DEFAULT_SELECT_COLUMNS = ["ppp.person_id", "ppp.payer_plan_period_id"] + def get_query_template(self) -> str: """Get the SQL query template for payer plan period criteria.""" return """ @@ -60,210 +57,293 @@ def get_query_template(self) -> str: WHERE @whereClause @additionalColumns """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for payer plan period criteria.""" return self.DEFAULT_COLUMNS - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.payer_concept_id", CriteriaColumn.START_DATE: "C.start_date", CriteriaColumn.END_DATE: "C.end_date", CriteriaColumn.VISIT_ID: "NULL", - CriteriaColumn.VISIT_ID: "NULL" + CriteriaColumn.VISIT_ID: "NULL", } return column_mapping.get(criteria_column, "NULL") - - def get_criteria_sql_with_options(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions]) -> str: + + def get_criteria_sql_with_options( + self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] + ) -> str: """Get SQL query for criteria with builder options.""" query = super().get_criteria_sql_with_options(criteria, options) - - start_date_expression = (BuilderUtils.date_string_to_sql(criteria.user_defined_period.start_date) - if criteria.user_defined_period is not None and criteria.user_defined_period.start_date is not None - else "C.start_date") + + start_date_expression = ( + BuilderUtils.date_string_to_sql(criteria.user_defined_period.start_date) + if criteria.user_defined_period is not None + and criteria.user_defined_period.start_date is not None + else "C.start_date" + ) query = query.replace("@startDateExpression", start_date_expression) - - end_date_expression = (BuilderUtils.date_string_to_sql(criteria.user_defined_period.end_date) - if criteria.user_defined_period is not None and criteria.user_defined_period.end_date is not None - else "C.end_date") + + end_date_expression = ( + BuilderUtils.date_string_to_sql(criteria.user_defined_period.end_date) + if criteria.user_defined_period is not None + and criteria.user_defined_period.end_date is not None + else "C.end_date" + ) query = query.replace("@endDateExpression", end_date_expression) - + return query - + def embed_codeset_clause(self, query: str, criteria: PayerPlanPeriod) -> str: """Embed codeset clause in query.""" return query.replace("@codesetClause", "") - - def embed_ordinal_expression(self, query: str, criteria: PayerPlanPeriod, where_clauses: List[str]) -> str: + + def embed_ordinal_expression( + self, query: str, criteria: PayerPlanPeriod, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") - - def resolve_select_clauses(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_select_clauses( + self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for payer plan period criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) - + # payer concept if criteria.payer_concept is not None: select_cols.append("ppp.payer_concept_id") - + # plan concept if criteria.plan_concept is not None: select_cols.append("ppp.plan_concept_id") - + # sponsor concept if criteria.sponsor_concept is not None: select_cols.append("ppp.sponsor_concept_id") - + # stop reason concept if criteria.stop_reason_concept is not None: select_cols.append("ppp.stop_reason_concept_id") - + # payer SourceConcept if criteria.payer_source_concept is not None: select_cols.append("ppp.payer_source_concept_id") - + # plan SourceConcept if criteria.plan_source_concept is not None: select_cols.append("ppp.plan_source_concept_id") - + # sponsor SourceConcept if criteria.sponsor_source_concept is not None: select_cols.append("ppp.sponsor_source_concept_id") - + # stop reason SourceConcept if criteria.stop_reason_source_concept is not None: select_cols.append("ppp.stop_reason_source_concept_id") - + # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "ppp.payer_plan_period_start_date" if criteria.date_adjustment.start_with == "start_date" else "ppp.payer_plan_period_end_date" - end_column = "ppp.payer_plan_period_start_date" if criteria.date_adjustment.end_with == "start_date" else "ppp.payer_plan_period_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "ppp.payer_plan_period_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "ppp.payer_plan_period_end_date" + ) + end_column = ( + "ppp.payer_plan_period_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "ppp.payer_plan_period_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: select_cols.append("ppp.payer_plan_period_start_date as start_date") select_cols.append("ppp.payer_plan_period_end_date as end_date") - + # Add domain concept column select_cols.append("ppp.payer_concept_id as domain_concept") - + # Add visit_id column (payer plan period doesn't have visit_id, so use NULL) select_cols.append("NULL as visit_id") - + return select_cols - - def resolve_join_clauses(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for payer plan period criteria.""" join_clauses = [] - - if (criteria.age_at_start is not None or - criteria.age_at_end is not None or - (criteria.gender is not None and len(criteria.gender) > 0) or - criteria.gender_cs is not None): - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + + if ( + criteria.age_at_start is not None + or criteria.age_at_end is not None + or (criteria.gender is not None and len(criteria.gender) > 0) + or criteria.gender_cs is not None + ): + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + return join_clauses - - def resolve_where_clauses(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for payer plan period criteria.""" where_clauses = [] - + # first if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") - + # check for user defined start/end dates if criteria.user_defined_period is not None: user_defined_period = criteria.user_defined_period - + if user_defined_period.start_date is not None: - start_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.start_date) - where_clauses.append(f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}") - + start_date_expression = BuilderUtils.date_string_to_sql( + user_defined_period.start_date + ) + where_clauses.append( + f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}" + ) + if user_defined_period.end_date is not None: - end_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.end_date) - where_clauses.append(f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}") - + end_date_expression = BuilderUtils.date_string_to_sql( + user_defined_period.end_date + ) + where_clauses.append( + f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}" + ) + # periodStartDate if criteria.period_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.period_start_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.start_date", criteria.period_start_date + ) if date_clause: where_clauses.append(date_clause) - + # periodEndDate if criteria.period_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.period_end_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.end_date", criteria.period_end_date + ) if date_clause: where_clauses.append(date_clause) - + # periodLength if criteria.period_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length + ) if numeric_clause: where_clauses.append(numeric_clause) - + # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start + ) if numeric_clause: where_clauses.append(numeric_clause) - + # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end + ) if numeric_clause: where_clauses.append(numeric_clause) - + # gender - if criteria.gender is not None and hasattr(criteria.gender, '__len__') and len(criteria.gender) > 0: + if ( + criteria.gender is not None + and hasattr(criteria.gender, "__len__") + and len(criteria.gender) > 0 + ): concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) + # genderCS if criteria.gender_cs is not None: - codeset_clause = BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id", criteria.gender_cs.is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, + "P.gender_concept_id", + criteria.gender_cs.is_exclusion, + ) if codeset_clause: where_clauses.append(codeset_clause) - + # payer concept if criteria.payer_concept is not None: - where_clauses.append(f"C.payer_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.payer_concept})") - + where_clauses.append( + f"C.payer_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.payer_concept})" + ) + # plan concept if criteria.plan_concept is not None: - where_clauses.append(f"C.plan_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_concept})") - + where_clauses.append( + f"C.plan_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_concept})" + ) + # sponsor concept if criteria.sponsor_concept is not None: - where_clauses.append(f"C.sponsor_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.sponsor_concept})") - + where_clauses.append( + f"C.sponsor_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.sponsor_concept})" + ) + # stop reason concept if criteria.stop_reason_concept is not None: - where_clauses.append(f"C.stop_reason_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.stop_reason_concept})") - + where_clauses.append( + f"C.stop_reason_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.stop_reason_concept})" + ) + # payer SourceConcept if criteria.payer_source_concept is not None: - where_clauses.append(f"C.payer_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.payer_source_concept})") - + where_clauses.append( + f"C.payer_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.payer_source_concept})" + ) + # plan SourceConcept if criteria.plan_source_concept is not None: - where_clauses.append(f"C.plan_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_source_concept})") - + where_clauses.append( + f"C.plan_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_source_concept})" + ) + # sponsor SourceConcept if criteria.sponsor_source_concept is not None: - where_clauses.append(f"C.sponsor_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.sponsor_source_concept})") - + where_clauses.append( + f"C.sponsor_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.sponsor_source_concept})" + ) + # stop reason SourceConcept if criteria.stop_reason_source_concept is not None: - where_clauses.append(f"C.stop_reason_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.stop_reason_source_concept})") - + where_clauses.append( + f"C.stop_reason_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.stop_reason_source_concept})" + ) + return where_clauses if where_clauses else ["1=1"] - + def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: """Get additional columns string with proper aliases. - + Java equivalent: PayerPlanPeriodSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) + return ", ".join( + [ + f"{self.get_table_column_for_criteria_column(col)} as {col.value}" + for col in columns + ] + ) diff --git a/circe/cohortdefinition/builders/procedure_occurrence.py b/circe/cohortdefinition/builders/procedure_occurrence.py index 87e59e7e..cafe6091 100644 --- a/circe/cohortdefinition/builders/procedure_occurrence.py +++ b/circe/cohortdefinition/builders/procedure_occurrence.py @@ -33,17 +33,17 @@ class ProcedureOccurrenceSqlBuilder(CriteriaSqlBuilder[Criteria]): """SQL builder for procedure occurrence criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.ProcedureOccurrenceSqlBuilder """ - + # Default columns are those that are specified in the template DEFAULT_COLUMNS = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - + # Default select columns are the columns that will always be returned from the subquery # Note: Matches Java output format exactly - ending with space on last item # DATE columns removed from defaults to be handled dynamically like in Java @@ -52,26 +52,26 @@ class ProcedureOccurrenceSqlBuilder(CriteriaSqlBuilder[Criteria]): "po.procedure_occurrence_id", "po.procedure_concept_id", "po.visit_occurrence_id", - "po.quantity" + "po.quantity", ] - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for this builder. - + Java equivalent: ProcedureOccurrenceSqlBuilder.getDefaultColumns() """ return self.DEFAULT_COLUMNS - + def get_query_template(self) -> str: """Get the SQL query template. - + Java equivalent: ProcedureOccurrenceSqlBuilder.getQueryTemplate() """ return PROCEDURE_OCCURRENCE_TEMPLATE - + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: """Get table column name for criteria column. - + Java equivalent: ProcedureOccurrenceSqlBuilder.getTableColumnForCriteriaColumn() """ if column == CriteriaColumn.DOMAIN_CONCEPT: @@ -88,157 +88,299 @@ def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: return "C.visit_occurrence_id" else: return f"C.{column.value}" - - def embed_ordinal_expression(self, query: str, criteria: Criteria, where_clauses: List[str]) -> str: + def embed_ordinal_expression( + self, query: str, criteria: Criteria, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query. - + Java equivalent: ProcedureOccurrenceSqlBuilder.embedOrdinalExpression() """ # first - if hasattr(criteria, 'first') and criteria.first: + if hasattr(criteria, "first") and criteria.first: where_clauses.append("C.ordinal = 1") - query = query.replace("@ordinalExpression", ", row_number() over (PARTITION BY po.person_id ORDER BY po.procedure_date, po.procedure_occurrence_id) as ordinal") + query = query.replace( + "@ordinalExpression", + ", row_number() over (PARTITION BY po.person_id ORDER BY po.procedure_date, po.procedure_occurrence_id) as ordinal", + ) else: query = query.replace("@ordinalExpression", "") - + return query - + def embed_codeset_clause(self, query: str, criteria: Criteria) -> str: """Embed codeset clause in query. - + Java equivalent: ProcedureOccurrenceSqlBuilder.embedCodesetClause() """ - return query.replace("@codesetClause", - BuilderUtils.get_codeset_join_expression( - criteria.codeset_id if hasattr(criteria, 'codeset_id') else None, - "po.procedure_concept_id", - criteria.procedure_source_concept if hasattr(criteria, 'procedure_source_concept') else None, - "po.procedure_source_concept_id" - )) - - def resolve_select_clauses(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> List[str]: + return query.replace( + "@codesetClause", + BuilderUtils.get_codeset_join_expression( + criteria.codeset_id if hasattr(criteria, "codeset_id") else None, + "po.procedure_concept_id", + ( + criteria.procedure_source_concept + if hasattr(criteria, "procedure_source_concept") + else None + ), + "po.procedure_source_concept_id", + ), + ) + + def resolve_select_clauses( + self, criteria: Criteria, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for criteria. - + Java equivalent: ProcedureOccurrenceSqlBuilder.resolveSelectClauses() """ select_cols = list(self.DEFAULT_SELECT_COLUMNS) - + # procedureType - if (hasattr(criteria, 'procedure_type') and criteria.procedure_type and len(criteria.procedure_type) > 0) or \ - (hasattr(criteria, 'procedure_type_cs') and criteria.procedure_type_cs is not None): + if ( + hasattr(criteria, "procedure_type") + and criteria.procedure_type + and len(criteria.procedure_type) > 0 + ) or ( + hasattr(criteria, "procedure_type_cs") + and criteria.procedure_type_cs is not None + ): select_cols.append("po.procedure_type_concept_id") # modifier - if (hasattr(criteria, 'modifier') and criteria.modifier and len(criteria.modifier) > 0) or \ - (hasattr(criteria, 'modifier_cs') and criteria.modifier_cs is not None): + if ( + hasattr(criteria, "modifier") + and criteria.modifier + and len(criteria.modifier) > 0 + ) or (hasattr(criteria, "modifier_cs") and criteria.modifier_cs is not None): select_cols.append("po.modifier_concept_id") - + # providerSpecialty - if (hasattr(criteria, 'provider_specialty') and criteria.provider_specialty and len(criteria.provider_specialty) > 0) or \ - (hasattr(criteria, 'provider_specialty_cs') and criteria.provider_specialty_cs is not None): + if ( + hasattr(criteria, "provider_specialty") + and criteria.provider_specialty + and len(criteria.provider_specialty) > 0 + ) or ( + hasattr(criteria, "provider_specialty_cs") + and criteria.provider_specialty_cs is not None + ): select_cols.append("po.provider_id") # dateAdjustment or default start/end dates - if hasattr(criteria, 'date_adjustment') and criteria.date_adjustment: - select_cols.append(BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, - "po.procedure_date" if criteria.date_adjustment.start_with == "start_date" else "DATEADD(day,1,po.procedure_date)", - "po.procedure_date" if criteria.date_adjustment.end_with == "start_date" else "DATEADD(day,1,po.procedure_date)" - )) + if hasattr(criteria, "date_adjustment") and criteria.date_adjustment: + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, + ( + "po.procedure_date" + if criteria.date_adjustment.start_with == "start_date" + else "DATEADD(day,1,po.procedure_date)" + ), + ( + "po.procedure_date" + if criteria.date_adjustment.end_with == "start_date" + else "DATEADD(day,1,po.procedure_date)" + ), + ) + ) else: - select_cols.append("po.procedure_date as start_date, DATEADD(day,1,po.procedure_date) as end_date") - + select_cols.append( + "po.procedure_date as start_date, DATEADD(day,1,po.procedure_date) as end_date" + ) + return select_cols - - def resolve_join_clauses(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: Criteria, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for criteria. - + Java equivalent: ProcedureOccurrenceSqlBuilder.resolveJoinClauses() """ join_clauses = [] - + # join to PERSON - if (hasattr(criteria, 'age') and criteria.age) or \ - (hasattr(criteria, 'gender') and criteria.gender and len(criteria.gender) > 0) or \ - (hasattr(criteria, 'gender_cs') and criteria.gender_cs is not None): - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + if ( + (hasattr(criteria, "age") and criteria.age) + or ( + hasattr(criteria, "gender") + and criteria.gender + and len(criteria.gender) > 0 + ) + or (hasattr(criteria, "gender_cs") and criteria.gender_cs is not None) + ): + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + # visitType - if (hasattr(criteria, 'visit_type') and criteria.visit_type and len(criteria.visit_type) > 0) or \ - (hasattr(criteria, 'visit_type_cs') and criteria.visit_type_cs is not None): - join_clauses.append("JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id") - + if ( + hasattr(criteria, "visit_type") + and criteria.visit_type + and len(criteria.visit_type) > 0 + ) or ( + hasattr(criteria, "visit_type_cs") and criteria.visit_type_cs is not None + ): + join_clauses.append( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" + ) + # providerSpecialty - if (hasattr(criteria, 'provider_specialty') and criteria.provider_specialty and len(criteria.provider_specialty) > 0) or \ - (hasattr(criteria, 'provider_specialty_cs') and criteria.provider_specialty_cs is not None): - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") - + if ( + hasattr(criteria, "provider_specialty") + and criteria.provider_specialty + and len(criteria.provider_specialty) > 0 + ) or ( + hasattr(criteria, "provider_specialty_cs") + and criteria.provider_specialty_cs is not None + ): + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) + return join_clauses - - def resolve_where_clauses(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: Criteria, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for criteria. - + Java equivalent: ProcedureOccurrenceSqlBuilder.resolveWhereClauses() """ where_clauses = list(super().resolve_where_clauses(criteria, options)) - + # occurrenceStartDate - if hasattr(criteria, 'occurrence_start_date') and criteria.occurrence_start_date: - where_clauses.append(BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date)) + if ( + hasattr(criteria, "occurrence_start_date") + and criteria.occurrence_start_date + ): + where_clauses.append( + BuilderUtils.build_date_range_clause( + "C.start_date", criteria.occurrence_start_date + ) + ) # procedureType - if hasattr(criteria, 'procedure_type') and criteria.procedure_type and len(criteria.procedure_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.procedure_type) - exclude = "not " if hasattr(criteria, 'procedure_type_exclude') and criteria.procedure_type_exclude else "" - where_clauses.append(f"C.procedure_type_concept_id {exclude}in ({','.join(map(str, concept_ids))})") - + if ( + hasattr(criteria, "procedure_type") + and criteria.procedure_type + and len(criteria.procedure_type) > 0 + ): + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.procedure_type + ) + exclude = ( + "not " + if hasattr(criteria, "procedure_type_exclude") + and criteria.procedure_type_exclude + else "" + ) + where_clauses.append( + f"C.procedure_type_concept_id {exclude}in ({','.join(map(str, concept_ids))})" + ) + # procedureTypeCS - if hasattr(criteria, 'procedure_type_cs') and criteria.procedure_type_cs is not None: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.procedure_type_cs.codeset_id, "C.procedure_type_concept_id")) + if ( + hasattr(criteria, "procedure_type_cs") + and criteria.procedure_type_cs is not None + ): + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.procedure_type_cs.codeset_id, "C.procedure_type_concept_id" + ) + ) # modifier - if hasattr(criteria, 'modifier') and criteria.modifier and len(criteria.modifier) > 0: + if ( + hasattr(criteria, "modifier") + and criteria.modifier + and len(criteria.modifier) > 0 + ): concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.modifier) - where_clauses.append(f"C.modifier_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.modifier_concept_id in ({','.join(map(str, concept_ids))})" + ) + # modifierCS - if hasattr(criteria, 'modifier_cs') and criteria.modifier_cs is not None: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.modifier_cs.codeset_id, "C.modifier_concept_id")) - + if hasattr(criteria, "modifier_cs") and criteria.modifier_cs is not None: + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.modifier_cs.codeset_id, "C.modifier_concept_id" + ) + ) + # quantity - if hasattr(criteria, 'quantity') and criteria.quantity: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity)) - + if hasattr(criteria, "quantity") and criteria.quantity: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity) + ) + # age - if hasattr(criteria, 'age') and criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) + if hasattr(criteria, "age") and criteria.age: + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age + ) + ) # gender - if hasattr(criteria, 'gender') and criteria.gender and len(criteria.gender) > 0: + if hasattr(criteria, "gender") and criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) # genderCS - if hasattr(criteria, 'gender_cs') and criteria.gender_cs is not None: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) - + if hasattr(criteria, "gender_cs") and criteria.gender_cs is not None: + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, "P.gender_concept_id" + ) + ) + # providerSpecialty - if hasattr(criteria, 'provider_specialty') and criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) - where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") - + if ( + hasattr(criteria, "provider_specialty") + and criteria.provider_specialty + and len(criteria.provider_specialty) > 0 + ): + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.provider_specialty + ) + where_clauses.append( + f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" + ) + # providerSpecialtyCS - if hasattr(criteria, 'provider_specialty_cs') and criteria.provider_specialty_cs is not None: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id")) + if ( + hasattr(criteria, "provider_specialty_cs") + and criteria.provider_specialty_cs is not None + ): + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id" + ) + ) # visitType - if hasattr(criteria, 'visit_type') and criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) - where_clauses.append(f"V.visit_concept_id in ({','.join(map(str, concept_ids))})") - + if ( + hasattr(criteria, "visit_type") + and criteria.visit_type + and len(criteria.visit_type) > 0 + ): + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.visit_type + ) + where_clauses.append( + f"V.visit_concept_id in ({','.join(map(str, concept_ids))})" + ) + # visitTypeCS - if hasattr(criteria, 'visit_type_cs') and criteria.visit_type_cs is not None: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) + if hasattr(criteria, "visit_type_cs") and criteria.visit_type_cs is not None: + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, "V.visit_concept_id" + ) + ) return where_clauses diff --git a/circe/cohortdefinition/builders/specimen.py b/circe/cohortdefinition/builders/specimen.py index 7f82b59c..3b8b1148 100644 --- a/circe/cohortdefinition/builders/specimen.py +++ b/circe/cohortdefinition/builders/specimen.py @@ -17,10 +17,10 @@ class SpecimenSqlBuilder(CriteriaSqlBuilder[Specimen]): """SQL builder for Specimen criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.SpecimenSqlBuilder """ - + def get_query_template(self) -> str: """Get the SQL query template for specimen criteria.""" return """-- Begin Specimen Criteria @@ -36,16 +36,18 @@ def get_query_template(self) -> str: @whereClause -- End Specimen Criteria """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for specimen criteria.""" return { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.specimen_concept_id", @@ -54,44 +56,60 @@ def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) CriteriaColumn.END_DATE: "C.specimen_date", CriteriaColumn.VISIT_ID: "C.visit_occurrence_id", CriteriaColumn.QUANTITY: "C.quantity", - CriteriaColumn.UNIT: "C.unit_concept_id" + CriteriaColumn.UNIT: "C.unit_concept_id", } return column_mapping.get(criteria_column, "NULL") - + def embed_codeset_clause(self, query: str, criteria: Specimen) -> str: """Embed codeset clause for specimen criteria.""" - return query.replace("@codesetClause", BuilderUtils.get_codeset_join_expression( - criteria.codeset_id, - "s.specimen_concept_id", - criteria.specimen_source_concept, - "s.specimen_source_concept_id" - )) - - def embed_ordinal_expression(self, query: str, criteria: Specimen, where_clauses: List[str]) -> str: + return query.replace( + "@codesetClause", + BuilderUtils.get_codeset_join_expression( + criteria.codeset_id, + "s.specimen_concept_id", + criteria.specimen_source_concept, + "s.specimen_source_concept_id", + ), + ) + + def embed_ordinal_expression( + self, query: str, criteria: Specimen, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query.""" if criteria.first: where_clauses.append("C.ordinal = 1") - query = query.replace("@ordinalExpression", ", row_number() over (PARTITION BY s.person_id ORDER BY s.specimen_date, s.specimen_id) as ordinal") + query = query.replace( + "@ordinalExpression", + ", row_number() over (PARTITION BY s.person_id ORDER BY s.specimen_date, s.specimen_id) as ordinal", + ) else: query = query.replace("@ordinalExpression", "") return query - def resolve_join_clauses(self, criteria: Specimen, options: Optional[BuilderOptions] = None) -> List[str]: + def resolve_join_clauses( + self, criteria: Specimen, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for specimen criteria.""" joins = [] - + # join to PERSON - if (criteria.age or - (criteria.gender and len(criteria.gender) > 0) or - (criteria.gender_cs and criteria.gender_cs.codeset_id)): - joins.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): + joins.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + return joins - - def resolve_where_clauses(self, criteria: Specimen, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: Specimen, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for specimen criteria.""" where_clauses = [] - + # occurrenceStartDate if criteria.occurrence_start_date: date_clause = BuilderUtils.build_date_range_clause( @@ -99,65 +117,111 @@ def resolve_where_clauses(self, criteria: Specimen, options: Optional[BuilderOpt ) if date_clause: where_clauses.append(date_clause) - + # specimenType if criteria.specimen_type and len(criteria.specimen_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.specimen_type) + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.specimen_type + ) op = "not in" if criteria.specimen_type_exclude else "in" - where_clauses.append(f"C.specimen_type_concept_id {op} ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.specimen_type_concept_id {op} ({','.join(map(str, concept_ids))})" + ) + # specimenTypeCS if criteria.specimen_type_cs and criteria.specimen_type_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.specimen_type_cs.codeset_id, "C.specimen_type_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.specimen_type_cs.codeset_id, "C.specimen_type_concept_id" + ) + ) # quantity if criteria.quantity: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity, ".4f")) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.quantity", criteria.quantity, ".4f" + ) + ) # unit if criteria.unit and len(criteria.unit) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.unit) - where_clauses.append(f"C.unit_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"C.unit_concept_id in ({','.join(map(str, concept_ids))})" + ) + # unitCS if criteria.unit_cs and criteria.unit_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.unit_cs.codeset_id, "C.unit_concept_id" + ) + ) # anatomicSite if criteria.anatomic_site and len(criteria.anatomic_site) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.anatomic_site) - where_clauses.append(f"C.anatomic_site_concept_id in ({','.join(map(str, concept_ids))})") - + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.anatomic_site + ) + where_clauses.append( + f"C.anatomic_site_concept_id in ({','.join(map(str, concept_ids))})" + ) + # anatomicSiteCS if criteria.anatomic_site_cs and criteria.anatomic_site_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.anatomic_site_cs.codeset_id, "C.anatomic_site_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.anatomic_site_cs.codeset_id, "C.anatomic_site_concept_id" + ) + ) # diseaseStatus if criteria.disease_status and len(criteria.disease_status) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.disease_status) - where_clauses.append(f"C.disease_status_concept_id in ({','.join(map(str, concept_ids))})") - + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.disease_status + ) + where_clauses.append( + f"C.disease_status_concept_id in ({','.join(map(str, concept_ids))})" + ) + # diseaseStatusCS if criteria.disease_status_cs and criteria.disease_status_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.disease_status_cs.codeset_id, "C.disease_status_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.disease_status_cs.codeset_id, "C.disease_status_concept_id" + ) + ) # sourceId if criteria.source_id: - where_clauses.append(BuilderUtils.build_text_filter_clause(criteria.source_id, "C.specimen_source_id")) + where_clauses.append( + BuilderUtils.build_text_filter_clause( + criteria.source_id, "C.specimen_source_id" + ) + ) # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause( - "YEAR(C.specimen_date) - P.year_of_birth", criteria.age - )) - + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "YEAR(C.specimen_date) - P.year_of_birth", criteria.age + ) + ) + # gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) + # genderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, "P.gender_concept_id" + ) + ) + return where_clauses diff --git a/circe/cohortdefinition/builders/utils.py b/circe/cohortdefinition/builders/utils.py index eb8aa61a..1dcd9e15 100644 --- a/circe/cohortdefinition/builders/utils.py +++ b/circe/cohortdefinition/builders/utils.py @@ -19,57 +19,65 @@ class BuilderOptions: """Builder options for SQL query generation. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.BuilderOptions """ - + def __init__(self): self.additional_columns: List[CriteriaColumn] = [] class BuilderUtils: """Utility class for SQL query building. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.BuilderUtils """ - + # SQL templates - equivalent to Java constants - CODESET_JOIN_TEMPLATE = "JOIN #Codesets {} on ({} = {}.concept_id and {}.codeset_id = {})" - CODESET_IN_TEMPLATE = "{} {} in (select concept_id from #Codesets where codeset_id = {})" + CODESET_JOIN_TEMPLATE = ( + "JOIN #Codesets {} on ({} = {}.concept_id and {}.codeset_id = {})" + ) + CODESET_IN_TEMPLATE = ( + "{} {} in (select concept_id from #Codesets where codeset_id = {})" + ) CODESET_NULL_TEMPLATE = "{} is {} null" - + # Date adjustment template - equivalent to Java ResourceHelper.GetResourceAsString - DATE_ADJUSTMENT_TEMPLATE = "DATEADD(day,{}, {}) as start_date, DATEADD(day,{}, {}) as end_date" - + DATE_ADJUSTMENT_TEMPLATE = ( + "DATEADD(day,{}, {}) as start_date, DATEADD(day,{}, {}) as end_date" + ) + STANDARD_ALIAS = "cs" NON_STANDARD_ALIAS = "cns" - + @staticmethod - def get_date_adjustment_expression(date_adjustment: DateAdjustment, start_column: str, end_column: str) -> str: + def get_date_adjustment_expression( + date_adjustment: DateAdjustment, start_column: str, end_column: str + ) -> str: """Get date adjustment expression for SQL. - + Java equivalent: BuilderUtils.getDateAdjustmentExpression() """ return BuilderUtils.DATE_ADJUSTMENT_TEMPLATE.format( date_adjustment.start_offset, start_column, date_adjustment.end_offset, - end_column + end_column, ) - + @staticmethod def get_codeset_join_expression( - standard_codeset_id: Optional[int], + standard_codeset_id: Optional[int], standard_concept_column: str, - source_codeset_id: Optional[int], - source_concept_column: str + source_codeset_id: Optional[int], + source_concept_column: str, ) -> str: """Get codeset join expression for SQL. - + Java equivalent: BuilderUtils.getCodesetJoinExpression() """ codeset_clauses = [] - + if standard_codeset_id is not None: codeset_clauses.append( BuilderUtils.CODESET_JOIN_TEMPLATE.format( @@ -77,10 +85,10 @@ def get_codeset_join_expression( standard_concept_column, BuilderUtils.STANDARD_ALIAS, BuilderUtils.STANDARD_ALIAS, - standard_codeset_id + standard_codeset_id, ) ) - + if source_codeset_id is not None: codeset_clauses.append( BuilderUtils.CODESET_JOIN_TEMPLATE.format( @@ -88,33 +96,39 @@ def get_codeset_join_expression( source_concept_column, BuilderUtils.NON_STANDARD_ALIAS, BuilderUtils.NON_STANDARD_ALIAS, - source_codeset_id + source_codeset_id, ) ) - + return " ".join(codeset_clauses) - + @staticmethod - def get_codeset_in_expression(codeset_id: int, column_name: str, is_exclusion: bool = False) -> str: + def get_codeset_in_expression( + codeset_id: int, column_name: str, is_exclusion: bool = False + ) -> str: """Get codeset IN expression for SQL. - + Java equivalent: BuilderUtils.getCodesetInExpression() """ operator = "not" if is_exclusion else "" - return BuilderUtils.CODESET_IN_TEMPLATE.format(operator, column_name, codeset_id) - + return BuilderUtils.CODESET_IN_TEMPLATE.format( + operator, column_name, codeset_id + ) + @staticmethod def get_concept_ids_from_concepts(concepts: List[Concept]) -> List[int]: """Get concept IDs from concept list. - + Java equivalent: BuilderUtils.getConceptIdsFromConcepts() """ - return [concept.concept_id for concept in concepts if concept.concept_id is not None] - + return [ + concept.concept_id for concept in concepts if concept.concept_id is not None + ] + @staticmethod def get_operator(op: str) -> str: """Get SQL operator for range comparison. - + Java equivalent: BuilderUtils.getOperator(String op) """ operators = { @@ -123,46 +137,52 @@ def get_operator(op: str) -> str: "eq": "=", "!eq": "<>", "gt": ">", - "gte": ">=" + "gte": ">=", } if op in operators: return operators[op] raise RuntimeError(f"Unknown operator type: {op}") @staticmethod - def build_date_range_clause(sql_expression: str, date_range: Optional[DateRange]) -> Optional[str]: + def build_date_range_clause( + sql_expression: str, date_range: Optional[DateRange] + ) -> Optional[str]: """Build date range clause for SQL. - + Java equivalent: BuilderUtils.buildDateRangeClause(String sqlExpression, DateRange range) """ if date_range is None or date_range.op is None: return None - + op = date_range.op.lower() - + # Handle "bt" (between) operator if op.endswith("bt"): negation = "not " if op.startswith("!") else "" return f"{negation}({sql_expression} >= {BuilderUtils.date_string_to_sql(date_range.value)} and {sql_expression} <= {BuilderUtils.date_string_to_sql(date_range.extent)})" - + # Handle other operators if date_range.value is None: return None - + return f"{sql_expression} {BuilderUtils.get_operator(op)} {BuilderUtils.date_string_to_sql(date_range.value)}" - + @staticmethod - def build_numeric_range_clause(sql_expression: str, numeric_range: Optional[NumericRange], format: Optional[str] = None) -> Optional[str]: + def build_numeric_range_clause( + sql_expression: str, + numeric_range: Optional[NumericRange], + format: Optional[str] = None, + ) -> Optional[str]: """Build numeric range clause for SQL. - + Java equivalent: BuilderUtils.buildNumericRangeClause(String sqlExpression, NumericRange range, String format) or buildNumericRangeClause(String sqlExpression, NumericRange range) """ if numeric_range is None or numeric_range.op is None: return None - + op = numeric_range.op.lower() - + if op.endswith("bt"): if numeric_range.value is None or numeric_range.extent is None: return None @@ -178,79 +198,85 @@ def build_numeric_range_clause(sql_expression: str, numeric_range: Optional[Nume else: if numeric_range.value is None: return None - + if format: val_str = f"{float(numeric_range.value):{format}}" return f"{sql_expression} {BuilderUtils.get_operator(op)} {val_str}" else: return f"{sql_expression} {BuilderUtils.get_operator(op)} {int(numeric_range.value)}" - + @staticmethod - def build_text_filter_clause(text_filter: Optional[Any], column_name: str) -> Optional[str]: + def build_text_filter_clause( + text_filter: Optional[Any], column_name: str + ) -> Optional[str]: """Build text filter clause for SQL. - + Java equivalent: BuilderUtils.buildTextFilterClause() """ if text_filter is None: return None - + # Handle simple string (legacy/direct usage) if isinstance(text_filter, str): return f"{column_name} LIKE '%{text_filter}%'" - + # Handle TextFilter object # Note: We use hasattr/getattr because we might not have the type imported directly involved in circular imports - text = getattr(text_filter, 'text', None) - op = getattr(text_filter, 'op', 'contains') - + text = getattr(text_filter, "text", None) + op = getattr(text_filter, "op", "contains") + if text is None: return None - + # Escape single quotes in text text = text.replace("'", "''") - - if op == "eq": - return f"{column_name} = '{text}'" + + if op == "eq": + return f"{column_name} = '{text}'" elif op == "!eq": - return f"{column_name} <> '{text}'" + return f"{column_name} <> '{text}'" elif op == "startsWith": - return f"{column_name} LIKE '{text}%'" + return f"{column_name} LIKE '{text}%'" elif op == "endsWith": - return f"{column_name} LIKE '%{text}'" + return f"{column_name} LIKE '%{text}'" elif op == "contains": - return f"{column_name} LIKE '%{text}%'" + return f"{column_name} LIKE '%{text}%'" elif op == "!contains": - return f"{column_name} NOT LIKE '%{text}%'" + return f"{column_name} NOT LIKE '%{text}%'" else: - # Default to exact match - return f"{column_name} = '{text}'" - + # Default to exact match + return f"{column_name} = '{text}'" + @staticmethod - def split_in_clause(column_name: str, values: List[int], max_length: int = 1000) -> str: + def split_in_clause( + column_name: str, values: List[int], max_length: int = 1000 + ) -> str: """Split IN clause for large value lists. - + Java equivalent: BuilderUtils.splitInClause() """ if not values: return "NULL" - + # Split into chunks chunks = [] for i in range(0, len(values), max_length): - chunk_values = values[i:i + max_length] + chunk_values = values[i : i + max_length] chunk_clause = f"{column_name} in ({','.join(map(str, chunk_values))})" chunks.append(chunk_clause) - + # Java implementation always wraps the result in parentheses return f"({' or '.join(chunks)})" - + @staticmethod def date_string_to_sql(date_string: str) -> str: """Convert date string to SQL format (DATEFROMPARTS). - + Java equivalent: BuilderUtils.dateStringToSql() """ - parts = date_string.split('-') + parts = date_string.split("-") if len(parts) != 3: - raise ValueError(f"Invalid date format: {date_string}. Expected YYYY-MM-DD.") + raise ValueError( + f"Invalid date format: {date_string}. Expected YYYY-MM-DD." + ) return f"DATEFROMPARTS({int(parts[0])}, {int(parts[1])}, {int(parts[2])})" diff --git a/circe/cohortdefinition/builders/visit_detail.py b/circe/cohortdefinition/builders/visit_detail.py index 62f525b4..4d1f9ccc 100644 --- a/circe/cohortdefinition/builders/visit_detail.py +++ b/circe/cohortdefinition/builders/visit_detail.py @@ -16,25 +16,25 @@ class VisitDetailSqlBuilder(CriteriaSqlBuilder[VisitDetail]): """SQL builder for Visit Detail criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.VisitDetailSqlBuilder """ - + # Default columns are those that are specified in the template, and don't need to be added if specified in 'additionalColumns' DEFAULT_COLUMNS = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_DETAIL_ID + CriteriaColumn.VISIT_DETAIL_ID, } - + # Default select columns are the columns that will always be returned from the subquery, but are added to based on the specific criteria DEFAULT_SELECT_COLUMNS = [ "vd.person_id", - "vd.visit_detail_id", + "vd.visit_detail_id", "vd.visit_detail_concept_id", - "vd.visit_occurrence_id" + "vd.visit_occurrence_id", ] - + def get_query_template(self) -> str: """Get the SQL query template for visit detail criteria.""" return """ @@ -59,180 +59,261 @@ def get_query_template(self) -> str: @whereClause @additionalColumns """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for visit detail criteria.""" return self.DEFAULT_COLUMNS - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.visit_detail_concept_id", CriteriaColumn.DURATION: "DATEDIFF(d, C.start_date, C.end_date)", CriteriaColumn.START_DATE: "C.start_date", CriteriaColumn.END_DATE: "C.end_date", - CriteriaColumn.VISIT_DETAIL_ID: "C.visit_detail_id" + CriteriaColumn.VISIT_DETAIL_ID: "C.visit_detail_id", } return column_mapping.get(criteria_column, "NULL") - + def embed_codeset_clause(self, query: str, criteria: VisitDetail) -> str: """Embed codeset clause in query.""" codeset_clause = BuilderUtils.get_codeset_join_expression( criteria.codeset_id, "vd.visit_detail_concept_id", criteria.visit_detail_source_concept, - "vd.visit_detail_source_concept_id" + "vd.visit_detail_source_concept_id", ) return query.replace("@codesetClause", codeset_clause) - - def embed_ordinal_expression(self, query: str, criteria: VisitDetail, where_clauses: List[str]) -> str: + + def embed_ordinal_expression( + self, query: str, criteria: VisitDetail, where_clauses: List[str] + ) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") - query = query.replace("@ordinalExpression", ", row_number() over (PARTITION BY vd.person_id ORDER BY vd.visit_detail_start_date, vd.visit_detail_id) as ordinal") + query = query.replace( + "@ordinalExpression", + ", row_number() over (PARTITION BY vd.person_id ORDER BY vd.visit_detail_start_date, vd.visit_detail_id) as ordinal", + ) else: query = query.replace("@ordinalExpression", "") return query - - def resolve_select_clauses(self, criteria: VisitDetail, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_select_clauses( + self, criteria: VisitDetail, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for visit detail criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) - + # visitType if criteria.visit_detail_type_cs is not None: select_cols.append("vd.visit_detail_type_concept_id") - + # providerSpecialty if criteria.provider_specialty_cs is not None: select_cols.append("vd.provider_id") - + # placeOfService if criteria.place_of_service_cs is not None: select_cols.append("vd.care_site_id") - + # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "vd.visit_detail_start_date" if criteria.date_adjustment.start_with == "start_date" else "vd.visit_detail_end_date" - end_column = "vd.visit_detail_start_date" if criteria.date_adjustment.end_with == "start_date" else "vd.visit_detail_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "vd.visit_detail_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "vd.visit_detail_end_date" + ) + end_column = ( + "vd.visit_detail_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "vd.visit_detail_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: select_cols.append("vd.visit_detail_start_date as start_date") select_cols.append("vd.visit_detail_end_date as end_date") - + # Add domain concept column select_cols.append("vd.visit_detail_concept_id as domain_concept") - + # Add visit_detail_id column select_cols.append("vd.visit_detail_id as visit_detail_id") - + return select_cols - - def resolve_join_clauses(self, criteria: VisitDetail, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: VisitDetail, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for visit detail criteria.""" join_clauses = [] - - if criteria.age is not None or criteria.gender_cs is not None or criteria.gender is not None: # join to PERSON - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - - if criteria.place_of_service_cs is not None or criteria.place_of_service_location is not None: - join_clauses.append("JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id") - + + if ( + criteria.age is not None + or criteria.gender_cs is not None + or criteria.gender is not None + ): # join to PERSON + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + + if ( + criteria.place_of_service_cs is not None + or criteria.place_of_service_location is not None + ): + join_clauses.append( + "JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id" + ) + if criteria.provider_specialty_cs is not None: - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") - + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) + if criteria.place_of_service_location is not None: - self.add_filtering_by_care_site_location_region(join_clauses, criteria.place_of_service_location) - + self.add_filtering_by_care_site_location_region( + join_clauses, criteria.place_of_service_location + ) + return join_clauses - - def resolve_where_clauses(self, criteria: VisitDetail, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: VisitDetail, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for visit detail criteria.""" where_clauses = [] - + # occurrenceStartDate if criteria.visit_detail_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.visit_detail_start_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.start_date", criteria.visit_detail_start_date + ) if date_clause: where_clauses.append(date_clause) - + # occurrenceEndDate if criteria.visit_detail_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.visit_detail_end_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.end_date", criteria.visit_detail_end_date + ) if date_clause: where_clauses.append(date_clause) - + # visitType if criteria.visit_detail_type_cs is not None: - self.add_where_clause(where_clauses, criteria.visit_detail_type_cs, "C.visit_detail_type_concept_id", criteria.visit_detail_type_exclude) - + self.add_where_clause( + where_clauses, + criteria.visit_detail_type_cs, + "C.visit_detail_type_concept_id", + criteria.visit_detail_type_exclude, + ) + # visitLength if criteria.visit_detail_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_detail_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_detail_length + ) if numeric_clause: where_clauses.append(numeric_clause) - + # age if criteria.age is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age + ) if numeric_clause: where_clauses.append(numeric_clause) - + # gender if criteria.gender is not None and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") - + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) + if criteria.gender_cs is not None: - self.add_where_clause(where_clauses, criteria.gender_cs, "P.gender_concept_id") - + self.add_where_clause( + where_clauses, criteria.gender_cs, "P.gender_concept_id" + ) + # providerSpecialty if criteria.provider_specialty_cs is not None: - self.add_where_clause(where_clauses, criteria.provider_specialty_cs, "PR.specialty_concept_id") - + self.add_where_clause( + where_clauses, criteria.provider_specialty_cs, "PR.specialty_concept_id" + ) + # placeOfService if criteria.place_of_service_cs is not None: - self.add_where_clause(where_clauses, criteria.place_of_service_cs, "CS.place_of_service_concept_id") - + self.add_where_clause( + where_clauses, + criteria.place_of_service_cs, + "CS.place_of_service_concept_id", + ) + return where_clauses - + def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: """Get additional columns string with proper aliases. - + Java equivalent: VisitDetailSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) - - def add_filtering_by_care_site_location_region(self, join_clauses: List[str], codeset_id: int): + return ", ".join( + [ + f"{self.get_table_column_for_criteria_column(col)} as {col.value}" + for col in columns + ] + ) + + def add_filtering_by_care_site_location_region( + self, join_clauses: List[str], codeset_id: int + ): """Add filtering by care site location region.""" - join_clauses.append(self.get_location_history_join("LH", "CARE_SITE", "C.care_site_id")) - join_clauses.append("JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id") + join_clauses.append( + self.get_location_history_join("LH", "CARE_SITE", "C.care_site_id") + ) + join_clauses.append( + "JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id" + ) self.add_filtering(join_clauses, codeset_id, "LOC.region_concept_id") - - def add_where_clause(self, where_clauses: List[str], concept_set_selection, concept_column: str, exclude: Optional[bool] = None): + + def add_where_clause( + self, + where_clauses: List[str], + concept_set_selection, + concept_column: str, + exclude: Optional[bool] = None, + ): """Add where clause for concept set selection.""" - is_exclusion = exclude if exclude is not None else concept_set_selection.is_exclusion + is_exclusion = ( + exclude if exclude is not None else concept_set_selection.is_exclusion + ) codeset_clause = BuilderUtils.get_codeset_in_expression( - concept_set_selection.codeset_id, - concept_column, - is_exclusion + concept_set_selection.codeset_id, concept_column, is_exclusion ) if codeset_clause: where_clauses.append(codeset_clause) - - def add_filtering(self, join_clauses: List[str], codeset_id: int, standard_concept_column: str): + + def add_filtering( + self, join_clauses: List[str], codeset_id: int, standard_concept_column: str + ): """Add filtering join clause.""" join_clauses.append( BuilderUtils.get_codeset_join_expression( - codeset_id, - standard_concept_column, - None, - None + codeset_id, standard_concept_column, None, None ) ) - - def get_location_history_join(self, alias: str, domain: str, entity_id_field: str) -> str: + + def get_location_history_join( + self, alias: str, domain: str, entity_id_field: str + ) -> str: """Get location history join clause.""" return f"""JOIN @cdm_database_schema.LOCATION_HISTORY {alias} on {alias}.entity_id = {entity_id_field} diff --git a/circe/cohortdefinition/builders/visit_occurrence.py b/circe/cohortdefinition/builders/visit_occurrence.py index 4388cb31..a41e851d 100644 --- a/circe/cohortdefinition/builders/visit_occurrence.py +++ b/circe/cohortdefinition/builders/visit_occurrence.py @@ -17,10 +17,10 @@ class VisitOccurrenceSqlBuilder(CriteriaSqlBuilder[VisitOccurrence]): """SQL builder for Visit Occurrence criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.VisitOccurrenceSqlBuilder """ - + def get_query_template(self) -> str: """Get the SQL query template for visit occurrence criteria.""" return """ @@ -37,16 +37,18 @@ def get_query_template(self) -> str: @whereClause -- End Visit Occurrence Criteria """ - + def get_default_columns(self) -> Set[CriteriaColumn]: """Get default columns for visit occurrence criteria.""" return { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } - - def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: + + def get_table_column_for_criteria_column( + self, criteria_column: CriteriaColumn + ) -> str: """Get table column for criteria column.""" if criteria_column == CriteriaColumn.DOMAIN_CONCEPT: return "C.visit_concept_id" @@ -59,152 +61,236 @@ def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) elif criteria_column == CriteriaColumn.VISIT_ID: return "C.visit_occurrence_id" else: - raise ValueError(f"Invalid CriteriaColumn for Visit Occurrence: {criteria_column}") - + raise ValueError( + f"Invalid CriteriaColumn for Visit Occurrence: {criteria_column}" + ) + def embed_codeset_clause(self, query: str, criteria: VisitOccurrence) -> str: """Embed codeset clause for visit occurrence criteria.""" codeset_clause = BuilderUtils.get_codeset_join_expression( criteria.codeset_id, "vo.visit_concept_id", criteria.visit_source_concept, - "vo.visit_source_concept_id" + "vo.visit_source_concept_id", ) return query.replace("@codesetClause", codeset_clause) - - def resolve_select_clauses(self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_select_clauses( + self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve select clauses for visit occurrence criteria.""" # Default select columns that are always returned select_cols = ["vo.person_id", "vo.visit_occurrence_id", "vo.visit_concept_id"] - + # visitType - if ((criteria.visit_type and len(criteria.visit_type) > 0) or - (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id)): + if (criteria.visit_type and len(criteria.visit_type) > 0) or ( + criteria.visit_type_cs and criteria.visit_type_cs.codeset_id + ): select_cols.append("vo.visit_type_concept_id") - + # providerSpecialty - if ((criteria.provider_specialty and len(criteria.provider_specialty) > 0) or - (criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id)): + if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( + criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id + ): select_cols.append("vo.provider_id") - + # placeOfService - if ((criteria.place_of_service and len(criteria.place_of_service) > 0) or - (criteria.place_of_service_cs and criteria.place_of_service_cs.codeset_id)): + if (criteria.place_of_service and len(criteria.place_of_service) > 0) or ( + criteria.place_of_service_cs and criteria.place_of_service_cs.codeset_id + ): select_cols.append("vo.care_site_id") - + # dateAdjustment or default start/end dates if criteria.date_adjustment: # Note: getDateAdjustmentExpression logic in Java-land: # BuilderUtils.getDateAdjustmentExpression(criteria.dateAdjustment, # criteria.dateAdjustment.startWith == DateAdjustment.DateType.START_DATE ? "vo.visit_start_date" : "vo.visit_end_date", # criteria.dateAdjustment.endWith == DateAdjustment.DateType.START_DATE ? "vo.visit_start_date" : "vo.visit_end_date") - start_col = "vo.visit_start_date" if criteria.date_adjustment.start_with == "START_DATE" else "vo.visit_end_date" - end_col = "vo.visit_start_date" if criteria.date_adjustment.end_with == "START_DATE" else "vo.visit_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_col, end_col)) + start_col = ( + "vo.visit_start_date" + if criteria.date_adjustment.start_with == "START_DATE" + else "vo.visit_end_date" + ) + end_col = ( + "vo.visit_start_date" + if criteria.date_adjustment.end_with == "START_DATE" + else "vo.visit_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_col, end_col + ) + ) else: - select_cols.append("vo.visit_start_date as start_date, vo.visit_end_date as end_date") - + select_cols.append( + "vo.visit_start_date as start_date, vo.visit_end_date as end_date" + ) + return select_cols - - def resolve_join_clauses(self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_join_clauses( + self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve join clauses for visit occurrence criteria.""" join_clauses = [] - + # Join to PERSON if age or gender conditions are present - if (criteria.age or - (criteria.gender and len(criteria.gender) > 0) or - (criteria.gender_cs and criteria.gender_cs.codeset_id)): - join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): + join_clauses.append( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + ) + # Join to CARE_SITE if place of service conditions are present - if ((criteria.place_of_service and len(criteria.place_of_service) > 0) or - (criteria.place_of_service_cs and criteria.place_of_service_cs.codeset_id) or - criteria.place_of_service_location is not None): - join_clauses.append("JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id") - + if ( + (criteria.place_of_service and len(criteria.place_of_service) > 0) + or ( + criteria.place_of_service_cs and criteria.place_of_service_cs.codeset_id + ) + or criteria.place_of_service_location is not None + ): + join_clauses.append( + "JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id" + ) + # Join to PROVIDER if provider specialty conditions are present - if ((criteria.provider_specialty and len(criteria.provider_specialty) > 0) or - (criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id)): - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") - + if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( + criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id + ): + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) + if criteria.place_of_service_location is not None: - self._add_filtering_by_care_site_location_region(join_clauses, criteria.place_of_service_location) - + self._add_filtering_by_care_site_location_region( + join_clauses, criteria.place_of_service_location + ) + return join_clauses - - def resolve_where_clauses(self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None) -> List[str]: + + def resolve_where_clauses( + self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None + ) -> List[str]: """Resolve where clauses for visit occurrence criteria.""" where_clauses = super().resolve_where_clauses(criteria, options) - + # occurrenceStartDate if criteria.occurrence_start_date: - where_clauses.append(BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date)) - + where_clauses.append( + BuilderUtils.build_date_range_clause( + "C.start_date", criteria.occurrence_start_date + ) + ) + # occurrenceEndDate if criteria.occurrence_end_date: - where_clauses.append(BuilderUtils.build_date_range_clause("C.end_date", criteria.occurrence_end_date)) - + where_clauses.append( + BuilderUtils.build_date_range_clause( + "C.end_date", criteria.occurrence_end_date + ) + ) + # visitType if criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.visit_type + ) exclude = "not " if criteria.visit_type_exclude else "" - where_clauses.append(f"C.visit_type_concept_id {exclude}in ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"C.visit_type_concept_id {exclude}in ({','.join(map(str, concept_ids))})" + ) # visitTypeCS if criteria.visit_type_cs and criteria.visit_type_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression( - criteria.visit_type_cs.codeset_id, "C.visit_type_concept_id", criteria.visit_type_cs.is_exclusion - )) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, + "C.visit_type_concept_id", + criteria.visit_type_cs.is_exclusion, + ) + ) + # visitLength if criteria.visit_length: - where_clauses.append(BuilderUtils.build_numeric_range_clause( - "DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_length - )) - + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_length + ) + ) + # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - )) - + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age + ) + ) + # gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" + ) # genderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression( - criteria.gender_cs.codeset_id, "P.gender_concept_id", criteria.gender_cs.is_exclusion - )) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, + "P.gender_concept_id", + criteria.gender_cs.is_exclusion, + ) + ) + # providerSpecialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) - where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.provider_specialty + ) + where_clauses.append( + f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" + ) # providerSpecialtyCS if criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression( - criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id", criteria.provider_specialty_cs.is_exclusion - )) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.provider_specialty_cs.codeset_id, + "PR.specialty_concept_id", + criteria.provider_specialty_cs.is_exclusion, + ) + ) + # placeOfService if criteria.place_of_service and len(criteria.place_of_service) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.place_of_service) - where_clauses.append(f"CS.place_of_service_concept_id in ({','.join(map(str, concept_ids))})") + concept_ids = BuilderUtils.get_concept_ids_from_concepts( + criteria.place_of_service + ) + where_clauses.append( + f"CS.place_of_service_concept_id in ({','.join(map(str, concept_ids))})" + ) # placeOfServiceCS if criteria.place_of_service_cs and criteria.place_of_service_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression( - criteria.place_of_service_cs.codeset_id, "CS.place_of_service_concept_id", criteria.place_of_service_cs.is_exclusion - )) - + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.place_of_service_cs.codeset_id, + "CS.place_of_service_concept_id", + criteria.place_of_service_cs.is_exclusion, + ) + ) + return where_clauses - + return where_clauses - - def embed_ordinal_expression(self, query: str, criteria: VisitOccurrence, where_clauses: List[str]) -> str: + + def embed_ordinal_expression( + self, query: str, criteria: VisitOccurrence, where_clauses: List[str] + ) -> str: """Embed ordinal expression for visit occurrence criteria.""" if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") @@ -213,23 +299,44 @@ def embed_ordinal_expression(self, query: str, criteria: VisitOccurrence, where_ else: return query.replace("@ordinalExpression", "") - def _add_filtering_by_care_site_location_region(self, join_clauses: List[str], codeset_id: int): + def _add_filtering_by_care_site_location_region( + self, join_clauses: List[str], codeset_id: int + ): """Add joins for filtering by care site location region.""" - join_clauses.append(self._get_location_history_join("LH", "CARE_SITE", "C.care_site_id")) - join_clauses.append("JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id") + join_clauses.append( + self._get_location_history_join("LH", "CARE_SITE", "C.care_site_id") + ) + join_clauses.append( + "JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id" + ) join_clauses.append( BuilderUtils.get_codeset_join_expression( - codeset_id, - "LOC.region_concept_id", - None, - None + codeset_id, "LOC.region_concept_id", None, None ) ) - def _get_location_history_join(self, alias: str, domain: str, entity_id_field: str) -> str: + def _get_location_history_join( + self, alias: str, domain: str, entity_id_field: str + ) -> str: """Get location history join expression.""" - return ("JOIN @cdm_database_schema.LOCATION_HISTORY " + alias + " " - + "on " + alias + ".entity_id = " + entity_id_field + " " - + "AND " + alias + ".domain_id = '" + domain + "' " - + "AND C.visit_start_date >= " + alias + ".start_date " - + "AND C.visit_end_date <= ISNULL(" + alias + ".end_date, DATEFROMPARTS(2099,12,31))") + return ( + "JOIN @cdm_database_schema.LOCATION_HISTORY " + + alias + + " " + + "on " + + alias + + ".entity_id = " + + entity_id_field + + " " + + "AND " + + alias + + ".domain_id = '" + + domain + + "' " + + "AND C.visit_start_date >= " + + alias + + ".start_date " + + "AND C.visit_end_date <= ISNULL(" + + alias + + ".end_date, DATEFROMPARTS(2099,12,31))" + ) diff --git a/circe/cohortdefinition/code_generator.py b/circe/cohortdefinition/code_generator.py index a42bb31e..8003fe35 100644 --- a/circe/cohortdefinition/code_generator.py +++ b/circe/cohortdefinition/code_generator.py @@ -1,4 +1,3 @@ - from typing import Any, List, Set, Type import textwrap from enum import Enum @@ -7,49 +6,54 @@ from .criteria import Criteria, CriteriaGroup from .core import Period + def to_python_code(obj: Any) -> str: """ - Converts a CohortExpression (or any circe model) into a human-readable Python code string + Converts a CohortExpression (or any circe model) into a human-readable Python code string that instantiates the object. """ imports: Set[str] = set() - + def _collect_imports(o: Any): - if hasattr(o, '__module__') and hasattr(o, '__name__') and o.__module__.startswith('circe.'): - # Try to import from the top level class map if possible, but for now specific modules - imports.add(f"from {o.__module__} import {o.__class__.__name__}") - - if hasattr(o, 'model_dump'): + if ( + hasattr(o, "__module__") + and hasattr(o, "__name__") + and o.__module__.startswith("circe.") + ): + # Try to import from the top level class map if possible, but for now specific modules + imports.add(f"from {o.__module__} import {o.__class__.__name__}") + + if hasattr(o, "model_dump"): # Access model_fields from the class, not the instance for name, field in o.__class__.model_fields.items(): val = getattr(o, name) if val is not None: if isinstance(val, list): for item in val: - _collect_imports(item) + _collect_imports(item) else: _collect_imports(val) # Initial pass to collect some imports - though we might just rely on a standard set or dynamic approach - # For a robust generator, we might want to just handle the traversal and printing, - # and maybe return imports separately? + # For a robust generator, we might want to just handle the traversal and printing, + # and maybe return imports separately? # Let's do the string generation directly. - + lines = [] - + # We will build a set of required imports as we traverse required_classes = set() def _repr(o: Any, indent_level: int = 0) -> str: indent = " " * indent_level - + if o is None: return "None" - + if instance_is_pydantic(o): required_classes.add(o.__class__) cls_name = o.__class__.__name__ - + # Get set fields fields = {} # Access model_fields from the class @@ -57,61 +61,64 @@ def _repr(o: Any, indent_level: int = 0) -> str: val = getattr(o, name) # Check for defaults? # Pydantic V2 doesn't have a simple "is_set" for fields without model_dump(exclude_unset) - # But we want to preserve structure even if it matches default maybe? + # But we want to preserve structure even if it matches default maybe? # Let's stick to non-None for now as per plan if val is not None: - # Check if it equals default - if val != field_info.get_default(): - fields[name] = val - + # Check if it equals default + if val != field_info.get_default(): + fields[name] = val + if not fields: - return f"{cls_name}()" + return f"{cls_name}()" args = [] for name, val in fields.items(): formatted_val = _repr(val, indent_level + 1) args.append(f"{name}={formatted_val}") - + # Format nicely # If arguments are short, inline them. If long, multiline. # Simple heuristic: if any arg value has a newline, or total length > 80, go multiline - + inner_str = ", ".join(args) if len(inner_str) > 80 or "\n" in inner_str: joiner = f",\n{indent} " - field_strs = [f"{name}={_repr(val, indent_level + 1)}" for name, val in fields.items()] + field_strs = [ + f"{name}={_repr(val, indent_level + 1)}" + for name, val in fields.items() + ] return f"{cls_name}(\n{indent} {joiner.join(field_strs)}\n{indent})" else: - return f"{cls_name}({inner_str})" + return f"{cls_name}({inner_str})" elif isinstance(o, list): if not o: return "[]" - + items = [_repr(i, indent_level + 1) for i in o] inner_str = ", ".join(items) - + if len(inner_str) > 60 or "\n" in inner_str: - joiner = f",\n{indent} " - return f"[\n{indent} {joiner.join(items)}\n{indent}]" + joiner = f",\n{indent} " + return f"[\n{indent} {joiner.join(items)}\n{indent}]" return f"[{inner_str}]" - + elif isinstance(o, Enum): required_classes.add(o.__class__) return f"{o.__class__.__name__}.{o.name}" - + elif isinstance(o, str): # Use repr to handle quotes and escaping safely return repr(o) - + else: return repr(o) def instance_is_pydantic(o): - return hasattr(o, 'model_dump') + return hasattr(o, "model_dump") code_body = _repr(obj) - + # Generate Imports import_lines = [] # Group by module @@ -121,18 +128,18 @@ def instance_is_pydantic(o): if mod not in module_map: module_map[mod] = [] module_map[mod].append(cls.__name__) - + for mod in sorted(module_map.keys()): classes = sorted(module_map[mod]) import_lines.append(f"from {mod} import {', '.join(classes)}") - + return "\n".join(import_lines) + "\n\n" + "cohort = " + code_body + def save_to_file(obj: Any, filename: str) -> None: """ Generates Python code for the object and saves it to a file. """ code = to_python_code(obj) - with open(filename, 'w') as f: + with open(filename, "w") as f: f.write(code) - diff --git a/circe/cohortdefinition/cohort.py b/circe/cohortdefinition/cohort.py index 47460f03..7cbbb5f8 100644 --- a/circe/cohortdefinition/cohort.py +++ b/circe/cohortdefinition/cohort.py @@ -10,10 +10,23 @@ from typing import List, Optional, Any, Union, TYPE_CHECKING import json -from pydantic import BaseModel, Field, ConfigDict, model_validator, field_validator, AliasChoices +from pydantic import ( + BaseModel, + Field, + ConfigDict, + model_validator, + field_validator, + AliasChoices, +) from .core import ( - ResultLimit, Period, CollapseSettings, EndStrategy, DateOffsetStrategy, CustomEraStrategy, - ObservationFilter, CirceBaseModel + ResultLimit, + Period, + CollapseSettings, + EndStrategy, + DateOffsetStrategy, + CustomEraStrategy, + ObservationFilter, + CirceBaseModel, ) from .criteria import Criteria, PrimaryCriteria, CriteriaGroup, CriteriaType @@ -41,73 +54,74 @@ class CohortExpression(CirceBaseModel): """Main cohort expression class containing all cohort definition components. - + Java equivalent: org.ohdsi.circe.cohortdefinition.CohortExpression """ concept_sets: List[ConceptSet] = Field( default_factory=list, validation_alias=AliasChoices("ConceptSets", "conceptSets"), - serialization_alias="ConceptSets" + serialization_alias="ConceptSets", ) qualified_limit: Optional[ResultLimit] = Field( default=None, validation_alias=AliasChoices("QualifiedLimit", "qualifiedLimit"), - serialization_alias="QualifiedLimit" + serialization_alias="QualifiedLimit", ) additional_criteria: Optional[CriteriaGroup] = Field( default=None, validation_alias=AliasChoices("AdditionalCriteria", "additionalCriteria"), - serialization_alias="AdditionalCriteria" + serialization_alias="AdditionalCriteria", ) - end_strategy: Optional[Union[EndStrategy, DateOffsetStrategy, CustomEraStrategy]] = Field( + end_strategy: Optional[ + Union[EndStrategy, DateOffsetStrategy, CustomEraStrategy] + ] = Field( default=None, validation_alias=AliasChoices("EndStrategy", "endStrategy"), - serialization_alias="EndStrategy" - ) - cdm_version_range: Optional[str] = Field( - default=None, - alias="cdmVersionRange" + serialization_alias="EndStrategy", ) + cdm_version_range: Optional[str] = Field(default=None, alias="cdmVersionRange") primary_criteria: Optional[PrimaryCriteria] = Field( default=None, validation_alias=AliasChoices("PrimaryCriteria", "primaryCriteria"), - serialization_alias="PrimaryCriteria" + serialization_alias="PrimaryCriteria", ) expression_limit: Optional[ResultLimit] = Field( default=None, validation_alias=AliasChoices("ExpressionLimit", "expressionLimit"), - serialization_alias="ExpressionLimit" + serialization_alias="ExpressionLimit", ) collapse_settings: Optional[CollapseSettings] = Field( default=None, validation_alias=AliasChoices("CollapseSettings", "collapseSettings"), - serialization_alias="CollapseSettings" + serialization_alias="CollapseSettings", ) title: Optional[str] = Field( default=None, validation_alias=AliasChoices("Title", "title"), - serialization_alias="Title" + serialization_alias="Title", ) inclusion_rules: List[InclusionRule] = Field( default_factory=list, validation_alias=AliasChoices("InclusionRules", "inclusionRules"), - serialization_alias="InclusionRules" + serialization_alias="InclusionRules", ) censor_window: Optional[Period] = Field( default=None, validation_alias=AliasChoices("CensorWindow", "censorWindow"), - serialization_alias="CensorWindow" + serialization_alias="CensorWindow", ) censoring_criteria: List[CriteriaType] = Field( default_factory=list, - validation_alias=AliasChoices("CensoringCriteria", "censoring_criteria", "censoringCriteria"), - serialization_alias="CensoringCriteria" + validation_alias=AliasChoices( + "CensoringCriteria", "censoring_criteria", "censoringCriteria" + ), + serialization_alias="CensoringCriteria", ) model_config = ConfigDict(populate_by_name=True) - - @field_validator('inclusion_rules', mode='before') + + @field_validator("inclusion_rules", mode="before") @classmethod def allow_none_inclusion_rules(cls, v: Any) -> Any: """Convert None to empty list for inclusion_rules.""" @@ -115,7 +129,7 @@ def allow_none_inclusion_rules(cls, v: Any) -> Any: return [] return v - @field_validator('concept_sets', mode='before') + @field_validator("concept_sets", mode="before") @classmethod def allow_none_concept_sets(cls, v: Any) -> Any: """Convert None to empty list for concept_sets.""" @@ -123,11 +137,11 @@ def allow_none_concept_sets(cls, v: Any) -> Any: return [] return v - @field_validator('end_strategy', mode='before') + @field_validator("end_strategy", mode="before") @classmethod def deserialize_end_strategy(cls, v: Any) -> Any: """Deserialize end strategy from polymorphic JSON format. - + End strategy can come as: - {"DateOffset": {"DateField": "StartDate", "Offset": 7}} - {"CustomEra": {...}} @@ -135,65 +149,77 @@ def deserialize_end_strategy(cls, v: Any) -> Any: """ if not v or not isinstance(v, dict): return v - + # Check if it has DateOffset key - if 'DateOffset' in v: - date_offset_data = v['DateOffset'] + if "DateOffset" in v: + date_offset_data = v["DateOffset"] return DateOffsetStrategy.model_validate(date_offset_data, strict=False) - + # Check if it has CustomEra key - if 'CustomEra' in v: - custom_era_data = v['CustomEra'] + if "CustomEra" in v: + custom_era_data = v["CustomEra"] return CustomEraStrategy.model_validate(custom_era_data, strict=False) - + # Otherwise, try to parse as base EndStrategy return EndStrategy.model_validate(v, strict=False) - - @field_validator('censoring_criteria', mode='before') + + @field_validator("censoring_criteria", mode="before") @classmethod def deserialize_censoring_criteria(cls, v: Any) -> Any: """Deserialize censoring criteria from polymorphic JSON format. - - Censoring criteria come as [{"ConditionOccurrence": {...}}, ...] + + Censoring criteria come as [{"ConditionOccurrence": {...}}, ...] and need to be unwrapped and deserialized to Criteria objects. """ if v is None: return [] if not v or not isinstance(v, list): return v - + from .criteria import ( - ConditionOccurrence, DrugExposure, ProcedureOccurrence, VisitOccurrence, - Observation, Measurement, DeviceExposure, Specimen, Death, VisitDetail, - ObservationPeriod, PayerPlanPeriod, LocationRegion, ConditionEra, - DrugEra, DoseEra + ConditionOccurrence, + DrugExposure, + ProcedureOccurrence, + VisitOccurrence, + Observation, + Measurement, + DeviceExposure, + Specimen, + Death, + VisitDetail, + ObservationPeriod, + PayerPlanPeriod, + LocationRegion, + ConditionEra, + DrugEra, + DoseEra, ) - + criteria_class_map = { - 'ConditionOccurrence': ConditionOccurrence, - 'DrugExposure': DrugExposure, - 'ProcedureOccurrence': ProcedureOccurrence, - 'VisitOccurrence': VisitOccurrence, - 'Observation': Observation, - 'Measurement': Measurement, - 'DeviceExposure': DeviceExposure, - 'Specimen': Specimen, - 'Death': Death, - 'VisitDetail': VisitDetail, - 'ObservationPeriod': ObservationPeriod, - 'PayerPlanPeriod': PayerPlanPeriod, - 'LocationRegion': LocationRegion, - 'ConditionEra': ConditionEra, - 'DrugEra': DrugEra, - 'DoseEra': DoseEra, + "ConditionOccurrence": ConditionOccurrence, + "DrugExposure": DrugExposure, + "ProcedureOccurrence": ProcedureOccurrence, + "VisitOccurrence": VisitOccurrence, + "Observation": Observation, + "Measurement": Measurement, + "DeviceExposure": DeviceExposure, + "Specimen": Specimen, + "Death": Death, + "VisitDetail": VisitDetail, + "ObservationPeriod": ObservationPeriod, + "PayerPlanPeriod": PayerPlanPeriod, + "LocationRegion": LocationRegion, + "ConditionEra": ConditionEra, + "DrugEra": DrugEra, + "DoseEra": DoseEra, } - + deserialized = [] for item in v: if not isinstance(item, dict): deserialized.append(item) continue - + # JSON format: {"ConditionOccurrence": {...}} - unwrap and deserialize criteria_type = None criteria_data = None @@ -202,37 +228,51 @@ def deserialize_censoring_criteria(cls, v: Any) -> Any: criteria_type = key criteria_data = item[key] break - + if criteria_type and criteria_data is not None: data_copy = dict(criteria_data) - if 'First' not in data_copy and 'first' not in data_copy: - data_copy['First'] = False - if criteria_type == 'Measurement' and 'MeasurementTypeExclude' not in data_copy and 'measurementTypeExclude' not in data_copy: - data_copy['MeasurementTypeExclude'] = False - if criteria_type == 'Observation' and 'ObservationTypeExclude' not in data_copy and 'observationTypeExclude' not in data_copy: - data_copy['ObservationTypeExclude'] = False - if criteria_type == 'ConditionOccurrence' and 'ConditionTypeExclude' not in data_copy and 'conditionTypeExclude' not in data_copy: - data_copy['ConditionTypeExclude'] = False - - criteria_obj = criteria_class_map[criteria_type].model_validate(data_copy, strict=False) + if "First" not in data_copy and "first" not in data_copy: + data_copy["First"] = False + if ( + criteria_type == "Measurement" + and "MeasurementTypeExclude" not in data_copy + and "measurementTypeExclude" not in data_copy + ): + data_copy["MeasurementTypeExclude"] = False + if ( + criteria_type == "Observation" + and "ObservationTypeExclude" not in data_copy + and "observationTypeExclude" not in data_copy + ): + data_copy["ObservationTypeExclude"] = False + if ( + criteria_type == "ConditionOccurrence" + and "ConditionTypeExclude" not in data_copy + and "conditionTypeExclude" not in data_copy + ): + data_copy["ConditionTypeExclude"] = False + + criteria_obj = criteria_class_map[criteria_type].model_validate( + data_copy, strict=False + ) deserialized.append(criteria_obj) else: deserialized.append(item) return deserialized - @model_validator(mode='before') + @model_validator(mode="before") @classmethod def normalize_before_validation(cls, data: Any) -> Any: """Normalize data before validation. - + Handles empty objects and other normalization needs. """ if isinstance(data, dict): # No longer dropping cdmVersionRange string since we now expect Optional[str] - if 'censorWindow' in data and data['censorWindow'] == {}: + if "censorWindow" in data and data["censorWindow"] == {}: data = dict(data) - data.pop('censorWindow') + data.pop("censorWindow") return data @@ -264,7 +304,9 @@ def remove_inclusion_rule_by_name(self, name: str) -> None: Removes an inclusion rule by its name """ if self.inclusion_rules: - self.inclusion_rules = [r for r in self.inclusion_rules if getattr(r, 'name', None) != name] + self.inclusion_rules = [ + r for r in self.inclusion_rules if getattr(r, "name", None) != name + ] def add_censoring_criteria(self, criteria: Criteria) -> None: """ @@ -279,19 +321,23 @@ def remove_censoring_criteria_by_type(self, criteria_type: str) -> None: Removes a censoring criteria by its type """ if self.censoring_criteria: - self.censoring_criteria = [c for c in self.censoring_criteria if c.__class__.__name__ != criteria_type] + self.censoring_criteria = [ + c + for c in self.censoring_criteria + if c.__class__.__name__ != criteria_type + ] def validate_expression(self) -> bool: """Validate the cohort expression.""" # Basic validation logic if not self.primary_criteria: return False - + if self.concept_sets: for concept_set in self.concept_sets: if not concept_set.id: return False - + return True def get_concept_set_ids(self) -> List[int]: @@ -299,16 +345,16 @@ def get_concept_set_ids(self) -> List[int]: if not self.concept_sets: return [] return [cs.id for cs in self.concept_sets if cs.id is not None] - - def check(self) -> List['Warning']: + + def check(self) -> List["Warning"]: """Run validation checks on this cohort expression. - + This method runs all validation checks defined in the check module and returns a list of warnings found during validation. - + Returns: A list of Warning objects. Empty list if no issues found. - + Example: >>> expression = CohortExpression(...) >>> warnings = expression.check() @@ -317,81 +363,85 @@ def check(self) -> List['Warning']: """ # Import here to avoid circular dependencies from ..check.checker import Checker - + checker = Checker() return checker.check(self) - def checksum(self, algorithm: str = 'sha256') -> str: + def checksum(self, algorithm: str = "sha256") -> str: """Calculate a checksum for this cohort expression. - + Args: algorithm: Hash algorithm to use (default: sha256) - + Returns: Hex digest of the checksum """ import hashlib import json - + # 1. Dump with defaults excluded to handle implicit defaults data = self.model_dump(exclude_unset=True, exclude_defaults=True, by_alias=True) - + # 2. Normalize: remove metadata, deduplicate concept sets, etc. normalized_data = self._normalize_for_checksum(data) - + # 3. Serialize to canonical JSON canonical_json = json.dumps(normalized_data, sort_keys=True) - + h = hashlib.new(algorithm) - h.update(canonical_json.encode('utf-8')) + h.update(canonical_json.encode("utf-8")) return h.hexdigest() def _normalize_for_checksum(self, data: Any) -> Any: """Recursively normalize data for checksum calculation. - + Removes metadata fields from Concepts, deduplicates ConceptSet items, and ensures consistent ordering. """ if isinstance(data, dict): # Handle ConceptSet Expression Items - if 'items' in data and isinstance(data['items'], list): + if "items" in data and isinstance(data["items"], list): # Check if these look like ConceptSetItems (have 'concept') - if data['items'] and isinstance(data['items'][0], dict) and 'concept' in data['items'][0]: + if ( + data["items"] + and isinstance(data["items"][0], dict) + and "concept" in data["items"][0] + ): normalized_items = [] seen_items = set() - - for item in data['items']: + + for item in data["items"]: # Normalize the item first norm_item = self._normalize_for_checksum(item) - + # Create a sortable/hashable representation for deduplication # We need to sort keys to ensure tuple order is consistent item_json = json.dumps(norm_item, sort_keys=True) - + if item_json not in seen_items: seen_items.add(item_json) normalized_items.append(norm_item) - + # Sort items to ensure list order doesn't affect hash # Sort by the JSON string representation normalized_items.sort(key=lambda x: json.dumps(x, sort_keys=True)) - + new_data = data.copy() - new_data['items'] = normalized_items + new_data["items"] = normalized_items return new_data # Handle Concept Objects (heuristically by fields) - if 'CONCEPT_ID' in data: + if "CONCEPT_ID" in data: # Keep ID, remove metadata names/codes/vocab # Keep only structural identifier - return {'CONCEPT_ID': data['CONCEPT_ID']} - + return {"CONCEPT_ID": data["CONCEPT_ID"]} + # Recurse for other dicts return {k: self._normalize_for_checksum(v) for k, v in data.items()} - + elif isinstance(data, list): return [self._normalize_for_checksum(item) for item in data] - + return data # ========================================================================= @@ -410,7 +460,7 @@ def is_first_event(self) -> bool: # Check if all criteria have first=True for criteria in self.primary_criteria.criteria_list: # Get the first attribute, handling both direct attribute and nested structure - first_value = getattr(criteria, 'first', None) + first_value = getattr(criteria, "first", None) if first_value is not True: return False @@ -449,7 +499,7 @@ def has_inclusion_rule_by_name(self, name: str) -> bool: return False for rule in self.inclusion_rules: - if getattr(rule, 'name', None) == name: + if getattr(rule, "name", None) == name: return True return False @@ -503,10 +553,10 @@ def get_end_strategy_type(self) -> Optional[str]: return None class_name = self.end_strategy.__class__.__name__ - if class_name == 'DateOffsetStrategy': - return 'DateOffset' - elif class_name == 'CustomEraStrategy': - return 'CustomEra' + if class_name == "DateOffsetStrategy": + return "DateOffset" + elif class_name == "CustomEraStrategy": + return "CustomEra" else: return class_name @@ -519,7 +569,10 @@ def get_primary_criteria_types(self) -> List[str]: if not self.primary_criteria or not self.primary_criteria.criteria_list: return [] - return [criteria.__class__.__name__ for criteria in self.primary_criteria.criteria_list] + return [ + criteria.__class__.__name__ + for criteria in self.primary_criteria.criteria_list + ] def has_observation_window(self) -> bool: """Check if observation window is defined in primary criteria. @@ -541,7 +594,7 @@ def get_primary_limit_type(self) -> Optional[str]: if not self.primary_criteria or not self.primary_criteria.primary_limit: return None - return getattr(self.primary_criteria.primary_limit, 'type', None) + return getattr(self.primary_criteria.primary_limit, "type", None) def get_concept_set_count(self) -> int: """Get the number of concept sets. @@ -561,13 +614,14 @@ def has_concept_sets(self) -> bool: def _repr_markdown_(self) -> str: """IPython notebook markdown representation. - + Returns: Markdown string defining the cohort. """ try: # Import locally to avoid circular dependencies from .printfriendly.markdown_render import MarkdownRender + renderer = MarkdownRender() return renderer.render_cohort_expression(self) except Exception as e: @@ -575,7 +629,7 @@ def _repr_markdown_(self) -> str: def __str__(self) -> str: """String representation of the cohort. - + Returns: Markdown string defining the cohort. """ diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index b635cbb8..d5e4fdfa 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -12,22 +12,48 @@ from typing import List, Optional, Dict, Any, Union from .cohort import CohortExpression from .criteria import ( - Criteria, CorelatedCriteria, DemographicCriteria, CriteriaGroup, PrimaryCriteria, - LocationRegion, ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, ObservationPeriod, - PayerPlanPeriod, ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail, - Occurrence -) -from .core import ( - Period, DateOffsetStrategy, CustomEraStrategy + Criteria, + CorelatedCriteria, + DemographicCriteria, + CriteriaGroup, + PrimaryCriteria, + LocationRegion, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + Occurrence, ) +from .core import Period, DateOffsetStrategy, CustomEraStrategy from .builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn from .builders import ( - ConditionOccurrenceSqlBuilder, DeathSqlBuilder, DeviceExposureSqlBuilder, - MeasurementSqlBuilder, ObservationSqlBuilder, SpecimenSqlBuilder, - VisitOccurrenceSqlBuilder, DrugExposureSqlBuilder, ProcedureOccurrenceSqlBuilder, - ConditionEraSqlBuilder, DrugEraSqlBuilder, DoseEraSqlBuilder, ObservationPeriodSqlBuilder, PayerPlanPeriodSqlBuilder, - VisitDetailSqlBuilder, LocationRegionSqlBuilder + ConditionOccurrenceSqlBuilder, + DeathSqlBuilder, + DeviceExposureSqlBuilder, + MeasurementSqlBuilder, + ObservationSqlBuilder, + SpecimenSqlBuilder, + VisitOccurrenceSqlBuilder, + DrugExposureSqlBuilder, + ProcedureOccurrenceSqlBuilder, + ConditionEraSqlBuilder, + DrugEraSqlBuilder, + DoseEraSqlBuilder, + ObservationPeriodSqlBuilder, + PayerPlanPeriodSqlBuilder, + VisitDetailSqlBuilder, + LocationRegionSqlBuilder, ) from .interfaces import IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher from .concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder @@ -35,7 +61,7 @@ class BuildExpressionQueryOptions: """Options for building expression queries. - + Java equivalent: org.ohdsi.circe.cohortdefinition.CohortExpressionQueryBuilder.BuildExpressionQueryOptions """ @@ -49,29 +75,31 @@ def __init__(self): self.generate_stats: bool = False @classmethod - def from_json(cls, json_str: str) -> 'BuildExpressionQueryOptions': + def from_json(cls, json_str: str) -> "BuildExpressionQueryOptions": """Create options from JSON string. - + Java equivalent: fromJson() """ try: data = json.loads(json_str) options = cls() - options.cohort_id_field_name = data.get('cohortIdFieldName') - options.cohort_id = data.get('cohortId') - options.cdm_schema = data.get('cdmSchema') - options.target_table = data.get('targetTable') - options.result_schema = data.get('resultSchema') - options.vocabulary_schema = data.get('vocabularySchema') - options.generate_stats = data.get('generateStats', False) + options.cohort_id_field_name = data.get("cohortIdFieldName") + options.cohort_id = data.get("cohortId") + options.cdm_schema = data.get("cdmSchema") + options.target_table = data.get("targetTable") + options.result_schema = data.get("resultSchema") + options.vocabulary_schema = data.get("vocabularySchema") + options.generate_stats = data.get("generateStats", False) return options except Exception as e: raise RuntimeError("Error parsing expression query options", e) -class CohortExpressionQueryBuilder(IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher): +class CohortExpressionQueryBuilder( + IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher +): """Main SQL query builder for cohort expressions. - + Java equivalent: org.ohdsi.circe.cohortdefinition.CohortExpressionQueryBuilder """ @@ -187,7 +215,6 @@ class CohortExpressionQueryBuilder(IGetCriteriaSqlDispatcher, IGetEndStrategySql ) P @primaryEventLimit""" - WINDOWED_CRITERIA_TEMPLATE = """ SELECT @indexId as index_id, A.person_id, A.event_id@additionalColumns FROM (@eventTable) P @@ -439,7 +466,7 @@ def __init__(self): def get_occurrence_operator(self, occurrence_type: int) -> str: """Get occurrence operator string. - + Java equivalent: getOccurrenceOperator() """ # Occurrence check { id: 0, name: 'Exactly', id: 1, name: 'At Most' }, { id: 2, name: 'At Least' } @@ -450,20 +477,22 @@ def get_occurrence_operator(self, occurrence_type: int) -> str: elif occurrence_type == 2: return ">=" else: - raise RuntimeError(f"Invalid occurrence operator received: type={occurrence_type}") + raise RuntimeError( + f"Invalid occurrence operator received: type={occurrence_type}" + ) def get_additional_columns(self, columns: List[CriteriaColumn], prefix: str) -> str: """Get additional columns string. - + Java equivalent: getAdditionalColumns() """ return ",".join([f"{prefix}{column.value}" for column in columns]) def wrap_criteria_query(self, query: str, group: CriteriaGroup) -> str: """Wrap criteria query with group logic. - + Java equivalent: wrapCriteriaQuery() - + This creates a nested structure where: 1. The base query is wrapped with Q+OP join and passed as event table to criteria group 2. The criteria group uses this as the E alias (via @eventTable in GROUP_QUERY_TEMPLATE) @@ -478,15 +507,17 @@ def wrap_criteria_query(self, query: str, group: CriteriaGroup) -> str: ) Q JOIN @cdm_database_schema.OBSERVATION_PERIOD OP on Q.person_id = OP.person_id and OP.observation_period_start_date <= Q.start_date and OP.observation_period_end_date >= Q.start_date""" - + # Step 2: Generate the criteria group query using the Q+OP query as the event table # This Q+OP query will become the E alias in GROUP_QUERY_TEMPLATE - group_query = self.get_criteria_group_query(group, f"""( + group_query = self.get_criteria_group_query( + group, + f"""( {q_op_query} -)""") +)""", + ) group_query = group_query.replace("@indexId", "0") - # Step 3: Wrap with PE selector # The PE wrapper just selects from the original query and joins to the group results wrapped_query = f""" @@ -499,10 +530,9 @@ def wrap_criteria_query(self, query: str, group: CriteriaGroup) -> str: """ return wrapped_query - def get_codeset_query(self, concept_sets: List[Any]) -> str: """Get codeset query. - + Java equivalent: getCodesetQuery() """ if not concept_sets: @@ -510,37 +540,45 @@ def get_codeset_query(self, concept_sets: List[Any]) -> str: union_selects = [] for cs in concept_sets: - if hasattr(cs, 'id') and hasattr(cs, 'expression'): - expression_query = self.concept_set_query_builder.build_expression_query(cs.expression) + if hasattr(cs, "id") and hasattr(cs, "expression"): + expression_query = ( + self.concept_set_query_builder.build_expression_query(cs.expression) + ) union_select = f"SELECT {cs.id} as codeset_id, c.concept_id FROM ({expression_query}\n) C" union_selects.append(union_select) union_query = " UNION ALL \n".join(union_selects) - codeset_inserts = f"INSERT INTO #Codesets (codeset_id, concept_id)\n{union_query};" + codeset_inserts = ( + f"INSERT INTO #Codesets (codeset_id, concept_id)\n{union_query};" + ) return self.CODESET_QUERY_TEMPLATE.replace("@codesetInserts", codeset_inserts) def get_censoring_events_query(self, censoring_criteria: List[Criteria]) -> str: """Get censoring events query. - + Java equivalent: getCensoringEventsQuery() """ criteria_queries = [] for criteria in censoring_criteria: criteria_query = self.get_criteria_sql(criteria) - censoring_query = self.CENSORING_QUERY_TEMPLATE.replace("@criteriaQuery", criteria_query) + censoring_query = self.CENSORING_QUERY_TEMPLATE.replace( + "@criteriaQuery", criteria_query + ) criteria_queries.append(censoring_query) return " UNION ALL ".join(criteria_queries) - def get_primary_events_query(self, primary_criteria: PrimaryCriteria, subquery: Optional[str] = None) -> str: + def get_primary_events_query( + self, primary_criteria: PrimaryCriteria, subquery: Optional[str] = None + ) -> str: """Get primary events query. - + Java equivalent: getPrimaryEventsQuery() """ if subquery is None: subquery = self._get_primary_events_subquery(primary_criteria) - + query = self.PRIMARY_EVENTS_TEMPLATE query = query.replace("@primaryEventsSubQuery", subquery) return query @@ -553,30 +591,48 @@ def _get_primary_events_subquery(self, primary_criteria: PrimaryCriteria) -> str for criteria in primary_criteria.criteria_list: criteria_queries.append(self.get_criteria_sql(criteria)) - query = query.replace("@criteriaQueries", "\nUNION ALL\n".join(criteria_queries)) + query = query.replace( + "@criteriaQueries", "\nUNION ALL\n".join(criteria_queries) + ) # Primary events filters primary_events_filters = [ f"DATEADD(day,{primary_criteria.observation_window.prior_days},OP.OBSERVATION_PERIOD_START_DATE) <= E.START_DATE AND DATEADD(day,{primary_criteria.observation_window.post_days},E.START_DATE) <= OP.OBSERVATION_PERIOD_END_DATE" ] - query = query.replace("@primaryEventsFilter", " AND ".join(primary_events_filters)) + query = query.replace( + "@primaryEventsFilter", " AND ".join(primary_events_filters) + ) # Event sort - event_sort = "DESC" if (primary_criteria.primary_limit and primary_criteria.primary_limit.type and str( - primary_criteria.primary_limit.type).upper() == "LAST") else "ASC" + event_sort = ( + "DESC" + if ( + primary_criteria.primary_limit + and primary_criteria.primary_limit.type + and str(primary_criteria.primary_limit.type).upper() == "LAST" + ) + else "ASC" + ) query = query.replace("@EventSort", event_sort) # Primary event limit - this filters P.ordinal - primary_event_limit = "" if (primary_criteria.primary_limit and primary_criteria.primary_limit.type and str( - primary_criteria.primary_limit.type).upper() == "ALL") else "WHERE P.ordinal = 1" + primary_event_limit = ( + "" + if ( + primary_criteria.primary_limit + and primary_criteria.primary_limit.type + and str(primary_criteria.primary_limit.type).upper() == "ALL" + ) + else "WHERE P.ordinal = 1" + ) query = query.replace("@primaryEventLimit", primary_event_limit) return query def get_final_cohort_query(self, censor_window: Optional[Period]) -> str: """Get final cohort query. - + Java equivalent: getFinalCohortQuery() """ query = "select @target_cohort_id as @cohort_id_field_name, person_id, @start_date, @end_date \nFROM #final_cohort CO" @@ -586,10 +642,14 @@ def get_final_cohort_query(self, censor_window: Optional[Period]) -> str: if censor_window and (censor_window.start_date or censor_window.end_date): if censor_window.start_date: - censor_start_date = BuilderUtils.date_string_to_sql(censor_window.start_date) + censor_start_date = BuilderUtils.date_string_to_sql( + censor_window.start_date + ) start_date = f"CASE WHEN start_date > {censor_start_date} THEN start_date ELSE {censor_start_date} END" if censor_window.end_date: - censor_end_date = BuilderUtils.date_string_to_sql(censor_window.end_date) + censor_end_date = BuilderUtils.date_string_to_sql( + censor_window.end_date + ) end_date = f"CASE WHEN end_date < {censor_end_date} THEN end_date ELSE {censor_end_date} END" query += "\nWHERE @start_date <= @end_date" @@ -600,7 +660,7 @@ def get_final_cohort_query(self, censor_window: Optional[Period]) -> str: def get_inclusion_rule_table_sql(self, expression: CohortExpression) -> str: """Get inclusion rule table SQL. - + Java equivalent: getInclusionRuleTableSql() Note: Java's StringUtils.join with one item doesn't add separator, so single rule wouldn't have UNION ALL. However, the test expects UNION ALL even with one rule, @@ -611,7 +671,9 @@ def get_inclusion_rule_table_sql(self, expression: CohortExpression) -> str: return empty_table union_template = "SELECT CAST({} as int) as rule_sequence" - union_list = [union_template.format(i) for i in range(len(expression.inclusion_rules))] + union_list = [ + union_template.format(i) for i in range(len(expression.inclusion_rules)) + ] # Join with UNION ALL - match Java behavior (no UNION ALL for single rule) if len(union_list) == 1: @@ -619,11 +681,13 @@ def get_inclusion_rule_table_sql(self, expression: CohortExpression) -> str: else: union_query = " UNION ALL ".join(union_list) - return self.INCLUSION_RULE_TEMP_TABLE_TEMPLATE.replace("@inclusionRuleUnions", union_query) + return self.INCLUSION_RULE_TEMP_TABLE_TEMPLATE.replace( + "@inclusionRuleUnions", union_query + ) def get_inclusion_analysis_query(self, event_table: str, mode_id: int) -> str: """Get inclusion analysis query. - + Java equivalent: getInclusionAnalysisQuery() """ result_sql = self.COHORT_INCLUSION_ANALYSIS_TEMPLATE @@ -633,19 +697,21 @@ def get_inclusion_analysis_query(self, event_table: str, mode_id: int) -> str: def _build_inclusion_analysis_section(self, expression: CohortExpression) -> str: """Build the inclusion analysis section for stats generation. - + This includes: - inclusion_rules table - best_events table - inclusion impact analysis queries - cleanup of temp tables - + Java equivalent: Part of generateCohort.sql template with @generateStats != 0 & @ruleTotal != 0 """ - rule_total = len(expression.inclusion_rules) if expression.inclusion_rules else 0 - + rule_total = ( + len(expression.inclusion_rules) if expression.inclusion_rules else 0 + ) + inclusion_rule_table = self.get_inclusion_rule_table_sql(expression) - + best_events_query = """ -- Find the event that is the 'best match' per person. -- the 'best match' is defined as the event that satisfies the most inclusion rules. @@ -666,10 +732,12 @@ def _build_inclusion_analysis_section(self, expression: CohortExpression) -> str WHERE ranked.rank_value = 1 ; """ - - inclusion_impact_event = self.get_inclusion_analysis_query("#qualified_events", 0) + + inclusion_impact_event = self.get_inclusion_analysis_query( + "#qualified_events", 0 + ) inclusion_impact_person = self.get_inclusion_analysis_query("#best_events", 1) - + cleanup = """ TRUNCATE TABLE #best_events; DROP TABLE #best_events; @@ -677,7 +745,7 @@ def _build_inclusion_analysis_section(self, expression: CohortExpression) -> str TRUNCATE TABLE #inclusion_rules; DROP TABLE #inclusion_rules; """ - + return f"""{{1 != 0 & {rule_total} != 0}}?{{ {inclusion_rule_table} @@ -698,17 +766,21 @@ def _build_inclusion_analysis_section(self, expression: CohortExpression) -> str {cleanup}}} """ - def build_expression_query(self, expression: str, options: BuildExpressionQueryOptions) -> str: + def build_expression_query( + self, expression: str, options: BuildExpressionQueryOptions + ) -> str: """Build expression query from JSON string. - + Java equivalent: buildExpressionQuery(String, BuildExpressionQueryOptions) """ cohort_expression = CohortExpression.model_validate_json(expression) return self.build_expression_query(cohort_expression, options) - def build_expression_query(self, expression: CohortExpression, options: BuildExpressionQueryOptions) -> str: + def build_expression_query( + self, expression: CohortExpression, options: BuildExpressionQueryOptions + ) -> str: """Build expression query from CohortExpression object. - + Java equivalent: buildExpressionQuery(CohortExpression, BuildExpressionQueryOptions) """ result_sql = self.COHORT_QUERY_TEMPLATE @@ -718,39 +790,57 @@ def build_expression_query(self, expression: CohortExpression, options: BuildExp result_sql = result_sql.replace("@codesetQuery", codeset_query) # Get inner primary events subquery (logic only) - primary_events_subquery = self._get_primary_events_subquery(expression.primary_criteria) - + primary_events_subquery = self._get_primary_events_subquery( + expression.primary_criteria + ) + # Primary events query (full wrapper) - primary_events_query = self.get_primary_events_query(expression.primary_criteria, primary_events_subquery) + primary_events_query = self.get_primary_events_query( + expression.primary_criteria, primary_events_subquery + ) result_sql = result_sql.replace("@primaryEventsQuery", primary_events_query) # Additional criteria query - this filters primary events based on additional conditions if expression.additional_criteria: # Generate criteria group query that joins with the pe (primary events) subquery - # The pe subquery is defined in PRIMARY_EVENTS_TEMPLATE and has columns: + # The pe subquery is defined in PRIMARY_EVENTS_TEMPLATE and has columns: # event_id, person_id, start_date, end_date, op_start_date, op_end_date, visit_occurrence_id additional_criteria_group_query = self.get_criteria_group_query( - expression.additional_criteria, - f"({primary_events_subquery})" + expression.additional_criteria, f"({primary_events_subquery})" ) # Create a JOIN clause that filters pe events based on the additional criteria additional_criteria_sql = f"\nJOIN (\n{additional_criteria_group_query}) AC ON AC.person_id = pe.person_id AND AC.event_id = pe.event_id" additional_criteria_sql = additional_criteria_sql.replace("@indexId", "0") - result_sql = result_sql.replace("@additionalCriteriaQuery", additional_criteria_sql) + result_sql = result_sql.replace( + "@additionalCriteriaQuery", additional_criteria_sql + ) else: result_sql = result_sql.replace("@additionalCriteriaQuery", "") # Qualified event sort - qualified_event_sort = "DESC" if (expression.qualified_limit and expression.qualified_limit.type and str( - expression.qualified_limit.type).upper() == "LAST") else "ASC" + qualified_event_sort = ( + "DESC" + if ( + expression.qualified_limit + and expression.qualified_limit.type + and str(expression.qualified_limit.type).upper() == "LAST" + ) + else "ASC" + ) result_sql = result_sql.replace("@QualifiedEventSort", qualified_event_sort) # Qualified limit filter - if expression.additional_criteria and expression.qualified_limit and expression.qualified_limit.type and str( - expression.qualified_limit.type).upper() != "ALL": - result_sql = result_sql.replace("@QualifiedLimitFilter", "WHERE QE.ordinal = 1") + if ( + expression.additional_criteria + and expression.qualified_limit + and expression.qualified_limit.type + and str(expression.qualified_limit.type).upper() != "ALL" + ): + result_sql = result_sql.replace( + "@QualifiedLimitFilter", "WHERE QE.ordinal = 1" + ) else: result_sql = result_sql.replace("@QualifiedLimitFilter", "") @@ -762,46 +852,73 @@ def build_expression_query(self, expression: CohortExpression, options: BuildExp for i, inclusion_rule in enumerate(expression.inclusion_rules): cg = inclusion_rule.expression inclusion_rule_insert = self.get_inclusion_rule_query(cg) - inclusion_rule_insert = inclusion_rule_insert.replace("@inclusion_rule_id", str(i)) + inclusion_rule_insert = inclusion_rule_insert.replace( + "@inclusion_rule_id", str(i) + ) inclusion_rule_inserts.append(inclusion_rule_insert) inclusion_rule_temp_tables.append(f"#Inclusion_{i}") - ir_temp_union = "\nUNION ALL\n".join([ - f"select inclusion_rule_id, person_id, event_id from {table}" - for table in inclusion_rule_temp_tables - ]) + ir_temp_union = "\nUNION ALL\n".join( + [ + f"select inclusion_rule_id, person_id, event_id from {table}" + for table in inclusion_rule_temp_tables + ] + ) inclusion_rule_inserts.append( - f"SELECT inclusion_rule_id, person_id, event_id\nINTO #inclusion_events\nFROM ({ir_temp_union}) I;") + f"SELECT inclusion_rule_id, person_id, event_id\nINTO #inclusion_events\nFROM ({ir_temp_union}) I;" + ) - inclusion_rule_inserts.extend([ - f"TRUNCATE TABLE {table};\nDROP TABLE {table};\n" - for table in inclusion_rule_temp_tables - ]) + inclusion_rule_inserts.extend( + [ + f"TRUNCATE TABLE {table};\nDROP TABLE {table};\n" + for table in inclusion_rule_temp_tables + ] + ) - result_sql = result_sql.replace("@inclusionCohortInserts", "\n".join(inclusion_rule_inserts)) + result_sql = result_sql.replace( + "@inclusionCohortInserts", "\n".join(inclusion_rule_inserts) + ) else: - result_sql = result_sql.replace("@inclusionCohortInserts", - "CREATE TABLE #inclusion_events (inclusion_rule_id bigint,\n\tperson_id bigint,\n\tevent_id bigint\n);") + result_sql = result_sql.replace( + "@inclusionCohortInserts", + "CREATE TABLE #inclusion_events (inclusion_rule_id bigint,\n\tperson_id bigint,\n\tevent_id bigint\n);", + ) - result_sql = result_sql.replace("@ruleTotal", - str(len(expression.inclusion_rules) if expression.inclusion_rules else 0)) + result_sql = result_sql.replace( + "@ruleTotal", + str(len(expression.inclusion_rules) if expression.inclusion_rules else 0), + ) # Included events query - creates #included_events from #qualified_events included_events_query = self.INCLUDED_EVENTS_TEMPLATE # Included event sort - determine sort order based on expression limit - included_event_sort = "DESC" if (expression.expression_limit and expression.expression_limit.type and str( - expression.expression_limit.type).upper() == "LAST") else "ASC" - included_events_query = included_events_query.replace("@IncludedEventSort", included_event_sort) + included_event_sort = ( + "DESC" + if ( + expression.expression_limit + and expression.expression_limit.type + and str(expression.expression_limit.type).upper() == "LAST" + ) + else "ASC" + ) + included_events_query = included_events_query.replace( + "@IncludedEventSort", included_event_sort + ) # Result limit filter - if expression.expression_limit and expression.expression_limit.type and str( - expression.expression_limit.type).upper() != "ALL": + if ( + expression.expression_limit + and expression.expression_limit.type + and str(expression.expression_limit.type).upper() != "ALL" + ): result_limit_filter = "WHERE Results.ordinal = 1" else: result_limit_filter = "" - included_events_query = included_events_query.replace("@ResultLimitFilter", result_limit_filter) + included_events_query = included_events_query.replace( + "@ResultLimitFilter", result_limit_filter + ) # Inclusion rule mask filter - only apply if there are inclusion rules if expression.inclusion_rules and len(expression.inclusion_rules) > 0: @@ -809,7 +926,9 @@ def build_expression_query(self, expression: CohortExpression, options: BuildExp inclusion_rule_mask_filter = f"{{{rule_count} != 0}}?{{\n -- the matching group with all bits set ( POWER(2,# of inclusion rules) - 1 = inclusion_rule_mask\n WHERE (MG.inclusion_rule_mask = POWER(cast(2 as bigint),{rule_count})-1)\n}}" else: inclusion_rule_mask_filter = "" - included_events_query = included_events_query.replace("@InclusionRuleMaskFilter", inclusion_rule_mask_filter) + included_events_query = included_events_query.replace( + "@InclusionRuleMaskFilter", inclusion_rule_mask_filter + ) result_sql = result_sql.replace("@includedEventsQuery", included_events_query) @@ -820,17 +939,26 @@ def build_expression_query(self, expression: CohortExpression, options: BuildExp if not isinstance(expression.end_strategy, DateOffsetStrategy): end_date_selects.append( - "-- By default, cohort exit at the event's op end date\nselect event_id, person_id, op_end_date as end_date from #included_events") + "-- By default, cohort exit at the event's op end date\nselect event_id, person_id, op_end_date as end_date from #included_events" + ) if expression.end_strategy: # Only DateOffsetStrategy and CustomEraStrategy have accept method - if isinstance(expression.end_strategy, (DateOffsetStrategy, CustomEraStrategy)): - result_sql = result_sql.replace("@strategy_ends_temp_tables", - expression.end_strategy.accept(self, "#included_events")) - result_sql = result_sql.replace("@strategy_ends_cleanup", - "TRUNCATE TABLE #strategy_ends;\nDROP TABLE #strategy_ends;\n") - - strategy_select = "SELECT event_id, person_id, end_date FROM #strategy_ends" + if isinstance( + expression.end_strategy, (DateOffsetStrategy, CustomEraStrategy) + ): + result_sql = result_sql.replace( + "@strategy_ends_temp_tables", + expression.end_strategy.accept(self, "#included_events"), + ) + result_sql = result_sql.replace( + "@strategy_ends_cleanup", + "TRUNCATE TABLE #strategy_ends;\nDROP TABLE #strategy_ends;\n", + ) + + strategy_select = ( + "SELECT event_id, person_id, end_date FROM #strategy_ends" + ) end_date_selects.append(f"-- End Date Strategy\n{strategy_select}") else: result_sql = result_sql.replace("@strategy_ends_temp_tables", "") @@ -838,20 +966,25 @@ def build_expression_query(self, expression: CohortExpression, options: BuildExp else: result_sql = result_sql.replace("@strategy_ends_temp_tables", "") result_sql = result_sql.replace("@strategy_ends_cleanup", "") - + if expression.censoring_criteria: - end_date_selects.append(f"-- Censor Events\n{self.get_censoring_events_query(expression.censoring_criteria)}") + end_date_selects.append( + f"-- Censor Events\n{self.get_censoring_events_query(expression.censoring_criteria)}" + ) final_cohort_query = self.get_final_cohort_query(expression.censor_window) result_sql = result_sql.replace("@finalCohortQuery", final_cohort_query) - - result_sql = result_sql.replace("@cohort_end_unions", "\nUNION ALL\n".join(end_date_selects)) - + result_sql = result_sql.replace( + "@cohort_end_unions", "\nUNION ALL\n".join(end_date_selects) + ) # Handle optional collapse_settings era_pad = "0" - if expression.collapse_settings and expression.collapse_settings.era_pad is not None: + if ( + expression.collapse_settings + and expression.collapse_settings.era_pad is not None + ): era_pad = str(expression.collapse_settings.era_pad) result_sql = result_sql.replace("@eraconstructorpad", era_pad) # Build inclusion analysis query (for stats generation) @@ -860,36 +993,58 @@ def build_expression_query(self, expression: CohortExpression, options: BuildExp # Add censored stats wrapper (even if empty) inclusion_analysis_query = "{1 != 0}?{\n-- BEGIN: Censored Stats\n\ndelete from @results_database_schema.cohort_censor_stats where @cohort_id_field_name = @target_cohort_id;\n\n-- END: Censored Stats\n}\n" # Always generate inclusion analysis if stats are requested, even if no rules - inclusion_analysis_query += self._build_inclusion_analysis_section(expression) - result_sql = result_sql.replace("@inclusionAnalysisQuery", inclusion_analysis_query) + inclusion_analysis_query += self._build_inclusion_analysis_section( + expression + ) + result_sql = result_sql.replace( + "@inclusionAnalysisQuery", inclusion_analysis_query + ) # Replace query parameters with tokens if options: if options.cdm_schema: - result_sql = result_sql.replace("@cdm_database_schema", options.cdm_schema) + result_sql = result_sql.replace( + "@cdm_database_schema", options.cdm_schema + ) if options.target_table: - result_sql = result_sql.replace("@target_database_schema.@target_cohort_table", options.target_table) + result_sql = result_sql.replace( + "@target_database_schema.@target_cohort_table", options.target_table + ) if options.result_schema: - result_sql = result_sql.replace("@results_database_schema", options.result_schema) + result_sql = result_sql.replace( + "@results_database_schema", options.result_schema + ) if options.vocabulary_schema: - result_sql = result_sql.replace("@vocabulary_database_schema", options.vocabulary_schema) + result_sql = result_sql.replace( + "@vocabulary_database_schema", options.vocabulary_schema + ) if options.cohort_id is not None: - result_sql = result_sql.replace("@target_cohort_id", str(options.cohort_id)) + result_sql = result_sql.replace( + "@target_cohort_id", str(options.cohort_id) + ) - result_sql = result_sql.replace("@generateStats", "1" if options.generate_stats else "0") + result_sql = result_sql.replace( + "@generateStats", "1" if options.generate_stats else "0" + ) if options.cohort_id_field_name: - result_sql = result_sql.replace("@cohort_id_field_name", options.cohort_id_field_name) + result_sql = result_sql.replace( + "@cohort_id_field_name", options.cohort_id_field_name + ) else: - result_sql = result_sql.replace("@cohort_id_field_name", self.DEFAULT_COHORT_ID_FIELD_NAME) + result_sql = result_sql.replace( + "@cohort_id_field_name", self.DEFAULT_COHORT_ID_FIELD_NAME + ) else: - result_sql = result_sql.replace("@cohort_id_field_name", self.DEFAULT_COHORT_ID_FIELD_NAME) + result_sql = result_sql.replace( + "@cohort_id_field_name", self.DEFAULT_COHORT_ID_FIELD_NAME + ) return result_sql def get_criteria_group_query(self, group: CriteriaGroup, event_table: str) -> str: """Get criteria group query. - + Java equivalent: getCriteriaGroupQuery() """ query = self.GROUP_QUERY_TEMPLATE @@ -919,7 +1074,9 @@ def get_criteria_group_query(self, group: CriteriaGroup, event_table: str) -> st index_id += 1 if not group.is_empty(): - query = query.replace("@criteriaQueries", "\nUNION ALL\n".join(additional_criteria_queries)) + query = query.replace( + "@criteriaQueries", "\nUNION ALL\n".join(additional_criteria_queries) + ) occurrence_count_clause = "HAVING COUNT(index_id) " if group.type and str(group.type).upper() == "ALL": @@ -946,20 +1103,26 @@ def get_criteria_group_query(self, group: CriteriaGroup, event_table: str) -> st def get_inclusion_rule_query(self, inclusion_rule: CriteriaGroup) -> str: """Get inclusion rule query. - + Java equivalent: getInclusionRuleQuery() """ result_sql = self.INCLUSION_RULE_QUERY_TEMPLATE - criteria_group_sql = self.get_criteria_group_query(inclusion_rule, '#qualified_events') + criteria_group_sql = self.get_criteria_group_query( + inclusion_rule, "#qualified_events" + ) criteria_group_sql = criteria_group_sql.replace("@indexId", "0") additional_criteria_query = f"\nJOIN (\n{criteria_group_sql}) AC on AC.person_id = pe.person_id AND AC.event_id = pe.event_id" - result_sql = result_sql.replace("@additionalCriteriaQuery", additional_criteria_query) + result_sql = result_sql.replace( + "@additionalCriteriaQuery", additional_criteria_query + ) result_sql = result_sql.replace("@eventTable", "#qualified_events") return result_sql - def get_demographic_criteria_query(self, criteria: DemographicCriteria, event_table: str) -> str: + def get_demographic_criteria_query( + self, criteria: DemographicCriteria, event_table: str + ) -> str: """Get demographic criteria query. - + Java equivalent: getDemographicCriteriaQuery() """ query = self.DEMOGRAPHIC_CRITERIA_QUERY_TEMPLATE @@ -970,75 +1133,122 @@ def get_demographic_criteria_query(self, criteria: DemographicCriteria, event_ta # Age if criteria.age: where_clauses.append( - BuilderUtils.build_numeric_range_clause("YEAR(E.start_date) - P.year_of_birth", criteria.age)) + BuilderUtils.build_numeric_range_clause( + "YEAR(E.start_date) - P.year_of_birth", criteria.age + ) + ) # Gender if criteria.gender: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append(f"P.gender_concept_id IN ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"P.gender_concept_id IN ({','.join(map(str, concept_ids))})" + ) # GenderCS if criteria.gender_cs: where_clauses.append( - BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id", - criteria.gender_cs.is_exclusion)) + BuilderUtils.get_codeset_in_expression( + criteria.gender_cs.codeset_id, + "P.gender_concept_id", + criteria.gender_cs.is_exclusion, + ) + ) # Race if criteria.race: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.race) - where_clauses.append(f"P.race_concept_id IN ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"P.race_concept_id IN ({','.join(map(str, concept_ids))})" + ) # RaceCS if criteria.race_cs: where_clauses.append( - BuilderUtils.get_codeset_in_expression(criteria.race_cs.codeset_id, "P.race_concept_id", - criteria.race_cs.is_exclusion)) + BuilderUtils.get_codeset_in_expression( + criteria.race_cs.codeset_id, + "P.race_concept_id", + criteria.race_cs.is_exclusion, + ) + ) # Ethnicity if criteria.ethnicity: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.ethnicity) - where_clauses.append(f"P.ethnicity_concept_id IN ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"P.ethnicity_concept_id IN ({','.join(map(str, concept_ids))})" + ) # EthnicityCS if criteria.ethnicity_cs: where_clauses.append( - BuilderUtils.get_codeset_in_expression(criteria.ethnicity_cs.codeset_id, "P.ethnicity_concept_id", - criteria.ethnicity_cs.is_exclusion)) + BuilderUtils.get_codeset_in_expression( + criteria.ethnicity_cs.codeset_id, + "P.ethnicity_concept_id", + criteria.ethnicity_cs.is_exclusion, + ) + ) # OccurrenceStartDate if criteria.occurrence_start_date: - where_clauses.append(BuilderUtils.build_date_range_clause("E.start_date", criteria.occurrence_start_date)) + where_clauses.append( + BuilderUtils.build_date_range_clause( + "E.start_date", criteria.occurrence_start_date + ) + ) # OccurrenceEndDate if criteria.occurrence_end_date: - where_clauses.append(BuilderUtils.build_date_range_clause("E.end_date", criteria.occurrence_end_date)) + where_clauses.append( + BuilderUtils.build_date_range_clause( + "E.end_date", criteria.occurrence_end_date + ) + ) if where_clauses: - query = query.replace("@whereClause", "WHERE " + " AND ".join(where_clauses)) + query = query.replace( + "@whereClause", "WHERE " + " AND ".join(where_clauses) + ) else: query = query.replace("@whereClause", "") return query - def _get_windowed_criteria_query_internal(self, sql_template: str, criteria: Any, event_table: str, - options: Optional[BuilderOptions]) -> str: + def _get_windowed_criteria_query_internal( + self, + sql_template: str, + criteria: Any, + event_table: str, + options: Optional[BuilderOptions], + ) -> str: """Get windowed criteria query (internal method with all parameters). - + Java equivalent: getWindowedCriteriaQuery(String, WindowedCriteria, String, BuilderOptions) """ check_observation_period = not criteria.ignore_observation_period query = sql_template - + # Handle case where criteria.criteria is still a dict (shouldn't happen, but be defensive) inner_criteria = criteria.criteria if isinstance(inner_criteria, dict): # Try to deserialize it - import here to avoid circular dependency issues from .criteria import ( - ConditionOccurrence as CO, DrugExposure as DE, ProcedureOccurrence as PO, - VisitOccurrence as VO, Observation as O, Measurement as M, DeviceExposure as DevE, - Specimen as S, Death as D, VisitDetail as VD, ObservationPeriod as OP, - PayerPlanPeriod as PPP, LocationRegion as LR, ConditionEra as CE, - DrugEra as DrE, DoseEra as DoE + ConditionOccurrence as CO, + DrugExposure as DE, + ProcedureOccurrence as PO, + VisitOccurrence as VO, + Observation as O, + Measurement as M, + DeviceExposure as DevE, + Specimen as S, + Death as D, + VisitDetail as VD, + ObservationPeriod as OP, + PayerPlanPeriod as PPP, + LocationRegion as LR, + ConditionEra as CE, + DrugEra as DrE, + DoseEra as DoE, ) criteria_type = None @@ -1052,22 +1262,22 @@ def _get_windowed_criteria_query_internal(self, sql_template: str, criteria: Any # (e.g., {"ObservationPeriod": {}} means "any observation period") if criteria_type and criteria_data is not None: criteria_class_map = { - 'ConditionOccurrence': ConditionOccurrence, - 'DrugExposure': DrugExposure, - 'ProcedureOccurrence': ProcedureOccurrence, - 'VisitOccurrence': VisitOccurrence, - 'Observation': Observation, - 'Measurement': Measurement, - 'DeviceExposure': DeviceExposure, - 'Specimen': Specimen, - 'Death': Death, - 'VisitDetail': VisitDetail, - 'ObservationPeriod': ObservationPeriod, - 'PayerPlanPeriod': PayerPlanPeriod, - 'LocationRegion': LocationRegion, - 'ConditionEra': ConditionEra, - 'DrugEra': DrugEra, - 'DoseEra': DoseEra, + "ConditionOccurrence": ConditionOccurrence, + "DrugExposure": DrugExposure, + "ProcedureOccurrence": ProcedureOccurrence, + "VisitOccurrence": VisitOccurrence, + "Observation": Observation, + "Measurement": Measurement, + "DeviceExposure": DeviceExposure, + "Specimen": Specimen, + "Death": Death, + "VisitDetail": VisitDetail, + "ObservationPeriod": ObservationPeriod, + "PayerPlanPeriod": PayerPlanPeriod, + "LocationRegion": LocationRegion, + "ConditionEra": ConditionEra, + "DrugEra": DrugEra, + "DoseEra": DoseEra, } if criteria_type in criteria_class_map: @@ -1075,22 +1285,41 @@ def _get_windowed_criteria_query_internal(self, sql_template: str, criteria: Any # Make a mutable copy to add defaults criteria_data = dict(criteria_data) if criteria_data else {} # Set default values for required fields that might be missing - if criteria_type == 'Measurement' and 'measurementTypeExclude' not in criteria_data: - criteria_data['measurementTypeExclude'] = False - if criteria_type == 'Observation' and 'observationTypeExclude' not in criteria_data: - criteria_data['observationTypeExclude'] = False - if criteria_type == 'ProcedureOccurrence' and 'procedureTypeExclude' not in criteria_data: - criteria_data['procedureTypeExclude'] = False - if criteria_type == 'DrugExposure' and 'drugTypeExclude' not in criteria_data: - criteria_data['drugTypeExclude'] = False + if ( + criteria_type == "Measurement" + and "measurementTypeExclude" not in criteria_data + ): + criteria_data["measurementTypeExclude"] = False + if ( + criteria_type == "Observation" + and "observationTypeExclude" not in criteria_data + ): + criteria_data["observationTypeExclude"] = False + if ( + criteria_type == "ProcedureOccurrence" + and "procedureTypeExclude" not in criteria_data + ): + criteria_data["procedureTypeExclude"] = False + if ( + criteria_type == "DrugExposure" + and "drugTypeExclude" not in criteria_data + ): + criteria_data["drugTypeExclude"] = False # Most criteria types require 'first' field - if 'first' not in criteria_data or criteria_data.get('first') is None: - criteria_data['first'] = False - inner_criteria = criteria_class_map[criteria_type].model_validate(criteria_data, strict=False) + if ( + "first" not in criteria_data + or criteria_data.get("first") is None + ): + criteria_data["first"] = False + inner_criteria = criteria_class_map[ + criteria_type + ].model_validate(criteria_data, strict=False) # Update the criteria object criteria.criteria = inner_criteria except Exception as e: - raise ValueError(f"Failed to deserialize criteria from dict: {criteria_type} - {e}") + raise ValueError( + f"Failed to deserialize criteria from dict: {criteria_type} - {e}" + ) else: raise ValueError(f"Unknown criteria type in dict: {criteria_type}") else: @@ -1101,28 +1330,52 @@ def _get_windowed_criteria_query_internal(self, sql_template: str, criteria: Any query = query.replace("@eventTable", event_table) if options and options.additional_columns: - query = query.replace("@additionalColumns", - ", " + self.get_additional_columns(options.additional_columns, "A.")) + query = query.replace( + "@additionalColumns", + ", " + self.get_additional_columns(options.additional_columns, "A."), + ) else: query = query.replace("@additionalColumns", "") # Build index date window expression clauses = [] if check_observation_period: - clauses.append("A.START_DATE >= P.OP_START_DATE AND A.START_DATE <= P.OP_END_DATE") + clauses.append( + "A.START_DATE >= P.OP_START_DATE AND A.START_DATE <= P.OP_END_DATE" + ) # StartWindow start_window = criteria.start_window if start_window: # Java: (useIndexEnd != null && useIndexEnd) - true only if not null AND true - start_index_date_expression = "P.END_DATE" if (start_window.use_index_end is not None and start_window.use_index_end) else "P.START_DATE" + start_index_date_expression = ( + "P.END_DATE" + if ( + start_window.use_index_end is not None + and start_window.use_index_end + ) + else "P.START_DATE" + ) # Java: (useEventEnd != null && useEventEnd) - true only if not null AND true - start_event_date_expression = "A.END_DATE" if (start_window.use_event_end is not None and start_window.use_event_end) else "A.START_DATE" + start_event_date_expression = ( + "A.END_DATE" + if ( + start_window.use_event_end is not None + and start_window.use_event_end + ) + else "A.START_DATE" + ) if start_window.start and start_window.start.days is not None: start_expression = f"DATEADD(day,{start_window.start.coeff * start_window.start.days},{start_index_date_expression})" else: - start_expression = "P.OP_START_DATE" if check_observation_period and start_window.start and start_window.start.coeff == -1 else "P.OP_END_DATE" if check_observation_period else None + start_expression = ( + "P.OP_START_DATE" + if check_observation_period + and start_window.start + and start_window.start.coeff == -1 + else "P.OP_END_DATE" if check_observation_period else None + ) if start_expression: clauses.append(f"{start_event_date_expression} >= {start_expression}") @@ -1130,7 +1383,13 @@ def _get_windowed_criteria_query_internal(self, sql_template: str, criteria: Any if start_window.end and start_window.end.days is not None: end_expression = f"DATEADD(day,{start_window.end.coeff * start_window.end.days},{start_index_date_expression})" else: - end_expression = "P.OP_START_DATE" if check_observation_period and start_window.end and start_window.end.coeff == -1 else "P.OP_END_DATE" if check_observation_period else None + end_expression = ( + "P.OP_START_DATE" + if check_observation_period + and start_window.end + and start_window.end.coeff == -1 + else "P.OP_END_DATE" if check_observation_period else None + ) if end_expression: clauses.append(f"{start_event_date_expression} <= {end_expression}") @@ -1139,14 +1398,26 @@ def _get_windowed_criteria_query_internal(self, sql_template: str, criteria: Any end_window = criteria.end_window if end_window: # Java: (useIndexEnd != null && useIndexEnd) - true only if not null AND true - end_index_date_expression = "P.END_DATE" if (end_window.use_index_end is not None and end_window.use_index_end) else "P.START_DATE" + end_index_date_expression = ( + "P.END_DATE" + if (end_window.use_index_end is not None and end_window.use_index_end) + else "P.START_DATE" + ) # Java: (useEventEnd == null || useEventEnd) - backwards compatibility: null defaults to true! - end_event_date_expression = "A.END_DATE" if (end_window.use_event_end is None or end_window.use_event_end) else "A.START_DATE" + end_event_date_expression = ( + "A.END_DATE" + if (end_window.use_event_end is None or end_window.use_event_end) + else "A.START_DATE" + ) if end_window.start.days is not None: start_expression = f"DATEADD(day,{end_window.start.coeff * end_window.start.days},{end_index_date_expression})" else: - start_expression = "P.OP_START_DATE" if check_observation_period and end_window.start.coeff == -1 else "P.OP_END_DATE" if check_observation_period else None + start_expression = ( + "P.OP_START_DATE" + if check_observation_period and end_window.start.coeff == -1 + else "P.OP_END_DATE" if check_observation_period else None + ) if start_expression: clauses.append(f"{end_event_date_expression} >= {start_expression}") @@ -1154,7 +1425,11 @@ def _get_windowed_criteria_query_internal(self, sql_template: str, criteria: Any if end_window.end.days is not None: end_expression = f"DATEADD(day,{end_window.end.coeff * end_window.end.days},{end_index_date_expression})" else: - end_expression = "P.OP_START_DATE" if check_observation_period and end_window.end.coeff == -1 else "P.OP_END_DATE" if check_observation_period else None + end_expression = ( + "P.OP_START_DATE" + if check_observation_period and end_window.end.coeff == -1 + else "P.OP_END_DATE" if check_observation_period else None + ) if end_expression: clauses.append(f"{end_event_date_expression} <= {end_expression}") @@ -1163,32 +1438,47 @@ def _get_windowed_criteria_query_internal(self, sql_template: str, criteria: Any if criteria.restrict_visit: clauses.append("A.visit_occurrence_id = P.visit_occurrence_id") - query = query.replace("@windowCriteria", " AND " + " AND ".join(clauses) if clauses else "") + query = query.replace( + "@windowCriteria", " AND " + " AND ".join(clauses) if clauses else "" + ) return query - def get_windowed_criteria_query(self, criteria: Any, event_table: str, - options: Optional[BuilderOptions] = None) -> str: + def get_windowed_criteria_query( + self, criteria: Any, event_table: str, options: Optional[BuilderOptions] = None + ) -> str: """Get windowed criteria query. - + Java equivalent: getWindowedCriteriaQuery(WindowedCriteria, String) and getWindowedCriteriaQuery(WindowedCriteria, String, BuilderOptions) """ - return self._get_windowed_criteria_query_internal(self.WINDOWED_CRITERIA_TEMPLATE, criteria, event_table, - options) + return self._get_windowed_criteria_query_internal( + self.WINDOWED_CRITERIA_TEMPLATE, criteria, event_table, options + ) - def get_corelated_criteria_query(self, corelated_criteria: CorelatedCriteria, event_table: str) -> str: + def get_corelated_criteria_query( + self, corelated_criteria: CorelatedCriteria, event_table: str + ) -> str: """Get corelated criteria query. - + Java equivalent: getCorelatedlCriteriaQuery() """ # Pick the appropriate query template # Handle None occurrence if corelated_criteria.occurrence is None: from .criteria import Occurrence as Occ - corelated_criteria.occurrence = Occ(type=Occ._AT_LEAST, count=1, is_distinct=False) + + corelated_criteria.occurrence = Occ( + type=Occ._AT_LEAST, count=1, is_distinct=False + ) from .criteria import Occurrence as Occ - query = self.ADDITIONAL_CRITERIA_LEFT_TEMPLATE if corelated_criteria.occurrence.type == Occ._AT_MOST or corelated_criteria.occurrence.count == 0 else self.ADDITIONAL_CRITERIA_INNER_TEMPLATE + + query = ( + self.ADDITIONAL_CRITERIA_LEFT_TEMPLATE + if corelated_criteria.occurrence.type == Occ._AT_MOST + or corelated_criteria.occurrence.count == 0 + else self.ADDITIONAL_CRITERIA_INNER_TEMPLATE + ) count_column_expression = "cc.event_id" @@ -1198,41 +1488,49 @@ def get_corelated_criteria_query(self, corelated_criteria: CorelatedCriteria, ev builder_options.additional_columns.append(CriteriaColumn.DOMAIN_CONCEPT) count_column_expression = f"cc.{CriteriaColumn.DOMAIN_CONCEPT.value}" else: - builder_options.additional_columns.append(corelated_criteria.occurrence.count_column) - count_column_expression = f"cc.{corelated_criteria.occurrence.count_column.value}" + builder_options.additional_columns.append( + corelated_criteria.occurrence.count_column + ) + count_column_expression = ( + f"cc.{corelated_criteria.occurrence.count_column.value}" + ) # If event_table is a query (not a temp table name like #qualified_events), # wrap it with observation period join to match reference SQL structure # Note: ignore_observation_period applies to window criteria, not the event table join # Check if event_table is a query (contains SELECT or FROM) vs a temp table name # Temp tables start with #, queries contain SELECT/FROM or are wrapped in parentheses - is_temp_table = event_table.strip().startswith('#') - is_query = not is_temp_table and ('SELECT' in event_table.upper() or 'FROM' in event_table.upper() or '(' in event_table) - + is_temp_table = event_table.strip().startswith("#") + is_query = not is_temp_table and ( + "SELECT" in event_table.upper() + or "FROM" in event_table.upper() + or "(" in event_table + ) + # Add observation period join to event table when it's a query (matches reference SQL) # BUT only if it doesn't already have op_start_date (to avoid double-wrapping) - if is_query and 'op_start_date' not in event_table.lower(): + if is_query and "op_start_date" not in event_table.lower(): # event_table is a query without OP join, wrap it with observation period join # Remove outer parentheses if present to avoid double nesting clean_event_table = event_table.strip() # Remove one level of parentheses if present - if clean_event_table.startswith('(') and clean_event_table.endswith(')'): + if clean_event_table.startswith("(") and clean_event_table.endswith(")"): # Count matching parentheses to ensure we remove only the outer pair paren_count = 0 remove_outer = True for i, char in enumerate(clean_event_table): - if char == '(': + if char == "(": paren_count += 1 - elif char == ')': + elif char == ")": paren_count -= 1 if paren_count == 0 and i < len(clean_event_table) - 1: # Found closing paren before the end - don't remove outer remove_outer = False break - + if remove_outer and paren_count == 0: clean_event_table = clean_event_table[1:-1].strip() - + event_table = f"""(SELECT Q.person_id, Q.event_id, Q.start_date, Q.end_date, Q.visit_occurrence_id, OP.observation_period_start_date as op_start_date, OP.observation_period_end_date as op_end_date FROM ( {clean_event_table} @@ -1241,8 +1539,9 @@ def get_corelated_criteria_query(self, corelated_criteria: CorelatedCriteria, ev and OP.observation_period_start_date <= Q.start_date and OP.observation_period_end_date >= Q.start_date )""" - - query = self._get_windowed_criteria_query_internal(query, corelated_criteria, event_table, builder_options) + query = self._get_windowed_criteria_query_internal( + query, corelated_criteria, event_table, builder_options + ) # Occurrence criteria occurrence_criteria = f"HAVING COUNT({'DISTINCT ' if corelated_criteria.occurrence.is_distinct else ''}{count_column_expression}) {self.get_occurrence_operator(corelated_criteria.occurrence.type)} {corelated_criteria.occurrence.count}" @@ -1251,20 +1550,33 @@ def get_corelated_criteria_query(self, corelated_criteria: CorelatedCriteria, ev return query - def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, criteria: Criteria, options: Optional[BuilderOptions] = None + ) -> str: """Get criteria SQL for any criteria type. - + Java equivalent: Various getCriteriaSql methods """ # Handle case where criteria is still a dict (shouldn't happen, but be defensive) if isinstance(criteria, dict): # Try to deserialize it - import here to avoid circular dependency issues from .criteria import ( - ConditionOccurrence as CO, DrugExposure as DE, ProcedureOccurrence as PO, - VisitOccurrence as VO, Observation as O, Measurement as M, DeviceExposure as DevE, - Specimen as S, Death as D, VisitDetail as VD, ObservationPeriod as OP, - PayerPlanPeriod as PPP, LocationRegion as LR, ConditionEra as CE, - DrugEra as DrE, DoseEra as DoE + ConditionOccurrence as CO, + DrugExposure as DE, + ProcedureOccurrence as PO, + VisitOccurrence as VO, + Observation as O, + Measurement as M, + DeviceExposure as DevE, + Specimen as S, + Death as D, + VisitDetail as VD, + ObservationPeriod as OP, + PayerPlanPeriod as PPP, + LocationRegion as LR, + ConditionEra as CE, + DrugEra as DrE, + DoseEra as DoE, ) criteria_type = None @@ -1278,22 +1590,22 @@ def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] # (e.g., {"ObservationPeriod": {}} means "any observation period") if criteria_type and criteria_data is not None: criteria_class_map = { - 'ConditionOccurrence': CO, - 'DrugExposure': DE, - 'ProcedureOccurrence': PO, - 'VisitOccurrence': VO, - 'Observation': O, - 'Measurement': M, - 'DeviceExposure': DevE, - 'Specimen': S, - 'Death': D, - 'VisitDetail': VD, - 'ObservationPeriod': OP, - 'PayerPlanPeriod': PPP, - 'LocationRegion': LR, - 'ConditionEra': CE, - 'DrugEra': DrE, - 'DoseEra': DoE, + "ConditionOccurrence": CO, + "DrugExposure": DE, + "ProcedureOccurrence": PO, + "VisitOccurrence": VO, + "Observation": O, + "Measurement": M, + "DeviceExposure": DevE, + "Specimen": S, + "Death": D, + "VisitDetail": VD, + "ObservationPeriod": OP, + "PayerPlanPeriod": PPP, + "LocationRegion": LR, + "ConditionEra": CE, + "DrugEra": DrE, + "DoseEra": DoE, } if criteria_type in criteria_class_map: @@ -1301,20 +1613,39 @@ def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] # Make a mutable copy to add defaults criteria_data = dict(criteria_data) if criteria_data else {} # Set default values for required fields that might be missing - if criteria_type == 'Measurement' and 'measurementTypeExclude' not in criteria_data: - criteria_data['measurementTypeExclude'] = False - if criteria_type == 'Observation' and 'observationTypeExclude' not in criteria_data: - criteria_data['observationTypeExclude'] = False - if criteria_type == 'ProcedureOccurrence' and 'procedureTypeExclude' not in criteria_data: - criteria_data['procedureTypeExclude'] = False - if criteria_type == 'DrugExposure' and 'drugTypeExclude' not in criteria_data: - criteria_data['drugTypeExclude'] = False + if ( + criteria_type == "Measurement" + and "measurementTypeExclude" not in criteria_data + ): + criteria_data["measurementTypeExclude"] = False + if ( + criteria_type == "Observation" + and "observationTypeExclude" not in criteria_data + ): + criteria_data["observationTypeExclude"] = False + if ( + criteria_type == "ProcedureOccurrence" + and "procedureTypeExclude" not in criteria_data + ): + criteria_data["procedureTypeExclude"] = False + if ( + criteria_type == "DrugExposure" + and "drugTypeExclude" not in criteria_data + ): + criteria_data["drugTypeExclude"] = False # Most criteria types require 'first' field - if 'first' not in criteria_data or criteria_data.get('first') is None: - criteria_data['first'] = False - criteria = criteria_class_map[criteria_type].model_validate(criteria_data, strict=False) + if ( + "first" not in criteria_data + or criteria_data.get("first") is None + ): + criteria_data["first"] = False + criteria = criteria_class_map[criteria_type].model_validate( + criteria_data, strict=False + ) except Exception as e: - raise ValueError(f"Failed to deserialize criteria from dict: {criteria_type} - {e}") + raise ValueError( + f"Failed to deserialize criteria from dict: {criteria_type} - {e}" + ) else: raise ValueError(f"Unknown criteria type in dict: {criteria_type}") else: @@ -1322,55 +1653,85 @@ def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] # Import here to avoid circular dependency - use the already imported names if isinstance(criteria, ConditionOccurrence): - return self._get_criteria_sql_from_builder(self.condition_occurrence_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.condition_occurrence_sql_builder, criteria, options + ) elif isinstance(criteria, Death): - return self._get_criteria_sql_from_builder(self.death_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.death_sql_builder, criteria, options + ) elif isinstance(criteria, DeviceExposure): - return self._get_criteria_sql_from_builder(self.device_exposure_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.device_exposure_sql_builder, criteria, options + ) elif isinstance(criteria, Measurement): - return self._get_criteria_sql_from_builder(self.measurement_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.measurement_sql_builder, criteria, options + ) elif isinstance(criteria, Observation): - return self._get_criteria_sql_from_builder(self.observation_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.observation_sql_builder, criteria, options + ) elif isinstance(criteria, Specimen): - return self._get_criteria_sql_from_builder(self.specimen_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.specimen_sql_builder, criteria, options + ) elif isinstance(criteria, VisitOccurrence): - return self._get_criteria_sql_from_builder(self.visit_occurrence_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.visit_occurrence_sql_builder, criteria, options + ) elif isinstance(criteria, DrugExposure): - return self._get_criteria_sql_from_builder(self.drug_exposure_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.drug_exposure_sql_builder, criteria, options + ) elif isinstance(criteria, ProcedureOccurrence): - return self._get_criteria_sql_from_builder(self.procedure_occurrence_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.procedure_occurrence_sql_builder, criteria, options + ) elif isinstance(criteria, DrugEra): - return self._get_criteria_sql_from_builder(self.drug_era_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.drug_era_sql_builder, criteria, options + ) elif isinstance(criteria, ConditionEra): - return self._get_criteria_sql_from_builder(self.condition_era_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.condition_era_sql_builder, criteria, options + ) elif isinstance(criteria, DoseEra): - return self._get_criteria_sql_from_builder(self.dose_era_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.dose_era_sql_builder, criteria, options + ) elif isinstance(criteria, ObservationPeriod): - return self._get_criteria_sql_from_builder(self.observation_period_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.observation_period_sql_builder, criteria, options + ) elif isinstance(criteria, PayerPlanPeriod): - return self._get_criteria_sql_from_builder(self.payer_plan_period_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.payer_plan_period_sql_builder, criteria, options + ) elif isinstance(criteria, VisitDetail): - return self._get_criteria_sql_from_builder(self.visit_detail_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.visit_detail_sql_builder, criteria, options + ) elif isinstance(criteria, LocationRegion): - return self._get_criteria_sql_from_builder(self.location_region_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.location_region_sql_builder, criteria, options + ) else: raise ValueError(f"Unsupported criteria type: {type(criteria)}") - def _get_criteria_sql_from_builder(self, builder: Any, criteria: Criteria, - options: Optional[BuilderOptions]) -> str: + def _get_criteria_sql_from_builder( + self, builder: Any, criteria: Criteria, options: Optional[BuilderOptions] + ) -> str: """Generic method to get criteria SQL from builder.""" query = builder.get_criteria_sql_with_options(criteria, options) return self.process_correlated_criteria(query, criteria) def process_correlated_criteria(self, query: str, criteria: Criteria) -> str: """Process correlated criteria.""" - if hasattr(criteria, 'correlated_criteria') and criteria.correlated_criteria: + if hasattr(criteria, "correlated_criteria") and criteria.correlated_criteria: query = self.wrap_criteria_query(query, criteria.correlated_criteria) return query - - - # IGetEndStrategySqlDispatcher implementation def get_date_field_for_offset_strategy(self, date_field: str) -> str: """Get date field for offset strategy.""" @@ -1380,7 +1741,9 @@ def get_date_field_for_offset_strategy(self, date_field: str) -> str: return "end_date" return "start_date" - def get_strategy_sql(self, strategy: Union[DateOffsetStrategy, CustomEraStrategy], event_table: str) -> str: + def get_strategy_sql( + self, strategy: Union[DateOffsetStrategy, CustomEraStrategy], event_table: str + ) -> str: """Get strategy SQL for date offset or custom era strategy.""" if isinstance(strategy, DateOffsetStrategy): return self._get_date_offset_strategy_sql(strategy, event_table) @@ -1389,36 +1752,52 @@ def get_strategy_sql(self, strategy: Union[DateOffsetStrategy, CustomEraStrategy else: raise ValueError(f"Unsupported strategy type: {type(strategy)}") - def _get_date_offset_strategy_sql(self, strategy: DateOffsetStrategy, event_table: str) -> str: + def _get_date_offset_strategy_sql( + self, strategy: DateOffsetStrategy, event_table: str + ) -> str: """Get strategy SQL for date offset strategy.""" - strategy_sql = self.DATE_OFFSET_STRATEGY_TEMPLATE.replace("@eventTable", event_table) + strategy_sql = self.DATE_OFFSET_STRATEGY_TEMPLATE.replace( + "@eventTable", event_table + ) strategy_sql = strategy_sql.replace("@offset", str(strategy.offset)) - strategy_sql = strategy_sql.replace("@dateField", self.get_date_field_for_offset_strategy(strategy.date_field)) + strategy_sql = strategy_sql.replace( + "@dateField", self.get_date_field_for_offset_strategy(strategy.date_field) + ) return strategy_sql - def _get_custom_era_strategy_sql(self, strategy: CustomEraStrategy, event_table: str) -> str: + def _get_custom_era_strategy_sql( + self, strategy: CustomEraStrategy, event_table: str + ) -> str: """Get strategy SQL for custom era strategy.""" if strategy.drug_codeset_id is None: raise RuntimeError("Drug Codeset ID cannot be NULL.") - drug_exposure_end_date_expression = self.DEFAULT_DRUG_EXPOSURE_END_DATE_EXPRESSION + drug_exposure_end_date_expression = ( + self.DEFAULT_DRUG_EXPOSURE_END_DATE_EXPRESSION + ) - strategy_sql = self.CUSTOM_ERA_STRATEGY_TEMPLATE.replace("@eventTable", event_table) - strategy_sql = strategy_sql.replace("@drugCodesetId", str(strategy.drug_codeset_id)) + strategy_sql = self.CUSTOM_ERA_STRATEGY_TEMPLATE.replace( + "@eventTable", event_table + ) + strategy_sql = strategy_sql.replace( + "@drugCodesetId", str(strategy.drug_codeset_id) + ) strategy_sql = strategy_sql.replace("@gapDays", str(strategy.gap_days)) strategy_sql = strategy_sql.replace("@offset", str(strategy.offset)) - strategy_sql = strategy_sql.replace("@drugExposureEndDateExpression", drug_exposure_end_date_expression) + strategy_sql = strategy_sql.replace( + "@drugExposureEndDateExpression", drug_exposure_end_date_expression + ) return strategy_sql - def _get_additional_columns(self, columns: List[CriteriaColumn], table_alias: str) -> str: + def _get_additional_columns( + self, columns: List[CriteriaColumn], table_alias: str + ) -> str: """Get additional columns for SQL query.""" if not columns: return "" - column_mappings = { - CriteriaColumn.DOMAIN_CONCEPT: "domain_concept" - } + column_mappings = {CriteriaColumn.DOMAIN_CONCEPT: "domain_concept"} column_clauses = [] for column in columns: @@ -1428,4 +1807,3 @@ def _get_additional_columns(self, columns: List[CriteriaColumn], table_alias: st column_clauses.append(f"{table_alias}{column.value}") return ", ".join(column_clauses) - diff --git a/circe/cohortdefinition/concept_set_expression_query_builder.py b/circe/cohortdefinition/concept_set_expression_query_builder.py index fbdeb016..aee40e57 100644 --- a/circe/cohortdefinition/concept_set_expression_query_builder.py +++ b/circe/cohortdefinition/concept_set_expression_query_builder.py @@ -15,21 +15,21 @@ class ConceptSetExpressionQueryBuilder: """SQL builder for concept set expressions. - + Java equivalent: org.ohdsi.circe.vocabulary.ConceptSetExpressionQueryBuilder """ - + # SQL templates - equivalent to Java ResourceHelper.GetResourceAsString # IMPORTANT: Must use @vocabulary_database_schema (not @cdm_database_schema) for concept lookups CONCEPT_SET_QUERY_TEMPLATE = "select concept_id from @vocabulary_database_schema.CONCEPT where @conceptIdIn\n" - + CONCEPT_SET_DESCENDANTS_TEMPLATE = """ select c.concept_id from @vocabulary_database_schema.CONCEPT c join @vocabulary_database_schema.CONCEPT_ANCESTOR ca on c.concept_id = ca.descendant_concept_id WHERE c.invalid_reason is null and @conceptIdIn """ - + CONCEPT_SET_MAPPED_TEMPLATE = """select distinct cr.concept_id_1 as concept_id FROM ( @@ -37,78 +37,107 @@ class ConceptSetExpressionQueryBuilder: ) C join @vocabulary_database_schema.concept_relationship cr on C.concept_id = cr.concept_id_2 and cr.relationship_id = 'Maps to' and cr.invalid_reason IS NULL """ - + CONCEPT_SET_INCLUDE_TEMPLATE = """select distinct I.concept_id FROM ( @includeQuery ) I """ - + CONCEPT_SET_EXCLUDE_TEMPLATE = """LEFT JOIN ( @excludeQuery ) E ON I.concept_id = E.concept_id WHERE E.concept_id is null """ - + MAX_IN_LENGTH = 1000 # Oracle limitation - + def get_concept_ids(self, concepts: List[Concept]) -> List[int]: """Get concept IDs from concept list. - + Java equivalent: getConceptIds() """ - return [concept.concept_id for concept in concepts if concept.concept_id is not None] - - def build_concept_set_sub_query(self, concepts: List[Concept], descendant_concepts: List[Concept]) -> str: + return [ + concept.concept_id for concept in concepts if concept.concept_id is not None + ] + + def build_concept_set_sub_query( + self, concepts: List[Concept], descendant_concepts: List[Concept] + ) -> str: """Build concept set sub-query. - + Java equivalent: buildConceptSetSubQuery() """ queries = [] - + if concepts: concept_ids = self.get_concept_ids(concepts) - concept_id_in = BuilderUtils.split_in_clause("concept_id", concept_ids, self.MAX_IN_LENGTH) - query = self.CONCEPT_SET_QUERY_TEMPLATE.replace("@conceptIdIn", concept_id_in) + concept_id_in = BuilderUtils.split_in_clause( + "concept_id", concept_ids, self.MAX_IN_LENGTH + ) + query = self.CONCEPT_SET_QUERY_TEMPLATE.replace( + "@conceptIdIn", concept_id_in + ) queries.append(query) - + if descendant_concepts: descendant_ids = self.get_concept_ids(descendant_concepts) - concept_id_in = BuilderUtils.split_in_clause("ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH) - query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace("@conceptIdIn", concept_id_in) + concept_id_in = BuilderUtils.split_in_clause( + "ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH + ) + query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace( + "@conceptIdIn", concept_id_in + ) queries.append(query) - + return "\nUNION ".join(queries) - - def build_concept_set_mapped_query(self, mapped_concepts: List[Concept], mapped_descendant_concepts: List[Concept]) -> str: + + def build_concept_set_mapped_query( + self, mapped_concepts: List[Concept], mapped_descendant_concepts: List[Concept] + ) -> str: """Build concept set mapped query. - + Java equivalent: buildConceptSetMappedQuery() """ - concept_set_query = self.build_concept_set_sub_query(mapped_concepts, mapped_descendant_concepts) - return self.CONCEPT_SET_MAPPED_TEMPLATE.replace("@conceptsetQuery", concept_set_query) - - def build_concept_set_query(self, concepts: List[Concept], descendant_concepts: List[Concept], - mapped_concepts: List[Concept], mapped_descendant_concepts: List[Concept]) -> str: + concept_set_query = self.build_concept_set_sub_query( + mapped_concepts, mapped_descendant_concepts + ) + return self.CONCEPT_SET_MAPPED_TEMPLATE.replace( + "@conceptsetQuery", concept_set_query + ) + + def build_concept_set_query( + self, + concepts: List[Concept], + descendant_concepts: List[Concept], + mapped_concepts: List[Concept], + mapped_descendant_concepts: List[Concept], + ) -> str: """Build concept set query. - + Java equivalent: buildConceptSetQuery() """ if not concepts: - return "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1" - - concept_set_query = self.build_concept_set_sub_query(concepts, descendant_concepts) - + return ( + "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1" + ) + + concept_set_query = self.build_concept_set_sub_query( + concepts, descendant_concepts + ) + if mapped_concepts or mapped_descendant_concepts: - mapped_query = self.build_concept_set_mapped_query(mapped_concepts, mapped_descendant_concepts) + mapped_query = self.build_concept_set_mapped_query( + mapped_concepts, mapped_descendant_concepts + ) concept_set_query += " UNION " + mapped_query - + return concept_set_query - + def build_expression_query(self, expression: ConceptSetExpression) -> str: """Build expression query for concept set. - + Java equivalent: buildExpressionQuery() """ # Handle included concepts @@ -116,21 +145,21 @@ def build_expression_query(self, expression: ConceptSetExpression) -> str: include_descendant_concepts = [] include_mapped_concepts = [] include_mapped_descendant_concepts = [] - + # Handle excluded concepts exclude_concepts = [] exclude_descendant_concepts = [] exclude_mapped_concepts = [] exclude_mapped_descendant_concepts = [] - + # Populate each sub-set of concepts from the flags set in each concept set item for item in expression.items: if not item.is_excluded: include_concepts.append(item.concept) - + if item.include_descendants: include_descendant_concepts.append(item.concept) - + if item.include_mapped: include_mapped_concepts.append(item.concept) if item.include_descendants: @@ -143,18 +172,18 @@ def build_expression_query(self, expression: ConceptSetExpression) -> str: exclude_mapped_concepts.append(item.concept) if item.include_descendants: exclude_mapped_descendant_concepts.append(item.concept) - + # Build the main concept set query concept_set_query = self.CONCEPT_SET_INCLUDE_TEMPLATE.replace( - "@includeQuery", + "@includeQuery", self.build_concept_set_query( - include_concepts, - include_descendant_concepts, - include_mapped_concepts, - include_mapped_descendant_concepts - ) + include_concepts, + include_descendant_concepts, + include_mapped_concepts, + include_mapped_descendant_concepts, + ), ) - + # Add exclusion query if needed if exclude_concepts: exclude_query = self.CONCEPT_SET_EXCLUDE_TEMPLATE.replace( @@ -163,9 +192,9 @@ def build_expression_query(self, expression: ConceptSetExpression) -> str: exclude_concepts, exclude_descendant_concepts, exclude_mapped_concepts, - exclude_mapped_descendant_concepts - ) + exclude_mapped_descendant_concepts, + ), ) concept_set_query += exclude_query - + return concept_set_query diff --git a/circe/cohortdefinition/core.py b/circe/cohortdefinition/core.py index d27e181a..b7898f10 100644 --- a/circe/cohortdefinition/core.py +++ b/circe/cohortdefinition/core.py @@ -9,7 +9,16 @@ """ from typing import List, Optional, Union, Any, TYPE_CHECKING -from pydantic import BaseModel, Field, ConfigDict, model_validator, field_validator, Discriminator, AliasChoices, model_serializer +from pydantic import ( + BaseModel, + Field, + ConfigDict, + model_validator, + field_validator, + Discriminator, + AliasChoices, + model_serializer, +) from enum import Enum from .utils import to_pascal_alias @@ -19,33 +28,33 @@ class CirceBaseModel(BaseModel): def model_dump_json(self, **kwargs): """Override model_dump_json to enforce Circe defaults.""" - kwargs.setdefault('by_alias', True) - kwargs.setdefault('exclude_none', True) + kwargs.setdefault("by_alias", True) + kwargs.setdefault("exclude_none", True) return super().model_dump_json(**kwargs) def model_dump(self, **kwargs): """Override model_dump to enforce Circe defaults.""" - kwargs.setdefault('by_alias', True) - kwargs.setdefault('exclude_none', True) + kwargs.setdefault("by_alias", True) + kwargs.setdefault("exclude_none", True) return super().model_dump(**kwargs) model_config = ConfigDict( alias_generator=to_pascal_alias, populate_by_name=True, # Allow extra fields to prevent validation errors on unknown fields - extra='ignore' + extra="ignore", ) - class CollapseType(str, Enum): """Enumeration for collapse types. - + Java equivalent: org.ohdsi.circe.cohortdefinition.CollapseType - + Note: Java enum only has ERA, but Python also supports collapse/no_collapse for backward compatibility and future use. """ + ERA = "ERA" COLLAPSE = "collapse" NO_COLLAPSE = "no_collapse" @@ -54,16 +63,20 @@ class CollapseType(str, Enum): def _missing_(cls, value): if isinstance(value, str): for member in cls: - if member.name.upper() == value.upper() or member.value.upper() == value.upper(): + if ( + member.name.upper() == value.upper() + or member.value.upper() == value.upper() + ): return member return super()._missing_(value) class DateType(str, Enum): """Enumeration for date types. - + Java equivalent: org.ohdsi.circe.cohortdefinition.DateType """ + START_DATE = "start_date" END_DATE = "end_date" @@ -71,177 +84,183 @@ class DateType(str, Enum): def _missing_(cls, value): if isinstance(value, str): for member in cls: - if member.name.upper() == value.upper() or member.value.upper() == value.upper(): + if ( + member.name.upper() == value.upper() + or member.value.upper() == value.upper() + ): return member return super()._missing_(value) class ResultLimit(CirceBaseModel): """Represents a result limit for cohort expressions. - + Java equivalent: org.ohdsi.circe.cohortdefinition.ResultLimit """ + type: Optional[str] = Field( default=None, validation_alias=AliasChoices("Type", "type"), - serialization_alias="Type" + serialization_alias="Type", ) class Period(CirceBaseModel): """Represents a time period with start and end dates. - + Java equivalent: org.ohdsi.circe.cohortdefinition.Period """ + start_date: Optional[str] = None end_date: Optional[str] = None - - model_config = ConfigDict( - populate_by_name=True, - alias_generator=to_pascal_alias - ) + + model_config = ConfigDict(populate_by_name=True, alias_generator=to_pascal_alias) class DateRange(CirceBaseModel): """Represents a date range with operation, extent, and value. - + Java equivalent: org.ohdsi.circe.cohortdefinition.DateRange """ + op: Optional[str] = Field( default=None, validation_alias=AliasChoices("Op", "op"), - serialization_alias="Op" + serialization_alias="Op", ) value: Optional[Union[str, float]] = Field( default=None, validation_alias=AliasChoices("Value", "value"), - serialization_alias="Value" + serialization_alias="Value", ) extent: Optional[Union[str, float]] = Field( default=None, validation_alias=AliasChoices("Extent", "extent"), - serialization_alias="Extent" + serialization_alias="Extent", ) class NumericRange(CirceBaseModel): """Represents a numeric range with operation, value, and extent. - + Java equivalent: org.ohdsi.circe.cohortdefinition.NumericRange """ + op: Optional[str] = Field( default=None, validation_alias=AliasChoices("Op", "op"), - serialization_alias="Op" + serialization_alias="Op", ) value: Optional[Union[int, float]] = Field( default=None, validation_alias=AliasChoices("Value", "value"), - serialization_alias="Value" + serialization_alias="Value", ) extent: Optional[Union[int, float]] = Field( default=None, validation_alias=AliasChoices("Extent", "extent"), - serialization_alias="Extent" + serialization_alias="Extent", ) class DateAdjustment(CirceBaseModel): """Represents date adjustment settings. - + Java equivalent: org.ohdsi.circe.cohortdefinition.DateAdjustment """ + start_offset: int = Field( validation_alias=AliasChoices("startOffset", "StartOffset"), - serialization_alias="startOffset" + serialization_alias="startOffset", ) end_offset: int = Field( validation_alias=AliasChoices("endOffset", "EndOffset"), - serialization_alias="endOffset" + serialization_alias="endOffset", ) start_with: Optional[DateType] = Field( default=DateType.START_DATE, validation_alias=AliasChoices("startWith", "StartWith"), - serialization_alias="startWith" + serialization_alias="startWith", ) end_with: Optional[DateType] = Field( default=DateType.END_DATE, validation_alias=AliasChoices("endWith", "EndWith"), - serialization_alias="endWith" + serialization_alias="endWith", ) - + model_config = ConfigDict(populate_by_name=True) class ObservationFilter(CirceBaseModel): """Represents observation window filter settings. - + Java equivalent: org.ohdsi.circe.cohortdefinition.ObservationFilter """ + prior_days: int = Field( validation_alias=AliasChoices("PriorDays", "priorDays"), - serialization_alias="PriorDays" + serialization_alias="PriorDays", ) post_days: int = Field( validation_alias=AliasChoices("PostDays", "postDays"), - serialization_alias="PostDays" + serialization_alias="PostDays", ) - + model_config = ConfigDict(populate_by_name=True) class CollapseSettings(CirceBaseModel): """Represents collapse settings for cohort expressions. - + Java equivalent: org.ohdsi.circe.cohortdefinition.CollapseSettings """ + era_pad: int = Field( - validation_alias=AliasChoices("EraPad", "eraPad"), - serialization_alias="EraPad" + validation_alias=AliasChoices("EraPad", "eraPad"), serialization_alias="EraPad" ) collapse_type: Optional[CollapseType] = Field( default=CollapseType.ERA, validation_alias=AliasChoices("CollapseType", "collapseType"), - serialization_alias="CollapseType" + serialization_alias="CollapseType", ) - + model_config = ConfigDict(populate_by_name=True) class EndStrategy(CirceBaseModel): """Represents the end strategy for cohort expressions. - + Java equivalent: org.ohdsi.circe.cohortdefinition.EndStrategy """ + include: Optional[str] = None # JsonTypeInfo.Id.NAME - @model_serializer(mode='wrap') + @model_serializer(mode="wrap") def _serialize_polymorphic(self, serializer, info): """Serialize with polymorphic type wrapper for Java compatibility.""" data = serializer(self) - if self.__class__.__name__ == 'DateOffsetStrategy': - return {'DateOffset': data} - if self.__class__.__name__ == 'CustomEraStrategy': - return {'CustomEra': data} + if self.__class__.__name__ == "DateOffsetStrategy": + return {"DateOffset": data} + if self.__class__.__name__ == "CustomEraStrategy": + return {"CustomEra": data} return data - - class ConceptSetSelection(CirceBaseModel): """Represents a concept set selection. - + Java equivalent: org.ohdsi.circe.cohortdefinition.ConceptSetSelection """ + codeset_id: Optional[int] = Field( default=None, validation_alias=AliasChoices("CodesetId", "codesetId"), - serialization_alias="CodesetId" + serialization_alias="CodesetId", ) is_exclusion: bool = Field( default=False, validation_alias=AliasChoices("IsExclusion", "isExclusion"), - serialization_alias="IsExclusion" + serialization_alias="IsExclusion", ) model_config = ConfigDict(populate_by_name=True) @@ -249,86 +268,86 @@ class ConceptSetSelection(CirceBaseModel): class TextFilter(CirceBaseModel): """Represents text filtering capabilities. - + Java equivalent: org.ohdsi.circe.cohortdefinition.TextFilter """ + text: Optional[str] = Field( default=None, validation_alias=AliasChoices("Text", "text"), - serialization_alias="Text" + serialization_alias="Text", ) op: Optional[str] = Field( default=None, validation_alias=AliasChoices("Op", "op"), - serialization_alias="Op" + serialization_alias="Op", ) class WindowBound(CirceBaseModel): """Represents a window bound for time windows. - + Java equivalent: org.ohdsi.circe.cohortdefinition.WindowBound """ + coeff: int = Field( - validation_alias=AliasChoices("Coeff", "coeff"), - serialization_alias="Coeff" + validation_alias=AliasChoices("Coeff", "coeff"), serialization_alias="Coeff" ) days: Optional[int] = Field( default=None, validation_alias=AliasChoices("Days", "days"), - serialization_alias="Days" + serialization_alias="Days", ) - + model_config = ConfigDict(populate_by_name=True) class Window(CirceBaseModel): """Represents a time window for criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.Window """ + start: Optional[WindowBound] = Field( default=None, validation_alias=AliasChoices("Start", "start"), - serialization_alias="Start" + serialization_alias="Start", ) end: Optional[WindowBound] = Field( default=None, validation_alias=AliasChoices("End", "end"), - serialization_alias="End" + serialization_alias="End", ) use_event_end: Optional[bool] = Field( default=None, validation_alias=AliasChoices("UseEventEnd", "useEventEnd"), - serialization_alias="UseEventEnd" + serialization_alias="UseEventEnd", ) use_index_end: Optional[bool] = Field( default=None, validation_alias=AliasChoices("UseIndexEnd", "useIndexEnd"), - serialization_alias="UseIndexEnd" + serialization_alias="UseIndexEnd", ) model_config = ConfigDict(populate_by_name=True) - - class DateOffsetStrategy(EndStrategy): """Date offset end strategy. - + Java equivalent: org.ohdsi.circe.cohortdefinition.DateOffsetStrategy """ + offset: int = Field( - validation_alias=AliasChoices("Offset", "offset"), - serialization_alias="Offset" + validation_alias=AliasChoices("Offset", "offset"), serialization_alias="Offset" ) date_field: str = Field( validation_alias=AliasChoices("DateField", "dateField"), - serialization_alias="DateField" + serialization_alias="DateField", ) model_config = ConfigDict(populate_by_name=True) - + def accept(self, dispatcher: Any, event_table: str) -> str: """Accept method for visitor pattern.""" return dispatcher.get_strategy_sql(self, event_table) @@ -336,32 +355,33 @@ def accept(self, dispatcher: Any, event_table: str) -> str: class CustomEraStrategy(EndStrategy): """Custom era end strategy. - + Java equivalent: org.ohdsi.circe.cohortdefinition.CustomEraStrategy """ + drug_codeset_id: Optional[int] = Field( default=None, validation_alias=AliasChoices("DrugCodesetId", "drugCodesetId"), - serialization_alias="DrugCodesetId" + serialization_alias="DrugCodesetId", ) gap_days: int = Field( default=0, validation_alias=AliasChoices("GapDays", "gapDays"), - serialization_alias="GapDays" + serialization_alias="GapDays", ) offset: int = Field( default=0, validation_alias=AliasChoices("Offset", "offset"), - serialization_alias="Offset" + serialization_alias="Offset", ) days_supply_override: Optional[int] = Field( default=None, validation_alias=AliasChoices("DaysSupplyOverride", "daysSupplyOverride"), - serialization_alias="DaysSupplyOverride" + serialization_alias="DaysSupplyOverride", ) model_config = ConfigDict(populate_by_name=True) - + def accept(self, dispatcher: Any, event_table: str) -> str: """Accept method for visitor pattern.""" return dispatcher.get_strategy_sql(self, event_table) diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index 1ff2d3a2..c201ec42 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -9,21 +9,38 @@ """ from typing import List, Optional, Any, ClassVar, Union, TYPE_CHECKING -from pydantic import BaseModel, Field, ConfigDict, model_serializer, AliasChoices, field_validator +from pydantic import ( + BaseModel, + Field, + ConfigDict, + model_serializer, + AliasChoices, + field_validator, +) from enum import Enum from ..vocabulary.concept import Concept from .core import ( - DateAdjustment, DateRange, NumericRange, ConceptSetSelection, - TextFilter, Window, Period, ResultLimit, ObservationFilter, - CollapseSettings, EndStrategy, CirceBaseModel + DateAdjustment, + DateRange, + NumericRange, + ConceptSetSelection, + TextFilter, + Window, + Period, + ResultLimit, + ObservationFilter, + CollapseSettings, + EndStrategy, + CirceBaseModel, ) class CriteriaColumn(str, Enum): """Represents a criteria column. - + Java equivalent: org.ohdsi.circe.cohortdefinition.builders.CriteriaColumn """ + DAYS_SUPPLY = "days_supply" DOMAIN_CONCEPT = "domain_concept_id" DOMAIN_SOURCE_CONCEPT = "domain_source_concept_id" @@ -61,20 +78,21 @@ def _missing_(cls, value): return cls.UNIT if value.upper() == "VISIT": return cls.VISIT_ID - + return super()._missing_(value) class Occurrence(CirceBaseModel): """Represents occurrence settings for criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.Occurrence - + Note: In Java, EXACTLY, AT_MOST, AT_LEAST are static final constants. The JSON schema extraction treats them as required fields, so we include them as required instance fields. They should always be set to their constant values. For class-level access, use the _EXACTLY, _AT_MOST, _AT_LEAST constants. """ + # Instance fields required by JSON schema (required fields) # These are required by schema - they represent constants but are fields in JSON # Default values are set to match the constant values for runtime convenience @@ -82,14 +100,27 @@ class Occurrence(CirceBaseModel): AT_MOST: int = Field(default=1, alias="AT_MOST", exclude=True) AT_LEAST: int = Field(default=2, alias="AT_LEAST", exclude=True) EXACTLY: int = Field(default=0, alias="EXACTLY", exclude=True) - - type: int = Field(validation_alias=AliasChoices("Type", "type"), serialization_alias="Type") - count: int = Field(validation_alias=AliasChoices("Count", "count"), serialization_alias="Count") - is_distinct: bool = Field(default=False, validation_alias=AliasChoices("IsDistinct", "isDistinct"), serialization_alias="IsDistinct") - count_column: Optional[CriteriaColumn] = Field(default=None, validation_alias=AliasChoices("CountColumn", "countColumn"), serialization_alias="CountColumn") + + type: int = Field( + validation_alias=AliasChoices("Type", "type"), serialization_alias="Type" + ) + count: int = Field( + validation_alias=AliasChoices("Count", "count"), serialization_alias="Count" + ) + is_distinct: bool = Field( + default=False, + validation_alias=AliasChoices("IsDistinct", "isDistinct"), + serialization_alias="IsDistinct", + ) + count_column: Optional[CriteriaColumn] = Field( + default=None, + validation_alias=AliasChoices("CountColumn", "countColumn"), + serialization_alias="CountColumn", + ) model_config = ConfigDict(populate_by_name=True) + # Class-level constants for code access (matching Java static final) # These are separate from instance fields to avoid shadowing Occurrence._EXACTLY = 0 @@ -99,32 +130,35 @@ class Occurrence(CirceBaseModel): class WindowedCriteria(CirceBaseModel): """Base class for windowed criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.WindowedCriteria """ - criteria: 'CriteriaType' = Field( + + criteria: "CriteriaType" = Field( validation_alias=AliasChoices("Criteria", "criteria"), - serialization_alias="Criteria" + serialization_alias="Criteria", ) start_window: Optional[Window] = Field( default=None, validation_alias=AliasChoices("StartWindow", "startWindow"), - serialization_alias="StartWindow" + serialization_alias="StartWindow", ) end_window: Optional[Window] = Field( default=None, validation_alias=AliasChoices("EndWindow", "endWindow"), - serialization_alias="EndWindow" + serialization_alias="EndWindow", ) restrict_visit: bool = Field( default=False, validation_alias=AliasChoices("RestrictVisit", "restrictVisit"), - serialization_alias="RestrictVisit" + serialization_alias="RestrictVisit", ) ignore_observation_period: bool = Field( default=False, - validation_alias=AliasChoices("IgnoreObservationPeriod", "ignoreObservationPeriod"), - serialization_alias="IgnoreObservationPeriod" + validation_alias=AliasChoices( + "IgnoreObservationPeriod", "ignoreObservationPeriod" + ), + serialization_alias="IgnoreObservationPeriod", ) model_config = ConfigDict(populate_by_name=True) @@ -136,63 +170,65 @@ class CorelatedCriteria(WindowedCriteria): The class also doesn't appear to be used much (there is a CorrelationGroup class that may supersede it? Java equivalent: org.ohdsi.circe.cohortdefinition.CorelatedCriteria """ + occurrence: Optional[Occurrence] = Field( default=None, validation_alias=AliasChoices("Occurrence", "occurrence"), - serialization_alias="Occurrence" + serialization_alias="Occurrence", ) model_config = ConfigDict(populate_by_name=True) class DemographicCriteria(CirceBaseModel): """Represents demographic criteria for cohort definition. - + Java equivalent: org.ohdsi.circe.cohortdefinition.DemographicCriteria """ + gender: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("Gender", "gender"), - serialization_alias="Gender" + serialization_alias="Gender", ) occurrence_end_date: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceEndDate", "occurrenceEndDate"), - serialization_alias="OccurrenceEndDate" + serialization_alias="OccurrenceEndDate", ) gender_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("GenderCS", "genderCS"), - serialization_alias="GenderCS" + serialization_alias="GenderCS", ) race: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("Race", "race"), - serialization_alias="Race" + serialization_alias="Race", ) ethnicity_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("EthnicityCS", "ethnicityCS"), - serialization_alias="EthnicityCS" + serialization_alias="EthnicityCS", ) age: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("Age", "age"), - serialization_alias="Age" + serialization_alias="Age", ) race_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("RaceCS", "raceCS"), - serialization_alias="RaceCS" + serialization_alias="RaceCS", ) ethnicity: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("Ethnicity", "ethnicity"), - serialization_alias="Ethnicity" + serialization_alias="Ethnicity", ) occurrence_start_date: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceStartDate", "occurrenceStartDate"), - serialization_alias="OccurrenceStartDate" + serialization_alias="OccurrenceStartDate", ) model_config = ConfigDict(populate_by_name=True) @@ -200,32 +236,33 @@ class DemographicCriteria(CirceBaseModel): class Criteria(CirceBaseModel): """Represents a criteria with date adjustment and correlated criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.Criteria """ + date_adjustment: Optional[DateAdjustment] = Field( default=None, validation_alias=AliasChoices("DateAdjustment", "dateAdjustment"), - serialization_alias="DateAdjustment" + serialization_alias="DateAdjustment", ) - correlated_criteria: Optional['CriteriaGroup'] = Field( + correlated_criteria: Optional["CriteriaGroup"] = Field( default=None, validation_alias=AliasChoices("CorrelatedCriteria", "correlatedCriteria"), - serialization_alias="CorrelatedCriteria" + serialization_alias="CorrelatedCriteria", ) include: Optional[str] = None # JsonTypeInfo.Id.NAME - - @model_serializer(mode='wrap') + + @model_serializer(mode="wrap") def _serialize_polymorphic(self, serializer, info): """Serialize with polymorphic type wrapper for Java compatibility.""" # Get the serialized data using default serialization data = serializer(self) # Wrap in class name for polymorphic deserialization in Java # Only wrap if this is a subclass (not the base Criteria class) - if self.__class__.__name__ != 'Criteria': + if self.__class__.__name__ != "Criteria": return {self.__class__.__name__: data} return data - + def accept(self, dispatcher: Any, options: Optional[Any] = None) -> str: """Accept method for visitor pattern.""" return dispatcher.get_criteria_sql(self, options) @@ -233,23 +270,24 @@ def accept(self, dispatcher: Any, options: Optional[Any] = None) -> str: class InclusionRule(CirceBaseModel): """Represents an inclusion rule for cohort definition. - + Java equivalent: org.ohdsi.circe.cohortdefinition.InclusionRule """ - expression: Optional['CriteriaGroup'] = Field( + + expression: Optional["CriteriaGroup"] = Field( default=None, validation_alias=AliasChoices("Expression", "expression"), - serialization_alias="Expression" + serialization_alias="Expression", ) description: Optional[str] = Field( default=None, validation_alias=AliasChoices("Description", "description"), - serialization_alias="Description" + serialization_alias="Description", ) name: Optional[str] = Field( default=None, validation_alias=AliasChoices("Name", "name"), - serialization_alias="Name" + serialization_alias="Name", ) @@ -257,104 +295,105 @@ class InclusionRule(CirceBaseModel): # CRITERIA DOMAIN CLASSES # ============================================================================= + class ConditionOccurrence(Criteria): """Condition occurrence criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.ConditionOccurrence """ + codeset_id: Optional[int] = Field( default=None, validation_alias=AliasChoices("CodesetId", "codesetId"), - serialization_alias="CodesetId" + serialization_alias="CodesetId", ) first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), - serialization_alias="First" + serialization_alias="First", ) occurrence_start_date: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceStartDate", "occurrenceStartDate"), - serialization_alias="OccurrenceStartDate" + serialization_alias="OccurrenceStartDate", ) occurrence_end_date: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceEndDate", "occurrenceEndDate"), - serialization_alias="OccurrenceEndDate" + serialization_alias="OccurrenceEndDate", ) condition_type: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("ConditionType", "conditionType"), - serialization_alias="ConditionType" + serialization_alias="ConditionType", ) condition_type_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("ConditionTypeCS", "conditionTypeCS"), - serialization_alias="ConditionTypeCS" + serialization_alias="ConditionTypeCS", ) condition_type_exclude: Optional[bool] = Field( default=False, validation_alias=AliasChoices("ConditionTypeExclude", "conditionTypeExclude"), - serialization_alias="ConditionTypeExclude" + serialization_alias="ConditionTypeExclude", ) stop_reason: Optional[TextFilter] = Field( default=None, validation_alias=AliasChoices("StopReason", "stopReason"), - serialization_alias="StopReason" + serialization_alias="StopReason", ) condition_source_concept: Optional[int] = Field( default=None, - validation_alias=AliasChoices("ConditionSourceConcept", "conditionSourceConcept"), - serialization_alias="ConditionSourceConcept" + validation_alias=AliasChoices( + "ConditionSourceConcept", "conditionSourceConcept" + ), + serialization_alias="ConditionSourceConcept", ) age: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("Age", "age"), - serialization_alias="Age" - ) - gender: Optional[List[Concept]] = Field( - default=None, - serialization_alias="gender" + serialization_alias="Age", ) + gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("GenderCS", "genderCS"), - serialization_alias="GenderCS" + serialization_alias="GenderCS", ) provider_specialty: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("ProviderSpecialty", "providerSpecialty"), - serialization_alias="ProviderSpecialty" + serialization_alias="ProviderSpecialty", ) provider_specialty_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("ProviderSpecialtyCS", "providerSpecialtyCS"), - serialization_alias="ProviderSpecialtyCS" + serialization_alias="ProviderSpecialtyCS", ) visit_type: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("VisitType", "visitType"), - serialization_alias="VisitType" + serialization_alias="VisitType", ) visit_type_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("VisitTypeCS", "visitTypeCS"), - serialization_alias="VisitTypeCS" + serialization_alias="VisitTypeCS", ) condition_status: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("ConditionStatus", "conditionStatus"), - serialization_alias="ConditionStatus" + serialization_alias="ConditionStatus", ) condition_status_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("ConditionStatusCS", "conditionStatusCS"), - serialization_alias="ConditionStatusCS" + serialization_alias="ConditionStatusCS", ) date_adjustment: Optional[DateAdjustment] = Field( default=None, validation_alias=AliasChoices("DateAdjustment", "dateAdjustment"), - serialization_alias="DateAdjustment" + serialization_alias="DateAdjustment", ) model_config = ConfigDict(populate_by_name=True) @@ -362,128 +401,126 @@ class ConditionOccurrence(Criteria): class DrugExposure(Criteria): """Drug exposure criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.DrugExposure """ - gender: Optional[List[Concept]] = Field( - default=None, - serialization_alias="gender" - ) + + gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") occurrence_end_date: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceEndDate", "occurrenceEndDate"), - serialization_alias="OccurrenceEndDate" + serialization_alias="OccurrenceEndDate", ) stop_reason: Optional[TextFilter] = Field( default=None, validation_alias=AliasChoices("StopReason", "stopReason"), - serialization_alias="StopReason" + serialization_alias="StopReason", ) drug_source_concept: Optional[int] = Field( default=None, validation_alias=AliasChoices("DrugSourceConcept", "drugSourceConcept"), - serialization_alias="DrugSourceConcept" + serialization_alias="DrugSourceConcept", ) gender_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("GenderCS", "genderCS"), - serialization_alias="GenderCS" + serialization_alias="GenderCS", ) drug_type: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("DrugType", "drugType"), - serialization_alias="DrugType" + serialization_alias="DrugType", ) drug_type_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("DrugTypeCS", "drugTypeCS"), - serialization_alias="DrugTypeCS" + serialization_alias="DrugTypeCS", ) drug_type_exclude: bool = Field( default=False, validation_alias=AliasChoices("DrugTypeExclude", "drugTypeExclude"), - serialization_alias="DrugTypeExclude" + serialization_alias="DrugTypeExclude", ) provider_specialty_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("ProviderSpecialtyCS", "providerSpecialtyCS"), - serialization_alias="ProviderSpecialtyCS" + serialization_alias="ProviderSpecialtyCS", ) visit_type_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("VisitTypeCS", "visitTypeCS"), - serialization_alias="VisitTypeCS" + serialization_alias="VisitTypeCS", ) visit_type: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("VisitType", "visitType"), - serialization_alias="VisitType" + serialization_alias="VisitType", ) route_concept: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("RouteConcept", "routeConcept"), - serialization_alias="RouteConcept" + serialization_alias="RouteConcept", ) route_concept_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("RouteConceptCS", "routeConceptCS"), - serialization_alias="RouteConceptCS" + serialization_alias="RouteConceptCS", ) codeset_id: Optional[int] = Field( default=None, validation_alias=AliasChoices("CodesetId", "codesetId"), - serialization_alias="CodesetId" + serialization_alias="CodesetId", ) first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), - serialization_alias="First" + serialization_alias="First", ) provider_specialty: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("ProviderSpecialty", "providerSpecialty"), - serialization_alias="ProviderSpecialty" + serialization_alias="ProviderSpecialty", ) age: Optional[NumericRange] = None occurrence_start_date: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceStartDate", "occurrenceStartDate"), - serialization_alias="OccurrenceStartDate" + serialization_alias="OccurrenceStartDate", ) dose_unit: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("DoseUnit", "doseUnit"), - serialization_alias="DoseUnit" + serialization_alias="DoseUnit", ) dose_unit_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("DoseUnitCS", "doseUnitCS"), - serialization_alias="DoseUnitCS" + serialization_alias="DoseUnitCS", ) lot_number: Optional[TextFilter] = Field( default=None, validation_alias=AliasChoices("LotNumber", "lotNumber"), - serialization_alias="LotNumber" + serialization_alias="LotNumber", ) quantity: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("Quantity", "quantity"), - serialization_alias="Quantity" + serialization_alias="Quantity", ) days_supply: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("DaysSupply", "daysSupply"), - serialization_alias="DaysSupply" + serialization_alias="DaysSupply", ) refills: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("Refills", "refills"), - serialization_alias="Refills" + serialization_alias="Refills", ) effective_drug_dose: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("EffectiveDrugDose", "effectiveDrugDose"), - serialization_alias="EffectiveDrugDose" + serialization_alias="EffectiveDrugDose", ) model_config = ConfigDict(populate_by_name=True) @@ -491,18 +528,29 @@ class DrugExposure(Criteria): class ProcedureOccurrence(Criteria): """Procedure occurrence criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.ProcedureOccurrence """ + gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") - procedure_source_concept: Optional[int] = Field(default=None, alias="ProcedureSourceConcept") + occurrence_end_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceEndDate" + ) + procedure_source_concept: Optional[int] = Field( + default=None, alias="ProcedureSourceConcept" + ) gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") procedure_type: Optional[List[Concept]] = Field(default=None, alias="ProcedureType") - procedure_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProcedureTypeCS") + procedure_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="ProcedureTypeCS" + ) procedure_type_exclude: bool = Field(default=False, alias="ProcedureTypeExclude") - provider_specialty_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProviderSpecialtyCS") - visit_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="VisitTypeCS") + provider_specialty_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="ProviderSpecialtyCS" + ) + visit_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="VisitTypeCS" + ) visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") modifier: Optional[List[Concept]] = Field(default=None, alias="Modifier") modifier_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ModifierCS") @@ -510,153 +558,181 @@ class ProcedureOccurrence(Criteria): first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), - serialization_alias="First" + serialization_alias="First", + ) + provider_specialty: Optional[List[Concept]] = Field( + default=None, alias="ProviderSpecialty" ) - provider_specialty: Optional[List[Concept]] = Field(default=None, alias="ProviderSpecialty") age: Optional[NumericRange] = None quantity: Optional[NumericRange] = Field(default=None, alias="Quantity") - occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") + occurrence_start_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceStartDate" + ) model_config = ConfigDict(populate_by_name=True) class VisitOccurrence(Criteria): """Visit occurrence criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.VisitOccurrence """ + codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field(default=None, alias="First") gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") + occurrence_end_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceEndDate" + ) gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") - visit_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="VisitTypeCS") + visit_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="VisitTypeCS" + ) visit_type_exclude: bool = Field(default=False, alias="VisitTypeExclude") - visit_source_concept: Optional[int] = Field(default=None, alias="VisitSourceConcept") + visit_source_concept: Optional[int] = Field( + default=None, alias="VisitSourceConcept" + ) visit_length: Optional[NumericRange] = Field(default=None, alias="VisitLength") - provider_specialty_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProviderSpecialtyCS") - provider_specialty: Optional[List[Concept]] = Field(default=None, alias="ProviderSpecialty") - place_of_service: Optional[List[Concept]] = Field(default=None, alias="PlaceOfService") - place_of_service_cs: Optional[ConceptSetSelection] = Field(default=None, alias="PlaceOfServiceCS") - place_of_service_location: Optional[int] = Field(default=None, alias="PlaceOfServiceLocation") + provider_specialty_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="ProviderSpecialtyCS" + ) + provider_specialty: Optional[List[Concept]] = Field( + default=None, alias="ProviderSpecialty" + ) + place_of_service: Optional[List[Concept]] = Field( + default=None, alias="PlaceOfService" + ) + place_of_service_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="PlaceOfServiceCS" + ) + place_of_service_location: Optional[int] = Field( + default=None, alias="PlaceOfServiceLocation" + ) age: Optional[NumericRange] = None - occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") + occurrence_start_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceStartDate" + ) model_config = ConfigDict(populate_by_name=True) class Observation(Criteria): """Observation criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.Observation """ + gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") occurrence_end_date: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceEndDate", "occurrenceEndDate"), - serialization_alias="OccurrenceEndDate" + serialization_alias="OccurrenceEndDate", ) observation_source_concept: Optional[int] = Field( default=None, - validation_alias=AliasChoices("ObservationSourceConcept", "observationSourceConcept"), - serialization_alias="ObservationSourceConcept" + validation_alias=AliasChoices( + "ObservationSourceConcept", "observationSourceConcept" + ), + serialization_alias="ObservationSourceConcept", ) gender_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("GenderCS", "genderCS"), - serialization_alias="GenderCS" + serialization_alias="GenderCS", ) observation_type: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("ObservationType", "observationType"), - serialization_alias="ObservationType" + serialization_alias="ObservationType", ) observation_type_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("ObservationTypeCS", "observationTypeCS"), - serialization_alias="ObservationTypeCS" + serialization_alias="ObservationTypeCS", ) observation_type_exclude: bool = Field( default=False, - validation_alias=AliasChoices("ObservationTypeExclude", "observationTypeExclude"), - serialization_alias="ObservationTypeExclude" + validation_alias=AliasChoices( + "ObservationTypeExclude", "observationTypeExclude" + ), + serialization_alias="ObservationTypeExclude", ) provider_specialty_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("ProviderSpecialtyCS", "providerSpecialtyCS"), - serialization_alias="ProviderSpecialtyCS" + serialization_alias="ProviderSpecialtyCS", ) visit_type_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("VisitTypeCS", "visitTypeCS"), - serialization_alias="VisitTypeCS" + serialization_alias="VisitTypeCS", ) visit_type: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("VisitType", "visitType"), - serialization_alias="VisitType" + serialization_alias="VisitType", ) value_as_number: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), - serialization_alias="ValueAsNumber" + serialization_alias="ValueAsNumber", ) unit: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("Unit", "unit"), - serialization_alias="Unit" + serialization_alias="Unit", ) unit_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("UnitCS", "unitCS"), - serialization_alias="UnitCS" + serialization_alias="UnitCS", ) value_as_concept: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("ValueAsConcept", "valueAsConcept"), - serialization_alias="ValueAsConcept" + serialization_alias="ValueAsConcept", ) value_as_concept_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("ValueAsConceptCS", "valueAsConceptCS"), - serialization_alias="ValueAsConceptCS" + serialization_alias="ValueAsConceptCS", ) qualifier: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("Qualifier", "qualifier"), - serialization_alias="Qualifier" + serialization_alias="Qualifier", ) qualifier_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("QualifierCS", "qualifierCS"), - serialization_alias="QualifierCS" + serialization_alias="QualifierCS", ) value_as_string: Optional[TextFilter] = Field( default=None, validation_alias=AliasChoices("ValueAsString", "valueAsString"), - serialization_alias="ValueAsString" + serialization_alias="ValueAsString", ) codeset_id: Optional[int] = Field( default=None, validation_alias=AliasChoices("CodesetId", "codesetId"), - serialization_alias="CodesetId" + serialization_alias="CodesetId", ) first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), - serialization_alias="First" + serialization_alias="First", ) provider_specialty: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("ProviderSpecialty", "providerSpecialty"), - serialization_alias="ProviderSpecialty" + serialization_alias="ProviderSpecialty", ) age: Optional[NumericRange] = None occurrence_start_date: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceStartDate", "occurrenceStartDate"), - serialization_alias="OccurrenceStartDate" + serialization_alias="OccurrenceStartDate", ) model_config = ConfigDict(populate_by_name=True) @@ -664,19 +740,30 @@ class Observation(Criteria): class Measurement(Criteria): """Measurement criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.Measurement """ + gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") - measurement_source_concept: Optional[int] = Field(default=None, alias="MeasurementSourceConcept") + occurrence_end_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceEndDate" + ) + measurement_source_concept: Optional[int] = Field( + default=None, alias="MeasurementSourceConcept" + ) gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - measurement_type: Optional[List[Concept]] = Field(default=None, alias="MeasurementType") - measurement_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="MeasurementTypeCS") + measurement_type: Optional[List[Concept]] = Field( + default=None, alias="MeasurementType" + ) + measurement_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="MeasurementTypeCS" + ) measurement_type_exclude: bool = Field( default=False, - validation_alias=AliasChoices("MeasurementTypeExclude", "measurementTypeExclude"), - serialization_alias="MeasurementTypeExclude" + validation_alias=AliasChoices( + "MeasurementTypeExclude", "measurementTypeExclude" + ), + serialization_alias="MeasurementTypeExclude", ) operator: Optional[List[Concept]] = None operator_cs: Optional[ConceptSetSelection] = Field(default=None, alias="OperatorCS") @@ -686,187 +773,271 @@ class Measurement(Criteria): unit_cs: Optional[ConceptSetSelection] = Field(default=None, alias="UnitCS") range_low: Optional[NumericRange] = Field(default=None, alias="RangeLow") range_high: Optional[NumericRange] = Field(default=None, alias="RangeHigh") - provider_specialty_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProviderSpecialtyCS") - visit_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="VisitTypeCS") + provider_specialty_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="ProviderSpecialtyCS" + ) + visit_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="VisitTypeCS" + ) visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") codeset_id: Optional[int] = Field( default=None, validation_alias=AliasChoices("CodesetId", "codesetId"), - serialization_alias="CodesetId" + serialization_alias="CodesetId", ) value_as_concept: Optional[List[Concept]] = Field( default=None, validation_alias=AliasChoices("ValueAsConcept", "valueAsConcept"), - serialization_alias="ValueAsConcept" + serialization_alias="ValueAsConcept", ) value_as_concept_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("ValueAsConceptCS", "valueAsConceptCS"), - serialization_alias="ValueAsConceptCS" + serialization_alias="ValueAsConceptCS", ) abnormal: Optional[bool] = Field( default=None, validation_alias=AliasChoices("Abnormal", "abnormal"), - serialization_alias="Abnormal" + serialization_alias="Abnormal", ) range_low_ratio: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("RangeLowRatio", "rangeLowRatio"), - serialization_alias="RangeLowRatio" + serialization_alias="RangeLowRatio", ) range_high_ratio: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("RangeHighRatio", "rangeHighRatio"), - serialization_alias="RangeHighRatio" + serialization_alias="RangeHighRatio", + ) + provider_specialty: Optional[List[Concept]] = Field( + default=None, alias="ProviderSpecialty" ) - provider_specialty: Optional[List[Concept]] = Field(default=None, alias="ProviderSpecialty") age: Optional[NumericRange] = None - occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") + occurrence_start_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceStartDate" + ) visits: Optional[List[Concept]] = None # Placeholder if needed, but not in list visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") - + first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), - serialization_alias="First" + serialization_alias="First", + ) + provider_specialty: Optional[List[Concept]] = Field( + default=None, alias="ProviderSpecialty" ) - provider_specialty: Optional[List[Concept]] = Field(default=None, alias="ProviderSpecialty") age: Optional[NumericRange] = None - occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") + occurrence_start_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceStartDate" + ) model_config = ConfigDict(populate_by_name=True) class DeviceExposure(Criteria): """Device exposure criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.DeviceExposure """ + gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") - device_source_concept: Optional[int] = Field(default=None, alias="DeviceSourceConcept") + occurrence_end_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceEndDate" + ) + device_source_concept: Optional[int] = Field( + default=None, alias="DeviceSourceConcept" + ) gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") device_type: Optional[List[Concept]] = Field(default=None, alias="DeviceType") - device_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="DeviceTypeCS") + device_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="DeviceTypeCS" + ) device_type_exclude: bool = Field(default=False, alias="DeviceTypeExclude") unique_device_id: Optional[TextFilter] = Field(default=None, alias="UniqueDeviceId") quantity: Optional[NumericRange] = None - provider_specialty_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProviderSpecialtyCS") - visit_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="VisitTypeCS") + provider_specialty_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="ProviderSpecialtyCS" + ) + visit_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="VisitTypeCS" + ) visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), - serialization_alias="First" + serialization_alias="First", + ) + provider_specialty: Optional[List[Concept]] = Field( + default=None, alias="ProviderSpecialty" ) - provider_specialty: Optional[List[Concept]] = Field(default=None, alias="ProviderSpecialty") age: Optional[NumericRange] = Field(default=None, alias="Age") - occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") + occurrence_start_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceStartDate" + ) model_config = ConfigDict(populate_by_name=True) class Specimen(Criteria): """Specimen criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.Specimen """ + gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") - specimen_source_concept: Optional[int] = Field(default=None, alias="SpecimenSourceConcept") + occurrence_end_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceEndDate" + ) + specimen_source_concept: Optional[int] = Field( + default=None, alias="SpecimenSourceConcept" + ) source_id: Optional[TextFilter] = Field(default=None, alias="SourceId") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") specimen_type: Optional[List[Concept]] = Field(default=None, alias="SpecimenType") - specimen_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="SpecimenTypeCS") + specimen_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="SpecimenTypeCS" + ) specimen_type_exclude: bool = Field(default=False, alias="SpecimenTypeExclude") unit: Optional[List[Concept]] = None unit_cs: Optional[ConceptSetSelection] = Field(default=None, alias="UnitCS") anatomic_site: Optional[List[Concept]] = Field(default=None, alias="AnatomicSite") - anatomic_site_cs: Optional[ConceptSetSelection] = Field(default=None, alias="AnatomicSiteCS") + anatomic_site_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="AnatomicSiteCS" + ) disease_status: Optional[List[Concept]] = Field(default=None, alias="DiseaseStatus") - disease_status_cs: Optional[ConceptSetSelection] = Field(default=None, alias="DiseaseStatusCS") + disease_status_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="DiseaseStatusCS" + ) quantity: Optional[NumericRange] = None codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), - serialization_alias="First" + serialization_alias="First", ) age: Optional[NumericRange] = None - occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") + occurrence_start_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceStartDate" + ) model_config = ConfigDict(populate_by_name=True) class Death(Criteria): """Death criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.Death """ + gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") - death_source_concept: Optional[int] = Field(default=None, alias="DeathSourceConcept") + occurrence_end_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceEndDate" + ) + death_source_concept: Optional[int] = Field( + default=None, alias="DeathSourceConcept" + ) gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") death_type: Optional[List[Concept]] = Field(default=None, alias="DeathType") - death_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="DeathTypeCS") + death_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="DeathTypeCS" + ) death_type_exclude: bool = Field( default=False, validation_alias=AliasChoices("DeathTypeExclude", "deathTypeExclude"), - serialization_alias="DeathTypeExclude" + serialization_alias="DeathTypeExclude", + ) + cause_source_concept: Optional[int] = Field( + default=None, alias="CauseSourceConcept" + ) + cause_source_concept_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="CauseSourceConceptCS" ) - cause_source_concept: Optional[int] = Field(default=None, alias="CauseSourceConcept") - cause_source_concept_cs: Optional[ConceptSetSelection] = Field(default=None, alias="CauseSourceConceptCS") codeset_id: Optional[int] = Field(default=None, alias="CodesetId") age: Optional[NumericRange] = None - occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") + occurrence_start_date: Optional[DateRange] = Field( + default=None, alias="OccurrenceStartDate" + ) model_config = ConfigDict(populate_by_name=True) class VisitDetail(Criteria): """Visit detail criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.VisitDetail """ + codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field(default=None, alias="First") - visit_detail_start_date: Optional[DateRange] = Field(default=None, alias="VisitDetailStartDate") - visit_detail_end_date: Optional[DateRange] = Field(default=None, alias="VisitDetailEndDate") - visit_detail_type: Optional[List[Concept]] = Field(default=None, alias="VisitDetailType") - visit_detail_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="VisitDetailTypeCS") - visit_detail_type_exclude: bool = Field(default=False, alias="VisitDetailTypeExclude") - visit_detail_source_concept: Optional[int] = Field(default=None, alias="VisitDetailSourceConcept") - visit_detail_length: Optional[NumericRange] = Field(default=None, alias="VisitDetailLength") - age: Optional[NumericRange] = Field(default=None, alias="Age") - gender: Optional[List[Concept]] = Field( - default=None, - serialization_alias="gender" + visit_detail_start_date: Optional[DateRange] = Field( + default=None, alias="VisitDetailStartDate" + ) + visit_detail_end_date: Optional[DateRange] = Field( + default=None, alias="VisitDetailEndDate" + ) + visit_detail_type: Optional[List[Concept]] = Field( + default=None, alias="VisitDetailType" ) + visit_detail_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="VisitDetailTypeCS" + ) + visit_detail_type_exclude: bool = Field( + default=False, alias="VisitDetailTypeExclude" + ) + visit_detail_source_concept: Optional[int] = Field( + default=None, alias="VisitDetailSourceConcept" + ) + visit_detail_length: Optional[NumericRange] = Field( + default=None, alias="VisitDetailLength" + ) + age: Optional[NumericRange] = Field(default=None, alias="Age") + gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - provider_specialty: Optional[List[Concept]] = Field(default=None, alias="ProviderSpecialty") - provider_specialty_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProviderSpecialtyCS") - place_of_service: Optional[List[Concept]] = Field(default=None, alias="PlaceOfService") - place_of_service_cs: Optional[ConceptSetSelection] = Field(default=None, alias="PlaceOfServiceCS") - place_of_service_location: Optional[int] = Field(default=None, alias="PlaceOfServiceLocation") + provider_specialty: Optional[List[Concept]] = Field( + default=None, alias="ProviderSpecialty" + ) + provider_specialty_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="ProviderSpecialtyCS" + ) + place_of_service: Optional[List[Concept]] = Field( + default=None, alias="PlaceOfService" + ) + place_of_service_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="PlaceOfServiceCS" + ) + place_of_service_location: Optional[int] = Field( + default=None, alias="PlaceOfServiceLocation" + ) discharge_to: Optional[List[Concept]] = Field(default=None, alias="DischargeTo") - discharge_to_cs: Optional[ConceptSetSelection] = Field(default=None, alias="DischargeToCS") + discharge_to_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="DischargeToCS" + ) model_config = ConfigDict(populate_by_name=True) class ObservationPeriod(Criteria): """Observation period criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.ObservationPeriod """ + first: Optional[bool] = Field(default=None, alias="First") - period_start_date: Optional[DateRange] = Field(default=None, alias="PeriodStartDate") + period_start_date: Optional[DateRange] = Field( + default=None, alias="PeriodStartDate" + ) period_end_date: Optional[DateRange] = Field(default=None, alias="PeriodEndDate") - user_defined_period: Optional[Period] = Field(default=None, alias="UserDefinedPeriod") + user_defined_period: Optional[Period] = Field( + default=None, alias="UserDefinedPeriod" + ) period_type: Optional[List[Concept]] = Field(default=None, alias="PeriodType") - period_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="PeriodTypeCS") + period_type_cs: Optional[ConceptSetSelection] = Field( + default=None, alias="PeriodTypeCS" + ) period_length: Optional[NumericRange] = Field(default=None, alias="PeriodLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") @@ -876,13 +1047,18 @@ class ObservationPeriod(Criteria): class PayerPlanPeriod(Criteria): """Payer plan period criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.PayerPlanPeriod """ + first: Optional[bool] = Field(default=None, alias="First") - period_start_date: Optional[DateRange] = Field(default=None, alias="PeriodStartDate") + period_start_date: Optional[DateRange] = Field( + default=None, alias="PeriodStartDate" + ) period_end_date: Optional[DateRange] = Field(default=None, alias="PeriodEndDate") - user_defined_period: Optional[Period] = Field(default=None, alias="UserDefinedPeriod") + user_defined_period: Optional[Period] = Field( + default=None, alias="UserDefinedPeriod" + ) period_length: Optional[NumericRange] = Field(default=None, alias="PeriodLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") @@ -892,19 +1068,26 @@ class PayerPlanPeriod(Criteria): plan_concept: Optional[int] = Field(default=None, alias="PlanConcept") sponsor_concept: Optional[int] = Field(default=None, alias="SponsorConcept") stop_reason_concept: Optional[int] = Field(default=None, alias="StopReasonConcept") - payer_source_concept: Optional[int] = Field(default=None, alias="PayerSourceConcept") + payer_source_concept: Optional[int] = Field( + default=None, alias="PayerSourceConcept" + ) plan_source_concept: Optional[int] = Field(default=None, alias="PlanSourceConcept") - sponsor_source_concept: Optional[int] = Field(default=None, alias="SponsorSourceConcept") - stop_reason_source_concept: Optional[int] = Field(default=None, alias="StopReasonSourceConcept") + sponsor_source_concept: Optional[int] = Field( + default=None, alias="SponsorSourceConcept" + ) + stop_reason_source_concept: Optional[int] = Field( + default=None, alias="StopReasonSourceConcept" + ) model_config = ConfigDict(populate_by_name=True) class LocationRegion(Criteria): """Location region criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.LocationRegion """ + codeset_id: Optional[int] = Field(default=None, alias="CodesetId") model_config = ConfigDict(populate_by_name=True) @@ -914,60 +1097,72 @@ class LocationRegion(Criteria): # ERA CRITERIA CLASSES # ============================================================================= + class ConditionEra(Criteria): """Condition era criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.ConditionEra """ + codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), - serialization_alias="First" + serialization_alias="First", ) era_start_date: Optional[DateRange] = Field(default=None, alias="EraStartDate") era_end_date: Optional[DateRange] = Field(default=None, alias="EraEndDate") - occurrence_count: Optional[NumericRange] = Field(default=None, alias="OccurrenceCount") + occurrence_count: Optional[NumericRange] = Field( + default=None, alias="OccurrenceCount" + ) era_length: Optional[NumericRange] = Field(default=None, alias="EraLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - date_adjustment: Optional[DateAdjustment] = Field(default=None, alias="DateAdjustment") + date_adjustment: Optional[DateAdjustment] = Field( + default=None, alias="DateAdjustment" + ) model_config = ConfigDict(populate_by_name=True) class DrugEra(Criteria): """Drug era criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.DrugEra """ + codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), - serialization_alias="First" + serialization_alias="First", ) era_start_date: Optional[DateRange] = Field(default=None, alias="EraStartDate") era_end_date: Optional[DateRange] = Field(default=None, alias="EraEndDate") - occurrence_count: Optional[NumericRange] = Field(default=None, alias="OccurrenceCount") + occurrence_count: Optional[NumericRange] = Field( + default=None, alias="OccurrenceCount" + ) gap_days: Optional[NumericRange] = Field(default=None, alias="GapDays") era_length: Optional[NumericRange] = Field(default=None, alias="EraLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - date_adjustment: Optional[DateAdjustment] = Field(default=None, alias="DateAdjustment") + date_adjustment: Optional[DateAdjustment] = Field( + default=None, alias="DateAdjustment" + ) model_config = ConfigDict(populate_by_name=True) class DoseEra(Criteria): """Dose era criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.DoseEra """ + codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field(default=None, alias="First") era_start_date: Optional[DateRange] = Field(default=None, alias="EraStartDate") @@ -988,46 +1183,53 @@ class DoseEra(Criteria): # GEOGRAPHIC CRITERIA # ============================================================================= + class GeoCriteria(Criteria): """Base class for geographic criteria. - + Java equivalent: org.ohdsi.circe.cohortdefinition.GeoCriteria """ + pass + # ============================================================================= # CRITERIA GROUP AND PRIMARY CRITERIA (Moved from core) # ============================================================================= + class CriteriaGroup(BaseModel): """Represents a group of criteria with logical operators. - + Java equivalent: org.ohdsi.circe.cohortdefinition.CriteriaGroup """ - criteria_list: List['CorelatedCriteria'] = Field( + + criteria_list: List["CorelatedCriteria"] = Field( default_factory=list, validation_alias=AliasChoices("CriteriaList", "criteriaList"), - serialization_alias="CriteriaList" + serialization_alias="CriteriaList", ) count: Optional[int] = Field( default=None, validation_alias=AliasChoices("Count", "count"), - serialization_alias="Count" + serialization_alias="Count", ) - groups: List['CriteriaGroup'] = Field( + groups: List["CriteriaGroup"] = Field( default_factory=list, validation_alias=AliasChoices("Groups", "groups"), - serialization_alias="Groups" + serialization_alias="Groups", ) demographic_criteria_list: List[DemographicCriteria] = Field( default_factory=list, - validation_alias=AliasChoices("DemographicCriteriaList", "demographicCriteriaList"), - serialization_alias="DemographicCriteriaList" + validation_alias=AliasChoices( + "DemographicCriteriaList", "demographicCriteriaList" + ), + serialization_alias="DemographicCriteriaList", ) type: Optional[str] = Field( default=None, validation_alias=AliasChoices("Type", "type"), - serialization_alias="Type" + serialization_alias="Type", ) model_config = ConfigDict(populate_by_name=True) @@ -1036,17 +1238,19 @@ def is_empty(self) -> bool: """Check if the criteria group is empty.""" has_criteria = self.criteria_list and len(self.criteria_list) > 0 has_groups = self.groups and len(self.groups) > 0 - has_demographic = self.demographic_criteria_list and len(self.demographic_criteria_list) > 0 + has_demographic = ( + self.demographic_criteria_list and len(self.demographic_criteria_list) > 0 + ) return not (has_criteria or has_groups or has_demographic) - - @field_validator('demographic_criteria_list', mode='before') + + @field_validator("demographic_criteria_list", mode="before") @classmethod def allow_none_demographic(cls, v: Any) -> Any: if v is None: return [] return v - @field_validator('groups', mode='before') + @field_validator("groups", mode="before") @classmethod def deserialize_groups(cls, v: Any) -> Any: # Same Logic as before, just local @@ -1065,8 +1269,8 @@ def deserialize_groups(cls, v: Any) -> Any: else: result.append(item) return result - - @field_validator('criteria_list', mode='before') + + @field_validator("criteria_list", mode="before") @classmethod def deserialize_criteria_list(cls, v: Any) -> Any: # Logic adapted for local CorelatedCriteria @@ -1074,39 +1278,58 @@ def deserialize_criteria_list(cls, v: Any) -> Any: return [] if not v or not isinstance(v, list): return v - + # Helper window normalizer (same as before) def normalize_window(window_dict: dict) -> dict: - if not isinstance(window_dict, dict): return window_dict + if not isinstance(window_dict, dict): + return window_dict normalized = {} - if 'UseEventEnd' in window_dict: normalized['useEventEnd'] = window_dict['UseEventEnd'] - elif 'useEventEnd' in window_dict: normalized['useEventEnd'] = window_dict['useEventEnd'] - if 'UseIndexEnd' in window_dict: normalized['useIndexEnd'] = window_dict['UseIndexEnd'] - elif 'useIndexEnd' in window_dict: normalized['useIndexEnd'] = window_dict['useIndexEnd'] - if 'useEventEnd' in normalized: normalized['useEventEnd'] = normalized['useEventEnd'] - if 'useIndexEnd' in normalized: normalized['useIndexEnd'] = normalized['useIndexEnd'] - - if 'Start' in window_dict: - start = window_dict['Start'] + if "UseEventEnd" in window_dict: + normalized["useEventEnd"] = window_dict["UseEventEnd"] + elif "useEventEnd" in window_dict: + normalized["useEventEnd"] = window_dict["useEventEnd"] + if "UseIndexEnd" in window_dict: + normalized["useIndexEnd"] = window_dict["UseIndexEnd"] + elif "useIndexEnd" in window_dict: + normalized["useIndexEnd"] = window_dict["useIndexEnd"] + if "useEventEnd" in normalized: + normalized["useEventEnd"] = normalized["useEventEnd"] + if "useIndexEnd" in normalized: + normalized["useIndexEnd"] = normalized["useIndexEnd"] + + if "Start" in window_dict: + start = window_dict["Start"] if isinstance(start, dict): - coeff = start.get('Coeff') if 'Coeff' in start else start.get('coeff', 0) - days = start.get('Days') if 'Days' in start else start.get('days') - normalized['start'] = {'coeff': coeff, 'days': days} - else: normalized['start'] = start - if 'End' in window_dict: - end = window_dict['End'] + coeff = ( + start.get("Coeff") + if "Coeff" in start + else start.get("coeff", 0) + ) + days = start.get("Days") if "Days" in start else start.get("days") + normalized["start"] = {"coeff": coeff, "days": days} + else: + normalized["start"] = start + if "End" in window_dict: + end = window_dict["End"] if isinstance(end, dict): - coeff = end.get('Coeff') if 'Coeff' in end else end.get('coeff', 0) - days = end.get('Days') if 'Days' in end else end.get('days') - normalized['end'] = {'coeff': coeff, 'days': days} - else: normalized['end'] = end - - if 'coeff' not in normalized and 'start' in normalized: - if isinstance(normalized['start'], dict) and 'coeff' in normalized['start']: - normalized['coeff'] = normalized['start']['coeff'] - else: normalized['coeff'] = 0 - elif 'coeff' not in normalized: normalized['coeff'] = 0 - if 'useEventEnd' not in normalized: normalized['useEventEnd'] = False + coeff = end.get("Coeff") if "Coeff" in end else end.get("coeff", 0) + days = end.get("Days") if "Days" in end else end.get("days") + normalized["end"] = {"coeff": coeff, "days": days} + else: + normalized["end"] = end + + if "coeff" not in normalized and "start" in normalized: + if ( + isinstance(normalized["start"], dict) + and "coeff" in normalized["start"] + ): + normalized["coeff"] = normalized["start"]["coeff"] + else: + normalized["coeff"] = 0 + elif "coeff" not in normalized: + normalized["coeff"] = 0 + if "useEventEnd" not in normalized: + normalized["useEventEnd"] = False return normalized deserialized = [] @@ -1114,81 +1337,158 @@ def normalize_window(window_dict: dict) -> dict: if not isinstance(item, dict): deserialized.append(item) continue - + item_copy = dict(item) - if 'StartWindow' in item_copy: item_copy['StartWindow'] = normalize_window(item_copy['StartWindow']) - elif 'startWindow' in item_copy: - item_copy['StartWindow'] = normalize_window(item_copy['startWindow']) - item_copy.pop('startWindow', None) - if 'EndWindow' in item_copy: item_copy['EndWindow'] = normalize_window(item_copy['EndWindow']) - elif 'endWindow' in item_copy: - item_copy['EndWindow'] = normalize_window(item_copy['endWindow']) - item_copy.pop('endWindow', None) + if "StartWindow" in item_copy: + item_copy["StartWindow"] = normalize_window(item_copy["StartWindow"]) + elif "startWindow" in item_copy: + item_copy["StartWindow"] = normalize_window(item_copy["startWindow"]) + item_copy.pop("startWindow", None) + if "EndWindow" in item_copy: + item_copy["EndWindow"] = normalize_window(item_copy["EndWindow"]) + elif "endWindow" in item_copy: + item_copy["EndWindow"] = normalize_window(item_copy["endWindow"]) + item_copy.pop("endWindow", None) # Polymorphic handling for Criteria field - if 'Criteria' in item_copy or 'criteria' in item_copy: - if 'Criteria' in item_copy: item_copy['criteria'] = item_copy.pop('Criteria') + if "Criteria" in item_copy or "criteria" in item_copy: + if "Criteria" in item_copy: + item_copy["criteria"] = item_copy.pop("Criteria") # Inner criteria deserialization - if isinstance(item_copy.get('criteria'), dict): - c_dict = item_copy['criteria'] + if isinstance(item_copy.get("criteria"), dict): + c_dict = item_copy["criteria"] c_type = next(iter(c_dict.keys()), None) if c_type and c_type in NAMES_TO_CLASSES: try: c_data = dict(c_dict[c_type]) # PascalCase defaults - if c_type == 'Measurement' and 'MeasurementTypeExclude' not in c_data and 'measurementTypeExclude' not in c_data: - c_data['MeasurementTypeExclude'] = False - if c_type == 'Observation' and 'ObservationTypeExclude' not in c_data and 'observationTypeExclude' not in c_data: - c_data['ObservationTypeExclude'] = False - if c_type == 'ConditionOccurrence' and 'ConditionTypeExclude' not in c_data and 'conditionTypeExclude' not in c_data: - c_data['ConditionTypeExclude'] = False - if 'First' not in c_data and 'first' not in c_data: - c_data['First'] = False - - c_obj = NAMES_TO_CLASSES[c_type].model_validate(c_data, strict=False) - item_copy['criteria'] = c_obj - except: pass - - if 'Occurrence' in item_copy: - occ = item_copy.pop('Occurrence') - item_copy['occurrence'] = Occurrence.model_validate(occ) if isinstance(occ, dict) else occ - elif 'occurrence' not in item_copy: - item_copy['occurrence'] = Occurrence(type=Occurrence._AT_LEAST, count=1, is_distinct=False) + if ( + c_type == "Measurement" + and "MeasurementTypeExclude" not in c_data + and "measurementTypeExclude" not in c_data + ): + c_data["MeasurementTypeExclude"] = False + if ( + c_type == "Observation" + and "ObservationTypeExclude" not in c_data + and "observationTypeExclude" not in c_data + ): + c_data["ObservationTypeExclude"] = False + if ( + c_type == "ConditionOccurrence" + and "ConditionTypeExclude" not in c_data + and "conditionTypeExclude" not in c_data + ): + c_data["ConditionTypeExclude"] = False + if "First" not in c_data and "first" not in c_data: + c_data["First"] = False + + c_obj = NAMES_TO_CLASSES[c_type].model_validate( + c_data, strict=False + ) + item_copy["criteria"] = c_obj + except: + pass + + if "Occurrence" in item_copy: + occ = item_copy.pop("Occurrence") + item_copy["occurrence"] = ( + Occurrence.model_validate(occ) if isinstance(occ, dict) else occ + ) + elif "occurrence" not in item_copy: + item_copy["occurrence"] = Occurrence( + type=Occurrence._AT_LEAST, count=1, is_distinct=False + ) try: deserialized.append(CorelatedCriteria.model_validate(item_copy)) - except: deserialized.append(item) - - elif any(k in item_copy for k in ['StartWindow', 'EndWindow', 'RestrictVisit', 'IgnoreObservationPeriod']): + except: + deserialized.append(item) + + elif any( + k in item_copy + for k in [ + "StartWindow", + "EndWindow", + "RestrictVisit", + "IgnoreObservationPeriod", + ] + ): # Implicit CorelatedCriteria - c_type = next((k for k in item_copy.keys() if k not in ['StartWindow', 'EndWindow', 'RestrictVisit', 'IgnoreObservationPeriod', 'Occurrence', 'criteria']), None) + c_type = next( + ( + k + for k in item_copy.keys() + if k + not in [ + "StartWindow", + "EndWindow", + "RestrictVisit", + "IgnoreObservationPeriod", + "Occurrence", + "criteria", + ] + ), + None, + ) if c_type and c_type in NAMES_TO_CLASSES: c_data = item_copy[c_type] # Explicitly deserialize inner criteria to avoid Pydantic union ambiguity try: # PascalCase defaults for specific types - if c_type == 'Measurement' and 'MeasurementTypeExclude' not in c_data and 'measurementTypeExclude' not in c_data: - c_data['MeasurementTypeExclude'] = False - if c_type == 'Observation' and 'ObservationTypeExclude' not in c_data and 'observationTypeExclude' not in c_data: - c_data['ObservationTypeExclude'] = False - if c_type == 'ConditionOccurrence' and 'ConditionTypeExclude' not in c_data and 'conditionTypeExclude' not in c_data: - c_data['ConditionTypeExclude'] = False - if 'First' not in c_data and 'first' not in c_data: - c_data['First'] = False - - c_obj = NAMES_TO_CLASSES[c_type].model_validate(c_data, strict=False) - + if ( + c_type == "Measurement" + and "MeasurementTypeExclude" not in c_data + and "measurementTypeExclude" not in c_data + ): + c_data["MeasurementTypeExclude"] = False + if ( + c_type == "Observation" + and "ObservationTypeExclude" not in c_data + and "observationTypeExclude" not in c_data + ): + c_data["ObservationTypeExclude"] = False + if ( + c_type == "ConditionOccurrence" + and "ConditionTypeExclude" not in c_data + and "conditionTypeExclude" not in c_data + ): + c_data["ConditionTypeExclude"] = False + if "First" not in c_data and "first" not in c_data: + c_data["First"] = False + + c_obj = NAMES_TO_CLASSES[c_type].model_validate( + c_data, strict=False + ) + corelated_dict = { - 'criteria': c_obj, - 'Occurrence': item_copy.get('Occurrence', {'Type': Occurrence._AT_LEAST, 'Count': 1, 'IsDistinct': False}) + "criteria": c_obj, + "Occurrence": item_copy.get( + "Occurrence", + { + "Type": Occurrence._AT_LEAST, + "Count": 1, + "IsDistinct": False, + }, + ), } - for f in ['StartWindow', 'EndWindow', 'RestrictVisit', 'IgnoreObservationPeriod']: - if f in item_copy: corelated_dict[f] = item_copy[f] - - deserialized.append(CorelatedCriteria.model_validate(corelated_dict)) - except: deserialized.append(item) - else: deserialized.append(item) - + for f in [ + "StartWindow", + "EndWindow", + "RestrictVisit", + "IgnoreObservationPeriod", + ]: + if f in item_copy: + corelated_dict[f] = item_copy[f] + + deserialized.append( + CorelatedCriteria.model_validate(corelated_dict) + ) + except: + deserialized.append(item) + else: + deserialized.append(item) + else: # Simple polymorphic wrapped in corelated c_type = next(iter(item_copy.keys()), None) @@ -1196,94 +1496,130 @@ def normalize_window(window_dict: dict) -> dict: try: c_data = item_copy[c_type] # PascalCase defaults - if c_type == 'Measurement' and 'MeasurementTypeExclude' not in c_data and 'measurementTypeExclude' not in c_data: - c_data['MeasurementTypeExclude'] = False - if c_type == 'Observation' and 'ObservationTypeExclude' not in c_data and 'observationTypeExclude' not in c_data: - c_data['ObservationTypeExclude'] = False - if c_type == 'ConditionOccurrence' and 'ConditionTypeExclude' not in c_data and 'conditionTypeExclude' not in c_data: - c_data['ConditionTypeExclude'] = False - if 'First' not in c_data and 'first' not in c_data: - c_data['First'] = False - - c_obj = NAMES_TO_CLASSES[c_type].model_validate(c_data, strict=False) - corelated_dict = {'criteria': c_obj} - deserialized.append(CorelatedCriteria.model_validate(corelated_dict)) - except: deserialized.append(item) - else: deserialized.append(item) + if ( + c_type == "Measurement" + and "MeasurementTypeExclude" not in c_data + and "measurementTypeExclude" not in c_data + ): + c_data["MeasurementTypeExclude"] = False + if ( + c_type == "Observation" + and "ObservationTypeExclude" not in c_data + and "observationTypeExclude" not in c_data + ): + c_data["ObservationTypeExclude"] = False + if ( + c_type == "ConditionOccurrence" + and "ConditionTypeExclude" not in c_data + and "conditionTypeExclude" not in c_data + ): + c_data["ConditionTypeExclude"] = False + if "First" not in c_data and "first" not in c_data: + c_data["First"] = False + + c_obj = NAMES_TO_CLASSES[c_type].model_validate( + c_data, strict=False + ) + corelated_dict = {"criteria": c_obj} + deserialized.append( + CorelatedCriteria.model_validate(corelated_dict) + ) + except: + deserialized.append(item) + else: + deserialized.append(item) return deserialized # Define CriteriaType Union for strict typing CriteriaType = Union[ - ConditionOccurrence, DrugExposure, ProcedureOccurrence, - VisitOccurrence, Observation, Measurement, DeviceExposure, - Specimen, Death, VisitDetail, ObservationPeriod, - PayerPlanPeriod, LocationRegion, ConditionEra, - DrugEra, DoseEra + ConditionOccurrence, + DrugExposure, + ProcedureOccurrence, + VisitOccurrence, + Observation, + Measurement, + DeviceExposure, + Specimen, + Death, + VisitDetail, + ObservationPeriod, + PayerPlanPeriod, + LocationRegion, + ConditionEra, + DrugEra, + DoseEra, ] # Map for dynamic lookup NAMES_TO_CLASSES = { - 'ConditionOccurrence': ConditionOccurrence, - 'DrugExposure': DrugExposure, - 'ProcedureOccurrence': ProcedureOccurrence, - 'VisitOccurrence': VisitOccurrence, - 'Observation': Observation, - 'Measurement': Measurement, - 'DeviceExposure': DeviceExposure, - 'Specimen': Specimen, - 'Death': Death, - 'VisitDetail': VisitDetail, - 'ObservationPeriod': ObservationPeriod, - 'PayerPlanPeriod': PayerPlanPeriod, - 'LocationRegion': LocationRegion, - 'ConditionEra': ConditionEra, - 'DrugEra': DrugEra, - 'DoseEra': DoseEra, + "ConditionOccurrence": ConditionOccurrence, + "DrugExposure": DrugExposure, + "ProcedureOccurrence": ProcedureOccurrence, + "VisitOccurrence": VisitOccurrence, + "Observation": Observation, + "Measurement": Measurement, + "DeviceExposure": DeviceExposure, + "Specimen": Specimen, + "Death": Death, + "VisitDetail": VisitDetail, + "ObservationPeriod": ObservationPeriod, + "PayerPlanPeriod": PayerPlanPeriod, + "LocationRegion": LocationRegion, + "ConditionEra": ConditionEra, + "DrugEra": DrugEra, + "DoseEra": DoseEra, } class PrimaryCriteria(BaseModel): """Represents the primary criteria for cohort definition. - + Java equivalent: org.ohdsi.circe.cohortdefinition.PrimaryCriteria """ + criteria_list: List[CriteriaType] = Field( default_factory=list, validation_alias=AliasChoices("CriteriaList", "criteriaList"), - serialization_alias="CriteriaList" + serialization_alias="CriteriaList", ) observation_window: Optional[ObservationFilter] = Field( default=None, validation_alias=AliasChoices("ObservationWindow", "observationWindow"), - serialization_alias="ObservationWindow" + serialization_alias="ObservationWindow", ) primary_limit: Optional[ResultLimit] = Field( default=None, - validation_alias=AliasChoices("PrimaryLimit", "PrimaryCriteriaLimit", "primaryCriteriaLimit", "primaryLimit", "PrimaryLimit"), - serialization_alias="PrimaryCriteriaLimit" + validation_alias=AliasChoices( + "PrimaryLimit", + "PrimaryCriteriaLimit", + "primaryCriteriaLimit", + "primaryLimit", + "PrimaryLimit", + ), + serialization_alias="PrimaryCriteriaLimit", ) model_config = ConfigDict(populate_by_name=True) - - @field_validator('criteria_list', mode='before') + + @field_validator("criteria_list", mode="before") @classmethod def deserialize_criteria_list(cls, v: Any) -> Any: if v is None: return [] if not v or not isinstance(v, list): return v - + deserialized = [] for item in v: if not isinstance(item, dict): deserialized.append(item) continue - + # Find the type key (e.g. "ConditionOccurrence" or "conditionOccurrence") c_type_raw = next(iter(item.keys()), None) - + # Case-insensitive lookup c_type = None if c_type_raw: @@ -1296,18 +1632,32 @@ def deserialize_criteria_list(cls, v: Any) -> Any: if k.lower() == c_type_raw.lower(): c_type = k break - + if c_type: try: c_data = dict(item[c_type_raw]) - if c_type == 'Measurement' and 'MeasurementTypeExclude' not in c_data: c_data['MeasurementTypeExclude'] = False - if c_type == 'Observation' and 'ObservationTypeExclude' not in c_data: c_data['ObservationTypeExclude'] = False - if c_type == 'ConditionOccurrence' and 'ConditionTypeExclude' not in c_data: c_data['ConditionTypeExclude'] = False - if 'First' not in c_data: c_data['First'] = False - + if ( + c_type == "Measurement" + and "MeasurementTypeExclude" not in c_data + ): + c_data["MeasurementTypeExclude"] = False + if ( + c_type == "Observation" + and "ObservationTypeExclude" not in c_data + ): + c_data["ObservationTypeExclude"] = False + if ( + c_type == "ConditionOccurrence" + and "ConditionTypeExclude" not in c_data + ): + c_data["ConditionTypeExclude"] = False + if "First" not in c_data: + c_data["First"] = False + obj = NAMES_TO_CLASSES[c_type].model_validate(c_data, strict=False) deserialized.append(obj) - except: deserialized.append(item) + except: + deserialized.append(item) else: deserialized.append(item) return deserialized diff --git a/circe/cohortdefinition/interfaces.py b/circe/cohortdefinition/interfaces.py index ae51eb0d..3a33c5c6 100644 --- a/circe/cohortdefinition/interfaces.py +++ b/circe/cohortdefinition/interfaces.py @@ -12,9 +12,22 @@ from abc import ABC, abstractmethod from typing import Optional from .criteria import ( - LocationRegion, ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, ObservationPeriod, - PayerPlanPeriod, ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail + LocationRegion, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, ) from .core import DateOffsetStrategy, CustomEraStrategy from .builders.utils import BuilderOptions @@ -22,102 +35,144 @@ class IGetCriteriaSqlDispatcher(ABC): """Interface for dispatching SQL generation for different criteria types. - + Java equivalent: org.ohdsi.circe.cohortdefinition.IGetCriteriaSqlDispatcher """ - + @abstractmethod - def get_criteria_sql(self, location_region: LocationRegion, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, location_region: LocationRegion, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for location region criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, condition_era: ConditionEra, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, condition_era: ConditionEra, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for condition era criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, condition_occurrence: ConditionOccurrence, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, + condition_occurrence: ConditionOccurrence, + options: Optional[BuilderOptions] = None, + ) -> str: """Generate SQL for condition occurrence criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, death: Death, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, death: Death, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for death criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, device_exposure: DeviceExposure, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, device_exposure: DeviceExposure, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for device exposure criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, dose_era: DoseEra, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, dose_era: DoseEra, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for dose era criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, drug_era: DrugEra, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, drug_era: DrugEra, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for drug era criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, drug_exposure: DrugExposure, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, drug_exposure: DrugExposure, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for drug exposure criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, measurement: Measurement, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, measurement: Measurement, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for measurement criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, observation: Observation, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, observation: Observation, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for observation criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, observation_period: ObservationPeriod, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, + observation_period: ObservationPeriod, + options: Optional[BuilderOptions] = None, + ) -> str: """Generate SQL for observation period criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, payer_plan_period: PayerPlanPeriod, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, + payer_plan_period: PayerPlanPeriod, + options: Optional[BuilderOptions] = None, + ) -> str: """Generate SQL for payer plan period criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, procedure_occurrence: ProcedureOccurrence, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, + procedure_occurrence: ProcedureOccurrence, + options: Optional[BuilderOptions] = None, + ) -> str: """Generate SQL for procedure occurrence criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, specimen: Specimen, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, specimen: Specimen, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for specimen criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, visit_occurrence: VisitOccurrence, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, + visit_occurrence: VisitOccurrence, + options: Optional[BuilderOptions] = None, + ) -> str: """Generate SQL for visit occurrence criteria.""" pass - + @abstractmethod - def get_criteria_sql(self, visit_detail: VisitDetail, options: Optional[BuilderOptions] = None) -> str: + def get_criteria_sql( + self, visit_detail: VisitDetail, options: Optional[BuilderOptions] = None + ) -> str: """Generate SQL for visit detail criteria.""" pass class IGetEndStrategySqlDispatcher(ABC): """Interface for dispatching SQL generation for end strategies. - + Java equivalent: org.ohdsi.circe.cohortdefinition.IGetEndStrategySqlDispatcher """ - + @abstractmethod def get_strategy_sql(self, strategy: DateOffsetStrategy, event_table: str) -> str: """Generate SQL for date offset strategy.""" pass - + @abstractmethod def get_strategy_sql(self, strategy: CustomEraStrategy, event_table: str) -> str: """Generate SQL for custom era strategy.""" diff --git a/circe/cohortdefinition/printfriendly/__init__.py b/circe/cohortdefinition/printfriendly/__init__.py index 0cca93d9..a4271942 100644 --- a/circe/cohortdefinition/printfriendly/__init__.py +++ b/circe/cohortdefinition/printfriendly/__init__.py @@ -11,6 +11,4 @@ from .markdown_render import MarkdownRender -__all__ = [ - "MarkdownRender" -] +__all__ = ["MarkdownRender"] diff --git a/circe/cohortdefinition/printfriendly/markdown_render.py b/circe/cohortdefinition/printfriendly/markdown_render.py index 5b3d2a90..6cc897a9 100644 --- a/circe/cohortdefinition/printfriendly/markdown_render.py +++ b/circe/cohortdefinition/printfriendly/markdown_render.py @@ -23,93 +23,103 @@ class MarkdownRender: """Generates human-readable markdown descriptions of cohort definitions. - + Java equivalent: org.ohdsi.circe.cohortdefinition.printfriendly.MarkdownRender - + This implementation uses Jinja2 templates to achieve 1:1 parity with the Java FreeMarker template implementation. Templates are located in the templates/ subdirectory and mirror the structure of Java's .ftl files. """ - - def __init__(self, concept_sets: Optional[List[ConceptSet]] = None, include_concept_sets: bool = False): + + def __init__( + self, + concept_sets: Optional[List[ConceptSet]] = None, + include_concept_sets: bool = False, + ): """Initialize the markdown renderer. - + Args: concept_sets: Optional list of concept sets for resolving codeset IDs to names include_concept_sets: Whether to include concept set tables in the output (default: False) """ self._concept_sets = concept_sets or [] self._include_concept_sets = include_concept_sets - + # Initialize Jinja2 environment - template_dir = Path(__file__).parent / 'templates' + template_dir = Path(__file__).parent / "templates" self._env = jinja2.Environment( loader=jinja2.FileSystemLoader(str(template_dir)), trim_blocks=True, lstrip_blocks=True, - autoescape=False # We're generating markdown, not HTML + autoescape=False, # We're generating markdown, not HTML ) - + # Register custom filters (matching Java utils.ftl) - self._env.filters['format_date'] = self._format_date - self._env.filters['format_number'] = self._format_number - + self._env.filters["format_date"] = self._format_date + self._env.filters["format_number"] = self._format_number + # Register global functions - self._env.globals['codeset_name'] = self._codeset_name - self._env.globals['format_date'] = self._format_date - self._env.globals['format_number'] = self._format_number - + self._env.globals["codeset_name"] = self._codeset_name + self._env.globals["format_date"] = self._format_date + self._env.globals["format_number"] = self._format_number + def render_cohort_expression( - self, - cohort_expression: Union[CohortExpression, str], - include_concept_sets: Optional[bool] = None, - title: Optional[str] = None + self, + cohort_expression: Union[CohortExpression, str], + include_concept_sets: Optional[bool] = None, + title: Optional[str] = None, ) -> str: """Render a cohort expression to markdown format. - + Java equivalent: renderCohort(CohortExpression) - + Args: cohort_expression: The cohort expression to render, or JSON string - include_concept_sets: Whether to include concept set tables in the output + include_concept_sets: Whether to include concept set tables in the output (overrides init parameter if provided) title: Optional title for the markdown output - + Returns: Markdown formatted string describing the cohort """ # Handle JSON string input if isinstance(cohort_expression, str): cohort_expression = CohortExpression.model_validate_json(cohort_expression) - + if not cohort_expression: return "# Invalid Cohort Expression\n\nNo cohort expression provided." - + # Update concept sets for resolving names if cohort_expression.concept_sets: self._concept_sets = cohort_expression.concept_sets - + # Determine whether to include concept sets - should_include = include_concept_sets if include_concept_sets is not None else self._include_concept_sets - + should_include = ( + include_concept_sets + if include_concept_sets is not None + else self._include_concept_sets + ) + # Load and render the main template - template = self._env.get_template('cohort_expression.j2') - + template = self._env.get_template("cohort_expression.j2") + return template.render( cohort=cohort_expression, conceptSets=self._concept_sets, title=title or cohort_expression.title or "Untitled Cohort", - include_concept_sets=should_include + include_concept_sets=should_include, ) - - def render_concept_set_list(self, concept_sets: Union[List[ConceptSet], str]) -> str: + + def render_concept_set_list( + self, concept_sets: Union[List[ConceptSet], str] + ) -> str: """Render a list of concept sets to markdown format. - + Java equivalent: renderConceptSetList(ConceptSet[]) - + Args: concept_sets: List of ConceptSet objects or JSON string - + Returns: Markdown formatted string describing the concept sets """ @@ -120,26 +130,26 @@ def render_concept_set_list(self, concept_sets: Union[List[ConceptSet], str]) -> concept_sets = [ConceptSet.model_validate(item) for item in data] else: concept_sets = [ConceptSet.model_validate(data)] - + if not concept_sets: return "No concept sets specified.\n" - + # Update internal concept sets for name resolution self._concept_sets = concept_sets - + # Load and render the concept set template - template = self._env.get_template('concept_set.j2') - + template = self._env.get_template("concept_set.j2") + return template.render(conceptSets=concept_sets) - + def render_concept_set(self, concept_set: Union[ConceptSet, str]) -> str: """Render a single concept set to markdown format. - + Java equivalent: renderConceptSet(ConceptSet) - + Args: concept_set: ConceptSet object or JSON string - + Returns: Markdown formatted string describing the concept set """ @@ -147,43 +157,45 @@ def render_concept_set(self, concept_set: Union[ConceptSet, str]) -> str: if isinstance(concept_set, str): data = json.loads(concept_set) concept_set = ConceptSet.model_validate(data) - + return self.render_concept_set_list([concept_set]) - + # ========================================================================= # Custom Filters and Functions (matching Java utils.ftl) # ========================================================================= - - def _codeset_name(self, codeset_id: Optional[int], default_name: str = "any") -> str: + + def _codeset_name( + self, codeset_id: Optional[int], default_name: str = "any" + ) -> str: """Get concept set name from codeset ID, or return default. - + Java equivalent: utils.codesetName() - + Args: codeset_id: Optional concept set ID default_name: Default name if codeset_id is None or not found - + Returns: Concept set name in quotes, or default name """ if codeset_id is None: return default_name - + # Find concept set by ID for concept_set in self._concept_sets: if concept_set.id == codeset_id: return f"'{concept_set.name}'" - + return default_name - + def _format_date(self, date_string: str) -> str: """Format date string from YYYY-MM-DD to "Month Day, Year". - + Java equivalent: utils.formatDate() - + Args: date_string: Date string in YYYY-MM-DD format - + Returns: Formatted date string like "January 1, 2010" """ @@ -195,21 +207,21 @@ def _format_date(self, date_string: str) -> str: return date_string except (ValueError, AttributeError): return "_invalid date_" - + def _format_number(self, value: Union[int, float]) -> str: """Format number with thousands separators and handle integer/float logic. - + Args: value: Number to format - + Returns: Formatted string (e.g. "1,500" or "1.5") """ if value is None: return "" - + # If matches integer, convert to int for clean formatting if isinstance(value, float) and value.is_integer(): value = int(value) - + return f"{value:,}" diff --git a/circe/cohortdefinition/utils.py b/circe/cohortdefinition/utils.py index 1fb0c4c4..19e7c6f2 100644 --- a/circe/cohortdefinition/utils.py +++ b/circe/cohortdefinition/utils.py @@ -7,36 +7,36 @@ def to_camel_alias(field_name: str) -> str: """Convert field name to camelCase for JSON compatibility. - + This is used as an alias_generator in Pydantic ConfigDict to automatically handle field name conversion from Python snake_case to JSON camelCase. - + Examples: prior_days -> priorDays era_pad -> eraPad collapse_type -> collapseType - + Args: field_name: Python field name (typically snake_case) - + Returns: camelCase version of the field name """ - if '_' in field_name: + if "_" in field_name: # Convert snake_case to camelCase - parts = field_name.split('_') - return parts[0] + ''.join(word.capitalize() for word in parts[1:]) + parts = field_name.split("_") + return parts[0] + "".join(word.capitalize() for word in parts[1:]) # Already in camelCase or single word, return as-is return field_name def to_pascal_alias(field_name: str) -> str: """Convert field name to PascalCase for Java JSON compatibility. - + This is used as an alias_generator in Pydantic ConfigDict to automatically handle field name conversion from Python snake_case to Java JSON PascalCase. Java CIRCE-BE uses PascalCase for all JSON field names. - + Examples: concept_sets -> ConceptSets primary_criteria -> PrimaryCriteria @@ -44,17 +44,16 @@ def to_pascal_alias(field_name: str) -> str: observation_window -> ObservationWindow codeset_id -> CodesetId condition_type_exclude -> ConditionTypeExclude - + Args: field_name: Python field name (typically snake_case) - + Returns: PascalCase version of the field name """ - if '_' in field_name: + if "_" in field_name: # Convert snake_case to PascalCase (capitalize all parts including first) - parts = field_name.split('_') - return ''.join(word.capitalize() for word in parts) + parts = field_name.split("_") + return "".join(word.capitalize() for word in parts) # Single word - capitalize first letter return field_name[0].upper() + field_name[1:] if field_name else field_name - diff --git a/circe/helper/cohort_modifiers.py b/circe/helper/cohort_modifiers.py index 636d8d9a..9eb0034e 100644 --- a/circe/helper/cohort_modifiers.py +++ b/circe/helper/cohort_modifiers.py @@ -85,6 +85,7 @@ # Internal helpers # --------------------------------------------------------------------------- + def _ensure_primary_criteria(expr: CohortExpression) -> PrimaryCriteria: """Return the PrimaryCriteria, creating an empty one if absent.""" if expr.primary_criteria is None: @@ -118,6 +119,7 @@ def _ensure_collapse_settings(expr: CohortExpression) -> CollapseSettings: # 1. Prior Observation Window # =========================================================================== + def set_prior_observation( cohort_expression: CohortExpression, days: int, @@ -152,6 +154,7 @@ def set_prior_observation( # 2. Post Observation Window # =========================================================================== + def set_post_observation( cohort_expression: CohortExpression, days: int, @@ -186,6 +189,7 @@ def set_post_observation( # 3. Limit to First Event # =========================================================================== + def set_limit_to_first_event( cohort_expression: CohortExpression, ) -> CohortExpression: @@ -213,6 +217,7 @@ def set_limit_to_first_event( # 4. Allow All Events # =========================================================================== + def set_allow_all_events( cohort_expression: CohortExpression, ) -> CohortExpression: @@ -240,6 +245,7 @@ def set_allow_all_events( # 6. Cohort Era (Collapse / Persistence Window) # =========================================================================== + def set_cohort_era( cohort_expression: CohortExpression, era_gap_days: int, @@ -275,6 +281,7 @@ def set_cohort_era( # 7. Age Criteria # =========================================================================== + def set_age_criteria( cohort_expression: CohortExpression, min_age: Optional[int] = None, @@ -342,7 +349,9 @@ def set_age_criteria( groups=[], ) else: - cohort_expression.additional_criteria.demographic_criteria_list.append(demographic) + cohort_expression.additional_criteria.demographic_criteria_list.append( + demographic + ) return cohort_expression @@ -351,6 +360,7 @@ def set_age_criteria( # 8. Gender Criteria # =========================================================================== + def set_gender_criteria( cohort_expression: CohortExpression, gender_concept_ids: Union[int, Sequence[int]], @@ -414,7 +424,9 @@ def set_gender_criteria( groups=[], ) else: - cohort_expression.additional_criteria.demographic_criteria_list.append(demographic) + cohort_expression.additional_criteria.demographic_criteria_list.append( + demographic + ) return cohort_expression @@ -423,6 +435,7 @@ def set_gender_criteria( # 9. End Date Strategy # =========================================================================== + def set_end_date_strategy( cohort_expression: CohortExpression, strategy: str, @@ -497,6 +510,7 @@ def set_end_date_strategy( # 10. Washout Period (alias for prior observation) # =========================================================================== + def set_washout_period( cohort_expression: CohortExpression, days: int, @@ -601,9 +615,7 @@ def set_clean_window( mode = criteria_mode.strip().lower() if mode not in ("any", "all"): - raise ValueError( - f"criteria_mode must be 'any' or 'all', got '{criteria_mode}'" - ) + raise ValueError(f"criteria_mode must be 'any' or 'all', got '{criteria_mode}'") pc = cohort_expression.primary_criteria if pc is None or not pc.criteria_list: @@ -692,6 +704,7 @@ def reset_clean_window( # 11. Restrict to Calendar Date Range # =========================================================================== + def set_date_range( cohort_expression: CohortExpression, start_date: Optional[Union[str, date]] = None, @@ -736,6 +749,7 @@ def set_date_range( # 12. Censor at Event # =========================================================================== + def set_censor_event( cohort_expression: CohortExpression, censor_criteria: Union[Criteria, CriteriaType], @@ -780,6 +794,7 @@ def clear_censor_events( # Reset helpers # =========================================================================== + def reset_observation_window( cohort_expression: CohortExpression, ) -> CohortExpression: @@ -885,6 +900,7 @@ def reset_date_range( # Convenience: apply multiple modifiers at once # =========================================================================== + def apply_standard_rules( cohort_expression: CohortExpression, prior_observation_days: int = 365, @@ -951,6 +967,3 @@ def apply_standard_rules( ) return cohort_expression - - - diff --git a/circe/vocabulary/__init__.py b/circe/vocabulary/__init__.py index d7b5fd0c..0e4bd7ca 100644 --- a/circe/vocabulary/__init__.py +++ b/circe/vocabulary/__init__.py @@ -9,10 +9,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from .concept import ( - Concept, ConceptSet, ConceptSetExpression, ConceptSetItem -) +from .concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem -__all__ = [ - "Concept", "ConceptSet", "ConceptSetExpression", "ConceptSetItem" -] \ No newline at end of file +__all__ = ["Concept", "ConceptSet", "ConceptSetExpression", "ConceptSetItem"] diff --git a/circe/vocabulary/concept.py b/circe/vocabulary/concept.py index 0051647c..ad1c089c 100644 --- a/circe/vocabulary/concept.py +++ b/circe/vocabulary/concept.py @@ -19,45 +19,54 @@ class Concept(BaseModel): Note: In Java, conceptId is Long (nullable), but JSON schema marks it as required. We make it Optional to match Java runtime behavior while maintaining schema compatibility. """ + concept_id: Optional[int] = Field( default=None, - validation_alias=AliasChoices("ConceptId", "CONCEPT_ID", "conceptId", "ConceptID"), - serialization_alias="CONCEPT_ID" + validation_alias=AliasChoices( + "ConceptId", "CONCEPT_ID", "conceptId", "ConceptID" + ), + serialization_alias="CONCEPT_ID", ) concept_name: Optional[str] = Field( default=None, validation_alias=AliasChoices("ConceptName", "CONCEPT_NAME", "conceptName"), - serialization_alias="CONCEPT_NAME" + serialization_alias="CONCEPT_NAME", ) concept_code: Optional[str] = Field( default=None, validation_alias=AliasChoices("ConceptCode", "CONCEPT_CODE", "conceptCode"), - serialization_alias="CONCEPT_CODE" + serialization_alias="CONCEPT_CODE", ) concept_class_id: Optional[str] = Field( default=None, - validation_alias=AliasChoices("ConceptClassId", "CONCEPT_CLASS_ID", "conceptClassId"), - serialization_alias="CONCEPT_CLASS_ID" + validation_alias=AliasChoices( + "ConceptClassId", "CONCEPT_CLASS_ID", "conceptClassId" + ), + serialization_alias="CONCEPT_CLASS_ID", ) standard_concept: Optional[str] = Field( default=None, - validation_alias=AliasChoices("StandardConcept", "STANDARD_CONCEPT", "standardConcept"), - serialization_alias="STANDARD_CONCEPT" + validation_alias=AliasChoices( + "StandardConcept", "STANDARD_CONCEPT", "standardConcept" + ), + serialization_alias="STANDARD_CONCEPT", ) invalid_reason: Optional[str] = Field( default=None, - validation_alias=AliasChoices("InvalidReason", "INVALID_REASON", "invalidReason"), - serialization_alias="INVALID_REASON" + validation_alias=AliasChoices( + "InvalidReason", "INVALID_REASON", "invalidReason" + ), + serialization_alias="INVALID_REASON", ) domain_id: Optional[str] = Field( default=None, validation_alias=AliasChoices("DomainId", "DOMAIN_ID", "domainId"), - serialization_alias="DOMAIN_ID" + serialization_alias="DOMAIN_ID", ) vocabulary_id: Optional[str] = Field( default=None, validation_alias=AliasChoices("VocabularyId", "VOCABULARY_ID", "vocabularyId"), - serialization_alias="VOCABULARY_ID" + serialization_alias="VOCABULARY_ID", ) model_config = ConfigDict(populate_by_name=True) @@ -65,9 +74,10 @@ class Concept(BaseModel): class ConceptSetItem(BaseModel): """Represents an item in a concept set. - + Java equivalent: org.ohdsi.circe.vocabulary.ConceptSetItem """ + concept: Optional[Concept] = None is_excluded: bool = Field(default=False, alias="isExcluded") include_mapped: bool = Field(default=False, alias="includeMapped") @@ -78,12 +88,13 @@ class ConceptSetItem(BaseModel): class ConceptSetExpression(BaseModel): """Represents a concept set expression. - + Java equivalent: org.ohdsi.circe.vocabulary.ConceptSetExpression - + Note: isExcluded, includeMapped, includeDescendants may not be present in all Java JSONs (they're sometimes only on the items), so we provide defaults. """ + concept: Optional[Concept] = None is_excluded: bool = Field(default=False, alias="isExcluded") include_mapped: bool = Field(default=False, alias="includeMapped") @@ -99,24 +110,25 @@ class ConceptSet(BaseModel): id: int = Field( alias="id", validation_alias=AliasChoices("id", "ID"), - description="Field: id (int)" + description="Field: id (int)", ) name: Optional[str] = Field( default=None, alias="name", validation_alias=AliasChoices("name", "NAME"), - description="Field: name (String)" + description="Field: name (String)", ) expression: Optional[ConceptSetExpression] = Field( default=None, alias="expression", validation_alias=AliasChoices("expression", "EXPRESSION"), - description="Field: expression (ConceptSetExpression)" + description="Field: expression (ConceptSetExpression)", ) model_config = ConfigDict(populate_by_name=True) + # Forward references will be resolved when all classes are imported ConceptSet.model_rebuild() diff --git a/circe/vocabulary/concept_set_expression_query_builder.py b/circe/vocabulary/concept_set_expression_query_builder.py index 604044e6..6ced62bd 100644 --- a/circe/vocabulary/concept_set_expression_query_builder.py +++ b/circe/vocabulary/concept_set_expression_query_builder.py @@ -15,95 +15,126 @@ class ConceptSetExpressionQueryBuilder: """SQL builder for concept set expressions. - + Java equivalent: org.ohdsi.circe.vocabulary.ConceptSetExpressionQueryBuilder """ - + # SQL templates - equivalent to Java ResourceHelper.GetResourceAsString - CONCEPT_SET_QUERY_TEMPLATE = "select concept_id from @vocabulary_database_schema.CONCEPT where @conceptIdIn" - + CONCEPT_SET_QUERY_TEMPLATE = ( + "select concept_id from @vocabulary_database_schema.CONCEPT where @conceptIdIn" + ) + CONCEPT_SET_DESCENDANTS_TEMPLATE = """ select c.concept_id from @vocabulary_database_schema.CONCEPT c join @vocabulary_database_schema.CONCEPT_ANCESTOR ca on c.concept_id = ca.descendant_concept_id WHERE c.invalid_reason is null and @conceptIdIn""" - + CONCEPT_SET_MAPPED_TEMPLATE = """select distinct cr.concept_id_1 as concept_id FROM ( @conceptsetQuery ) C join @vocabulary_database_schema.concept_relationship cr on C.concept_id = cr.concept_id_2 and cr.relationship_id = 'Maps to' and cr.invalid_reason IS NULL""" - + CONCEPT_SET_INCLUDE_TEMPLATE = """select distinct I.concept_id FROM ( @includeQuery ) I""" - + CONCEPT_SET_EXCLUDE_TEMPLATE = """LEFT JOIN ( @excludeQuery ) E ON I.concept_id = E.concept_id WHERE E.concept_id is null""" - + MAX_IN_LENGTH = 1000 # Oracle limitation - + def get_concept_ids(self, concepts: List[Concept]) -> List[int]: """Get concept IDs from concept list. - + Java equivalent: getConceptIds() """ - return [concept.concept_id for concept in concepts if concept.concept_id is not None] - - def build_concept_set_sub_query(self, concepts: List[Concept], descendant_concepts: List[Concept]) -> str: + return [ + concept.concept_id for concept in concepts if concept.concept_id is not None + ] + + def build_concept_set_sub_query( + self, concepts: List[Concept], descendant_concepts: List[Concept] + ) -> str: """Build concept set sub-query. - + Java equivalent: buildConceptSetSubQuery() """ queries = [] - + if concepts: concept_ids = self.get_concept_ids(concepts) - concept_id_in = BuilderUtils.split_in_clause("concept_id", concept_ids, self.MAX_IN_LENGTH) - query = self.CONCEPT_SET_QUERY_TEMPLATE.replace("@conceptIdIn", concept_id_in) + concept_id_in = BuilderUtils.split_in_clause( + "concept_id", concept_ids, self.MAX_IN_LENGTH + ) + query = self.CONCEPT_SET_QUERY_TEMPLATE.replace( + "@conceptIdIn", concept_id_in + ) queries.append(query) - + if descendant_concepts: descendant_ids = self.get_concept_ids(descendant_concepts) - concept_id_in = BuilderUtils.split_in_clause("ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH) - query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace("@conceptIdIn", concept_id_in) + concept_id_in = BuilderUtils.split_in_clause( + "ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH + ) + query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace( + "@conceptIdIn", concept_id_in + ) queries.append(query) - + return " UNION ".join(queries) - - def build_concept_set_mapped_query(self, mapped_concepts: List[Concept], mapped_descendant_concepts: List[Concept]) -> str: + + def build_concept_set_mapped_query( + self, mapped_concepts: List[Concept], mapped_descendant_concepts: List[Concept] + ) -> str: """Build concept set mapped query. - + Java equivalent: buildConceptSetMappedQuery() """ - concept_set_query = self.build_concept_set_sub_query(mapped_concepts, mapped_descendant_concepts) - return self.CONCEPT_SET_MAPPED_TEMPLATE.replace("@conceptsetQuery", concept_set_query) - - def build_concept_set_query(self, concepts: List[Concept], descendant_concepts: List[Concept], - mapped_concepts: List[Concept], mapped_descendant_concepts: List[Concept]) -> str: + concept_set_query = self.build_concept_set_sub_query( + mapped_concepts, mapped_descendant_concepts + ) + return self.CONCEPT_SET_MAPPED_TEMPLATE.replace( + "@conceptsetQuery", concept_set_query + ) + + def build_concept_set_query( + self, + concepts: List[Concept], + descendant_concepts: List[Concept], + mapped_concepts: List[Concept], + mapped_descendant_concepts: List[Concept], + ) -> str: """Build concept set query. - + Java equivalent: buildConceptSetQuery() """ if not concepts: - return "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1" - - concept_set_query = self.build_concept_set_sub_query(concepts, descendant_concepts) - + return ( + "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1" + ) + + concept_set_query = self.build_concept_set_sub_query( + concepts, descendant_concepts + ) + if mapped_concepts or mapped_descendant_concepts: - mapped_query = self.build_concept_set_mapped_query(mapped_concepts, mapped_descendant_concepts) + mapped_query = self.build_concept_set_mapped_query( + mapped_concepts, mapped_descendant_concepts + ) concept_set_query += " UNION " + mapped_query - + return concept_set_query - + def build_expression_query(self, expression: ConceptSetExpression) -> str: """Build expression query for concept set. - + Java equivalent: buildExpressionQuery() """ # Handle included concepts @@ -111,21 +142,21 @@ def build_expression_query(self, expression: ConceptSetExpression) -> str: include_descendant_concepts = [] include_mapped_concepts = [] include_mapped_descendant_concepts = [] - + # Handle excluded concepts exclude_concepts = [] exclude_descendant_concepts = [] exclude_mapped_concepts = [] exclude_mapped_descendant_concepts = [] - + # Populate each sub-set of concepts from the flags set in each concept set item for item in expression.items: if not item.is_excluded: include_concepts.append(item.concept) - + if item.include_descendants: include_descendant_concepts.append(item.concept) - + if item.include_mapped: include_mapped_concepts.append(item.concept) if item.include_descendants: @@ -138,18 +169,18 @@ def build_expression_query(self, expression: ConceptSetExpression) -> str: exclude_mapped_concepts.append(item.concept) if item.include_descendants: exclude_mapped_descendant_concepts.append(item.concept) - + # Build the main concept set query concept_set_query = self.CONCEPT_SET_INCLUDE_TEMPLATE.replace( - "@includeQuery", + "@includeQuery", self.build_concept_set_query( - include_concepts, - include_descendant_concepts, - include_mapped_concepts, - include_mapped_descendant_concepts - ) + include_concepts, + include_descendant_concepts, + include_mapped_concepts, + include_mapped_descendant_concepts, + ), ) - + # Add exclusion query if needed if exclude_concepts: exclude_query = self.CONCEPT_SET_EXCLUDE_TEMPLATE.replace( @@ -158,9 +189,9 @@ def build_expression_query(self, expression: ConceptSetExpression) -> str: exclude_concepts, exclude_descendant_concepts, exclude_mapped_concepts, - exclude_mapped_descendant_concepts - ) + exclude_mapped_descendant_concepts, + ), ) concept_set_query += exclude_query - + return concept_set_query From 5339ac9fe87f17d9fc4e4d91fe0cc413c9890fbc Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Wed, 25 Feb 2026 10:33:27 -0800 Subject: [PATCH 05/62] code formatting --- circe/__init__.py | 105 +++++++------- circe/api.py | 5 +- circe/chat.py | 6 +- circe/check/__init__.py | 2 +- circe/check/check.py | 3 +- circe/check/checker.py | 35 ++--- circe/check/checkers/__init__.py | 54 ++++---- circe/check/checkers/attribute_check.py | 2 +- .../checkers/attribute_checker_factory.py | 3 +- circe/check/checkers/base_check.py | 3 +- circe/check/checkers/base_checker_factory.py | 1 + .../checkers/base_corelated_criteria_check.py | 4 +- circe/check/checkers/base_criteria_check.py | 5 +- circe/check/checkers/base_value_check.py | 13 +- circe/check/checkers/comparisons.py | 15 +- circe/check/checkers/concept_check.py | 2 +- .../check/checkers/concept_checker_factory.py | 17 +-- .../checkers/concept_set_criteria_check.py | 12 +- .../checkers/concept_set_selection_check.py | 2 +- .../concept_set_selection_checker_factory.py | 7 +- .../checkers/criteria_checker_factory.py | 27 ++-- .../checkers/criteria_contradictions_check.py | 7 +- .../check/checkers/death_time_window_check.py | 6 +- circe/check/checkers/domain_type_check.py | 17 +-- circe/check/checkers/drug_domain_check.py | 9 +- circe/check/checkers/drug_era_check.py | 2 +- .../checkers/duplicates_concept_set_check.py | 3 +- .../checkers/duplicates_criteria_check.py | 7 +- .../checkers/events_progression_check.py | 1 + circe/check/checkers/exit_criteria_check.py | 2 +- .../exit_criteria_days_offset_check.py | 2 +- circe/check/checkers/incomplete_rule_check.py | 1 + circe/check/checkers/initial_event_check.py | 2 +- .../check/checkers/no_exit_criteria_check.py | 2 +- circe/check/checkers/ocurrence_check.py | 2 +- circe/check/checkers/range_check.py | 7 +- circe/check/checkers/range_checker_factory.py | 41 +++--- circe/check/checkers/text_check.py | 2 +- circe/check/checkers/text_checker_factory.py | 11 +- circe/check/checkers/time_pattern_check.py | 9 +- circe/check/checkers/time_window_check.py | 11 +- circe/check/checkers/unused_concepts_check.py | 13 +- circe/check/checkers/warning_reporter.py | 2 +- circe/check/operations/__init__.py | 8 +- .../operations/conditional_operations.py | 2 +- .../check/operations/executive_operations.py | 4 +- circe/check/operations/operations.py | 5 +- circe/check/utils/criteria_name_helper.py | 24 ++-- circe/check/warnings/__init__.py | 2 +- circe/check/warnings/concept_set_warning.py | 3 +- circe/cli.py | 2 +- circe/cohortdefinition/__init__.py | 89 ++++++------ circe/cohortdefinition/builders/__init__.py | 20 +-- circe/cohortdefinition/builders/base.py | 3 +- .../builders/condition_era.py | 7 +- .../builders/condition_occurrence.py | 7 +- circe/cohortdefinition/builders/death.py | 10 +- .../builders/device_exposure.py | 10 +- circe/cohortdefinition/builders/dose_era.py | 7 +- circe/cohortdefinition/builders/drug_era.py | 7 +- .../builders/drug_exposure.py | 3 +- .../builders/location_region.py | 7 +- .../cohortdefinition/builders/measurement.py | 10 +- .../cohortdefinition/builders/observation.py | 10 +- .../builders/observation_period.py | 7 +- .../builders/payer_plan_period.py | 7 +- .../builders/procedure_occurrence.py | 3 +- circe/cohortdefinition/builders/specimen.py | 10 +- circe/cohortdefinition/builders/utils.py | 7 +- .../cohortdefinition/builders/visit_detail.py | 7 +- .../builders/visit_occurrence.py | 10 +- circe/cohortdefinition/code_generator.py | 6 +- circe/cohortdefinition/cohort.py | 44 +++--- .../cohort_expression_query_builder.py | 131 +++++++++--------- .../concept_set_expression_query_builder.py | 1 + circe/cohortdefinition/core.py | 14 +- circe/cohortdefinition/criteria.py | 26 ++-- circe/cohortdefinition/interfaces.py | 9 +- .../printfriendly/markdown_render.py | 7 +- circe/helper/__init__.py | 42 +++--- circe/helper/cohort_modifiers.py | 1 - circe/vocabulary/concept.py | 5 +- .../concept_set_expression_query_builder.py | 3 +- 83 files changed, 558 insertions(+), 504 deletions(-) diff --git a/circe/__init__.py b/circe/__init__.py index f85ea7fb..743fdb3c 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -24,73 +24,74 @@ __email__ = "circe-python@ohdsi.org" __license__ = "Apache License 2.0" -# Main exports -from .cohortdefinition import CohortExpression -from .vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem -from .api import ( - cohort_expression_from_json, - build_cohort_query, - cohort_print_friendly, -) +import importlib +import inspect +import pkgutil +# --------------------------------------------------------------------- +# Embedded interpreter (e.g. R reticulate) bootstrapping for Pydantic +# --------------------------------------------------------------------- +import sys +from typing import Dict + +from pydantic import BaseModel + +import circe as package from circe.cohortdefinition import ( CohortExpression, - Criteria, + CollapseSettings, + CollapseType, + ConceptSetSelection, + ConditionEra, + ConditionOccurrence, CorelatedCriteria, - DemographicCriteria, - Occurrence, + Criteria, CriteriaColumn, - InclusionRule, - CollapseType, - DateType, - ResultLimit, - Period, - DateRange, - NumericRange, - DateAdjustment, - ObservationFilter, - CollapseSettings, - EndStrategy, - PrimaryCriteria, CriteriaGroup, - ConceptSetSelection, - Window, - TextFilter, - GeoCriteria, - WindowedCriteria, - DateOffsetStrategy, CustomEraStrategy, - ConditionOccurrence, + DateAdjustment, + DateOffsetStrategy, + DateRange, + DateType, + Death, + DemographicCriteria, + DeviceExposure, + DoseEra, + DrugEra, DrugExposure, + EndStrategy, + GeoCriteria, InclusionRule, - WindowBound, - ProcedureOccurrence, - VisitOccurrence, - Observation, + LocationRegion, Measurement, - DeviceExposure, - Specimen, - Death, - VisitDetail, + NumericRange, + Observation, + ObservationFilter, ObservationPeriod, + Occurrence, PayerPlanPeriod, - LocationRegion, - ConditionEra, - DrugEra, - DoseEra, + Period, + PrimaryCriteria, + ProcedureOccurrence, + ResultLimit, + Specimen, + TextFilter, + VisitDetail, + VisitOccurrence, + Window, + WindowBound, + WindowedCriteria, ) -from typing import Dict +from .api import ( + build_cohort_query, + cohort_expression_from_json, + cohort_print_friendly, +) -# --------------------------------------------------------------------- -# Embedded interpreter (e.g. R reticulate) bootstrapping for Pydantic -# --------------------------------------------------------------------- -import sys -import pkgutil -import importlib -import inspect -from pydantic import BaseModel -import circe as package +# Main exports +from .cohortdefinition import CohortExpression +from .vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem def safe_model_rebuild(package): diff --git a/circe/api.py b/circe/api.py index 31fd6a96..b4deac57 100644 --- a/circe/api.py +++ b/circe/api.py @@ -7,11 +7,12 @@ - cohort_print_friendly(): Generate Markdown from cohort expression """ -from typing import Optional, List +from typing import List, Optional + from .cohortdefinition import ( + BuildExpressionQueryOptions, CohortExpression, CohortExpressionQueryBuilder, - BuildExpressionQueryOptions, MarkdownRender, ) from .vocabulary.concept import ConceptSet diff --git a/circe/chat.py b/circe/chat.py index 8c8ffa7c..25261c11 100644 --- a/circe/chat.py +++ b/circe/chat.py @@ -2,12 +2,12 @@ Chat module for interacting with LLMs to generate cohort definitions. """ -import sys -import os import json +import os import re +import sys from pathlib import Path -from typing import Optional, List, Dict, Any +from typing import Any, Dict, List, Optional from circe.prompt_builder import CohortPromptBuilder, ConceptSet diff --git a/circe/check/__init__.py b/circe/check/__init__.py index d7941042..665a4735 100644 --- a/circe/check/__init__.py +++ b/circe/check/__init__.py @@ -7,9 +7,9 @@ from .check import Check from .checker import Checker +from .constants import Constants from .warning import Warning from .warning_severity import WarningSeverity -from .constants import Constants __all__ = [ "Check", diff --git a/circe/check/check.py b/circe/check/check.py index 54315288..f3440f2a 100644 --- a/circe/check/check.py +++ b/circe/check/check.py @@ -10,7 +10,8 @@ """ from abc import ABC, abstractmethod -from typing import List, TYPE_CHECKING +from typing import TYPE_CHECKING, List + from .warning import Warning if TYPE_CHECKING: diff --git a/circe/check/checker.py b/circe/check/checker.py index 18ae6759..d34f72bd 100644 --- a/circe/check/checker.py +++ b/circe/check/checker.py @@ -10,6 +10,7 @@ """ from typing import List + from .check import Check from .warning import Warning @@ -39,32 +40,32 @@ def _get_checks(self) -> List[Check]: A list of Check instances to run against the expression. """ # Import checkers here to avoid circular dependencies - from .checkers.unused_concepts_check import UnusedConceptsCheck + from .checkers.attribute_check import AttributeCheck + from .checkers.concept_check import ConceptCheck + from .checkers.concept_set_criteria_check import ConceptSetCriteriaCheck + from .checkers.concept_set_selection_check import ConceptSetSelectionCheck + from .checkers.criteria_contradictions_check import CriteriaContradictionsCheck + from .checkers.death_time_window_check import DeathTimeWindowCheck + from .checkers.domain_type_check import DomainTypeCheck + from .checkers.drug_domain_check import DrugDomainCheck + from .checkers.drug_era_check import DrugEraCheck + from .checkers.duplicates_concept_set_check import DuplicatesConceptSetCheck + from .checkers.duplicates_criteria_check import DuplicatesCriteriaCheck + from .checkers.empty_concept_set_check import EmptyConceptSetCheck + from .checkers.events_progression_check import EventsProgressionCheck from .checkers.exit_criteria_check import ExitCriteriaCheck from .checkers.exit_criteria_days_offset_check import ( ExitCriteriaDaysOffsetCheck, ) - from .checkers.range_check import RangeCheck - from .checkers.concept_check import ConceptCheck - from .checkers.concept_set_selection_check import ConceptSetSelectionCheck - from .checkers.attribute_check import AttributeCheck - from .checkers.text_check import TextCheck from .checkers.incomplete_rule_check import IncompleteRuleCheck from .checkers.initial_event_check import InitialEventCheck from .checkers.no_exit_criteria_check import NoExitCriteriaCheck - from .checkers.concept_set_criteria_check import ConceptSetCriteriaCheck - from .checkers.drug_era_check import DrugEraCheck from .checkers.ocurrence_check import OcurrenceCheck - from .checkers.duplicates_criteria_check import DuplicatesCriteriaCheck - from .checkers.duplicates_concept_set_check import DuplicatesConceptSetCheck - from .checkers.drug_domain_check import DrugDomainCheck - from .checkers.empty_concept_set_check import EmptyConceptSetCheck - from .checkers.events_progression_check import EventsProgressionCheck - from .checkers.time_window_check import TimeWindowCheck + from .checkers.range_check import RangeCheck + from .checkers.text_check import TextCheck from .checkers.time_pattern_check import TimePatternCheck - from .checkers.domain_type_check import DomainTypeCheck - from .checkers.criteria_contradictions_check import CriteriaContradictionsCheck - from .checkers.death_time_window_check import DeathTimeWindowCheck + from .checkers.time_window_check import TimeWindowCheck + from .checkers.unused_concepts_check import UnusedConceptsCheck checks: List[Check] = [ UnusedConceptsCheck(), diff --git a/circe/check/checkers/__init__.py b/circe/check/checkers/__init__.py index 7f8ee642..d1b2fb34 100644 --- a/circe/check/checkers/__init__.py +++ b/circe/check/checkers/__init__.py @@ -4,47 +4,47 @@ This module contains specific checker implementations for validating cohort definitions. """ +from .attribute_check import AttributeCheck +from .attribute_checker_factory import AttributeCheckerFactory from .base_check import BaseCheck -from .base_criteria_check import BaseCriteriaCheck +from .base_checker_factory import BaseCheckerFactory from .base_corelated_criteria_check import BaseCorelatedCriteriaCheck +from .base_criteria_check import BaseCriteriaCheck from .base_iterable_check import BaseIterableCheck from .base_value_check import BaseValueCheck -from .base_checker_factory import BaseCheckerFactory -from .attribute_checker_factory import AttributeCheckerFactory +from .comparisons import Comparisons +from .concept_check import ConceptCheck from .concept_checker_factory import ConceptCheckerFactory +from .concept_set_criteria_check import ConceptSetCriteriaCheck +from .concept_set_selection_check import ConceptSetSelectionCheck from .concept_set_selection_checker_factory import ConceptSetSelectionCheckerFactory from .criteria_checker_factory import CriteriaCheckerFactory -from .range_checker_factory import RangeCheckerFactory -from .text_checker_factory import TextCheckerFactory -from .warning_reporter import WarningReporter -from .warning_reporter_helper import WarningReporterHelper -from .comparisons import Comparisons - -# Checker implementations -from .unused_concepts_check import UnusedConceptsCheck +from .criteria_contradictions_check import CriteriaContradictionsCheck +from .death_time_window_check import DeathTimeWindowCheck +from .domain_type_check import DomainTypeCheck +from .drug_domain_check import DrugDomainCheck +from .drug_era_check import DrugEraCheck +from .duplicates_concept_set_check import DuplicatesConceptSetCheck +from .duplicates_criteria_check import DuplicatesCriteriaCheck +from .empty_concept_set_check import EmptyConceptSetCheck +from .events_progression_check import EventsProgressionCheck from .exit_criteria_check import ExitCriteriaCheck from .exit_criteria_days_offset_check import ExitCriteriaDaysOffsetCheck -from .range_check import RangeCheck -from .concept_check import ConceptCheck -from .concept_set_selection_check import ConceptSetSelectionCheck -from .attribute_check import AttributeCheck -from .text_check import TextCheck from .incomplete_rule_check import IncompleteRuleCheck from .initial_event_check import InitialEventCheck from .no_exit_criteria_check import NoExitCriteriaCheck -from .concept_set_criteria_check import ConceptSetCriteriaCheck -from .drug_era_check import DrugEraCheck from .ocurrence_check import OcurrenceCheck -from .duplicates_criteria_check import DuplicatesCriteriaCheck -from .duplicates_concept_set_check import DuplicatesConceptSetCheck -from .drug_domain_check import DrugDomainCheck -from .empty_concept_set_check import EmptyConceptSetCheck -from .events_progression_check import EventsProgressionCheck -from .time_window_check import TimeWindowCheck +from .range_check import RangeCheck +from .range_checker_factory import RangeCheckerFactory +from .text_check import TextCheck +from .text_checker_factory import TextCheckerFactory from .time_pattern_check import TimePatternCheck -from .domain_type_check import DomainTypeCheck -from .criteria_contradictions_check import CriteriaContradictionsCheck -from .death_time_window_check import DeathTimeWindowCheck +from .time_window_check import TimeWindowCheck + +# Checker implementations +from .unused_concepts_check import UnusedConceptsCheck +from .warning_reporter import WarningReporter +from .warning_reporter_helper import WarningReporterHelper __all__ = [ # Base classes diff --git a/circe/check/checkers/attribute_check.py b/circe/check/checkers/attribute_check.py index e899cb8c..c729cb27 100644 --- a/circe/check/checkers/attribute_check.py +++ b/circe/check/checkers/attribute_check.py @@ -9,9 +9,9 @@ """ from ..warning_severity import WarningSeverity +from .attribute_checker_factory import AttributeCheckerFactory from .base_value_check import BaseValueCheck from .warning_reporter import WarningReporter -from .attribute_checker_factory import AttributeCheckerFactory class AttributeCheck(BaseValueCheck): diff --git a/circe/check/checkers/attribute_checker_factory.py b/circe/check/checkers/attribute_checker_factory.py index 3c303cb7..d7264ae9 100644 --- a/circe/check/checkers/attribute_checker_factory.py +++ b/circe/check/checkers/attribute_checker_factory.py @@ -8,7 +8,8 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Callable, Any +from typing import Any, Callable + from ..constants import Constants from .base_checker_factory import BaseCheckerFactory from .warning_reporter import WarningReporter diff --git a/circe/check/checkers/base_check.py b/circe/check/checkers/base_check.py index 4af38f73..29ec5413 100644 --- a/circe/check/checkers/base_check.py +++ b/circe/check/checkers/base_check.py @@ -8,7 +8,8 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Any +from typing import Any, List + from ..check import Check from ..warning import Warning from ..warning_severity import WarningSeverity diff --git a/circe/check/checkers/base_checker_factory.py b/circe/check/checkers/base_checker_factory.py index 77ff0b59..e2b15b18 100644 --- a/circe/check/checkers/base_checker_factory.py +++ b/circe/check/checkers/base_checker_factory.py @@ -10,6 +10,7 @@ """ from typing import Callable + from .warning_reporter import WarningReporter # Import at runtime to avoid circular dependencies diff --git a/circe/check/checkers/base_corelated_criteria_check.py b/circe/check/checkers/base_corelated_criteria_check.py index 98f20b67..84ccacd8 100644 --- a/circe/check/checkers/base_corelated_criteria_check.py +++ b/circe/check/checkers/base_corelated_criteria_check.py @@ -14,13 +14,13 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import Criteria, CorelatedCriteria + from ...cohortdefinition.criteria import CorelatedCriteria, Criteria except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import Criteria, CorelatedCriteria + from ...cohortdefinition.criteria import CorelatedCriteria, Criteria class BaseCorelatedCriteriaCheck(BaseIterableCheck): diff --git a/circe/check/checkers/base_criteria_check.py b/circe/check/checkers/base_criteria_check.py index 7e84d09d..6703602b 100644 --- a/circe/check/checkers/base_criteria_check.py +++ b/circe/check/checkers/base_criteria_check.py @@ -9,19 +9,20 @@ """ from typing import Optional + from .base_iterable_check import BaseIterableCheck from .warning_reporter import WarningReporter # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import Criteria, CorelatedCriteria + from ...cohortdefinition.criteria import CorelatedCriteria, Criteria except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import Criteria, CorelatedCriteria + from ...cohortdefinition.criteria import CorelatedCriteria, Criteria class BaseCriteriaCheck(BaseIterableCheck): diff --git a/circe/check/checkers/base_value_check.py b/circe/check/checkers/base_value_check.py index 520b6fd0..5ca633a5 100644 --- a/circe/check/checkers/base_value_check.py +++ b/circe/check/checkers/base_value_check.py @@ -10,6 +10,7 @@ """ from typing import Optional + from .base_check import BaseCheck from .warning_reporter import WarningReporter @@ -17,11 +18,11 @@ try: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import ( - Criteria, CorelatedCriteria, + Criteria, + CriteriaGroup, DemographicCriteria, PrimaryCriteria, - CriteriaGroup, ) except ImportError: from typing import TYPE_CHECKING @@ -29,11 +30,11 @@ if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import ( - Criteria, CorelatedCriteria, + Criteria, + CriteriaGroup, DemographicCriteria, PrimaryCriteria, - CriteriaGroup, ) @@ -152,11 +153,11 @@ def _check_criteria(self, criteria, reporter: WarningReporter, name: str) -> Non """ # Import here to avoid circular dependencies from ...cohortdefinition.criteria import ( - Criteria, CorelatedCriteria, + Criteria, + CriteriaGroup, DemographicCriteria, ) - from ...cohortdefinition.criteria import CriteriaGroup # Check CriteriaGroup if isinstance(criteria, CriteriaGroup): diff --git a/circe/check/checkers/comparisons.py b/circe/check/checkers/comparisons.py index 1d8bc002..895f31d4 100644 --- a/circe/check/checkers/comparisons.py +++ b/circe/check/checkers/comparisons.py @@ -9,18 +9,19 @@ """ from datetime import datetime -from typing import Optional, List, Callable, TYPE_CHECKING -from ...cohortdefinition.core import NumericRange, DateRange, Period -from ...vocabulary.concept import ConceptSet, Concept +from typing import TYPE_CHECKING, Callable, List, Optional + +from ...cohortdefinition.core import DateRange, NumericRange, Period +from ...vocabulary.concept import Concept, ConceptSet if TYPE_CHECKING: - from ...cohortdefinition.criteria import Criteria from ...cohortdefinition.core import ObservationFilter, Window + from ...cohortdefinition.criteria import Criteria else: # Import at runtime to avoid circular dependencies try: - from ...cohortdefinition.criteria import Criteria from ...cohortdefinition.core import ObservationFilter, Window + from ...cohortdefinition.criteria import Criteria except ImportError: pass @@ -48,7 +49,7 @@ def start_is_greater_than_end(range_val) -> bool: return False # Import here to avoid circular dependencies - from ...cohortdefinition.core import NumericRange, DateRange, Period + from ...cohortdefinition.core import DateRange, NumericRange, Period if isinstance(range_val, NumericRange): if range_val.value is None or range_val.extent is None: @@ -253,8 +254,8 @@ def compare_criteria(c1: "Criteria", c2: "Criteria") -> bool: Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, + VisitOccurrence, ) if isinstance(c1, ConditionEra): diff --git a/circe/check/checkers/concept_check.py b/circe/check/checkers/concept_check.py index a19b60ff..86e54d12 100644 --- a/circe/check/checkers/concept_check.py +++ b/circe/check/checkers/concept_check.py @@ -9,8 +9,8 @@ """ from .base_value_check import BaseValueCheck -from .warning_reporter import WarningReporter from .concept_checker_factory import ConceptCheckerFactory +from .warning_reporter import WarningReporter class ConceptCheck(BaseValueCheck): diff --git a/circe/check/checkers/concept_checker_factory.py b/circe/check/checkers/concept_checker_factory.py index 0014b4e6..0c069369 100644 --- a/circe/check/checkers/concept_checker_factory.py +++ b/circe/check/checkers/concept_checker_factory.py @@ -9,19 +9,20 @@ """ from typing import Callable, List, Optional + from ..constants import Constants +from ..operations.operations import Operations from .base_checker_factory import BaseCheckerFactory from .warning_reporter import WarningReporter -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.criteria import ( - Criteria, - DemographicCriteria, ConditionEra, ConditionOccurrence, + Criteria, Death, + DemographicCriteria, DeviceExposure, DoseEra, DrugEra, @@ -29,10 +30,10 @@ Measurement, Observation, ObservationPeriod, + PayerPlanPeriod, ProcedureOccurrence, Specimen, VisitOccurrence, - PayerPlanPeriod, ) from ...vocabulary.concept import Concept except ImportError: @@ -40,11 +41,11 @@ if TYPE_CHECKING: from ...cohortdefinition.criteria import ( - Criteria, - DemographicCriteria, ConditionEra, ConditionOccurrence, + Criteria, Death, + DemographicCriteria, DeviceExposure, DoseEra, DrugEra, @@ -52,10 +53,10 @@ Measurement, Observation, ObservationPeriod, + PayerPlanPeriod, ProcedureOccurrence, Specimen, VisitOccurrence, - PayerPlanPeriod, ) from ...vocabulary.concept import Concept @@ -113,10 +114,10 @@ def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], No Measurement, Observation, ObservationPeriod, + PayerPlanPeriod, ProcedureOccurrence, Specimen, VisitOccurrence, - PayerPlanPeriod, ) def check_condition_era(c: "ConditionEra") -> None: diff --git a/circe/check/checkers/concept_set_criteria_check.py b/circe/check/checkers/concept_set_criteria_check.py index 1469aad8..fb97a00c 100644 --- a/circe/check/checkers/concept_set_criteria_check.py +++ b/circe/check/checkers/concept_set_criteria_check.py @@ -8,9 +8,9 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from ..warning_severity import WarningSeverity from ..operations.operations import Operations from ..utils.criteria_name_helper import CriteriaNameHelper +from ..warning_severity import WarningSeverity from .base_criteria_check import BaseCriteriaCheck from .warning_reporter import WarningReporter from .warning_reporter_helper import WarningReporterHelper @@ -18,9 +18,9 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.criteria import ( - Criteria, ConditionEra, ConditionOccurrence, + Criteria, Death, DeviceExposure, DoseEra, @@ -30,17 +30,17 @@ Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, + VisitOccurrence, ) except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cohortdefinition.criteria import ( - Criteria, ConditionEra, ConditionOccurrence, + Criteria, Death, DeviceExposure, DoseEra, @@ -50,8 +50,8 @@ Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, + VisitOccurrence, ) @@ -100,8 +100,8 @@ def _check_criteria( Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, + VisitOccurrence, ) Operations.match(criteria).is_a(ConditionEra).then( diff --git a/circe/check/checkers/concept_set_selection_check.py b/circe/check/checkers/concept_set_selection_check.py index 64b53803..6580934e 100644 --- a/circe/check/checkers/concept_set_selection_check.py +++ b/circe/check/checkers/concept_set_selection_check.py @@ -9,8 +9,8 @@ """ from .base_value_check import BaseValueCheck -from .warning_reporter import WarningReporter from .concept_set_selection_checker_factory import ConceptSetSelectionCheckerFactory +from .warning_reporter import WarningReporter class ConceptSetSelectionCheck(BaseValueCheck): diff --git a/circe/check/checkers/concept_set_selection_checker_factory.py b/circe/check/checkers/concept_set_selection_checker_factory.py index 12496106..67efa591 100644 --- a/circe/check/checkers/concept_set_selection_checker_factory.py +++ b/circe/check/checkers/concept_set_selection_checker_factory.py @@ -9,25 +9,26 @@ """ from typing import Callable, Optional + from ..constants import Constants +from ..operations.operations import Operations from .base_checker_factory import BaseCheckerFactory from .warning_reporter import WarningReporter -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: - from ...cohortdefinition.criteria import Criteria, DemographicCriteria, VisitDetail from ...cohortdefinition.core import ConceptSetSelection + from ...cohortdefinition.criteria import Criteria, DemographicCriteria, VisitDetail except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: + from ...cohortdefinition.core import ConceptSetSelection from ...cohortdefinition.criteria import ( Criteria, DemographicCriteria, VisitDetail, ) - from ...cohortdefinition.core import ConceptSetSelection class ConceptSetSelectionCheckerFactory(BaseCheckerFactory): diff --git a/circe/check/checkers/criteria_checker_factory.py b/circe/check/checkers/criteria_checker_factory.py index 27321eb6..237c473b 100644 --- a/circe/check/checkers/criteria_checker_factory.py +++ b/circe/check/checkers/criteria_checker_factory.py @@ -9,53 +9,54 @@ """ from typing import Callable, List, Optional + from .base_checker_factory import BaseCheckerFactory from .warning_reporter import WarningReporter # Import at runtime to avoid circular dependencies try: - from ...vocabulary.concept import ConceptSet + from ...cohortdefinition.core import ConceptSetSelection from ...cohortdefinition.criteria import ( - Criteria, ConditionEra, ConditionOccurrence, + Criteria, Death, DeviceExposure, DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - LocationRegion, + VisitOccurrence, ) - from ...cohortdefinition.core import ConceptSetSelection + from ...vocabulary.concept import ConceptSet except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: - from ...vocabulary.concept import ConceptSet + from ...cohortdefinition.core import ConceptSetSelection from ...cohortdefinition.criteria import ( - Criteria, ConditionEra, ConditionOccurrence, + Criteria, Death, DeviceExposure, DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - LocationRegion, + VisitOccurrence, ) - from ...cohortdefinition.core import ConceptSetSelection + from ...vocabulary.concept import ConceptSet class CriteriaCheckerFactory: @@ -99,6 +100,7 @@ def get_criteria_checker( A function that returns True if the criteria uses the concept set """ # Import here to avoid circular dependencies + from ...cohortdefinition.core import ConceptSetSelection from ...cohortdefinition.criteria import ( ConditionEra, ConditionOccurrence, @@ -107,15 +109,14 @@ def get_criteria_checker( DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - LocationRegion, + VisitOccurrence, ) - from ...cohortdefinition.core import ConceptSetSelection def check_condition_era(c: "ConditionEra") -> bool: return c.codeset_id == self._concept_set.id diff --git a/circe/check/checkers/criteria_contradictions_check.py b/circe/check/checkers/criteria_contradictions_check.py index 090315fc..6da30bdc 100644 --- a/circe/check/checkers/criteria_contradictions_check.py +++ b/circe/check/checkers/criteria_contradictions_check.py @@ -8,12 +8,13 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Tuple, Optional -from ..warning_severity import WarningSeverity +from typing import List, Optional, Tuple + from ..utils.criteria_name_helper import CriteriaNameHelper +from ..warning_severity import WarningSeverity from .base_corelated_criteria_check import BaseCorelatedCriteriaCheck -from .warning_reporter import WarningReporter from .comparisons import Comparisons +from .warning_reporter import WarningReporter # Import at runtime to avoid circular dependencies try: diff --git a/circe/check/checkers/death_time_window_check.py b/circe/check/checkers/death_time_window_check.py index 83586724..1b4ccf99 100644 --- a/circe/check/checkers/death_time_window_check.py +++ b/circe/check/checkers/death_time_window_check.py @@ -8,12 +8,12 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from ..warning_severity import WarningSeverity +from ..operations.operations import Operations from ..utils.criteria_name_helper import CriteriaNameHelper +from ..warning_severity import WarningSeverity from .base_corelated_criteria_check import BaseCorelatedCriteriaCheck -from .warning_reporter import WarningReporter from .comparisons import Comparisons -from ..operations.operations import Operations +from .warning_reporter import WarningReporter # Import at runtime to avoid circular dependencies try: diff --git a/circe/check/checkers/domain_type_check.py b/circe/check/checkers/domain_type_check.py index d6f6d623..cfa5d879 100644 --- a/circe/check/checkers/domain_type_check.py +++ b/circe/check/checkers/domain_type_check.py @@ -9,20 +9,21 @@ """ from typing import List -from ..warning_severity import WarningSeverity -from ..utils.criteria_name_helper import CriteriaNameHelper + from ..operations.execution import Execution +from ..operations.operations import Operations +from ..utils.criteria_name_helper import CriteriaNameHelper +from ..warning_severity import WarningSeverity from .base_criteria_check import BaseCriteriaCheck from .warning_reporter import WarningReporter from .warning_reporter_helper import WarningReporterHelper -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import ( - Criteria, ConditionOccurrence, + Criteria, Death, DeviceExposure, DrugExposure, @@ -30,8 +31,8 @@ Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, + VisitOccurrence, ) except ImportError: from typing import TYPE_CHECKING @@ -39,8 +40,8 @@ if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import ( - Criteria, ConditionOccurrence, + Criteria, Death, DeviceExposure, DrugExposure, @@ -48,8 +49,8 @@ Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, + VisitOccurrence, ) @@ -99,8 +100,8 @@ def add_warning() -> None: Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, + VisitOccurrence, ) Operations.match(criteria).is_a(ConditionOccurrence).then( diff --git a/circe/check/checkers/drug_domain_check.py b/circe/check/checkers/drug_domain_check.py index 0318feeb..5ad241c3 100644 --- a/circe/check/checkers/drug_domain_check.py +++ b/circe/check/checkers/drug_domain_check.py @@ -9,24 +9,25 @@ """ from typing import List, Optional + +from ..operations.operations import Operations from ..warning_severity import WarningSeverity from .base_check import BaseCheck from .warning_reporter import WarningReporter -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import Criteria from ...cohortdefinition.core import CustomEraStrategy + from ...cohortdefinition.criteria import Criteria from ...vocabulary.concept import ConceptSet except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import Criteria from ...cohortdefinition.core import CustomEraStrategy + from ...cohortdefinition.criteria import Criteria from ...vocabulary.concept import ConceptSet @@ -109,8 +110,8 @@ def _map_criteria(self, criteria: "Criteria") -> Optional[int]: Observation, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, + VisitOccurrence, ) return ( diff --git a/circe/check/checkers/drug_era_check.py b/circe/check/checkers/drug_era_check.py index 0d032eaf..208c20f5 100644 --- a/circe/check/checkers/drug_era_check.py +++ b/circe/check/checkers/drug_era_check.py @@ -8,10 +8,10 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from ..operations.operations import Operations from ..warning_severity import WarningSeverity from .base_corelated_criteria_check import BaseCorelatedCriteriaCheck from .warning_reporter import WarningReporter -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: diff --git a/circe/check/checkers/duplicates_concept_set_check.py b/circe/check/checkers/duplicates_concept_set_check.py index 26d352af..b30f7e37 100644 --- a/circe/check/checkers/duplicates_concept_set_check.py +++ b/circe/check/checkers/duplicates_concept_set_check.py @@ -9,10 +9,11 @@ """ from typing import TYPE_CHECKING + from ..warning_severity import WarningSeverity from .base_check import BaseCheck -from .warning_reporter import WarningReporter from .comparisons import Comparisons +from .warning_reporter import WarningReporter if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression diff --git a/circe/check/checkers/duplicates_criteria_check.py b/circe/check/checkers/duplicates_criteria_check.py index a2c58f34..d2532890 100644 --- a/circe/check/checkers/duplicates_criteria_check.py +++ b/circe/check/checkers/duplicates_criteria_check.py @@ -9,8 +9,9 @@ """ from typing import List, Tuple -from ..warning_severity import WarningSeverity + from ..utils.criteria_name_helper import CriteriaNameHelper +from ..warning_severity import WarningSeverity from .base_criteria_check import BaseCriteriaCheck from .warning_reporter import WarningReporter @@ -93,11 +94,11 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: Measurement, Observation, ObservationPeriod, + PayerPlanPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - PayerPlanPeriod, + VisitOccurrence, ) if isinstance(c1, ConditionEra): diff --git a/circe/check/checkers/events_progression_check.py b/circe/check/checkers/events_progression_check.py index 65d93687..72d92e7f 100644 --- a/circe/check/checkers/events_progression_check.py +++ b/circe/check/checkers/events_progression_check.py @@ -10,6 +10,7 @@ from enum import Enum from typing import Optional + from ..warning_severity import WarningSeverity from .base_check import BaseCheck from .warning_reporter import WarningReporter diff --git a/circe/check/checkers/exit_criteria_check.py b/circe/check/checkers/exit_criteria_check.py index 9d619fed..ed2d1a08 100644 --- a/circe/check/checkers/exit_criteria_check.py +++ b/circe/check/checkers/exit_criteria_check.py @@ -8,9 +8,9 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from ..operations.operations import Operations from .base_check import BaseCheck from .warning_reporter import WarningReporter -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: diff --git a/circe/check/checkers/exit_criteria_days_offset_check.py b/circe/check/checkers/exit_criteria_days_offset_check.py index 4e8e56f8..6633367d 100644 --- a/circe/check/checkers/exit_criteria_days_offset_check.py +++ b/circe/check/checkers/exit_criteria_days_offset_check.py @@ -8,10 +8,10 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from ..operations.operations import Operations from ..warning_severity import WarningSeverity from .base_check import BaseCheck from .warning_reporter import WarningReporter -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: diff --git a/circe/check/checkers/incomplete_rule_check.py b/circe/check/checkers/incomplete_rule_check.py index 07b3671e..b4821817 100644 --- a/circe/check/checkers/incomplete_rule_check.py +++ b/circe/check/checkers/incomplete_rule_check.py @@ -9,6 +9,7 @@ """ from typing import List + from ..warning import Warning from ..warning_severity import WarningSeverity from ..warnings.incomplete_rule_warning import IncompleteRuleWarning diff --git a/circe/check/checkers/initial_event_check.py b/circe/check/checkers/initial_event_check.py index c25ef33d..a4595b4a 100644 --- a/circe/check/checkers/initial_event_check.py +++ b/circe/check/checkers/initial_event_check.py @@ -8,9 +8,9 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from ..operations.operations import Operations from .base_check import BaseCheck from .warning_reporter import WarningReporter -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: diff --git a/circe/check/checkers/no_exit_criteria_check.py b/circe/check/checkers/no_exit_criteria_check.py index b9d08428..5cd4c773 100644 --- a/circe/check/checkers/no_exit_criteria_check.py +++ b/circe/check/checkers/no_exit_criteria_check.py @@ -8,10 +8,10 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from ..operations.operations import Operations from ..warning_severity import WarningSeverity from .base_check import BaseCheck from .warning_reporter import WarningReporter -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: diff --git a/circe/check/checkers/ocurrence_check.py b/circe/check/checkers/ocurrence_check.py index 5fdebdda..b50e36fc 100644 --- a/circe/check/checkers/ocurrence_check.py +++ b/circe/check/checkers/ocurrence_check.py @@ -8,10 +8,10 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from ..operations.operations import Operations from ..warning_severity import WarningSeverity from .base_corelated_criteria_check import BaseCorelatedCriteriaCheck from .warning_reporter import WarningReporter -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: diff --git a/circe/check/checkers/range_check.py b/circe/check/checkers/range_check.py index 09b15b5b..6f0731bd 100644 --- a/circe/check/checkers/range_check.py +++ b/circe/check/checkers/range_check.py @@ -9,23 +9,24 @@ """ from typing import Optional + from ..warning_severity import WarningSeverity from .base_value_check import BaseValueCheck -from .warning_reporter import WarningReporter from .range_checker_factory import RangeCheckerFactory +from .warning_reporter import WarningReporter # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import CorelatedCriteria from ...cohortdefinition.core import ObservationFilter, Window + from ...cohortdefinition.criteria import CorelatedCriteria except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import CorelatedCriteria from ...cohortdefinition.core import ObservationFilter, Window + from ...cohortdefinition.criteria import CorelatedCriteria class RangeCheck(BaseValueCheck): diff --git a/circe/check/checkers/range_checker_factory.py b/circe/check/checkers/range_checker_factory.py index f00e833d..7969fdd8 100644 --- a/circe/check/checkers/range_checker_factory.py +++ b/circe/check/checkers/range_checker_factory.py @@ -9,62 +9,63 @@ """ from typing import Callable, Optional + from ..constants import Constants +from ..operations.operations import Operations from .base_checker_factory import BaseCheckerFactory -from .warning_reporter import WarningReporter from .comparisons import Comparisons -from ..operations.operations import Operations +from .warning_reporter import WarningReporter # Import at runtime to avoid circular dependencies try: + from ...cohortdefinition.cohort import CohortExpression + from ...cohortdefinition.core import DateRange, NumericRange, Period from ...cohortdefinition.criteria import ( - Criteria, - DemographicCriteria, ConditionEra, ConditionOccurrence, + Criteria, Death, + DemographicCriteria, DeviceExposure, DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, ObservationPeriod, + PayerPlanPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - PayerPlanPeriod, - LocationRegion, + VisitOccurrence, ) - from ...cohortdefinition.core import NumericRange, DateRange, Period - from ...cohortdefinition.cohort import CohortExpression except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: + from ...cohortdefinition.cohort import CohortExpression + from ...cohortdefinition.core import DateRange, NumericRange, Period from ...cohortdefinition.criteria import ( - Criteria, - DemographicCriteria, ConditionEra, ConditionOccurrence, + Criteria, Death, + DemographicCriteria, DeviceExposure, DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, ObservationPeriod, + PayerPlanPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - PayerPlanPeriod, - LocationRegion, + VisitOccurrence, ) - from ...cohortdefinition.core import NumericRange, DateRange, Period - from ...cohortdefinition.cohort import CohortExpression class RangeCheckerFactory(BaseCheckerFactory): @@ -124,15 +125,15 @@ def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], No DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, ObservationPeriod, + PayerPlanPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - PayerPlanPeriod, - LocationRegion, + VisitOccurrence, ) if isinstance(criteria, ConditionEra): @@ -620,7 +621,7 @@ def _check_range(self, range_val, criteria_name: str, attribute: str) -> None: return # Import here to avoid circular dependencies - from ...cohortdefinition.core import NumericRange, DateRange + from ...cohortdefinition.core import DateRange, NumericRange def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) diff --git a/circe/check/checkers/text_check.py b/circe/check/checkers/text_check.py index 7c3627e4..49152bee 100644 --- a/circe/check/checkers/text_check.py +++ b/circe/check/checkers/text_check.py @@ -10,8 +10,8 @@ from ..warning_severity import WarningSeverity from .base_value_check import BaseValueCheck -from .warning_reporter import WarningReporter from .text_checker_factory import TextCheckerFactory +from .warning_reporter import WarningReporter class TextCheck(BaseValueCheck): diff --git a/circe/check/checkers/text_checker_factory.py b/circe/check/checkers/text_checker_factory.py index 88b96258..9af4f21d 100644 --- a/circe/check/checkers/text_checker_factory.py +++ b/circe/check/checkers/text_checker_factory.py @@ -9,37 +9,38 @@ """ from typing import Callable, Optional + from ..constants import Constants +from ..operations.operations import Operations from .base_checker_factory import BaseCheckerFactory from .warning_reporter import WarningReporter -from ..operations.operations import Operations # Import at runtime to avoid circular dependencies try: + from ...cohortdefinition.core import TextFilter from ...cohortdefinition.criteria import ( + ConditionOccurrence, Criteria, DemographicCriteria, - ConditionOccurrence, DeviceExposure, DrugExposure, Observation, Specimen, ) - from ...cohortdefinition.core import TextFilter except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: + from ...cohortdefinition.core import TextFilter from ...cohortdefinition.criteria import ( + ConditionOccurrence, Criteria, DemographicCriteria, - ConditionOccurrence, DeviceExposure, DrugExposure, Observation, Specimen, ) - from ...cohortdefinition.core import TextFilter class TextCheckerFactory(BaseCheckerFactory): diff --git a/circe/check/checkers/time_pattern_check.py b/circe/check/checkers/time_pattern_check.py index f654c099..64512fb6 100644 --- a/circe/check/checkers/time_pattern_check.py +++ b/circe/check/checkers/time_pattern_check.py @@ -8,25 +8,26 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional from collections import Counter -from ..warning_severity import WarningSeverity +from typing import List, Optional + from ..utils.criteria_name_helper import CriteriaNameHelper +from ..warning_severity import WarningSeverity from .base_corelated_criteria_check import BaseCorelatedCriteriaCheck from .warning_reporter import WarningReporter # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import CorelatedCriteria from ...cohortdefinition.core import Window + from ...cohortdefinition.criteria import CorelatedCriteria except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import CorelatedCriteria from ...cohortdefinition.core import Window + from ...cohortdefinition.criteria import CorelatedCriteria class TimeWindowInfo: diff --git a/circe/check/checkers/time_window_check.py b/circe/check/checkers/time_window_check.py index 9bca0704..d50f2dd7 100644 --- a/circe/check/checkers/time_window_check.py +++ b/circe/check/checkers/time_window_check.py @@ -9,25 +9,26 @@ """ from typing import Optional -from ..warning_severity import WarningSeverity + +from ..operations.operations import Operations from ..utils.criteria_name_helper import CriteriaNameHelper +from ..warning_severity import WarningSeverity from .base_corelated_criteria_check import BaseCorelatedCriteriaCheck -from .warning_reporter import WarningReporter from .comparisons import Comparisons -from ..operations.operations import Operations +from .warning_reporter import WarningReporter # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import CorelatedCriteria from ...cohortdefinition.core import ObservationFilter + from ...cohortdefinition.criteria import CorelatedCriteria except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import CorelatedCriteria from ...cohortdefinition.core import ObservationFilter + from ...cohortdefinition.criteria import CorelatedCriteria class TimeWindowCheck(BaseCorelatedCriteriaCheck): diff --git a/circe/check/checkers/unused_concepts_check.py b/circe/check/checkers/unused_concepts_check.py index 8bf3fbf3..76e9f456 100644 --- a/circe/check/checkers/unused_concepts_check.py +++ b/circe/check/checkers/unused_concepts_check.py @@ -9,27 +9,30 @@ """ from typing import List, Optional + from ..warning_severity import WarningSeverity from ..warnings.concept_set_warning import ConceptSetWarning from .base_check import BaseCheck -from .warning_reporter import WarningReporter from .criteria_checker_factory import CriteriaCheckerFactory +from .warning_reporter import WarningReporter # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import Criteria, CorelatedCriteria from ...cohortdefinition.core import CustomEraStrategy - from ...cohortdefinition.criteria import CriteriaGroup + from ...cohortdefinition.criteria import CorelatedCriteria, Criteria, CriteriaGroup from ...vocabulary.concept import ConceptSet except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import Criteria, CorelatedCriteria from ...cohortdefinition.core import CustomEraStrategy - from ...cohortdefinition.criteria import CriteriaGroup + from ...cohortdefinition.criteria import ( + CorelatedCriteria, + Criteria, + CriteriaGroup, + ) from ...vocabulary.concept import ConceptSet diff --git a/circe/check/checkers/warning_reporter.py b/circe/check/checkers/warning_reporter.py index ba071818..dea20423 100644 --- a/circe/check/checkers/warning_reporter.py +++ b/circe/check/checkers/warning_reporter.py @@ -9,7 +9,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Protocol, Any +from typing import Any, Protocol class WarningReporter(Protocol): diff --git a/circe/check/operations/__init__.py b/circe/check/operations/__init__.py index 920ea84f..b23bc8c6 100644 --- a/circe/check/operations/__init__.py +++ b/circe/check/operations/__init__.py @@ -4,14 +4,14 @@ This module contains operational classes for check processing. """ -from .execution import Execution +# Type alias for convenience (Callable[[], None]) +from typing import Callable + from .conditional_operations import ConditionalOperations +from .execution import Execution from .executive_operations import ExecutiveOperations from .operations import Operations -# Type alias for convenience (Callable[[], None]) -from typing import Callable - Executable = Callable[[], None] __all__ = [ diff --git a/circe/check/operations/conditional_operations.py b/circe/check/operations/conditional_operations.py index d1a79294..3081c812 100644 --- a/circe/check/operations/conditional_operations.py +++ b/circe/check/operations/conditional_operations.py @@ -9,7 +9,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Protocol, TypeVar, Generic, Callable, Any +from typing import Any, Callable, Generic, Protocol, TypeVar T = TypeVar("T") V = TypeVar("V") diff --git a/circe/check/operations/executive_operations.py b/circe/check/operations/executive_operations.py index a4e1d124..8d1c7387 100644 --- a/circe/check/operations/executive_operations.py +++ b/circe/check/operations/executive_operations.py @@ -9,12 +9,12 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Protocol, TypeVar, Generic, Callable +from typing import Callable, Generic, Protocol, TypeVar T = TypeVar("T") V = TypeVar("V") -from .execution import Execution from .conditional_operations import ConditionalOperations +from .execution import Execution class ExecutiveOperations(Protocol, Generic[T, V]): diff --git a/circe/check/operations/operations.py b/circe/check/operations/operations.py index c162046f..f2d5bc3e 100644 --- a/circe/check/operations/operations.py +++ b/circe/check/operations/operations.py @@ -9,9 +9,10 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import TypeVar, Generic, Callable, Any, Optional -from .execution import Execution +from typing import Any, Callable, Generic, Optional, TypeVar + from .conditional_operations import ConditionalOperations +from .execution import Execution from .executive_operations import ExecutiveOperations T = TypeVar("T") diff --git a/circe/check/utils/criteria_name_helper.py b/circe/check/utils/criteria_name_helper.py index 9b175ddd..8ba64c2e 100644 --- a/circe/check/utils/criteria_name_helper.py +++ b/circe/check/utils/criteria_name_helper.py @@ -22,15 +22,15 @@ DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, + ObservationPeriod, + PayerPlanPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - ObservationPeriod, - PayerPlanPeriod, - LocationRegion, + VisitOccurrence, ) except ImportError: from typing import TYPE_CHECKING @@ -44,15 +44,15 @@ DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, + ObservationPeriod, + PayerPlanPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - ObservationPeriod, - PayerPlanPeriod, - LocationRegion, + VisitOccurrence, ) @@ -84,15 +84,15 @@ def get_criteria_name(criteria) -> str: DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, + ObservationPeriod, + PayerPlanPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - ObservationPeriod, - PayerPlanPeriod, - LocationRegion, + VisitOccurrence, ) return ( diff --git a/circe/check/warnings/__init__.py b/circe/check/warnings/__init__.py index e69e51e2..d0f6f988 100644 --- a/circe/check/warnings/__init__.py +++ b/circe/check/warnings/__init__.py @@ -5,8 +5,8 @@ """ from .base_warning import BaseWarning -from .default_warning import DefaultWarning from .concept_set_warning import ConceptSetWarning +from .default_warning import DefaultWarning from .incomplete_rule_warning import IncompleteRuleWarning __all__ = [ diff --git a/circe/check/warnings/concept_set_warning.py b/circe/check/warnings/concept_set_warning.py index e9846ab0..0be3c8c2 100644 --- a/circe/check/warnings/concept_set_warning.py +++ b/circe/check/warnings/concept_set_warning.py @@ -9,9 +9,10 @@ """ from typing import Optional + +from ...vocabulary.concept import ConceptSet from ..warning_severity import WarningSeverity from .base_warning import BaseWarning -from ...vocabulary.concept import ConceptSet class ConceptSetWarning(BaseWarning): diff --git a/circe/cli.py b/circe/cli.py index ea3a6925..e642c5f8 100644 --- a/circe/cli.py +++ b/circe/cli.py @@ -8,7 +8,7 @@ import sys from pathlib import Path -from .api import cohort_expression_from_json, build_cohort_query, cohort_print_friendly +from .api import build_cohort_query, cohort_expression_from_json, cohort_print_friendly from .cohortdefinition import BuildExpressionQueryOptions from .cohortdefinition.code_generator import to_python_code diff --git a/circe/cohortdefinition/__init__.py b/circe/cohortdefinition/__init__.py index aa217d52..95497790 100644 --- a/circe/cohortdefinition/__init__.py +++ b/circe/cohortdefinition/__init__.py @@ -10,62 +10,57 @@ """ from .cohort import CohortExpression -from .criteria import ( - Criteria, - CorelatedCriteria, - DemographicCriteria, - Occurrence, - CriteriaColumn, - InclusionRule, - # Moved from core - CriteriaGroup, - PrimaryCriteria, - WindowedCriteria, - # Criteria Domain Classes - ConditionOccurrence, - DrugExposure, - ProcedureOccurrence, - VisitOccurrence, - Observation, - Measurement, - DeviceExposure, - Specimen, - Death, - VisitDetail, - ObservationPeriod, - PayerPlanPeriod, - LocationRegion, - # Era Criteria Classes - ConditionEra, - DrugEra, - DoseEra, - # Geographic Criteria - GeoCriteria, +from .cohort_expression_query_builder import ( + BuildExpressionQueryOptions, + CohortExpressionQueryBuilder, ) -from .core import ( +from .concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder +from .core import ( # Supporting Classes + CollapseSettings, CollapseType, - DateType, - ResultLimit, - Period, + ConceptSetSelection, + CustomEraStrategy, + DateAdjustment, + DateOffsetStrategy, DateRange, + DateType, + EndStrategy, NumericRange, - DateAdjustment, ObservationFilter, - CollapseSettings, - EndStrategy, - ConceptSetSelection, - # Supporting Classes + Period, + ResultLimit, TextFilter, - WindowBound, Window, - DateOffsetStrategy, - CustomEraStrategy, + WindowBound, ) -from .cohort_expression_query_builder import ( - CohortExpressionQueryBuilder, - BuildExpressionQueryOptions, +from .criteria import ( # Moved from core; Criteria Domain Classes; Era Criteria Classes; Geographic Criteria + ConditionEra, + ConditionOccurrence, + CorelatedCriteria, + Criteria, + CriteriaColumn, + CriteriaGroup, + Death, + DemographicCriteria, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + GeoCriteria, + InclusionRule, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + Occurrence, + PayerPlanPeriod, + PrimaryCriteria, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, + WindowedCriteria, ) -from .concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder from .interfaces import IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher from .printfriendly import MarkdownRender diff --git a/circe/cohortdefinition/builders/__init__.py b/circe/cohortdefinition/builders/__init__.py index 177e2ba8..f907b0de 100644 --- a/circe/cohortdefinition/builders/__init__.py +++ b/circe/cohortdefinition/builders/__init__.py @@ -9,24 +9,24 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from .utils import BuilderUtils, BuilderOptions, CriteriaColumn from .base import CriteriaSqlBuilder +from .condition_era import ConditionEraSqlBuilder from .condition_occurrence import ConditionOccurrenceSqlBuilder -from .drug_exposure import DrugExposureSqlBuilder -from .procedure_occurrence import ProcedureOccurrenceSqlBuilder from .death import DeathSqlBuilder -from .visit_occurrence import VisitOccurrenceSqlBuilder -from .observation import ObservationSqlBuilder -from .measurement import MeasurementSqlBuilder from .device_exposure import DeviceExposureSqlBuilder -from .specimen import SpecimenSqlBuilder -from .condition_era import ConditionEraSqlBuilder -from .drug_era import DrugEraSqlBuilder from .dose_era import DoseEraSqlBuilder +from .drug_era import DrugEraSqlBuilder +from .drug_exposure import DrugExposureSqlBuilder +from .location_region import LocationRegionSqlBuilder +from .measurement import MeasurementSqlBuilder +from .observation import ObservationSqlBuilder from .observation_period import ObservationPeriodSqlBuilder from .payer_plan_period import PayerPlanPeriodSqlBuilder +from .procedure_occurrence import ProcedureOccurrenceSqlBuilder +from .specimen import SpecimenSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn from .visit_detail import VisitDetailSqlBuilder -from .location_region import LocationRegionSqlBuilder +from .visit_occurrence import VisitOccurrenceSqlBuilder __all__ = [ # Utility classes diff --git a/circe/cohortdefinition/builders/base.py b/circe/cohortdefinition/builders/base.py index cee2c2fe..172a0bef 100644 --- a/circe/cohortdefinition/builders/base.py +++ b/circe/cohortdefinition/builders/base.py @@ -9,8 +9,9 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set, TypeVar, Generic from abc import ABC, abstractmethod +from typing import Generic, List, Optional, Set, TypeVar + from ..criteria import Criteria from .utils import BuilderOptions, CriteriaColumn diff --git a/circe/cohortdefinition/builders/condition_era.py b/circe/cohortdefinition/builders/condition_era.py index 1660c333..663d3552 100644 --- a/circe/cohortdefinition/builders/condition_era.py +++ b/circe/cohortdefinition/builders/condition_era.py @@ -8,10 +8,11 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + from ..criteria import ConditionEra +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class ConditionEraSqlBuilder(CriteriaSqlBuilder[ConditionEra]): diff --git a/circe/cohortdefinition/builders/condition_occurrence.py b/circe/cohortdefinition/builders/condition_occurrence.py index c1594c65..9706a8ac 100644 --- a/circe/cohortdefinition/builders/condition_occurrence.py +++ b/circe/cohortdefinition/builders/condition_occurrence.py @@ -8,10 +8,11 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + from ..criteria import ConditionOccurrence +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class ConditionOccurrenceSqlBuilder(CriteriaSqlBuilder[ConditionOccurrence]): diff --git a/circe/cohortdefinition/builders/death.py b/circe/cohortdefinition/builders/death.py index b8f2be3b..72d3aa85 100644 --- a/circe/cohortdefinition/builders/death.py +++ b/circe/cohortdefinition/builders/death.py @@ -8,11 +8,13 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from pydantic import BaseModel, Field, ConfigDict -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field + from ..criteria import Death +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class DeathSqlBuilder(CriteriaSqlBuilder[Death]): diff --git a/circe/cohortdefinition/builders/device_exposure.py b/circe/cohortdefinition/builders/device_exposure.py index 8806ee5e..5dd3c192 100644 --- a/circe/cohortdefinition/builders/device_exposure.py +++ b/circe/cohortdefinition/builders/device_exposure.py @@ -8,11 +8,13 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from pydantic import BaseModel, Field, ConfigDict -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field + from ..criteria import DeviceExposure +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class DeviceExposureSqlBuilder(CriteriaSqlBuilder[DeviceExposure]): diff --git a/circe/cohortdefinition/builders/dose_era.py b/circe/cohortdefinition/builders/dose_era.py index c4d8393b..1eb795cd 100644 --- a/circe/cohortdefinition/builders/dose_era.py +++ b/circe/cohortdefinition/builders/dose_era.py @@ -8,10 +8,11 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + from ..criteria import DoseEra +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class DoseEraSqlBuilder(CriteriaSqlBuilder[DoseEra]): diff --git a/circe/cohortdefinition/builders/drug_era.py b/circe/cohortdefinition/builders/drug_era.py index 83909a3b..76457424 100644 --- a/circe/cohortdefinition/builders/drug_era.py +++ b/circe/cohortdefinition/builders/drug_era.py @@ -8,10 +8,11 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + from ..criteria import DrugEra +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class DrugEraSqlBuilder(CriteriaSqlBuilder[DrugEra]): diff --git a/circe/cohortdefinition/builders/drug_exposure.py b/circe/cohortdefinition/builders/drug_exposure.py index 6b6d963a..44492f60 100644 --- a/circe/cohortdefinition/builders/drug_exposure.py +++ b/circe/cohortdefinition/builders/drug_exposure.py @@ -10,9 +10,10 @@ """ from typing import List, Optional, Set + from ..criteria import DrugExposure from .base import CriteriaSqlBuilder -from .utils import BuilderOptions, CriteriaColumn, BuilderUtils +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn # SQL template - equivalent to Java ResourceHelper.GetResourceAsString DRUG_EXPOSURE_TEMPLATE = """-- Begin Drug Exposure Criteria diff --git a/circe/cohortdefinition/builders/location_region.py b/circe/cohortdefinition/builders/location_region.py index 2c7e167d..48742a28 100644 --- a/circe/cohortdefinition/builders/location_region.py +++ b/circe/cohortdefinition/builders/location_region.py @@ -8,10 +8,11 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + from ..criteria import LocationRegion +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class LocationRegionSqlBuilder(CriteriaSqlBuilder[LocationRegion]): diff --git a/circe/cohortdefinition/builders/measurement.py b/circe/cohortdefinition/builders/measurement.py index 49c8ed82..221aa870 100644 --- a/circe/cohortdefinition/builders/measurement.py +++ b/circe/cohortdefinition/builders/measurement.py @@ -8,11 +8,13 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from pydantic import BaseModel, Field, ConfigDict -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field + from ..criteria import Measurement +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class MeasurementSqlBuilder(CriteriaSqlBuilder[Measurement]): diff --git a/circe/cohortdefinition/builders/observation.py b/circe/cohortdefinition/builders/observation.py index 77bdd16e..e0bf114e 100644 --- a/circe/cohortdefinition/builders/observation.py +++ b/circe/cohortdefinition/builders/observation.py @@ -8,11 +8,13 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from pydantic import BaseModel, Field, ConfigDict -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field + from ..criteria import Observation +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class ObservationSqlBuilder(CriteriaSqlBuilder[Observation]): diff --git a/circe/cohortdefinition/builders/observation_period.py b/circe/cohortdefinition/builders/observation_period.py index 404e5971..f08b8a83 100644 --- a/circe/cohortdefinition/builders/observation_period.py +++ b/circe/cohortdefinition/builders/observation_period.py @@ -8,10 +8,11 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + from ..criteria import ObservationPeriod +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class ObservationPeriodSqlBuilder(CriteriaSqlBuilder[ObservationPeriod]): diff --git a/circe/cohortdefinition/builders/payer_plan_period.py b/circe/cohortdefinition/builders/payer_plan_period.py index dbf863ad..e1b40fd6 100644 --- a/circe/cohortdefinition/builders/payer_plan_period.py +++ b/circe/cohortdefinition/builders/payer_plan_period.py @@ -8,10 +8,11 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + from ..criteria import PayerPlanPeriod +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class PayerPlanPeriodSqlBuilder(CriteriaSqlBuilder[PayerPlanPeriod]): diff --git a/circe/cohortdefinition/builders/procedure_occurrence.py b/circe/cohortdefinition/builders/procedure_occurrence.py index cafe6091..ff3b6c95 100644 --- a/circe/cohortdefinition/builders/procedure_occurrence.py +++ b/circe/cohortdefinition/builders/procedure_occurrence.py @@ -10,9 +10,10 @@ """ from typing import List, Optional, Set + from ..criteria import Criteria from .base import CriteriaSqlBuilder -from .utils import BuilderOptions, CriteriaColumn, BuilderUtils +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn # SQL template - equivalent to Java ResourceHelper.GetResourceAsString # Note: Uses lowercase select/from to match Java output diff --git a/circe/cohortdefinition/builders/specimen.py b/circe/cohortdefinition/builders/specimen.py index 3b8b1148..702b1c21 100644 --- a/circe/cohortdefinition/builders/specimen.py +++ b/circe/cohortdefinition/builders/specimen.py @@ -8,11 +8,13 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from pydantic import BaseModel, Field, ConfigDict -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field + from ..criteria import Specimen +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class SpecimenSqlBuilder(CriteriaSqlBuilder[Specimen]): diff --git a/circe/cohortdefinition/builders/utils.py b/circe/cohortdefinition/builders/utils.py index 1dcd9e15..95471af6 100644 --- a/circe/cohortdefinition/builders/utils.py +++ b/circe/cohortdefinition/builders/utils.py @@ -9,11 +9,12 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set, Dict, Any -from enum import Enum from abc import ABC, abstractmethod -from ..core import DateRange, DateAdjustment, NumericRange, ConceptSetSelection +from enum import Enum +from typing import Any, Dict, List, Optional, Set + from ...vocabulary.concept import Concept +from ..core import ConceptSetSelection, DateAdjustment, DateRange, NumericRange from ..criteria import CriteriaColumn diff --git a/circe/cohortdefinition/builders/visit_detail.py b/circe/cohortdefinition/builders/visit_detail.py index 4d1f9ccc..358fac15 100644 --- a/circe/cohortdefinition/builders/visit_detail.py +++ b/circe/cohortdefinition/builders/visit_detail.py @@ -8,10 +8,11 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + from ..criteria import VisitDetail +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class VisitDetailSqlBuilder(CriteriaSqlBuilder[VisitDetail]): diff --git a/circe/cohortdefinition/builders/visit_occurrence.py b/circe/cohortdefinition/builders/visit_occurrence.py index a41e851d..1447c8ca 100644 --- a/circe/cohortdefinition/builders/visit_occurrence.py +++ b/circe/cohortdefinition/builders/visit_occurrence.py @@ -8,11 +8,13 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Set, List, Optional, Any -from pydantic import BaseModel, Field, ConfigDict -from .base import CriteriaSqlBuilder -from .utils import CriteriaColumn, BuilderOptions, BuilderUtils +from typing import Any, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field + from ..criteria import VisitOccurrence +from .base import CriteriaSqlBuilder +from .utils import BuilderOptions, BuilderUtils, CriteriaColumn class VisitOccurrenceSqlBuilder(CriteriaSqlBuilder[VisitOccurrence]): diff --git a/circe/cohortdefinition/code_generator.py b/circe/cohortdefinition/code_generator.py index 8003fe35..fc1bc4e1 100644 --- a/circe/cohortdefinition/code_generator.py +++ b/circe/cohortdefinition/code_generator.py @@ -1,10 +1,12 @@ -from typing import Any, List, Set, Type import textwrap from enum import Enum +from typing import Any, List, Set, Type + from pydantic import BaseModel + from .cohort import CohortExpression, ConceptSet -from .criteria import Criteria, CriteriaGroup from .core import Period +from .criteria import Criteria, CriteriaGroup def to_python_code(obj: Any) -> str: diff --git a/circe/cohortdefinition/cohort.py b/circe/cohortdefinition/cohort.py index 7cbbb5f8..a8c010e8 100644 --- a/circe/cohortdefinition/cohort.py +++ b/circe/cohortdefinition/cohort.py @@ -8,27 +8,29 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Any, Union, TYPE_CHECKING import json +from typing import TYPE_CHECKING, Any, List, Optional, Union + from pydantic import ( + AliasChoices, BaseModel, - Field, ConfigDict, - model_validator, + Field, field_validator, - AliasChoices, + model_validator, ) + from .core import ( - ResultLimit, - Period, + CirceBaseModel, CollapseSettings, - EndStrategy, - DateOffsetStrategy, CustomEraStrategy, + DateOffsetStrategy, + EndStrategy, ObservationFilter, - CirceBaseModel, + Period, + ResultLimit, ) -from .criteria import Criteria, PrimaryCriteria, CriteriaGroup, CriteriaType +from .criteria import Criteria, CriteriaGroup, CriteriaType, PrimaryCriteria if TYPE_CHECKING: from ..check.warning import Warning @@ -177,22 +179,22 @@ def deserialize_censoring_criteria(cls, v: Any) -> Any: return v from .criteria import ( + ConditionEra, ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, DrugExposure, - ProcedureOccurrence, - VisitOccurrence, - Observation, + LocationRegion, Measurement, - DeviceExposure, - Specimen, - Death, - VisitDetail, + Observation, ObservationPeriod, PayerPlanPeriod, - LocationRegion, - ConditionEra, - DrugEra, - DoseEra, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, ) criteria_class_map = { diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index d5e4fdfa..52ff132d 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -9,54 +9,55 @@ """ import json -from typing import List, Optional, Dict, Any, Union +from typing import Any, Dict, List, Optional, Union + +from .builders import ( + ConditionEraSqlBuilder, + ConditionOccurrenceSqlBuilder, + DeathSqlBuilder, + DeviceExposureSqlBuilder, + DoseEraSqlBuilder, + DrugEraSqlBuilder, + DrugExposureSqlBuilder, + LocationRegionSqlBuilder, + MeasurementSqlBuilder, + ObservationPeriodSqlBuilder, + ObservationSqlBuilder, + PayerPlanPeriodSqlBuilder, + ProcedureOccurrenceSqlBuilder, + SpecimenSqlBuilder, + VisitDetailSqlBuilder, + VisitOccurrenceSqlBuilder, +) +from .builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn from .cohort import CohortExpression +from .concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder +from .core import CustomEraStrategy, DateOffsetStrategy, Period from .criteria import ( - Criteria, - CorelatedCriteria, - DemographicCriteria, - CriteriaGroup, - PrimaryCriteria, - LocationRegion, ConditionEra, ConditionOccurrence, + CorelatedCriteria, + Criteria, + CriteriaGroup, Death, + DemographicCriteria, DeviceExposure, DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, ObservationPeriod, + Occurrence, PayerPlanPeriod, + PrimaryCriteria, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, - Occurrence, -) -from .core import Period, DateOffsetStrategy, CustomEraStrategy -from .builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn -from .builders import ( - ConditionOccurrenceSqlBuilder, - DeathSqlBuilder, - DeviceExposureSqlBuilder, - MeasurementSqlBuilder, - ObservationSqlBuilder, - SpecimenSqlBuilder, - VisitOccurrenceSqlBuilder, - DrugExposureSqlBuilder, - ProcedureOccurrenceSqlBuilder, - ConditionEraSqlBuilder, - DrugEraSqlBuilder, - DoseEraSqlBuilder, - ObservationPeriodSqlBuilder, - PayerPlanPeriodSqlBuilder, - VisitDetailSqlBuilder, - LocationRegionSqlBuilder, + VisitOccurrence, ) from .interfaces import IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher -from .concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder class BuildExpressionQueryOptions: @@ -935,7 +936,7 @@ def build_expression_query( # End date selects end_date_selects = [] - from .core import EndStrategy, DateOffsetStrategy, CustomEraStrategy + from .core import CustomEraStrategy, DateOffsetStrategy, EndStrategy if not isinstance(expression.end_strategy, DateOffsetStrategy): end_date_selects.append( @@ -1232,24 +1233,22 @@ def _get_windowed_criteria_query_internal( inner_criteria = criteria.criteria if isinstance(inner_criteria, dict): # Try to deserialize it - import here to avoid circular dependency issues - from .criteria import ( - ConditionOccurrence as CO, - DrugExposure as DE, - ProcedureOccurrence as PO, - VisitOccurrence as VO, - Observation as O, - Measurement as M, - DeviceExposure as DevE, - Specimen as S, - Death as D, - VisitDetail as VD, - ObservationPeriod as OP, - PayerPlanPeriod as PPP, - LocationRegion as LR, - ConditionEra as CE, - DrugEra as DrE, - DoseEra as DoE, - ) + from .criteria import ConditionEra as CE + from .criteria import ConditionOccurrence as CO + from .criteria import Death as D + from .criteria import DeviceExposure as DevE + from .criteria import DoseEra as DoE + from .criteria import DrugEra as DrE + from .criteria import DrugExposure as DE + from .criteria import LocationRegion as LR + from .criteria import Measurement as M + from .criteria import Observation as O + from .criteria import ObservationPeriod as OP + from .criteria import PayerPlanPeriod as PPP + from .criteria import ProcedureOccurrence as PO + from .criteria import Specimen as S + from .criteria import VisitDetail as VD + from .criteria import VisitOccurrence as VO criteria_type = None criteria_data = None @@ -1560,24 +1559,22 @@ def get_criteria_sql( # Handle case where criteria is still a dict (shouldn't happen, but be defensive) if isinstance(criteria, dict): # Try to deserialize it - import here to avoid circular dependency issues - from .criteria import ( - ConditionOccurrence as CO, - DrugExposure as DE, - ProcedureOccurrence as PO, - VisitOccurrence as VO, - Observation as O, - Measurement as M, - DeviceExposure as DevE, - Specimen as S, - Death as D, - VisitDetail as VD, - ObservationPeriod as OP, - PayerPlanPeriod as PPP, - LocationRegion as LR, - ConditionEra as CE, - DrugEra as DrE, - DoseEra as DoE, - ) + from .criteria import ConditionEra as CE + from .criteria import ConditionOccurrence as CO + from .criteria import Death as D + from .criteria import DeviceExposure as DevE + from .criteria import DoseEra as DoE + from .criteria import DrugEra as DrE + from .criteria import DrugExposure as DE + from .criteria import LocationRegion as LR + from .criteria import Measurement as M + from .criteria import Observation as O + from .criteria import ObservationPeriod as OP + from .criteria import PayerPlanPeriod as PPP + from .criteria import ProcedureOccurrence as PO + from .criteria import Specimen as S + from .criteria import VisitDetail as VD + from .criteria import VisitOccurrence as VO criteria_type = None criteria_data = None diff --git a/circe/cohortdefinition/concept_set_expression_query_builder.py b/circe/cohortdefinition/concept_set_expression_query_builder.py index aee40e57..944008e4 100644 --- a/circe/cohortdefinition/concept_set_expression_query_builder.py +++ b/circe/cohortdefinition/concept_set_expression_query_builder.py @@ -9,6 +9,7 @@ """ from typing import List, Optional + from ..vocabulary.concept import Concept, ConceptSetExpression, ConceptSetItem from .builders.utils import BuilderUtils diff --git a/circe/cohortdefinition/core.py b/circe/cohortdefinition/core.py index b7898f10..59371a61 100644 --- a/circe/cohortdefinition/core.py +++ b/circe/cohortdefinition/core.py @@ -8,18 +8,20 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Union, Any, TYPE_CHECKING +from enum import Enum +from typing import TYPE_CHECKING, Any, List, Optional, Union + from pydantic import ( + AliasChoices, BaseModel, - Field, ConfigDict, - model_validator, - field_validator, Discriminator, - AliasChoices, + Field, + field_validator, model_serializer, + model_validator, ) -from enum import Enum + from .utils import to_pascal_alias diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index c201ec42..5e1591df 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -8,30 +8,32 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Any, ClassVar, Union, TYPE_CHECKING +from enum import Enum +from typing import TYPE_CHECKING, Any, ClassVar, List, Optional, Union + from pydantic import ( + AliasChoices, BaseModel, - Field, ConfigDict, - model_serializer, - AliasChoices, + Field, field_validator, + model_serializer, ) -from enum import Enum + from ..vocabulary.concept import Concept from .core import ( + CirceBaseModel, + CollapseSettings, + ConceptSetSelection, DateAdjustment, DateRange, + EndStrategy, NumericRange, - ConceptSetSelection, - TextFilter, - Window, + ObservationFilter, Period, ResultLimit, - ObservationFilter, - CollapseSettings, - EndStrategy, - CirceBaseModel, + TextFilter, + Window, ) diff --git a/circe/cohortdefinition/interfaces.py b/circe/cohortdefinition/interfaces.py index 3a33c5c6..00d4a0ba 100644 --- a/circe/cohortdefinition/interfaces.py +++ b/circe/cohortdefinition/interfaces.py @@ -11,8 +11,10 @@ from abc import ABC, abstractmethod from typing import Optional + +from .builders.utils import BuilderOptions +from .core import CustomEraStrategy, DateOffsetStrategy from .criteria import ( - LocationRegion, ConditionEra, ConditionOccurrence, Death, @@ -20,17 +22,16 @@ DoseEra, DrugEra, DrugExposure, + LocationRegion, Measurement, Observation, ObservationPeriod, PayerPlanPeriod, ProcedureOccurrence, Specimen, - VisitOccurrence, VisitDetail, + VisitOccurrence, ) -from .core import DateOffsetStrategy, CustomEraStrategy -from .builders.utils import BuilderOptions class IGetCriteriaSqlDispatcher(ABC): diff --git a/circe/cohortdefinition/printfriendly/markdown_render.py b/circe/cohortdefinition/printfriendly/markdown_render.py index 6cc897a9..380defb3 100644 --- a/circe/cohortdefinition/printfriendly/markdown_render.py +++ b/circe/cohortdefinition/printfriendly/markdown_render.py @@ -11,14 +11,15 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Union +import json from datetime import datetime from pathlib import Path -import json +from typing import List, Optional, Union + import jinja2 -from ..cohort import CohortExpression from ...vocabulary.concept import ConceptSet +from ..cohort import CohortExpression class MarkdownRender: diff --git a/circe/helper/__init__.py b/circe/helper/__init__.py index f05cd1d4..883f66e5 100644 --- a/circe/helper/__init__.py +++ b/circe/helper/__init__.py @@ -5,34 +5,30 @@ It mirrors the Java CIRCE-BE helper package structure. """ -from .cohort_modifiers import ( - # Constants - GENDER_MALE_CONCEPT_ID, +from .cohort_modifiers import ( # Constants; Modifier functions; Reset helpers; Convenience GENDER_FEMALE_CONCEPT_ID, - # Modifier functions - set_prior_observation, - set_post_observation, - set_limit_to_first_event, - set_allow_all_events, - set_cohort_era, - set_age_criteria, - set_gender_criteria, - set_end_date_strategy, - set_washout_period, - set_clean_window, - set_date_range, - set_censor_event, + GENDER_MALE_CONCEPT_ID, + apply_standard_rules, clear_censor_events, - # Reset helpers - reset_observation_window, reset_age_criteria, - reset_gender_criteria, - reset_end_strategy, - reset_collapse_settings, reset_clean_window, + reset_collapse_settings, reset_date_range, - # Convenience - apply_standard_rules, + reset_end_strategy, + reset_gender_criteria, + reset_observation_window, + set_age_criteria, + set_allow_all_events, + set_censor_event, + set_clean_window, + set_cohort_era, + set_date_range, + set_end_date_strategy, + set_gender_criteria, + set_limit_to_first_event, + set_post_observation, + set_prior_observation, + set_washout_period, ) __all__ = [ diff --git a/circe/helper/cohort_modifiers.py b/circe/helper/cohort_modifiers.py index 9eb0034e..629a6a78 100644 --- a/circe/helper/cohort_modifiers.py +++ b/circe/helper/cohort_modifiers.py @@ -51,7 +51,6 @@ ) from ..vocabulary.concept import Concept - # --------------------------------------------------------------------------- # Constants – OMOP standard concept IDs for gender # --------------------------------------------------------------------------- diff --git a/circe/vocabulary/concept.py b/circe/vocabulary/concept.py index ad1c089c..25ffb9a6 100644 --- a/circe/vocabulary/concept.py +++ b/circe/vocabulary/concept.py @@ -8,8 +8,9 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Any -from pydantic import BaseModel, Field, ConfigDict, AliasChoices +from typing import Any, List, Optional + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field class Concept(BaseModel): diff --git a/circe/vocabulary/concept_set_expression_query_builder.py b/circe/vocabulary/concept_set_expression_query_builder.py index 6ced62bd..ab8bb644 100644 --- a/circe/vocabulary/concept_set_expression_query_builder.py +++ b/circe/vocabulary/concept_set_expression_query_builder.py @@ -9,8 +9,9 @@ """ from typing import List, Optional -from .concept import Concept, ConceptSetExpression, ConceptSetItem + from ..cohortdefinition.builders.utils import BuilderUtils +from .concept import Concept, ConceptSetExpression, ConceptSetItem class ConceptSetExpressionQueryBuilder: From dd3baec0a4a387c813d2dd4493fcc54e28c2d1a0 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Wed, 25 Feb 2026 10:39:49 -0800 Subject: [PATCH 06/62] Release stuff --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fb6e3d6..75ea0e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-02-25 + +### Added +- Helper functions for altering cohort objects in consistent ways (enforcing first event, prior observation period, etc.) +- Default behaviour of circe models is now better with empty lists (consistent with Java implementation) +- Additional validation checks for cohort expression objects + ## [0.1.0] - 2026-01-23 ### Added diff --git a/pyproject.toml b/pyproject.toml index a5668506..31f1fb8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ohdsi-circe-python-alpha" -version = "0.1.0" +version = "0.2.0" description = "Python implementation of OHDSI CIRCE-BE for cohort definition and SQL generation" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "Apache-2.0"} @@ -22,11 +22,12 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Medical Science Apps.", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", From f6d81c4b962d135f496668e52eabdd6fc19fb670 Mon Sep 17 00:00:00 2001 From: Egill Axfjord Fridgeirsson Date: Fri, 13 Mar 2026 14:06:31 +0100 Subject: [PATCH 07/62] Feature/ibis execution api (#13) * Add native ibis execution engine and expression loader * Expose experimental execution API and optional extras * Add execution API tests and package structure coverage * Validate write options for append vs overwrite * Raise ibis optional extra minimum to 11 --------- Co-authored-by: Jamie Gilbert --- README.md | 14 + circe/__init__.py | 15 + circe/execution/__init__.py | 13 + circe/execution/build_context.py | 605 +++++++++++++ circe/execution/builders/__init__.py | 17 + circe/execution/builders/common.py | 793 ++++++++++++++++++ circe/execution/builders/condition_era.py | 55 ++ .../builders/condition_occurrence.py | 100 +++ circe/execution/builders/death.py | 61 ++ circe/execution/builders/device_exposure.py | 84 ++ circe/execution/builders/dose_era.py | 58 ++ circe/execution/builders/drug_era.py | 50 ++ circe/execution/builders/drug_exposure.py | 107 +++ circe/execution/builders/groups.py | 486 +++++++++++ circe/execution/builders/measurement.py | 216 +++++ circe/execution/builders/observation.py | 106 +++ .../execution/builders/observation_period.py | 69 ++ circe/execution/builders/payer_plan_period.py | 85 ++ circe/execution/builders/pipeline.py | 189 +++++ circe/execution/builders/post_processing.py | 116 +++ .../builders/procedure_occurrence.py | 85 ++ circe/execution/builders/registry.py | 47 ++ circe/execution/builders/specimen.py | 84 ++ circe/execution/builders/visit_detail.py | 88 ++ circe/execution/builders/visit_occurrence.py | 90 ++ circe/execution/criteria_compat.py | 203 +++++ circe/execution/ibis.py | 225 +++++ circe/execution/ibis_compat.py | 41 + circe/execution/options.py | 38 + circe/io.py | 61 ++ pyproject.toml | 15 +- tests/test_execution_api.py | 239 ++++++ tests/test_package_structure.py | 92 +- 33 files changed, 4509 insertions(+), 38 deletions(-) create mode 100644 circe/execution/__init__.py create mode 100644 circe/execution/build_context.py create mode 100644 circe/execution/builders/__init__.py create mode 100644 circe/execution/builders/common.py create mode 100644 circe/execution/builders/condition_era.py create mode 100644 circe/execution/builders/condition_occurrence.py create mode 100644 circe/execution/builders/death.py create mode 100644 circe/execution/builders/device_exposure.py create mode 100644 circe/execution/builders/dose_era.py create mode 100644 circe/execution/builders/drug_era.py create mode 100644 circe/execution/builders/drug_exposure.py create mode 100644 circe/execution/builders/groups.py create mode 100644 circe/execution/builders/measurement.py create mode 100644 circe/execution/builders/observation.py create mode 100644 circe/execution/builders/observation_period.py create mode 100644 circe/execution/builders/payer_plan_period.py create mode 100644 circe/execution/builders/pipeline.py create mode 100644 circe/execution/builders/post_processing.py create mode 100644 circe/execution/builders/procedure_occurrence.py create mode 100644 circe/execution/builders/registry.py create mode 100644 circe/execution/builders/specimen.py create mode 100644 circe/execution/builders/visit_detail.py create mode 100644 circe/execution/builders/visit_occurrence.py create mode 100644 circe/execution/criteria_compat.py create mode 100644 circe/execution/ibis.py create mode 100644 circe/execution/ibis_compat.py create mode 100644 circe/execution/options.py create mode 100644 circe/io.py create mode 100644 tests/test_execution_api.py diff --git a/README.md b/README.md index 4e87bfd6..ff131791 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,19 @@ sql = build_cohort_query(cohort, options) print(sql) ``` +### Experimental Ibis Execution API + +An experimental backend-native execution API is available under +`circe.execution`. + +```python +from circe.execution import ExecutionOptions, IbisExecutor + +# Requires optional extras, e.g. `pip install ohdsi-circe-python-alpha[ibis-duckdb]` +executor = IbisExecutor(conn, ExecutionOptions(cdm_schema="main")) +events = executor.build(cohort) # lazy ibis relation +``` + ## What's Included This package provides a complete Python implementation of CIRCE-BE with: @@ -181,6 +194,7 @@ circe/ │ ├── operations/ # Check operations │ ├── utils/ # Check utilities │ └── warnings/ # Warning classes +├── execution/ # Experimental backend-native execution APIs ├── helper/ # Utility helper classes ├── api.py # High-level API functions └── cli.py # Command-line interface diff --git a/circe/__init__.py b/circe/__init__.py index 743fdb3c..3f9c09f8 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -91,6 +91,14 @@ # Main exports from .cohortdefinition import CohortExpression +from .execution import ( + ExecutionOptions, + IbisExecutor, + build_ibis, + to_polars, + write_cohort, +) +from .io import load_expression from .vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -222,4 +230,11 @@ def get_json_schema() -> dict: "build_cohort_query", "cohort_print_friendly", "safe_model_rebuild", + # I/O and experimental execution API + "load_expression", + "ExecutionOptions", + "IbisExecutor", + "build_ibis", + "to_polars", + "write_cohort", ] diff --git a/circe/execution/__init__.py b/circe/execution/__init__.py new file mode 100644 index 00000000..c0adffbf --- /dev/null +++ b/circe/execution/__init__.py @@ -0,0 +1,13 @@ +"""Experimental backend execution APIs.""" + +from .ibis import IbisExecutor, build_ibis, to_polars, write_cohort +from .options import ExecutionOptions, SchemaName + +__all__ = [ + "ExecutionOptions", + "SchemaName", + "IbisExecutor", + "build_ibis", + "to_polars", + "write_cohort", +] diff --git a/circe/execution/build_context.py b/circe/execution/build_context.py new file mode 100644 index 00000000..e2a2d40b --- /dev/null +++ b/circe/execution/build_context.py @@ -0,0 +1,605 @@ +from __future__ import annotations + +import uuid +import weakref +from dataclasses import dataclass +from functools import reduce +from pathlib import Path +from typing import Callable, Iterable, Optional, Tuple, Union + +import ibis +import ibis.common.exceptions as ibis_exc +import ibis.expr.types as ir + +from ..vocabulary.concept import ConceptSet +from .ibis_compat import table_from_literal_list + +Database = Union[str, Tuple[str, str]] + + +def _qualify(database: Database | None, name: str) -> str: + """Only for statements were constructing outside of Ibis.""" + if database is None: + return name + if isinstance(database, tuple): + return ".".join(database + (name,)) + return f"{database}.{name}" + + +def _table(conn: ibis.BaseBackend, database: Database | None, name: str) -> ir.Table: + return conn.table(name, database=database) + + +def _warn(message: str) -> None: + print(f"Warning: {message}") + + +def _analyze_table( + conn: ibis.BaseBackend, *, backend: str | None, qualified_name: str +) -> None: + if not backend: + return + if backend in ("postgres", "duckdb"): + conn.raw_sql(f"ANALYZE {qualified_name}") + return + if backend == "databricks": + conn.raw_sql(f"ANALYZE TABLE {qualified_name} COMPUTE STATISTICS") + + +def _drop_table_safely( + conn: ibis.BaseBackend, + *, + name: str, + database: Database | None = None, + warning_label: str, +) -> None: + try: + conn.drop_table(name, database=database, force=True) + except Exception as exc: + _warn(f"could not drop {warning_label}: {exc}") + + +@dataclass(frozen=True) +class CohortBuildOptions: + cdm_schema: Optional[str] = None + vocabulary_schema: Optional[str] = None + result_schema: Optional[str] = None + target_table: Optional[str] = None + cohort_id: Optional[int] = None + generate_stats: bool = False + temp_emulation_schema: Optional[str] = None + profile_dir: Optional[str] = None + capture_sql: bool = False + backend: Optional[str] = None + materialize_stages: bool = True + materialize_codesets: bool = True + + +@dataclass +class CodesetResource: + table: ir.Table + _dropper: Optional[Callable[[], None]] = None + + def cleanup(self): + if self._dropper: + try: + self._dropper() + finally: + self._dropper = None + + +class BuildContext: + """Holds shared state (connection, schemas, compiled codesets) used across builders.""" + + def __init__( + self, + conn: ibis.BaseBackend, + options: CohortBuildOptions, + codeset_resource: CodesetResource | ir.Table, + ): + self._conn = conn + self._options = options + if isinstance(codeset_resource, CodesetResource): + self._codeset_resource = codeset_resource + else: + self._codeset_resource = CodesetResource(table=codeset_resource) + self._codesets = self._codeset_resource.table + self._cleanup_callbacks: list[Callable[[], None]] = [] + self._correlated_cache: dict[str, ir.Table] = {} + self._profile_dir = None + if options.profile_dir: + path = Path(options.profile_dir).resolve() + path.mkdir(parents=True, exist_ok=True) + self._profile_dir = path + self._captured_sql: list[tuple[str, str]] = [] + self._slice_cache: dict[str, ir.Table] = {} + weakref.finalize(self, self.close) + + def _table(self, database: Optional[str], name: str) -> ir.Table: + try: + return _table(self._conn, database, name) + except ( + ibis_exc.IbisError, + TypeError, + ValueError, + AttributeError, + NotImplementedError, + ): + return self._conn.sql(f"SELECT * FROM {_qualify(database, name)}") + + def table(self, name: str) -> ir.Table: + """Return a CDM table.""" + return self._table(self._options.cdm_schema, name) + + def vocabulary_table(self, name: str) -> ir.Table: + """Return a vocabulary table (concept, concept_ancestor, etc.).""" + schema = self._options.vocabulary_schema or self._options.cdm_schema + return self._table(schema, name) + + def codeset(self, codeset_id: int, *, is_exclusion: bool = False) -> ir.Table: + """Return concepts for the requested codeset. `is_exclusion` is provided for parity with Circe.""" + _ = is_exclusion # placeholder for future differentiated handling + return self._codesets.filter(self._codesets.codeset_id == codeset_id) + + def get_cached_correlated(self, key: str) -> ir.Table | None: + return self._correlated_cache.get(key) + + def cache_correlated(self, key: str, table: ir.Table) -> None: + self._correlated_cache[key] = table + + def materialize( + self, + expr: ir.Table, + *, + label: str, + temp: bool = True, + analyze: bool = True, + ) -> ir.Table: + """ + Materialize an Ibis expression, capturing a unique DuckDB profiling + artifact for this step. + """ + step_id = uuid.uuid4().hex[:8] + table_name = f"_stage_{label}_{step_id}" + backend = self._options.backend + + # "temp emulation" means: create a *real* table in a chosen database/schema. + use_temp_emulation = temp and self._options.temp_emulation_schema is not None + database: Database | None = ( + self._options.temp_emulation_schema if use_temp_emulation else None + ) + temp_flag = False if use_temp_emulation else temp + + # duckdb profiling setup for local dev + profile_filename: Path | None = None + profiling_enabled = False + if backend == "duckdb" and self._profile_dir is not None: + profile_filename = ( + self._profile_dir / f"ibis_profile_{label}_{step_id}.json" + ).resolve() + try: + escaped = str(profile_filename).replace("'", "''") + self._conn.raw_sql(f"SET profiling_output='{escaped}'") + self._conn.raw_sql("SET enable_profiling='json'") + self._conn.raw_sql("SET profiling_coverage='ALL'") + profiling_enabled = True + except Exception as exc: + _warn(f"could not enable DuckDB profiling for {label}: {exc}") + + try: + self._conn.create_table( + table_name, + obj=expr, + database=database, + temp=temp_flag, + overwrite=True, + ) + if self._options.capture_sql: + self._captured_sql.append((table_name, self._conn.compile(expr))) + finally: + if profiling_enabled: + try: + self._conn.raw_sql("PRAGMA disable_profiling") + except Exception as exc: + _warn(f"could not disable DuckDB profiling for {label}: {exc}") + + if profiling_enabled and profile_filename is not None: + print(f"[Profile Captured]: {profile_filename} (Table: {table_name})") + + if analyze: + qualified = _qualify(database, table_name) + try: + _analyze_table(self._conn, backend=backend, qualified_name=qualified) + except Exception as exc: + _warn(f"could not analyze table {qualified}: {exc}") + + def _drop(): + _drop_table_safely( + self._conn, + name=table_name, + database=database, + warning_label=f"table {table_name} in {database}", + ) + + self.register_cleanup(_drop) + return _table(self._conn, database, table_name) + + def should_materialize_stages(self) -> bool: + return bool(self._options.materialize_stages) + + def maybe_materialize( + self, + expr: ir.Table, + *, + label: str, + temp: bool = True, + analyze: bool = True, + ) -> ir.Table: + if not self.should_materialize_stages(): + return expr + return self.materialize(expr, label=label, temp=temp, analyze=analyze) + + def write_cohort_table( + self, + events: ir.Table, + *, + table_name: str | None = None, + database: Database | None = None, + overwrite: bool = True, + append: bool = False, + ) -> ir.Table: + """ + Persist cohort rows to a results table. + + Output schema matches OHDSI cohort tables: + (cohort_definition_id, subject_id, cohort_start_date, cohort_end_date) + """ + if append and overwrite: + raise ValueError( + "`append=True` and `overwrite=True` cannot be used together." + ) + target_table = table_name or self._options.target_table + if not target_table: + raise ValueError( + "target_table must be set (argument or CohortBuildOptions.target_table)" + ) + target_db = database if database is not None else self._options.result_schema + if target_db is None: + raise ValueError( + "result_schema must be set (argument or CohortBuildOptions.result_schema)" + ) + + cohort_id = self._options.cohort_id + cohort_id_expr = ( + ibis.literal(int(cohort_id), type="int64") + if cohort_id is not None + else ibis.null().cast("int64") + ) + + result = events.select( + cohort_id_expr.name("cohort_definition_id"), + events.person_id.cast("int64").name("subject_id"), + events.start_date.cast("date").name("cohort_start_date"), + events.end_date.cast("date").name("cohort_end_date"), + ) + + obj = result + if append: + try: + existing = _table(self._conn, target_db, target_table) + obj = existing.union(result, distinct=False) + except ( + ibis_exc.IbisError, + TypeError, + ValueError, + AttributeError, + NotImplementedError, + ): + obj = result + + self._conn.create_table( + target_table, + obj=obj, + database=target_db, + temp=False, + overwrite=overwrite, + ) + return _table(self._conn, target_db, target_table) + + @property + def codesets(self) -> ir.Table: + return self._codesets + + @property + def conn(self) -> ibis.BaseBackend: + return self._conn + + def options(self) -> CohortBuildOptions: + return self._options + + def captured_sql(self) -> list[tuple[str, str]]: + return list(self._captured_sql) + + def register_cleanup(self, callback: Callable[[], None]): + self._cleanup_callbacks.append(callback) + + def get_or_materialize_slice( + self, + cache_key: str, + expr: ir.Table, + *, + label: str | None = None, + ) -> ir.Table: + """Materialize an expression once and reuse the resulting temp table for later lookups.""" + if not self.should_materialize_stages(): + return expr.view() + cached = self._slice_cache.get(cache_key) + if cached is not None: + return cached + label_hint = label or "slice" + table = self.materialize(expr, label=label_hint, temp=True, analyze=True) + self._slice_cache[cache_key] = table + return table + + def close(self): + if self._codeset_resource is not None: + self._codeset_resource.cleanup() + self._codeset_resource = None # type: ignore[assignment] + while self._cleanup_callbacks: + callback = self._cleanup_callbacks.pop() + try: + callback() + except Exception as exc: + _warn(f"cleanup callback failed: {exc}") + self._captured_sql.clear() + self._slice_cache.clear() + + +def compile_codesets( + conn: ibis.BaseBackend, + concept_sets: list[ConceptSet], + options: CohortBuildOptions, +) -> CodesetResource: + """Rebuild Circe concept set logic as an ibis expression.""" + + vocab_schema = options.vocabulary_schema or options.cdm_schema + concept = _table(conn, vocab_schema, "concept") + concept_ancestor = _table(conn, vocab_schema, "concept_ancestor") + concept_relationship = _table(conn, vocab_schema, "concept_relationship") + + compiled = [] + for concept_set in concept_sets or []: + compiled_expr = _compile_single_codeset( + concept, concept_ancestor, concept_relationship, concept_set + ) + if compiled_expr is not None: + compiled.append(compiled_expr) + + if not compiled: + compiled_expr = _empty_codeset_table() + else: + compiled_expr = _union_all(compiled).distinct() + + if not options.materialize_codesets: + return CodesetResource(table=compiled_expr) + + return _materialize_codesets(conn, compiled_expr, options) + + +def _compile_single_codeset( + concept: ir.Table, + concept_ancestor: ir.Table, + concept_relationship: ir.Table, + concept_set: ConceptSet, +) -> Optional[ir.Table]: + expression = concept_set.expression + if expression is None or not expression.items: + return None + + include_ids: list[int] = [] + include_descendant_ids: list[int] = [] + include_mapped_ids: list[int] = [] + include_mapped_descendant_ids: list[int] = [] + + exclude_ids: list[int] = [] + exclude_descendant_ids: list[int] = [] + exclude_mapped_ids: list[int] = [] + exclude_mapped_descendant_ids: list[int] = [] + + for item in expression.items: + if item.concept is None or item.concept.concept_id is None: + continue + target_include = not bool(item.is_excluded) + include_descendants = bool(item.include_descendants) + include_mapped = bool(item.include_mapped) + concept_id = int(item.concept.concept_id) + + if target_include: + include_ids.append(concept_id) + if include_descendants: + include_descendant_ids.append(concept_id) + if include_mapped: + include_mapped_ids.append(concept_id) + if include_descendants: + include_mapped_descendant_ids.append(concept_id) + else: + exclude_ids.append(concept_id) + if include_descendants: + exclude_descendant_ids.append(concept_id) + if include_mapped: + exclude_mapped_ids.append(concept_id) + if include_descendants: + exclude_mapped_descendant_ids.append(concept_id) + + include_expr = _union_distinct( + [ + _ids_memtable(include_ids), + _descendants(concept, concept_ancestor, include_descendant_ids), + _mapped_concepts( + concept, + concept_ancestor, + concept_relationship, + include_mapped_ids, + include_mapped_descendant_ids, + ), + ] + ) + + if include_expr is None: + return None + + exclude_expr = _union_distinct( + [ + _ids_memtable(exclude_ids), + _descendants(concept, concept_ancestor, exclude_descendant_ids), + _mapped_concepts( + concept, + concept_ancestor, + concept_relationship, + exclude_mapped_ids, + exclude_mapped_descendant_ids, + ), + ] + ) + + if exclude_expr is not None: + include_expr = include_expr.anti_join(exclude_expr, ["concept_id"]) + + codeset_literal = ibis.literal(int(concept_set.id), type="int64") + return include_expr.mutate(codeset_id=codeset_literal)[["codeset_id", "concept_id"]] + + +def _ids_memtable(ids: list[int]) -> Optional[ir.Table]: + if not ids: + return None + return table_from_literal_list( + ids, column_name="concept_id", element_type="int64" + ).distinct() + + +def _descendants( + concept: ir.Table, concept_ancestor: ir.Table, ancestor_ids: list[int] +) -> Optional[ir.Table]: + if not ancestor_ids: + return None + return ( + concept_ancestor.filter(concept_ancestor.ancestor_concept_id.isin(ancestor_ids)) + .join(concept, concept_ancestor.descendant_concept_id == concept.concept_id) + .filter(concept.invalid_reason.isnull()) + .select(concept.concept_id.cast("int64").name("concept_id")) + .distinct() + ) + + +def _mapped_concepts( + concept: ir.Table, + concept_ancestor: ir.Table, + concept_relationship: ir.Table, + concepts_to_map: list[int], + concepts_with_descendants_to_map: list[int], +) -> Optional[ir.Table]: + sources = _union_distinct( + [ + _ids_memtable(concepts_to_map), + _descendants(concept, concept_ancestor, concepts_with_descendants_to_map), + ] + ) + + if sources is None: + return None + + valid_relationships = concept_relationship.filter( + [ + concept_relationship.relationship_id == "Maps to", + concept_relationship.invalid_reason.isnull(), + ] + ) + + return ( + sources.join( + valid_relationships, sources.concept_id == valid_relationships.concept_id_2 + ) + .select(valid_relationships.concept_id_1.cast("int64").name("concept_id")) + .distinct() + ) + + +def _empty_codeset_table() -> ir.Table: + empty_concepts = table_from_literal_list( + [], column_name="concept_id", element_type="int64" + ) + empty_codesets = empty_concepts.mutate( + codeset_id=ibis.null().cast("int64"), + ) + return empty_codesets.select("codeset_id", "concept_id") + + +def _materialize_codesets( + conn: ibis.BaseBackend, + expr: ir.Table, + options: CohortBuildOptions, +) -> CodesetResource: + name = f"_codesets_{uuid.uuid4().hex}" + if options.temp_emulation_schema: + database: Database = options.temp_emulation_schema + conn.create_table( + name, + obj=expr, + database=database, + temp=False, + overwrite=True, + ) + table = _table(conn, database, name) + qualified = _qualify(database, name) + + def _drop(): + _drop_table_safely( + conn, + name=name, + database=database, + warning_label=f"codeset table {name} in {database}", + ) + + else: + conn.create_table( + name, + obj=expr, + temp=True, + overwrite=True, + ) + table = _table(conn, None, name) + qualified = _qualify(None, name) + + def _drop(): + _drop_table_safely( + conn, + name=name, + warning_label=f"codeset temp table {name}", + ) + + backend = options.backend + if backend: + try: + _analyze_table(conn, backend=backend, qualified_name=qualified) + except Exception as exc: + _warn(f"could not analyze codeset table {qualified}: {exc}") + + resource = CodesetResource(table=table, _dropper=_drop) + weakref.finalize(resource, resource.cleanup) + return resource + + +def _union_distinct(tables: Iterable[Optional[ir.Table]]) -> Optional[ir.Table]: + valid_tables = [t for t in tables if t is not None] + if not valid_tables: + return None + + return reduce( + lambda left, right: left.union(right, distinct=True), + valid_tables[1:], + valid_tables[0], + ) + + +def _union_all(tables: list[ir.Table]) -> ir.Table: + return reduce(lambda left, right: left.union(right), tables[1:], tables[0]) diff --git a/circe/execution/builders/__init__.py b/circe/execution/builders/__init__.py new file mode 100644 index 00000000..8b5adfc7 --- /dev/null +++ b/circe/execution/builders/__init__.py @@ -0,0 +1,17 @@ +from . import condition_era # noqa: F401 +from . import condition_occurrence # noqa: F401 +from . import death # noqa: F401 +from . import device_exposure # noqa: F401 +from . import dose_era # noqa: F401 +from . import drug_era # noqa: F401 +from . import drug_exposure # noqa: F401 +from . import measurement # noqa: F401 +from . import observation # noqa: F401 +from . import observation_period # noqa: F401 +from . import payer_plan_period # noqa: F401 +from . import procedure_occurrence # noqa: F401 +from . import specimen # noqa: F401 +from . import visit_detail # noqa: F401 +from . import visit_occurrence # noqa: F401 +from .pipeline import build_primary_events # noqa: F401 +from .registry import build_events, register # noqa: F401 diff --git a/circe/execution/builders/common.py b/circe/execution/builders/common.py new file mode 100644 index 00000000..e1e2f4d0 --- /dev/null +++ b/circe/execution/builders/common.py @@ -0,0 +1,793 @@ +from __future__ import annotations + +from typing import Any, Callable, Optional, Sequence, cast + +import ibis +import ibis.expr.types as ir +from ibis.expr.api import row_number + +from ...cohortdefinition.core import ( + CollapseSettings, + CollapseType, + ConceptSetSelection, + CustomEraStrategy, + DateOffsetStrategy, + DateRange, + EndStrategy, + NumericRange, + TextFilter, +) +from ...vocabulary.concept import Concept +from ..build_context import BuildContext + +OutputFormatter = Callable[[ir.Table], ir.Table] + + +def _person_subset(ctx: BuildContext, columns: list[str]) -> ir.Table: + person = ctx.table("person") + missing = [col for col in columns if col not in person.columns] + if missing: + raise ValueError(f"Person table missing required columns: {missing}") + return person.select(columns) + + +def standardize_output( + table: ir.Table, + *, + primary_key: str, + start_column: str, + end_column: str, +) -> ir.Table: + """Project and rename columns to the strict builder output contract.""" + start_expr = table[start_column].cast("timestamp") + same_column = end_column == start_column + if same_column: + end_expr = start_expr + needs_offset = ibis.literal(True) + elif end_column in table.columns: + end_raw = table[end_column].cast("timestamp") + end_expr = ibis.coalesce(end_raw, start_expr).cast("timestamp") + needs_offset = end_raw.isnull() + else: + end_expr = start_expr + needs_offset = ibis.literal(True) + one_day = ibis.interval(days=1) + end_expr = ibis.ifelse(needs_offset, cast(Any, end_expr) + one_day, end_expr).cast( + "timestamp" + ) + visit_expr = ( + table.visit_occurrence_id.cast("int64") + if "visit_occurrence_id" in table.columns + else ibis.null().cast("int64") + ).name("visit_occurrence_id") + return table.select( + table.person_id.cast("int64").name("person_id"), + table[primary_key].cast("int64").name("event_id"), + start_expr.name("start_date"), + end_expr.name("end_date"), + visit_expr, + ) + + +def project_event_columns( + table: ir.Table, + *, + primary_key: str, + start_column: str, + end_column: str, + include_visit_occurrence: bool = False, +) -> ir.Table: + keep = ["person_id", primary_key, start_column] + if end_column in table.columns: + keep.append(end_column) + elif include_visit_occurrence and start_column != end_column: + keep.append(end_column) + if include_visit_occurrence and "visit_occurrence_id" in table.columns: + keep.append("visit_occurrence_id") + unique_keep = [ + col + for i, col in enumerate(keep) + if col in table.columns and col not in keep[:i] + ] + return table.select(*(table[col] for col in unique_keep)) + + +def apply_codeset_filter( + table: ir.Table, + concept_column: str, + codeset_id: Optional[int], + ctx: BuildContext, +) -> ir.Table: + if codeset_id is None: + return table + base_columns = table.columns + left = table.view() + concepts = ctx.codesets.filter( + ctx.codesets["codeset_id"] == ibis.literal(codeset_id) + ).view() + joined = left.join(concepts, [left[concept_column] == concepts["concept_id"]]) + return _project_columns(joined, base_columns) + + +def apply_concept_set_selection( + table: ir.Table, + column: str, + selection: Optional[ConceptSetSelection], + ctx: BuildContext, +) -> ir.Table: + if selection is None or selection.codeset_id is None: + return table + base_columns = table.columns + left = table.view() + codeset_table = ctx.codesets.filter( + ctx.codesets["codeset_id"] == ibis.literal(selection.codeset_id) + ).view() + if selection.is_exclusion: + return left.anti_join(codeset_table, [left[column] == codeset_table.concept_id]) + joined = left.join(codeset_table, [left[column] == codeset_table.concept_id]) + return _project_columns(joined, base_columns) + + +def coerce_concept_set_selection( + value: object | None, +) -> Optional[ConceptSetSelection]: + if value is None: + return None + if isinstance(value, ConceptSetSelection): + return value + if hasattr(value, "codeset_id"): + return cast(ConceptSetSelection, value) + try: + return ConceptSetSelection(CodesetId=int(cast(Any, value))) + except (TypeError, ValueError) as exc: + raise ValueError(f"Unsupported concept set selection value: {value!r}") from exc + + +def apply_concept_criteria( + table: ir.Table, + *, + column: str, + concepts: Sequence[Concept] | None, + selection: Optional[ConceptSetSelection], + ctx: BuildContext, + exclude: bool = False, +) -> ir.Table: + table = apply_concept_filters(table, column, concepts, exclude=exclude) + return apply_concept_set_selection(table, column, selection, ctx) + + +def apply_date_range( + table: ir.Table, column: str, date_range: Optional[DateRange] +) -> ir.Table: + if not date_range: + return table + expr = table[column] + if date_range.op.endswith("bt"): + lower = ibis.literal(date_range.value) + upper = ibis.literal(date_range.extent) + predicate = expr.between(lower, upper) + if date_range.op.startswith("!"): + predicate = ~predicate + else: + comparator = _map_operator(date_range.op) + operand = ibis.literal(date_range.value) + predicate = comparator(expr, operand) + return table.filter(predicate) + + +def apply_numeric_range( + table: ir.Table, column, numeric_range: Optional[NumericRange] +) -> ir.Table: + if not numeric_range or numeric_range.value is None: + return table + op = numeric_range.op or "eq" + + expr = table[column] if isinstance(column, str) else column + if op.endswith("bt"): + lower = ibis.literal(numeric_range.value) + upper = ibis.literal(numeric_range.extent) + predicate = expr.between(lower, upper) + if op.startswith("!"): + predicate = ~predicate + else: + comparator = _map_operator(op) + operand = ibis.literal(numeric_range.value) + predicate = comparator(expr, operand) + return table.filter(predicate) + + +def apply_text_filter( + table: ir.Table, column: str, text_filter: Optional[TextFilter] +) -> ir.Table: + if not text_filter or not text_filter.text: + return table + op = text_filter.op or "contains" + negate = op.startswith("!") + core = op[1:] if negate else op + core = core.lower() + prefix = "%" if core in {"endswith", "contains"} else "" + suffix = "%" if core in {"startswith", "contains"} else "" + pattern = f"{prefix}{text_filter.text}{suffix}" + col_expr = cast(ir.StringValue, table[column]) + predicate = col_expr.like(pattern) + if negate: + predicate = ~predicate + return table.filter(predicate) + + +def apply_interval_range( + table: ir.Table, + start_column: str, + end_column: str, + interval_range: Optional[NumericRange], +) -> ir.Table: + if not interval_range or interval_range.value is None: + return table + + op = (interval_range.op or "gte").lower() + value = int(interval_range.value) + start = cast(Any, table[start_column]) + end = table[end_column] + + def _interval(days: int): + return ibis.interval(days=int(days)) + + if op.endswith("bt"): + if interval_range.extent is None: + raise ValueError("Between operator for interval range requires an extent") + lower = _interval(value) + upper = _interval(int(interval_range.extent)) + predicate = (end >= start + lower) & (end <= start + upper) + if op.startswith("!"): + predicate = ~predicate + return table.filter(predicate) + + target = _interval(value) + if op == "lt": + predicate = end < start + target + elif op == "lte": + predicate = end <= start + target + elif op == "gt": + predicate = end > start + target + elif op == "gte": + predicate = end >= start + target + elif op == "eq": + predicate = (end >= start + target) & (end < start + _interval(value + 1)) + elif op == "!eq": + predicate = ~((end >= start + target) & (end < start + _interval(value + 1))) + else: + raise ValueError(f"Unsupported operator for interval range: {op}") + + return table.filter(predicate) + + +def _map_operator(op: str): + mapping = { + "lt": lambda a, b: a < b, + "lte": lambda a, b: a <= b, + "eq": lambda a, b: a == b, + "!eq": lambda a, b: a != b, + "gt": lambda a, b: a > b, + "gte": lambda a, b: a >= b, + } + if op not in mapping: + raise ValueError(f"Operator {op} not supported") + return mapping[op] + + +def apply_concept_filters( + table: ir.Table, + column: str, + include_concepts: Sequence[Concept] | None, + exclude: bool = False, +) -> ir.Table: + if not include_concepts: + return table + concept_ids = [c.concept_id for c in include_concepts if c.concept_id is not None] + if not concept_ids: + return table + predicate = table[column].isin(cast(Any, concept_ids)) + if exclude: + predicate = ~predicate + return table.filter(predicate) + + +def apply_age_filter( + table: ir.Table, + age_range: Optional[NumericRange], + ctx: BuildContext, + start_column: str, +) -> ir.Table: + if not age_range: + return table + base_columns = table.columns + person = _person_subset(ctx, ["person_id", "year_of_birth"]) + joined = table.join(person, ["person_id"]) + start_expr = cast(ir.TimestampValue, _ensure_timestamp(joined[start_column])) + age_expr = start_expr.year() - cast(Any, joined.year_of_birth) + joined = joined.mutate(_criteria_age=age_expr) + filtered = apply_numeric_range(joined, "_criteria_age", age_range) + filtered = filtered.drop("_criteria_age") + return _project_columns(filtered, base_columns) + + +def apply_gender_filter( + table: ir.Table, + genders: list[Concept] | None, + gender_selection: Optional[ConceptSetSelection], + ctx: BuildContext, +) -> ir.Table: + return _apply_person_concept_filter( + table, + person_column="gender_concept_id", + concepts=genders, + selection=gender_selection, + ctx=ctx, + ) + + +def apply_race_filter( + table: ir.Table, + races: list[Concept] | None, + race_selection: Optional[ConceptSetSelection], + ctx: BuildContext, +) -> ir.Table: + return _apply_person_concept_filter( + table, + person_column="race_concept_id", + concepts=races, + selection=race_selection, + ctx=ctx, + ) + + +def apply_ethnicity_filter( + table: ir.Table, + ethnicities: list[Concept] | None, + ethnicity_selection: Optional[ConceptSetSelection], + ctx: BuildContext, +) -> ir.Table: + return _apply_person_concept_filter( + table, + person_column="ethnicity_concept_id", + concepts=ethnicities, + selection=ethnicity_selection, + ctx=ctx, + ) + + +def _apply_person_concept_filter( + table: ir.Table, + *, + person_column: str, + concepts: Sequence[Concept] | None, + selection: Optional[ConceptSetSelection], + ctx: BuildContext, +) -> ir.Table: + if not concepts and not selection: + return table + base_columns = table.columns + person = _person_subset(ctx, ["person_id", person_column]) + joined = table.join(person, ["person_id"]) + joined = apply_concept_criteria( + joined, + column=person_column, + concepts=concepts, + selection=selection, + ctx=ctx, + ) + return _project_columns(joined, base_columns) + + +def apply_observation_window( + events: ir.Table, + observation_window, + ctx: BuildContext, +) -> ir.Table: + if observation_window is None: + return events + observation = ctx.table("observation_period").select( + "person_id", "observation_period_start_date", "observation_period_end_date" + ) + # Use a view to ensure subsequent joins don't mix incompatible relations. + left = events.view() + joined = left.join(observation, ["person_id"]) + prior_days = ibis.interval(days=int(observation_window.prior_days or 0)) + post_days = ibis.interval(days=int(observation_window.post_days or 0)) + start_col = _ensure_timestamp(joined.observation_period_start_date) + end_col = _ensure_timestamp(joined.observation_period_end_date) + start_bound = start_col + cast(Any, prior_days) + end_bound = end_col - cast(Any, post_days) + filtered = joined.filter( + (joined.start_date >= start_bound) & (joined.start_date <= end_bound) + ) + base_projection = [filtered[col] for col in events.columns] + base_projection.extend( + filtered[col] + for col in ("observation_period_start_date", "observation_period_end_date") + if col in filtered.columns + ) + return filtered.select(*base_projection) + + +def apply_first_event(table: ir.Table, start_column: str, primary_key: str) -> ir.Table: + window = ibis.window( + group_by=table.person_id, + order_by=[table[start_column], table[primary_key]], + ) + + ranked = table.mutate(_row_num=row_number().over(window)) + filtered = ranked.filter(ranked["_row_num"] == ibis.literal(0)) + keep_columns = [col for col in table.columns if col != "_row_num"] + if keep_columns: + return filtered.select(*(filtered[col] for col in keep_columns)) + return filtered.drop("_row_num") + + +def apply_visit_concept_filters( + table: ir.Table, + visit_types: list[Concept] | None, + visit_selection: Optional[ConceptSetSelection], + ctx: BuildContext, +) -> ir.Table: + return apply_concept_criteria( + table, + column="visit_concept_id", + concepts=visit_types, + selection=visit_selection, + ctx=ctx, + ) + + +def apply_provider_specialty_filter( + table: ir.Table, + provider_specialties: list[Concept] | None, + provider_specialty_selection: Optional[ConceptSetSelection], + ctx: BuildContext, + provider_column: str = "provider_id", +) -> ir.Table: + if not provider_specialties and not provider_specialty_selection: + return table + provider = ctx.table("provider") + provider = apply_concept_criteria( + provider, + column="specialty_concept_id", + concepts=provider_specialties, + selection=provider_specialty_selection, + ctx=ctx, + ) + filtered = provider.select(provider.provider_id) + return table.semi_join(filtered, [table[provider_column] == filtered.provider_id]) + + +def apply_care_site_filter( + table: ir.Table, + place_of_service_selection: Optional[ConceptSetSelection], + ctx: BuildContext, + care_site_column: str = "care_site_id", +) -> ir.Table: + if not place_of_service_selection: + return table + care_site = ctx.table("care_site") + filtered = apply_concept_set_selection( + care_site, "place_of_service_concept_id", place_of_service_selection, ctx + ) + filtered = filtered.select(filtered.care_site_id) + return table.semi_join(filtered, [table[care_site_column] == filtered.care_site_id]) + + +def apply_location_region_filter( + table: ir.Table, + *, + care_site_column: str, + location_codeset_id: Optional[int], + start_column: str, + end_column: str, + ctx: BuildContext, +) -> ir.Table: + if not location_codeset_id: + return table + base_columns = table.columns + care_site = ctx.table("care_site") + location_history = ctx.table("location_history") + location = ctx.table("location") + joined = table.join(care_site, [table[care_site_column] == care_site.care_site_id]) + start_expr = _ensure_timestamp(joined[start_column]) + end_expr = _ensure_timestamp(joined[end_column]) + lh = location_history + lh_condition = ( + (joined[care_site_column] == lh.entity_id) + & (lh.domain_id == ibis.literal("CARE_SITE")) + & (start_expr >= lh.start_date) + & ( + end_expr + <= ibis.coalesce(lh.end_date, ibis.literal("2099-12-31").cast("date")) + ) + ) + joined = joined.join(lh, [lh_condition]) + joined = joined.join(location, [joined.location_id == location.location_id]) + codeset = ctx.codesets.filter( + ctx.codesets.codeset_id == ibis.literal(location_codeset_id) + ) + filtered = joined.join(codeset, [location.region_concept_id == codeset.concept_id]) + return _project_columns(filtered, base_columns) + + +def apply_user_defined_period( + table: ir.Table, + start_column: str, + end_column: str, + period, +) -> tuple[ir.Table, str, str]: + if not period: + return table, start_column, end_column + + base_start = table[start_column] + base_end = table[end_column] + additions = {} + new_start = start_column + new_end = end_column + + if getattr(period, "start_date", None): + literal = _literal_like(period.start_date, base_start) + additions["_user_defined_start"] = literal + table = table.filter((base_start <= literal) & (base_end >= literal)) + new_start = "_user_defined_start" + + if getattr(period, "end_date", None): + literal = _literal_like(period.end_date, base_end) + additions["_user_defined_end"] = literal + table = table.filter((base_start <= literal) & (base_end >= literal)) + new_end = "_user_defined_end" + + if additions: + table = table.mutate(**additions) + + return table, new_start, new_end + + +def _literal_like(value, reference): + literal = ibis.literal(value) + dtype = reference.type() + if dtype.is_timestamp(): + return literal.cast("timestamp") + if dtype.is_date(): + return literal.cast("date") + return literal + + +def _ensure_timestamp(expr: ir.Value) -> ir.Value: + dtype = expr.type() + if dtype.is_timestamp(): + return expr + if dtype.is_date(): + return expr.cast("timestamp") + if dtype.is_string(): + return ibis.to_timestamp(expr) + raise ValueError(f"Cannot convert expression of type {dtype} to timestamp") + + +def _cast_like(expr: ir.Value, reference: ir.Value) -> ir.Value: + target_type = reference.type() + if expr.type() == target_type: + return expr + return expr.cast(cast(Any, target_type)) + + +def _project_columns(table: ir.Table, column_names: Sequence[str]) -> ir.Table: + available = [name for name in column_names if name in table.columns] + if not available: + return table + return table.select(*[table[name] for name in available]) + + +def apply_end_strategy( + events: ir.Table, + strategy: Optional[EndStrategy | DateOffsetStrategy | CustomEraStrategy], + ctx: BuildContext, +) -> ir.Table: + date_offset, custom_era = _resolve_end_strategy_parts(strategy) + if not date_offset and not custom_era: + if "observation_period_end_date" in events.columns: + op_end = _cast_like( + _ensure_timestamp(events.observation_period_end_date), events.end_date + ) + return events.mutate(end_date=op_end) + return events + result = events + if custom_era: + result = _apply_custom_era_strategy(result, custom_era, ctx) + if date_offset: + interval = ibis.interval(days=int(date_offset.offset)) + date_field = str(date_offset.date_field or "StartDate").lower() + anchor = ( + _ensure_timestamp(result.start_date) + if date_field == "startdate" + else _ensure_timestamp(result.end_date) + ) + shifted = anchor + cast(Any, interval) + if "observation_period_end_date" in result.columns: + shifted = ibis.least( + shifted, + _ensure_timestamp(result.observation_period_end_date), + ) + result = result.mutate(end_date=_cast_like(shifted, result.end_date)) + return result + + +def has_end_strategy( + strategy: Optional[EndStrategy | DateOffsetStrategy | CustomEraStrategy], +) -> bool: + date_offset, custom_era = _resolve_end_strategy_parts(strategy) + return bool(date_offset or custom_era) + + +def _resolve_end_strategy_parts( + strategy: Optional[EndStrategy | DateOffsetStrategy | CustomEraStrategy], +) -> tuple[Optional[DateOffsetStrategy], Optional[CustomEraStrategy]]: + if strategy is None: + return None, None + + if isinstance(strategy, DateOffsetStrategy): + return strategy, None + + if isinstance(strategy, CustomEraStrategy): + return None, strategy + + date_offset = getattr(strategy, "date_offset", None) + custom_era = getattr(strategy, "custom_era", None) + + if isinstance(date_offset, dict): + date_offset = DateOffsetStrategy.model_validate(date_offset, strict=False) + if isinstance(custom_era, dict): + custom_era = CustomEraStrategy.model_validate(custom_era, strict=False) + + return date_offset, custom_era + + +def collapse_events(events: ir.Table, settings: CollapseSettings | None) -> ir.Table: + if not settings or settings.collapse_type != CollapseType.ERA: + return events + pad_interval = ibis.interval(days=int(settings.era_pad or 0)) + order_by = [events.start_date, events.end_date, events.event_id] + prev_window = ibis.window( + group_by=events.person_id, + order_by=order_by, + preceding=(None, 1), + ) + extended_end = events.end_date + cast(Any, pad_interval) + prev_max = extended_end.max().over(prev_window) + is_start = ibis.ifelse( + prev_max.notnull() & (prev_max >= events.start_date), + 0, + 1, + ) + annotated = events.mutate( + extended_end=extended_end, + is_start=is_start, + ) + is_start_col = cast(ir.IntegerColumn, annotated["is_start"]) + era_window = ibis.window( + group_by=annotated.person_id, + order_by=[ + annotated.start_date, + ibis.desc(is_start_col), + annotated.end_date, + annotated.event_id, + ], + ) + era_id = is_start_col.cumsum().over(era_window) + grouped = annotated.mutate(_era_id=era_id) + max_end = cast(ir.IntervalScalar, grouped.extended_end.max()) + collapsed = grouped.group_by(grouped.person_id, grouped._era_id).aggregate( + start_date=grouped.start_date.min(), + end_date=(max_end - pad_interval), + visit_occurrence_id=grouped.visit_occurrence_id.max(), + ) + final_window = ibis.window( + order_by=[collapsed.person_id, collapsed.start_date, collapsed.end_date] + ) + collapsed = collapsed.mutate( + event_id=(ibis.row_number().over(final_window) + 1) + ).select("person_id", "event_id", "start_date", "end_date", "visit_occurrence_id") + return collapsed + + +def _apply_custom_era_strategy( + events: ir.Table, strategy: CustomEraStrategy, ctx: BuildContext +) -> ir.Table: + if strategy.drug_codeset_id is None: + raise ValueError("Custom era strategy requires a drug codeset id.") + + persons = events.select(events.person_id).distinct() + codeset = ctx.codesets.filter(ctx.codesets.codeset_id == strategy.drug_codeset_id) + drug_exposure = ctx.table("drug_exposure") + + def _exposure_query(concept_column: str) -> ir.Table: + return ( + drug_exposure.join(persons, ["person_id"]) + .join(codeset, drug_exposure[concept_column] == codeset.concept_id) + .select( + drug_exposure.person_id, + drug_exposure.drug_exposure_start_date.name("drug_exposure_start_date"), + _drug_exposure_end(drug_exposure, strategy).name( + "drug_exposure_end_date" + ), + ) + ) + + exposures = _exposure_query("drug_concept_id").union( + _exposure_query("drug_source_concept_id"), distinct=False + ) + + gap = int(strategy.gap_days or 0) + offset = int(strategy.offset or 0) + extend_interval = ibis.interval(days=gap + offset) + + dt = exposures.select( + exposures.person_id, + exposures.drug_exposure_start_date.name("start_date"), + (exposures.drug_exposure_end_date + extend_interval).name("extended_end"), + ).distinct() + + prev_max_window = ibis.window( + group_by=dt.person_id, + order_by=[dt.start_date, dt.extended_end], + preceding=(None, 1), + ) + prev_running_max = dt.extended_end.max().over(prev_max_window) + is_start = ibis.ifelse( + prev_running_max.notnull() & (prev_running_max >= dt.start_date), 0, 1 + ) + staged = dt.mutate(is_start=is_start).view() + cumsum_window = ibis.window( + group_by=staged.person_id, order_by=[staged.start_date, staged.extended_end] + ) + group_idx = staged.is_start.cumsum().over(cumsum_window) + annotated = staged.mutate(group_idx=group_idx) + + eras = annotated.group_by(annotated.person_id, annotated.group_idx).aggregate( + era_start=annotated.start_date.min(), + era_end=(annotated.extended_end.max() - ibis.interval(days=gap)), + ) + + join_condition = ( + (events.person_id == eras.person_id) + & (events.start_date >= eras.era_start) + & (events.start_date <= eras.era_end) + ) + joined = events.join(eras, join_condition, how="inner") + if not joined.columns: + return events.limit(0) + supplemental = [ + joined[column] + for column in ("observation_period_start_date", "observation_period_end_date") + if column in joined.columns + ] + return joined.select( + joined.person_id, + joined.event_id, + joined.start_date, + joined.era_end.name("end_date"), + joined.visit_occurrence_id, + *supplemental, + ) + + +def _drug_exposure_end( + drug_exposure: ir.Table, strategy: CustomEraStrategy +) -> ir.Value: + start = drug_exposure.drug_exposure_start_date + if strategy.days_supply_override is not None: + return start + ibis.interval(days=int(strategy.days_supply_override)) + + end_candidates = [ + drug_exposure.drug_exposure_end_date, + ibis.ifelse( + drug_exposure.days_supply.notnull(), + start + (ibis.interval(days=1) * drug_exposure.days_supply.cast("int64")), + ibis.null(), + ), + start + ibis.interval(days=1), + ] + return ibis.coalesce(*end_candidates) diff --git a/circe/execution/builders/condition_era.py b/circe/execution/builders/condition_era.py new file mode 100644 index 00000000..277e90ae --- /dev/null +++ b/circe/execution/builders/condition_era.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import ConditionEra +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_interval_range, + apply_numeric_range, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("ConditionEra") +def build_condition_era(criteria: ConditionEra, ctx: BuildContext): + table = ctx.table("condition_era") + + table = apply_codeset_filter( + table, "condition_concept_id", criteria.codeset_id, ctx + ) + table = apply_date_range(table, "condition_era_start_date", criteria.era_start_date) + table = apply_date_range(table, "condition_era_end_date", criteria.era_end_date) + table = apply_numeric_range( + table, "condition_occurrence_count", criteria.occurrence_count + ) + table = apply_interval_range( + table, "condition_era_start_date", "condition_era_end_date", criteria.era_length + ) + + if criteria.age_at_start: + table = apply_age_filter( + table, criteria.age_at_start, ctx, "condition_era_start_date" + ) + if criteria.age_at_end: + table = apply_age_filter( + table, criteria.age_at_end, ctx, "condition_era_end_date" + ) + + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + + if criteria.first: + table = apply_first_event(table, "condition_era_start_date", "condition_era_id") + + events = standardize_output( + table, + primary_key="condition_era_id", + start_column="condition_era_start_date", + end_column="condition_era_end_date", + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/condition_occurrence.py b/circe/execution/builders/condition_occurrence.py new file mode 100644 index 00000000..29b041be --- /dev/null +++ b/circe/execution/builders/condition_occurrence.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import ConditionOccurrence +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_concept_criteria, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_visit_concept_filters, + coerce_concept_set_selection, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("ConditionOccurrence") +def build_condition_occurrence(criteria: ConditionOccurrence, ctx: BuildContext): + table = ctx.table("condition_occurrence") + + concept_column = criteria.get_concept_id_column() + table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) + if criteria.first: + table = apply_first_event( + table, criteria.get_start_date_column(), criteria.get_primary_key_column() + ) + + table = apply_date_range( + table, criteria.get_start_date_column(), criteria.occurrence_start_date + ) + table = apply_date_range( + table, criteria.get_end_date_column(), criteria.occurrence_end_date + ) + + table = apply_concept_criteria( + table, + column="condition_type_concept_id", + concepts=criteria.condition_type, + selection=criteria.condition_type_cs, + ctx=ctx, + exclude=bool(criteria.condition_type_exclude), + ) + + table = apply_concept_criteria( + table, + column="condition_status_concept_id", + concepts=getattr(criteria, "condition_status", None), + selection=None, + ctx=ctx, + ) + + if criteria.age: + table = apply_age_filter( + table, criteria.age, ctx, criteria.get_start_date_column() + ) + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + + source_filter = getattr(criteria, "condition_source_concept", None) + selection = coerce_concept_set_selection(source_filter) + if selection is not None: + table = apply_concept_criteria( + table, + column="condition_source_concept_id", + concepts=None, + selection=selection, + ctx=ctx, + ) + + visit_source = getattr(criteria, "visit_source_concept", None) + needs_visit_filters = bool( + criteria.visit_type or criteria.visit_type_cs or visit_source is not None + ) + if needs_visit_filters: + visit = ctx.table("visit_occurrence").select( + "person_id", + "visit_occurrence_id", + "visit_concept_id", + "visit_source_concept_id", + ) + table = table.join( + visit, + (table.visit_occurrence_id == visit.visit_occurrence_id) + & (table.person_id == visit.person_id), + ) + table = apply_visit_concept_filters( + table, criteria.visit_type, criteria.visit_type_cs, ctx + ) + if visit_source is not None: + table = table.filter(table.visit_source_concept_id == int(visit_source)) + + events = standardize_output( + table, + primary_key=criteria.get_primary_key_column(), + start_column=criteria.get_start_date_column(), + end_column=criteria.get_end_date_column(), + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/death.py b/circe/execution/builders/death.py new file mode 100644 index 00000000..ff2a76f7 --- /dev/null +++ b/circe/execution/builders/death.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import ibis + +from ...cohortdefinition.criteria import Death +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_concept_criteria, + apply_date_range, + apply_gender_filter, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("Death") +def build_death(criteria: Death, ctx: BuildContext): + table = ctx.table("death") + + table = apply_codeset_filter(table, "cause_concept_id", criteria.codeset_id, ctx) + + table = apply_date_range( + table, "death_date", getattr(criteria, "occurrence_start_date", None) + ) + + table = apply_concept_criteria( + table, + column="death_type_concept_id", + concepts=criteria.death_type, + selection=criteria.death_type_cs, + ctx=ctx, + exclude=bool(getattr(criteria, "death_type_exclude", False)), + ) + + if getattr(criteria, "death_source_concept", None) is not None: + table = apply_codeset_filter( + table, + "cause_source_concept_id", + int(criteria.death_source_concept), + ctx, + ) + + if criteria.age: + table = apply_age_filter( + table, criteria.age, ctx, criteria.get_start_date_column() + ) + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + + window = ibis.window(order_by=[table.person_id, table.death_date]) + table = table.mutate(death_event_id=ibis.row_number().over(window)) + + events = standardize_output( + table, + primary_key="death_event_id", + start_column="death_date", + end_column="death_date", + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/device_exposure.py b/circe/execution/builders/device_exposure.py new file mode 100644 index 00000000..9ef9cffd --- /dev/null +++ b/circe/execution/builders/device_exposure.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import DeviceExposure +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_concept_criteria, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_numeric_range, + apply_provider_specialty_filter, + apply_text_filter, + apply_visit_concept_filters, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("DeviceExposure") +def build_device_exposure(criteria: DeviceExposure, ctx: BuildContext): + table = ctx.table("device_exposure") + + concept_column = criteria.get_concept_id_column() + table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) + + table = apply_date_range( + table, criteria.get_start_date_column(), criteria.occurrence_start_date + ) + table = apply_date_range( + table, criteria.get_end_date_column(), criteria.occurrence_end_date + ) + + table = apply_concept_criteria( + table, + column="device_type_concept_id", + concepts=criteria.device_type, + selection=criteria.device_type_cs, + ctx=ctx, + exclude=bool(criteria.device_type_exclude), + ) + + table = apply_numeric_range(table, "quantity", criteria.quantity) + table = apply_text_filter( + table, "unique_device_id", getattr(criteria, "unique_device_id", None) + ) + + if criteria.age: + table = apply_age_filter( + table, criteria.age, ctx, criteria.get_start_date_column() + ) + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + table = apply_provider_specialty_filter( + table, + getattr(criteria, "provider_specialty", None), + getattr(criteria, "provider_specialty_cs", None), + ctx, + provider_column="provider_id", + ) + table = apply_visit_concept_filters( + table, criteria.visit_type, criteria.visit_type_cs, ctx + ) + if criteria.device_source_concept is not None: + table = apply_codeset_filter( + table, + "device_source_concept_id", + criteria.device_source_concept, + ctx, + ) + + if criteria.first: + table = apply_first_event( + table, criteria.get_start_date_column(), criteria.get_primary_key_column() + ) + + events = standardize_output( + table, + primary_key=criteria.get_primary_key_column(), + start_column=criteria.get_start_date_column(), + end_column=criteria.get_end_date_column(), + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/dose_era.py b/circe/execution/builders/dose_era.py new file mode 100644 index 00000000..c70ea978 --- /dev/null +++ b/circe/execution/builders/dose_era.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import DoseEra +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_concept_criteria, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_interval_range, + apply_numeric_range, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("DoseEra") +def build_dose_era(criteria: DoseEra, ctx: BuildContext): + table = ctx.table("dose_era") + + table = apply_codeset_filter(table, "drug_concept_id", criteria.codeset_id, ctx) + table = apply_date_range(table, "dose_era_start_date", criteria.era_start_date) + table = apply_date_range(table, "dose_era_end_date", criteria.era_end_date) + + table = apply_concept_criteria( + table, + column="unit_concept_id", + concepts=criteria.unit, + selection=criteria.unit_cs, + ctx=ctx, + ) + + table = apply_numeric_range(table, "dose_value", criteria.dose_value) + table = apply_interval_range( + table, "dose_era_start_date", "dose_era_end_date", criteria.era_length + ) + + if criteria.age_at_start: + table = apply_age_filter( + table, criteria.age_at_start, ctx, "dose_era_start_date" + ) + if criteria.age_at_end: + table = apply_age_filter(table, criteria.age_at_end, ctx, "dose_era_end_date") + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + + if criteria.first: + table = apply_first_event(table, "dose_era_start_date", "dose_era_id") + + events = standardize_output( + table, + primary_key="dose_era_id", + start_column="dose_era_start_date", + end_column="dose_era_end_date", + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/drug_era.py b/circe/execution/builders/drug_era.py new file mode 100644 index 00000000..5a65b0e3 --- /dev/null +++ b/circe/execution/builders/drug_era.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import DrugEra +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_interval_range, + apply_numeric_range, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("DrugEra") +def build_drug_era(criteria: DrugEra, ctx: BuildContext): + table = ctx.table("drug_era") + + table = apply_codeset_filter(table, "drug_concept_id", criteria.codeset_id, ctx) + table = apply_date_range(table, "drug_era_start_date", criteria.era_start_date) + table = apply_date_range(table, "drug_era_end_date", criteria.era_end_date) + table = apply_numeric_range(table, "drug_exposure_count", criteria.occurrence_count) + table = apply_numeric_range(table, "gap_days", criteria.gap_days) + table = apply_interval_range( + table, "drug_era_start_date", "drug_era_end_date", criteria.era_length + ) + + if criteria.age_at_start: + table = apply_age_filter( + table, criteria.age_at_start, ctx, "drug_era_start_date" + ) + if criteria.age_at_end: + table = apply_age_filter(table, criteria.age_at_end, ctx, "drug_era_end_date") + + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + + if criteria.first: + table = apply_first_event(table, "drug_era_start_date", "drug_era_id") + + events = standardize_output( + table, + primary_key="drug_era_id", + start_column="drug_era_start_date", + end_column="drug_era_end_date", + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/drug_exposure.py b/circe/execution/builders/drug_exposure.py new file mode 100644 index 00000000..d00fb27e --- /dev/null +++ b/circe/execution/builders/drug_exposure.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import DrugExposure +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_concept_criteria, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_numeric_range, + apply_provider_specialty_filter, + apply_text_filter, + apply_visit_concept_filters, + coerce_concept_set_selection, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("DrugExposure") +def build_drug_exposure(criteria: DrugExposure, ctx: BuildContext): + table = ctx.table("drug_exposure") + + concept_column = criteria.get_concept_id_column() + table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) + if criteria.first: + table = apply_first_event( + table, criteria.get_start_date_column(), criteria.get_primary_key_column() + ) + + table = apply_date_range( + table, criteria.get_start_date_column(), criteria.occurrence_start_date + ) + table = apply_date_range( + table, criteria.get_end_date_column(), criteria.occurrence_end_date + ) + + table = apply_concept_criteria( + table, + column="drug_type_concept_id", + concepts=criteria.drug_type, + selection=criteria.drug_type_cs, + ctx=ctx, + exclude=bool(getattr(criteria, "drug_type_exclude", False)), + ) + table = apply_concept_criteria( + table, + column="route_concept_id", + concepts=criteria.route_concept, + selection=criteria.route_concept_cs, + ctx=ctx, + ) + table = apply_concept_criteria( + table, + column="dose_unit_concept_id", + concepts=getattr(criteria, "dose_unit", []), + selection=getattr(criteria, "dose_unit_cs", None), + ctx=ctx, + ) + + table = apply_numeric_range(table, "quantity", criteria.quantity) + table = apply_numeric_range(table, "days_supply", criteria.days_supply) + table = apply_numeric_range(table, "refills", criteria.refills) + table = apply_text_filter( + table, "stop_reason", getattr(criteria, "stop_reason", None) + ) + table = apply_text_filter( + table, "lot_number", getattr(criteria, "lot_number", None) + ) + + if criteria.age: + table = apply_age_filter( + table, criteria.age, ctx, criteria.get_start_date_column() + ) + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + table = apply_provider_specialty_filter( + table, + getattr(criteria, "provider_specialty", None), + getattr(criteria, "provider_specialty_cs", None), + ctx, + provider_column="provider_id", + ) + table = apply_visit_concept_filters( + table, criteria.visit_type, criteria.visit_type_cs, ctx + ) + + source_filter = getattr(criteria, "drug_source_concept", None) + selection = coerce_concept_set_selection(source_filter) + if selection is not None: + table = apply_concept_criteria( + table, + column="drug_source_concept_id", + concepts=None, + selection=selection, + ctx=ctx, + ) + + events = standardize_output( + table, + primary_key=criteria.get_primary_key_column(), + start_column=criteria.get_start_date_column(), + end_column=criteria.get_end_date_column(), + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/groups.py b/circe/execution/builders/groups.py new file mode 100644 index 00000000..da845113 --- /dev/null +++ b/circe/execution/builders/groups.py @@ -0,0 +1,486 @@ +from __future__ import annotations + +from typing import Callable + +import ibis +import ibis.common.exceptions as ibis_exc +import ibis.expr.types as ir + +from ...cohortdefinition.core import ObservationFilter +from ...cohortdefinition.criteria import ( + Criteria, + CriteriaColumn, + CriteriaGroup, + VisitDetail, +) +from ..build_context import BuildContext +from ..criteria_compat import ( + CorrelatedCriteria, + DemoGraphicCriteria, + OccurrenceType, + parse_single_criteria, +) +from .common import ( + apply_age_filter, + apply_date_range, + apply_ethnicity_filter, + apply_gender_filter, + apply_observation_window, + apply_race_filter, +) +from .registry import build_events + + +def apply_criteria_group( + events: ir.Table, group: CriteriaGroup | None, ctx: BuildContext +) -> ir.Table: + mask = _group_mask(events, group, ctx) + if mask is None: + return events + return events.filter(mask) + + +def _correlated_mask( + events: ir.Table, correlated: CorrelatedCriteria, ctx: BuildContext +) -> ir.Value: + criteria_model = correlated.criteria + if criteria_model and not isinstance(criteria_model, ir.Expr): + criteria_model = parse_single_criteria(criteria_model) + if criteria_model is None: + return ibis.literal(True) + + count_column_name, count_column_enum = _resolve_count_column(correlated.occurrence) + + base_events = build_events(criteria_model, ctx) + base_events = _attach_count_columns( + base_events, + criteria_model, + ctx, + count_column_name=count_column_name, + count_column_enum=count_column_enum, + ) + requires_corr_end_alignment = _requires_observation_period_end_alignment(correlated) + zero_window: ObservationFilter | None = None + if not correlated.ignore_observation_period: + zero_window = ObservationFilter(prior_days=0, post_days=0) + base_events = apply_observation_window(base_events, zero_window, ctx) + + index_events = events + if not correlated.ignore_observation_period: + missing_observation_bounds = ( + "observation_period_start_date" not in index_events.columns + or "observation_period_end_date" not in index_events.columns + ) + if missing_observation_bounds: + zero_window = zero_window or ObservationFilter(prior_days=0, post_days=0) + index_events = apply_observation_window(index_events, zero_window, ctx) + + select_fields = [ + base_events.person_id, + base_events.event_id.name("_corr_event_id"), + base_events.start_date.name("_corr_start_date"), + base_events.end_date.name("_corr_end_date"), + ] + if "visit_occurrence_id" in base_events.columns: + select_fields.append( + base_events.visit_occurrence_id.name("_corr_visit_occurrence_id") + ) + if count_column_name and count_column_name in base_events.columns: + select_fields.append(base_events[count_column_name]) + + criteria_events = base_events.select(*select_fields) + join_condition = index_events.person_id == criteria_events.person_id + if not correlated.ignore_observation_period: + if "observation_period_start_date" in index_events.columns: + join_condition &= ( + criteria_events._corr_start_date + >= index_events.observation_period_start_date + ) + if "observation_period_end_date" in index_events.columns: + join_condition &= ( + criteria_events._corr_start_date + <= index_events.observation_period_end_date + ) + if requires_corr_end_alignment: + join_condition &= ( + criteria_events._corr_end_date + <= index_events.observation_period_end_date + ) + window_condition = _build_window_condition( + index_events, criteria_events, correlated + ) + if window_condition is not None: + join_condition &= window_condition + + occurrence = correlated.occurrence + occ_type = getattr(occurrence, "type", None) + if isinstance(occ_type, int): + occ_type = OccurrenceType(occurrence.type) + + require_same_visit = bool(correlated.restrict_visit) + if correlated.restrict_visit is None and isinstance(criteria_model, VisitDetail): + require_same_visit = True + + if require_same_visit: + if ( + "visit_occurrence_id" in index_events.columns + and "_corr_visit_occurrence_id" in criteria_events.columns + ): + join_condition &= ( + index_events.visit_occurrence_id.notnull() + & criteria_events._corr_visit_occurrence_id.notnull() + & ( + index_events.visit_occurrence_id + == criteria_events._corr_visit_occurrence_id + ) + ) + + joined = index_events.join(criteria_events, join_condition, how="left") + + corr_event_id = joined._corr_event_id + count_expr = corr_event_id + if count_column_name and count_column_name in joined.columns: + count_expr = joined[count_column_name] + match_expr = corr_event_id.notnull() + joined = joined.mutate( + _corr_match_value=ibis.ifelse(match_expr, count_expr, ibis.null()), + ) + + if correlated.occurrence and correlated.occurrence.is_distinct: + aggregator = joined._corr_match_value.nunique() + else: + aggregator = joined._corr_match_value.count() + + aggregated = joined.group_by(joined.person_id, joined.event_id).aggregate( + match_count=aggregator + ) + predicate = _occurrence_predicate(aggregated.match_count, correlated.occurrence) + matching_ids = ( + aggregated.filter(predicate).select("person_id", "event_id").distinct() + ) + return _event_membership_mask(events, matching_ids) + + +def _group_mask( + events: ir.Table, group: CriteriaGroup | None, ctx: BuildContext +) -> ir.Value | None: + if not group or group.is_empty(): + return None + + masks: list[ir.Value] = [] + for correlated in group.criteria_list or []: + masks.append(_correlated_mask(events, correlated, ctx)) + + for demographic in group.demographic_criteria_list or []: + demo_mask = _demographic_mask(events, demographic, ctx) + if demo_mask is not None: + masks.append(demo_mask) + + for subgroup in group.groups or []: + sub_mask = _group_mask(events, subgroup, ctx) + if sub_mask is not None: + masks.append(sub_mask) + + if not masks: + return None + + group_type = (group.type or "ALL").upper() + if group_type == "ANY": + return _combine_any(masks) + if group_type.startswith("AT_"): + count = group.count + if group_type.endswith("LEAST"): + threshold = count if count is not None else 1 + return _combine_threshold(masks, threshold, at_least=True) + threshold = count if count is not None else 0 + return _combine_threshold(masks, threshold, at_least=False) + return _combine_all(masks) + + +def _combine_all(masks: list[ir.Value]) -> ir.Value: + combined = masks[0] + for mask in masks[1:]: + combined = combined & mask + return combined + + +def _combine_any(masks: list[ir.Value]) -> ir.Value: + combined = masks[0] + for mask in masks[1:]: + combined = combined | mask + return combined + + +def _combine_threshold( + masks: list[ir.Value], threshold: int, *, at_least: bool +) -> ir.Value: + def _to_int(mask: ir.Value) -> ir.Value: + return ibis.ifelse( + mask, ibis.literal(1, type="int64"), ibis.literal(0, type="int64") + ) + + total = _to_int(masks[0]) + for mask in masks[1:]: + total = total + _to_int(mask) + return total >= threshold if at_least else total <= threshold + + +def _demographic_mask( + events: ir.Table, demographic: DemoGraphicCriteria, ctx: BuildContext +) -> ir.Value | None: + if demographic is None: + return None + + filtered = events + applied = False + if demographic.age: + filtered = apply_age_filter(filtered, demographic.age, ctx, "start_date") + applied = True + if demographic.gender or demographic.gender_cs: + filtered = apply_gender_filter( + filtered, demographic.gender, demographic.gender_cs, ctx + ) + applied = True + if demographic.race or demographic.race_cs: + filtered = apply_race_filter( + filtered, demographic.race, demographic.race_cs, ctx + ) + applied = True + if demographic.ethnicity or demographic.ethnicity_cs: + filtered = apply_ethnicity_filter( + filtered, demographic.ethnicity, demographic.ethnicity_cs, ctx + ) + applied = True + if demographic.occurrence_start_date: + filtered = apply_date_range( + filtered, "start_date", demographic.occurrence_start_date + ) + applied = True + if demographic.occurrence_end_date: + filtered = apply_date_range( + filtered, "end_date", demographic.occurrence_end_date + ) + applied = True + + if not applied: + return None + + filtered_ids = filtered.select(filtered.person_id, filtered.event_id).distinct() + return _event_membership_mask(events, filtered_ids) + + +def _event_membership_mask(events: ir.Table, ids: ir.Table) -> ir.Value: + keys = ids.mutate(_event_key=_event_key_expr(ids)).select("_event_key") + return _event_key_expr(events).isin(keys._event_key) + + +def _event_key_expr(table: ir.Table) -> ir.Value: + return ( + table.person_id.cast("string") + + ibis.literal(":") + + table.event_id.cast("string") + ) + + +def _occurrence_predicate(count_expr: ir.Value, occurrence) -> ir.Value: + if occurrence is None: + return count_expr > 0 + + occ_type = occurrence.type + if isinstance(occ_type, int): + occ_type = OccurrenceType(occurrence.type) + + if occ_type == OccurrenceType.EXACTLY: + return count_expr == occurrence.count + if occ_type == OccurrenceType.AT_LEAST: + return count_expr >= occurrence.count + if occ_type == OccurrenceType.AT_MOST: + return count_expr <= occurrence.count + return count_expr > 0 + + +def _build_window_condition( + index_events: ir.Table, correlated_events: ir.Table, correlated: CorrelatedCriteria +) -> ir.Value: + cond = ibis.literal(True) + + if correlated.start_window: + correlated_start = _correlated_window_value( + correlated_events, + correlated.start_window.use_event_end, + default="start", + ) + lower = _apply_endpoint_anchor( + index_events, + correlated.start_window.start, + correlated.start_window.use_index_end, + ) + upper = _apply_endpoint_anchor( + index_events, + correlated.start_window.end, + correlated.start_window.use_index_end, + ) + if lower is not None: + cond &= correlated_start >= lower + if upper is not None: + cond &= correlated_start <= upper + + if correlated.end_window: + lower = _apply_endpoint_anchor( + index_events, + correlated.end_window.start, + correlated.end_window.use_index_end, + default_to_index_end=False, + ) + upper = _apply_endpoint_anchor( + index_events, + correlated.end_window.end, + correlated.end_window.use_index_end, + default_to_index_end=False, + ) + correlated_end = _correlated_window_value( + correlated_events, + correlated.end_window.use_event_end, + default="end", + ) + if lower is not None: + cond &= correlated_end >= lower + if upper is not None: + cond &= correlated_end <= upper + + return cond + + +def _apply_endpoint_anchor( + events: ir.Table, + endpoint, + use_index_end: bool | None, + *, + default_to_index_end: bool = False, +): + anchor = ( + events.end_date + if (use_index_end or (use_index_end is None and default_to_index_end)) + else events.start_date + ) + if not endpoint or endpoint.days is None: + return None + days = ibis.interval(days=int(endpoint.days)) + coeff = endpoint.coeff if endpoint.coeff is not None else 1 + return anchor + days * coeff + + +def _correlated_window_value( + correlated_events: ir.Table, + use_event_end: bool | None, + *, + default: str, +) -> ir.Value: + if use_event_end is True: + return correlated_events._corr_end_date + if use_event_end is False: + return correlated_events._corr_start_date + if default == "end": + return correlated_events._corr_end_date + return correlated_events._corr_start_date + + +_COUNT_COLUMN_MAPPING: dict[CriteriaColumn, str] = { + CriteriaColumn.START_DATE: "_corr_start_date", + CriteriaColumn.END_DATE: "_corr_end_date", + CriteriaColumn.VISIT_ID: "_corr_visit_occurrence_id", + CriteriaColumn.DOMAIN_CONCEPT: "_corr_domain_concept_id", + CriteriaColumn.DOMAIN_SOURCE_CONCEPT: "_corr_domain_source_concept_id", +} + + +_COUNT_COLUMN_SOURCES: dict[CriteriaColumn, Callable[[Criteria], str]] = { + CriteriaColumn.DOMAIN_CONCEPT: lambda criteria: criteria.get_concept_id_column(), + CriteriaColumn.DOMAIN_SOURCE_CONCEPT: lambda criteria: _source_concept_column( + criteria + ), +} + + +def _resolve_count_column(occurrence): + if occurrence is None or occurrence.count_column is None: + return None, None + column = occurrence.count_column + enum_value: CriteriaColumn | None = None + if isinstance(column, CriteriaColumn): + enum_value = column + else: + value = str(column) + if value.upper() in CriteriaColumn.__members__: + enum_value = CriteriaColumn[value.upper()] + else: + lower = value.lower() + for member in CriteriaColumn: + if member.value == lower: + enum_value = member + break + if enum_value is None: + return None, None + return _COUNT_COLUMN_MAPPING.get(enum_value), enum_value + + +def _source_concept_column(criteria) -> str: + prefix = criteria.snake_case_class_name().split("_")[0] + return f"{prefix}_source_concept_id" + + +def _attach_count_columns( + events: ir.Table, + criteria_model, + ctx: BuildContext, + *, + count_column_name: str | None, + count_column_enum: CriteriaColumn | None, +) -> ir.Table: + if not count_column_name or not count_column_enum: + return events + source_getter = _COUNT_COLUMN_SOURCES.get(count_column_enum) + if source_getter is None: + return events + source_column = source_getter(criteria_model) + if source_column is None: + return events + table_name = criteria_model.snake_case_class_name() + try: + domain_table = ctx.table(table_name) + except ( + ibis_exc.IbisError, + TypeError, + ValueError, + AttributeError, + NotImplementedError, + ): + return events + if source_column not in domain_table.columns: + return events + primary_key = criteria_model.get_primary_key_column() + if primary_key not in domain_table.columns: + return events + lookup = domain_table.select( + domain_table[primary_key].name("_corr_join_key"), + domain_table[source_column].name(count_column_name), + ) + augmented = events.join( + lookup, events.event_id == lookup._corr_join_key, how="left" + ) + base_columns = events.columns + projection = [augmented[name] for name in base_columns if name in augmented.columns] + projection.append(augmented[count_column_name]) + return augmented.select(*projection) + + +def _requires_observation_period_end_alignment(correlated: CorrelatedCriteria) -> bool: + if correlated.start_window and correlated.start_window.use_event_end: + return True + if correlated.end_window and correlated.end_window.use_event_end: + return True + occurrence = correlated.occurrence + if occurrence and occurrence.count_column is not None: + resolved, _ = _resolve_count_column(occurrence) + return resolved == "_corr_end_date" + return False diff --git a/circe/execution/builders/measurement.py b/circe/execution/builders/measurement.py new file mode 100644 index 00000000..6c20e15e --- /dev/null +++ b/circe/execution/builders/measurement.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import ibis + +from ...cohortdefinition.criteria import Measurement +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_concept_criteria, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_numeric_range, + apply_provider_specialty_filter, + apply_visit_concept_filters, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("Measurement") +def build_measurement(criteria: Measurement, ctx: BuildContext): + table = ctx.table("measurement") + concept_column = criteria.get_concept_id_column() + table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) + if criteria.first: + table = apply_first_event( + table, criteria.get_start_date_column(), criteria.get_primary_key_column() + ) + + table = apply_date_range( + table, criteria.get_start_date_column(), criteria.occurrence_start_date + ) + table = apply_date_range( + table, criteria.get_end_date_column(), criteria.occurrence_end_date + ) + + table = apply_concept_criteria( + table, + column="measurement_type_concept_id", + concepts=criteria.measurement_type, + selection=criteria.measurement_type_cs, + ctx=ctx, + exclude=bool(criteria.measurement_type_exclude), + ) + + table = apply_concept_criteria( + table, + column="operator_concept_id", + concepts=getattr(criteria, "operator_concept", None), + selection=getattr(criteria, "operator_concept_cs", None), + ctx=ctx, + ) + + value_column = "value_as_number" + if criteria.unit: + table = apply_concept_criteria( + table, + column="unit_concept_id", + concepts=criteria.unit, + selection=None, + ctx=ctx, + ) + table, value_column = _maybe_normalize_units( + table, criteria.unit, criteria.value_as_number + ) + table = apply_concept_criteria( + table, + column="unit_concept_id", + concepts=None, + selection=criteria.unit_cs, + ctx=ctx, + ) + + table = apply_concept_criteria( + table, + column="value_as_concept_id", + concepts=criteria.value_as_concept, + selection=criteria.value_as_concept_cs, + ctx=ctx, + ) + + table = apply_numeric_range(table, value_column, criteria.value_as_number) + table = apply_numeric_range(table, "range_low", criteria.range_low) + table = apply_numeric_range(table, "range_high", criteria.range_high) + if getattr(criteria, "range_low_ratio", None): + denom = ibis.ifelse(table.range_low == 0, ibis.null(), table.range_low) + ratio = (table.value_as_number / denom).name("_range_low_ratio") + table = table.mutate(_range_low_ratio=ratio) + table = apply_numeric_range(table, "_range_low_ratio", criteria.range_low_ratio) + if getattr(criteria, "range_high_ratio", None): + denom = ibis.ifelse(table.range_high == 0, ibis.null(), table.range_high) + ratio = (table.value_as_number / denom).name("_range_high_ratio") + table = table.mutate(_range_high_ratio=ratio) + table = apply_numeric_range( + table, "_range_high_ratio", criteria.range_high_ratio + ) + + if getattr(criteria, "abnormal", None): + abnormal_predicate = ( + (table.value_as_number < table.range_low) + | (table.value_as_number > table.range_high) + | table.value_as_concept_id.isin([4155142, 4155143]) + ) + table = table.filter(abnormal_predicate) + + if criteria.age: + table = apply_age_filter( + table, criteria.age, ctx, criteria.get_start_date_column() + ) + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + table = apply_provider_specialty_filter( + table, + getattr(criteria, "provider_specialty", None), + getattr(criteria, "provider_specialty_cs", None), + ctx, + provider_column="provider_id", + ) + table = apply_visit_concept_filters( + table, criteria.visit_type, criteria.visit_type_cs, ctx + ) + if criteria.measurement_source_concept is not None: + table = apply_codeset_filter( + table, + "measurement_source_concept_id", + criteria.measurement_source_concept, + ctx, + ) + + events = standardize_output( + table, + primary_key=criteria.get_primary_key_column(), + start_column=criteria.get_start_date_column(), + end_column=criteria.get_end_date_column(), + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) + + +def _maybe_normalize_units(table, units, value_range): + """ + Best-effort unit normalization for numeric comparisons. + + Circe generally relies on unit-specific criteria rows (separate thresholds per unit scale). + Normalizing in that situation breaks parity (e.g. neutrophil counts expressed as 10..1500 cells/uL). + + Strategy: + - Always normalize mass to kilograms (pounds -> kg). + - For cell counts, only normalize when the numeric range appears to be in the canonical 10^9/L scale. + Heuristic: upper bound <= 100. + """ + unit_ids = [ + concept.concept_id for concept in units if concept.concept_id is not None + ] + if not unit_ids: + return table, "value_as_number" + if not all(unit_id in _UNIT_NORMALIZATION for unit_id in unit_ids): + return table, "value_as_number" + groups = {_UNIT_NORMALIZATION[unit_id][0] for unit_id in unit_ids} + if len(groups) != 1: + return table, "value_as_number" + + group = next(iter(groups)) + if group == "mass_kg": + should_normalize = True + elif group == "count_10e9_per_l": + should_normalize = _range_looks_like_canonical_cell_count(value_range) + else: + should_normalize = False + + if not should_normalize: + return table, "value_as_number" + + multiplier = _unit_multiplier_expr(table.unit_concept_id, unit_ids) + normalized = (table.value_as_number * multiplier).name("_normalized_value") + table = table.mutate(_normalized_value=normalized) + return table, "_normalized_value" + + +def _range_looks_like_canonical_cell_count(value_range) -> bool: + if value_range is None or value_range.value is None: + return False + op = (value_range.op or "eq").lower() + upper = float(value_range.value) + if op.endswith("bt") and value_range.extent is not None: + upper = max(upper, float(value_range.extent)) + # Canonical 10^9/L scale is typically << 100; high thresholds indicate raw unit ranges. + return upper <= 100.0 + + +def _unit_multiplier_expr(unit_column, unit_ids): + multiplier_expr = ibis.literal(1.0) + for unit_id in unit_ids: + multiplier = _UNIT_NORMALIZATION[unit_id][1] + multiplier_expr = ibis.ifelse( + unit_column == ibis.literal(unit_id), + ibis.literal(multiplier), + multiplier_expr, + ) + return multiplier_expr + + +_UNIT_NORMALIZATION = { + # Mass + 9529: ("mass_kg", 1.0), # kilogram + 3195625: ("mass_kg", 0.45359237), # pound + # Cell counts per liter (expressed in 10^9/L) + 9444: ("count_10e9_per_l", 1.0), # billion per liter + 44777588: ("count_10e9_per_l", 1.0), + 8848: ("count_10e9_per_l", 1.0), # thousand per microliter + 8816: ("count_10e9_per_l", 1.0), # million per milliliter + 8961: ("count_10e9_per_l", 1.0), # thousand per cubic millimeter + 8784: ("count_10e9_per_l", 0.001), # cells per microliter + 8647: ("count_10e9_per_l", 0.001), # per microliter +} diff --git a/circe/execution/builders/observation.py b/circe/execution/builders/observation.py new file mode 100644 index 00000000..9b100c26 --- /dev/null +++ b/circe/execution/builders/observation.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import Observation +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_concept_criteria, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_numeric_range, + apply_provider_specialty_filter, + apply_text_filter, + apply_visit_concept_filters, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("Observation") +def build_observation(criteria: Observation, ctx: BuildContext): + table = ctx.table("observation") + table = apply_codeset_filter( + table, criteria.get_concept_id_column(), criteria.codeset_id, ctx + ) + + table = apply_date_range( + table, criteria.get_start_date_column(), criteria.occurrence_start_date + ) + table = apply_date_range( + table, criteria.get_end_date_column(), criteria.occurrence_end_date + ) + + table = apply_concept_criteria( + table, + column="observation_type_concept_id", + concepts=criteria.observation_type, + selection=criteria.observation_type_cs, + ctx=ctx, + exclude=bool(criteria.observation_type_exclude), + ) + + table = apply_concept_criteria( + table, + column="qualifier_concept_id", + concepts=criteria.qualifier, + selection=criteria.qualifier_cs, + ctx=ctx, + ) + + table = apply_concept_criteria( + table, + column="unit_concept_id", + concepts=criteria.unit, + selection=criteria.unit_cs, + ctx=ctx, + ) + + table = apply_concept_criteria( + table, + column="value_as_concept_id", + concepts=criteria.value_as_concept, + selection=criteria.value_as_concept_cs, + ctx=ctx, + ) + + table = apply_numeric_range(table, "value_as_number", criteria.value_as_number) + table = apply_text_filter(table, "value_as_string", criteria.value_as_string) + + if criteria.age: + table = apply_age_filter( + table, criteria.age, ctx, criteria.get_start_date_column() + ) + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + table = apply_provider_specialty_filter( + table, + getattr(criteria, "provider_specialty", None), + getattr(criteria, "provider_specialty_cs", None), + ctx, + provider_column="provider_id", + ) + table = apply_visit_concept_filters( + table, criteria.visit_type, criteria.visit_type_cs, ctx + ) + if criteria.observation_source_concept is not None: + table = apply_codeset_filter( + table, + "observation_source_concept_id", + criteria.observation_source_concept, + ctx, + ) + + if criteria.first: + table = apply_first_event( + table, criteria.get_start_date_column(), criteria.get_primary_key_column() + ) + + events = standardize_output( + table, + primary_key=criteria.get_primary_key_column(), + start_column=criteria.get_start_date_column(), + end_column=criteria.get_end_date_column(), + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/observation_period.py b/circe/execution/builders/observation_period.py new file mode 100644 index 00000000..33e73f64 --- /dev/null +++ b/circe/execution/builders/observation_period.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import ObservationPeriod +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_concept_criteria, + apply_date_range, + apply_first_event, + apply_interval_range, + apply_user_defined_period, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("ObservationPeriod") +def build_observation_period(criteria: ObservationPeriod, ctx: BuildContext): + table = ctx.table("observation_period") + + table = apply_date_range( + table, "observation_period_start_date", criteria.period_start_date + ) + table = apply_date_range( + table, "observation_period_end_date", criteria.period_end_date + ) + + table = apply_concept_criteria( + table, + column="period_type_concept_id", + concepts=criteria.period_type, + selection=criteria.period_type_cs, + ctx=ctx, + ) + + table = apply_interval_range( + table, + "observation_period_start_date", + "observation_period_end_date", + criteria.period_length, + ) + + if criteria.age_at_start: + table = apply_age_filter( + table, criteria.age_at_start, ctx, "observation_period_start_date" + ) + if criteria.age_at_end: + table = apply_age_filter( + table, criteria.age_at_end, ctx, "observation_period_end_date" + ) + + table, start_column, end_column = apply_user_defined_period( + table, + "observation_period_start_date", + "observation_period_end_date", + criteria.user_defined_period, + ) + + if criteria.first: + table = apply_first_event(table, start_column, "observation_period_id") + + events = standardize_output( + table, + primary_key="observation_period_id", + start_column=start_column, + end_column=end_column, + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/payer_plan_period.py b/circe/execution/builders/payer_plan_period.py new file mode 100644 index 00000000..c4b3a063 --- /dev/null +++ b/circe/execution/builders/payer_plan_period.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import PayerPlanPeriod +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_interval_range, + apply_user_defined_period, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("PayerPlanPeriod") +def build_payer_plan_period(criteria: PayerPlanPeriod, ctx: BuildContext): + table = ctx.table("payer_plan_period") + + table = apply_date_range( + table, "payer_plan_period_start_date", criteria.period_start_date + ) + table = apply_date_range( + table, "payer_plan_period_end_date", criteria.period_end_date + ) + + table = apply_interval_range( + table, + "payer_plan_period_start_date", + "payer_plan_period_end_date", + criteria.period_length, + ) + + if criteria.age_at_start: + table = apply_age_filter( + table, criteria.age_at_start, ctx, "payer_plan_period_start_date" + ) + if criteria.age_at_end: + table = apply_age_filter( + table, criteria.age_at_end, ctx, "payer_plan_period_end_date" + ) + + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + + table = apply_codeset_filter(table, "payer_concept_id", criteria.payer_concept, ctx) + table = apply_codeset_filter(table, "plan_concept_id", criteria.plan_concept, ctx) + table = apply_codeset_filter( + table, "sponsor_concept_id", criteria.sponsor_concept, ctx + ) + table = apply_codeset_filter( + table, "stop_reason_concept_id", criteria.stop_reason_concept, ctx + ) + table = apply_codeset_filter( + table, "payer_source_concept_id", criteria.payer_source_concept, ctx + ) + table = apply_codeset_filter( + table, "plan_source_concept_id", criteria.plan_source_concept, ctx + ) + table = apply_codeset_filter( + table, "sponsor_source_concept_id", criteria.sponsor_source_concept, ctx + ) + table = apply_codeset_filter( + table, "stop_reason_source_concept_id", criteria.stop_reason_source_concept, ctx + ) + + table, start_column, end_column = apply_user_defined_period( + table, + "payer_plan_period_start_date", + "payer_plan_period_end_date", + criteria.user_defined_period, + ) + + if criteria.first: + table = apply_first_event(table, start_column, "payer_plan_period_id") + + events = standardize_output( + table, + primary_key="payer_plan_period_id", + start_column=start_column, + end_column=end_column, + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/pipeline.py b/circe/execution/builders/pipeline.py new file mode 100644 index 00000000..e97288b2 --- /dev/null +++ b/circe/execution/builders/pipeline.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import ibis +import ibis.common.exceptions as ibis_exc +import ibis.expr.types as ir +import polars as pl + +from ...cohortdefinition import CohortExpression +from ..build_context import BuildContext +from . import condition_era # noqa: F401 +from . import condition_occurrence # noqa: F401 +from . import death # noqa: F401 +from . import device_exposure # noqa: F401 +from . import dose_era # noqa: F401 +from . import drug_era # noqa: F401 +from . import drug_exposure # noqa: F401 +from . import measurement # noqa: F401 +from . import observation # noqa: F401 +from . import observation_period # noqa: F401 +from . import payer_plan_period # noqa: F401 +from . import procedure_occurrence # noqa: F401 +from . import specimen # noqa: F401 +from . import visit_detail # noqa: F401 +from . import visit_occurrence # noqa: F401 +from .common import ( + apply_end_strategy, + apply_observation_window, + collapse_events, + has_end_strategy, +) +from .groups import apply_criteria_group +from .post_processing import apply_censor_window, apply_censoring, apply_inclusion_rules +from .registry import build_events + +OUTPUT_SCHEMA = { + "person_id": pl.Int64, + "event_id": pl.Int64, + "start_date": pl.Datetime, + "end_date": pl.Datetime, + "visit_occurrence_id": pl.Int64, +} + + +def build_primary_events(expression: CohortExpression, ctx: BuildContext): + def _maybe_materialize(table: ir.Table, label: str) -> ir.Table: + return ctx.maybe_materialize(table, label=label, analyze=True) + + primary = expression.primary_criteria + if primary is None or not primary.criteria_list: + return None + event_tables: list[ir.Table] = [] + for criteria in primary.criteria_list: + table = build_events(criteria, ctx) + if table is None: + continue + event_tables.append(table) + if not event_tables: + return None + if ctx.should_materialize_stages(): + materialized: list[ir.Table] = [] + for idx, table in enumerate(event_tables, start=1): + materialized.append( + ctx.maybe_materialize(table, label=f"primary_src_{idx}", analyze=True) + ) + event_tables = materialized + events = event_tables[0] + for table in event_tables[1:]: + events = events.union(table, distinct=False) + events = events.mutate(_source_event_id=events.event_id) + events = apply_observation_window(events, primary.observation_window, ctx) + events = _assign_primary_event_ids(events) + if _should_limit(primary.primary_limit): + events = _apply_result_limit(events, primary.primary_limit) + + events = ctx.maybe_materialize(events, label="primary_events", analyze=True) + + # Short-circuit the remainder of the pipeline when no primary events exist. + if ctx.should_materialize_stages(): + try: + primary_count = events.count().execute() + except (ibis_exc.IbisError, RuntimeError, ValueError, TypeError): + primary_count = None + if primary_count == 0: + events = _drop_aux_columns(events) + return events.limit(0) + + events = apply_criteria_group(events, expression.additional_criteria, ctx) + if expression.additional_criteria: + events = ctx.maybe_materialize( + events, label="additional_criteria", analyze=True + ) + + events = apply_inclusion_rules(events, expression.inclusion_rules, ctx) + if expression.inclusion_rules: + events = ctx.maybe_materialize(events, label="inclusion", analyze=True) + # Circe ignores QualifiedLimit, so we do the same to preserve parity. + if _should_limit(expression.expression_limit): + events = _apply_result_limit(events, expression.expression_limit) + events = apply_end_strategy(events, expression.end_strategy, ctx) + if has_end_strategy(expression.end_strategy): + events = _maybe_materialize(events, label="strategy_ends") + + # Censoring should cut the cohort end date, so apply it after end strategy. + events = apply_censoring(events, expression.censoring_criteria, ctx) + if expression.censoring_criteria: + events = ctx.maybe_materialize(events, label="censoring", analyze=True) + events = apply_censor_window(events, expression.censor_window, ctx) + events = _drop_aux_columns(events) + events = collapse_events(events, expression.collapse_settings) + if expression.collapse_settings and expression.collapse_settings.collapse_type: + events = _maybe_materialize(events, label="final_cohort") + return events + + +def build_primary_events_polars( + expression: CohortExpression, ctx: BuildContext +) -> pl.DataFrame: + events = build_primary_events(expression, ctx) + if events is None: + return pl.DataFrame(schema=OUTPUT_SCHEMA) + return events.to_polars() + + +def _assign_primary_event_ids(events): + if "_source_event_id" not in events.columns: + events = events.mutate(_source_event_id=events.event_id) + order = [events.person_id, events.start_date, events._source_event_id] + person_window = ibis.window(group_by=events.person_id, order_by=order[1:]) + person_rank = ibis.row_number().over(person_window) + events = events.mutate( + # Keep event ids unique *within* a person to avoid global sorts/shuffles. + # Most downstream logic keys by (person_id, event_id). + event_id=(person_rank + 1), + _person_ordinal=(person_rank + 1), + ) + supplemental = [ + events[column] + for column in ("observation_period_start_date", "observation_period_end_date") + if column in events.columns + ] + return events.select( + events.person_id, + events.event_id, + events.start_date, + events.end_date, + events.visit_occurrence_id, + events._source_event_id, + events._person_ordinal, + *supplemental, + ) + + +def _apply_result_limit(events: ir.Table, limit) -> ir.Table: + if not limit or (limit.type or "ALL").lower() == "all": + return events + + order_by = [events.start_date] + if "event_id" in events.columns: + order_by.append(events.event_id) + + w = ibis.window(group_by=events.person_id, order_by=order_by) + + helper = "__mitos_rn__" + + ranked = events.mutate(**{helper: ibis.row_number().over(w)}) + limited = ranked.filter(ranked[helper] == 0) + + return limited.select([limited[c] for c in events.columns]) + + +def _drop_aux_columns(events: ir.Table) -> ir.Table: + drop_cols = [ + col + for col in ( + "_source_event_id", + "_person_ordinal", + "observation_period_start_date", + "observation_period_end_date", + "_result_row", + ) + if col in events.columns + ] + if drop_cols: + events = events.drop(*drop_cols) + return events + + +def _should_limit(limit) -> bool: + return bool(limit and (limit.type or "all").lower() != "all") diff --git a/circe/execution/builders/post_processing.py b/circe/execution/builders/post_processing.py new file mode 100644 index 00000000..c537a25c --- /dev/null +++ b/circe/execution/builders/post_processing.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import ibis +import ibis.expr.types as ir + +from ...cohortdefinition.criteria import Criteria, InclusionRule +from ..build_context import BuildContext +from .groups import apply_criteria_group +from .registry import build_events + + +def apply_additional_criteria(events: ir.Table, group, ctx: BuildContext) -> ir.Table: + return apply_criteria_group(events, group, ctx) + + +def apply_inclusion_rules( + events: ir.Table, rules: list[InclusionRule], ctx: BuildContext +) -> ir.Table: + if not rules: + return events + + base_events = events.select(events.person_id, events.event_id) + bit_hits = [] + used_bits: list[int] = [] + for idx, rule in enumerate(rules): + rule_events = apply_criteria_group(events, rule.expression, ctx) + if rule_events is None: + continue + bit_value = 1 << idx + used_bits.append(bit_value) + bit_hits.append( + rule_events.select( + rule_events.person_id, + rule_events.event_id, + ibis.literal(bit_value, type="int64").name("_rule_bit"), + ).distinct() + ) + if not bit_hits: + return events + + union_hits = bit_hits[0] + for table in bit_hits[1:]: + union_hits = union_hits.union(table, distinct=False) + + union_hits = ctx.maybe_materialize(union_hits, label="inclusion_hits", analyze=True) + + mask = union_hits.group_by(union_hits.person_id, union_hits.event_id).aggregate( + # Postgres returns NUMERIC for SUM(BIGINT), which breaks bitwise ops. + # Ibis also infers SUM(int64) -> int64 and may optimize away an int64 cast, + # so we force an intermediate cast to keep the SQL-level cast. + _rule_mask=union_hits._rule_bit.sum() + .cast("decimal(38,0)") + .cast("int64") + ) + target_mask = sum(used_bits) + target_literal = ibis.literal(target_mask, type="int64") + mask = mask.filter((mask._rule_mask & target_literal) == target_literal) + + filtered_ids = base_events.inner_join(mask, ["person_id", "event_id"]) + return events.inner_join(filtered_ids, ["person_id", "event_id"]).select( + events.columns + ) + + +def apply_censoring( + events: ir.Table, criteria_list: list[Criteria], ctx: BuildContext +) -> ir.Table: + if not criteria_list: + return events + censor_tables = [ + build_events(criteria, ctx) for criteria in criteria_list if criteria + ] + if not censor_tables: + return events + censor_events = censor_tables[0] + for table in censor_tables[1:]: + censor_events = censor_events.union(table) + + censor_events = censor_events.select( + censor_events.person_id, + censor_events.start_date.name("censor_start"), + ) + joined = events.join( + censor_events, + (events.person_id == censor_events.person_id) + & (censor_events.censor_start >= events.start_date), + how="left", + ) + min_censor = joined.group_by(joined.person_id, joined.event_id).aggregate( + censor_date=joined.censor_start.min() + ) + event_columns = events.columns + events = events.left_join( + min_censor, + (events.person_id == min_censor.person_id) + & (events.event_id == min_censor.event_id), + ) + events = events.select(*event_columns, min_censor.censor_date) + events = events.mutate( + end_date=ibis.ifelse( + events.censor_date.notnull() & (events.censor_date < events.end_date), + events.censor_date, + events.end_date, + ) + ).select(*event_columns) + return events + + +def apply_censor_window(events: ir.Table, window, ctx: BuildContext) -> ir.Table: + if not window: + return events + if window.start_date: + events = events.filter(events.start_date >= ibis.timestamp(window.start_date)) + if window.end_date: + events = events.filter(events.end_date <= ibis.timestamp(window.end_date)) + return events diff --git a/circe/execution/builders/procedure_occurrence.py b/circe/execution/builders/procedure_occurrence.py new file mode 100644 index 00000000..a7425ff0 --- /dev/null +++ b/circe/execution/builders/procedure_occurrence.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import ProcedureOccurrence +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_concept_criteria, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_numeric_range, + apply_provider_specialty_filter, + apply_visit_concept_filters, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("ProcedureOccurrence") +def build_procedure_occurrence(criteria: ProcedureOccurrence, ctx: BuildContext): + table = ctx.table("procedure_occurrence") + + concept_column = criteria.get_concept_id_column() + table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) + if criteria.first: + table = apply_first_event( + table, criteria.get_start_date_column(), criteria.get_primary_key_column() + ) + + table = apply_date_range( + table, criteria.get_start_date_column(), criteria.occurrence_start_date + ) + table = apply_date_range( + table, criteria.get_end_date_column(), criteria.occurrence_end_date + ) + + table = apply_concept_criteria( + table, + column="procedure_type_concept_id", + concepts=criteria.procedure_type, + selection=criteria.procedure_type_cs, + ctx=ctx, + exclude=bool(criteria.procedure_type_exclude), + ) + + table = apply_concept_criteria( + table, + column="modifier_concept_id", + concepts=criteria.modifier, + selection=criteria.modifier_cs, + ctx=ctx, + ) + + table = apply_numeric_range(table, "quantity", criteria.quantity) + + if criteria.age: + table = apply_age_filter( + table, criteria.age, ctx, criteria.get_start_date_column() + ) + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + table = apply_provider_specialty_filter( + table, + getattr(criteria, "provider_specialty", None), + getattr(criteria, "provider_specialty_cs", None), + ctx, + provider_column="provider_id", + ) + table = apply_visit_concept_filters( + table, criteria.visit_type, criteria.visit_type_cs, ctx + ) + + if criteria.procedure_source_concept is not None: + table = apply_codeset_filter( + table, "procedure_source_concept_id", criteria.procedure_source_concept, ctx + ) + + events = standardize_output( + table, + primary_key=criteria.get_primary_key_column(), + start_column=criteria.get_start_date_column(), + end_column=criteria.get_end_date_column(), + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/registry.py b/circe/execution/builders/registry.py new file mode 100644 index 00000000..fdec2cae --- /dev/null +++ b/circe/execution/builders/registry.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import hashlib +from collections.abc import Callable +from typing import Dict + +import ibis.expr.types as ir + +from ...cohortdefinition.criteria import Criteria +from ..build_context import BuildContext + +_REGISTRY: Dict[str, Callable[[Criteria, BuildContext], ir.Table]] = {} + + +def register(criteria_name: str): + def decorator(func: Callable[[Criteria, BuildContext], ir.Table]): + _REGISTRY[criteria_name] = func + return func + + return decorator + + +def get_builder(criteria: Criteria): + name = criteria.__class__.__name__ + try: + return _REGISTRY[name] + except KeyError as exc: + raise ValueError(f"No builder registered for criteria {name}") from exc + + +def build_events(criteria: Criteria, ctx: BuildContext) -> ir.Table: + builder = get_builder(criteria) + table = builder(criteria, ctx) + cache_key, label = _criteria_cache_key(criteria) + return ctx.get_or_materialize_slice(cache_key, table, label=label) + + +def _criteria_cache_key(criteria: Criteria) -> tuple[str, str]: + payload = criteria.model_dump_json( + by_alias=True, + exclude_defaults=False, + exclude_none=False, + ) + raw_key = f"{criteria.__class__.__name__}:{payload}" + digest = hashlib.sha1(raw_key.encode("utf-8")).hexdigest()[:8] + label = f"{criteria.__class__.__name__.lower()}_{digest}" + return raw_key, label diff --git a/circe/execution/builders/specimen.py b/circe/execution/builders/specimen.py new file mode 100644 index 00000000..4bb7c9bc --- /dev/null +++ b/circe/execution/builders/specimen.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import Specimen +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_concept_criteria, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_numeric_range, + apply_text_filter, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("Specimen") +def build_specimen(criteria: Specimen, ctx: BuildContext): + table = ctx.table("specimen") + + table = apply_codeset_filter(table, "specimen_concept_id", criteria.codeset_id, ctx) + table = apply_date_range(table, "specimen_date", criteria.occurrence_start_date) + + table = apply_concept_criteria( + table, + column="specimen_type_concept_id", + concepts=criteria.specimen_type, + selection=criteria.specimen_type_cs, + ctx=ctx, + exclude=bool(criteria.specimen_type_exclude), + ) + + table = apply_numeric_range(table, "quantity", criteria.quantity) + + table = apply_concept_criteria( + table, + column="unit_concept_id", + concepts=criteria.unit, + selection=criteria.unit_cs, + ctx=ctx, + ) + + table = apply_concept_criteria( + table, + column="anatomic_site_concept_id", + concepts=criteria.anatomic_site, + selection=criteria.anatomic_site_cs, + ctx=ctx, + ) + + table = apply_concept_criteria( + table, + column="disease_status_concept_id", + concepts=criteria.disease_status, + selection=criteria.disease_status_cs, + ctx=ctx, + ) + + table = apply_text_filter(table, "specimen_source_id", criteria.source_id) + if criteria.specimen_source_concept is not None: + table = apply_codeset_filter( + table, + "specimen_source_concept_id", + criteria.specimen_source_concept, + ctx, + ) + + if criteria.age: + table = apply_age_filter(table, criteria.age, ctx, "specimen_date") + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + + if criteria.first: + table = apply_first_event(table, "specimen_date", "specimen_id") + + events = standardize_output( + table, + primary_key="specimen_id", + start_column="specimen_date", + end_column="specimen_date", + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/visit_detail.py b/circe/execution/builders/visit_detail.py new file mode 100644 index 00000000..5f6fac59 --- /dev/null +++ b/circe/execution/builders/visit_detail.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import VisitDetail +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_care_site_filter, + apply_codeset_filter, + apply_concept_set_selection, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_interval_range, + apply_location_region_filter, + apply_provider_specialty_filter, + project_event_columns, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("VisitDetail") +def build_visit_detail(criteria: VisitDetail, ctx: BuildContext): + table = ctx.table("visit_detail") + + table = apply_codeset_filter( + table, "visit_detail_concept_id", criteria.codeset_id, ctx + ) + if criteria.first: + table = apply_first_event(table, "visit_detail_start_date", "visit_detail_id") + table = apply_date_range( + table, "visit_detail_start_date", criteria.visit_detail_start_date + ) + table = apply_date_range( + table, "visit_detail_end_date", criteria.visit_detail_end_date + ) + table = apply_concept_set_selection( + table, "visit_detail_type_concept_id", criteria.visit_detail_type_cs, ctx + ) + if criteria.visit_detail_source_concept is not None: + table = apply_codeset_filter( + table, + "visit_detail_source_concept_id", + criteria.visit_detail_source_concept, + ctx, + ) + table = apply_interval_range( + table, + "visit_detail_start_date", + "visit_detail_end_date", + criteria.visit_detail_length, + ) + + if criteria.age: + table = apply_age_filter(table, criteria.age, ctx, "visit_detail_end_date") + table = apply_gender_filter(table, [], criteria.gender_cs, ctx) + table = apply_provider_specialty_filter( + table, + None, + criteria.provider_specialty_cs, + ctx, + ) + table = apply_care_site_filter(table, criteria.place_of_service_cs, ctx) + table = apply_location_region_filter( + table, + care_site_column="care_site_id", + location_codeset_id=criteria.place_of_service_location, + start_column="visit_detail_start_date", + end_column="visit_detail_end_date", + ctx=ctx, + ) + + table = project_event_columns( + table, + primary_key="visit_detail_id", + start_column="visit_detail_start_date", + end_column="visit_detail_end_date", + include_visit_occurrence=True, + ) + + events = standardize_output( + table, + primary_key="visit_detail_id", + start_column="visit_detail_start_date", + end_column="visit_detail_end_date", + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/visit_occurrence.py b/circe/execution/builders/visit_occurrence.py new file mode 100644 index 00000000..d9e873ee --- /dev/null +++ b/circe/execution/builders/visit_occurrence.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import VisitOccurrence +from ..build_context import BuildContext +from .common import ( + apply_age_filter, + apply_codeset_filter, + apply_concept_criteria, + apply_date_range, + apply_first_event, + apply_gender_filter, + apply_numeric_range, + apply_provider_specialty_filter, + project_event_columns, + standardize_output, +) +from .groups import apply_criteria_group +from .registry import register + + +@register("VisitOccurrence") +def build_visit_occurrence(criteria: VisitOccurrence, ctx: BuildContext): + table = ctx.table("visit_occurrence") + + concept_column = criteria.get_concept_id_column() + table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) + + table = apply_date_range( + table, criteria.get_start_date_column(), criteria.occurrence_start_date + ) + table = apply_date_range( + table, criteria.get_end_date_column(), criteria.occurrence_end_date + ) + + table = apply_concept_criteria( + table, + column="visit_type_concept_id", + concepts=criteria.visit_type, + selection=criteria.visit_type_cs, + ctx=ctx, + exclude=bool(criteria.visit_type_exclude), + ) + + table = apply_provider_specialty_filter( + table, + criteria.provider_specialty, + criteria.provider_specialty_cs, + ctx, + ) + table = apply_concept_criteria( + table, + column="place_of_service_concept_id", + concepts=criteria.place_of_service, + selection=criteria.place_of_service_cs, + ctx=ctx, + ) + if criteria.visit_length: + table = apply_numeric_range(table, "visit_length", criteria.visit_length) + + if criteria.age: + table = apply_age_filter( + table, criteria.age, ctx, criteria.get_start_date_column() + ) + table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) + + if criteria.visit_source_concept is not None: + table = apply_codeset_filter( + table, "visit_source_concept_id", criteria.visit_source_concept, ctx + ) + + if criteria.first: + table = apply_first_event( + table, criteria.get_start_date_column(), criteria.get_primary_key_column() + ) + + table = project_event_columns( + table, + primary_key=criteria.get_primary_key_column(), + start_column=criteria.get_start_date_column(), + end_column=criteria.get_end_date_column(), + include_visit_occurrence=True, + ) + + events = standardize_output( + table, + primary_key=criteria.get_primary_key_column(), + start_column=criteria.get_start_date_column(), + end_column=criteria.get_end_date_column(), + ) + return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/criteria_compat.py b/circe/execution/criteria_compat.py new file mode 100644 index 00000000..fb52f2b6 --- /dev/null +++ b/circe/execution/criteria_compat.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from enum import IntEnum +from typing import Any + +from ..cohortdefinition.criteria import ( + ConditionEra, + ConditionOccurrence, + CorelatedCriteria, + Criteria, + Death, + DemographicCriteria, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, +) + +CorrelatedCriteria = CorelatedCriteria +DemoGraphicCriteria = DemographicCriteria + + +class OccurrenceType(IntEnum): + EXACTLY = 0 + AT_MOST = 1 + AT_LEAST = 2 + + +_CONCEPT_ID_OVERRIDES: dict[str, str] = { + "Death": "cause_concept_id", + "DoseEra": "drug_concept_id", + "VisitDetail": "visit_detail_concept_id", +} + +_PRIMARY_KEY_OVERRIDES: dict[str, str] = { + "Death": "person_id", +} + +_START_DATE_OVERRIDES: dict[str, str] = { + "ConditionEra": "condition_era_start_date", + "DrugExposure": "drug_exposure_start_date", + "Measurement": "measurement_date", + "Observation": "observation_date", + "DeviceExposure": "device_exposure_start_date", + "ProcedureOccurrence": "procedure_date", + "DrugEra": "drug_era_start_date", + "DoseEra": "dose_era_start_date", + "ObservationPeriod": "observation_period_start_date", + "Specimen": "specimen_date", + "Death": "death_date", + "VisitDetail": "visit_detail_start_date", + "PayerPlanPeriod": "payer_plan_period_start_date", +} + +_END_DATE_OVERRIDES: dict[str, str] = { + "ConditionEra": "condition_era_end_date", + "DrugExposure": "drug_exposure_end_date", + "Measurement": "measurement_date", + "Observation": "observation_date", + "DeviceExposure": "device_exposure_end_date", + "ProcedureOccurrence": "procedure_date", + "DrugEra": "drug_era_end_date", + "DoseEra": "dose_era_end_date", + "ObservationPeriod": "observation_period_end_date", + "Specimen": "specimen_date", + "Death": "death_date", + "VisitDetail": "visit_detail_end_date", + "PayerPlanPeriod": "payer_plan_period_end_date", +} + + +def _to_snake_case(name: str) -> str: + output: list[str] = [] + for idx, char in enumerate(name): + if char.isupper() and idx > 0: + output.append("_") + output.append(char.lower()) + return "".join(output) + + +def _snake_case_class_name(cls: type[Criteria]) -> str: + return _to_snake_case(cls.__name__) + + +def _get_concept_id_column(self: Criteria) -> str: + cls_name = self.__class__.__name__ + overridden = _CONCEPT_ID_OVERRIDES.get(cls_name) + if overridden: + return overridden + table_name = self.snake_case_class_name() + return f"{table_name.split('_')[0]}_concept_id" + + +def _get_primary_key_column(self: Criteria) -> str: + cls_name = self.__class__.__name__ + overridden = _PRIMARY_KEY_OVERRIDES.get(cls_name) + if overridden: + return overridden + return f"{self.snake_case_class_name()}_id" + + +def _get_start_date_column(self: Criteria) -> str: + cls_name = self.__class__.__name__ + overridden = _START_DATE_OVERRIDES.get(cls_name) + if overridden: + return overridden + return f"{self.snake_case_class_name().split('_')[0]}_start_date" + + +def _get_end_date_column(self: Criteria) -> str: + cls_name = self.__class__.__name__ + overridden = _END_DATE_OVERRIDES.get(cls_name) + if overridden: + return overridden + return f"{self.snake_case_class_name().split('_')[0]}_end_date" + + +def ensure_criteria_compat() -> None: + if getattr(Criteria, "_execution_compat_patched", False): + return + + Criteria.snake_case_class_name = classmethod(_snake_case_class_name) + Criteria.get_concept_id_column = _get_concept_id_column + Criteria.get_primary_key_column = _get_primary_key_column + Criteria.get_start_date_column = _get_start_date_column + Criteria.get_end_date_column = _get_end_date_column + Criteria._execution_compat_patched = True + + +CRITERIA_TYPE_MAP: dict[str, type[Criteria]] = { + "ConditionOccurrence": ConditionOccurrence, + "ConditionEra": ConditionEra, + "VisitOccurrence": VisitOccurrence, + "DrugExposure": DrugExposure, + "DrugEra": DrugEra, + "DoseEra": DoseEra, + "ObservationPeriod": ObservationPeriod, + "Measurement": Measurement, + "Observation": Observation, + "Specimen": Specimen, + "DeviceExposure": DeviceExposure, + "ProcedureOccurrence": ProcedureOccurrence, + "Death": Death, + "VisitDetail": VisitDetail, + "PayerPlanPeriod": PayerPlanPeriod, +} +CRITERIA_TYPE_MAP_CASEFOLD: dict[str, type[Criteria]] = { + name.casefold(): model for name, model in CRITERIA_TYPE_MAP.items() +} + + +def parse_single_criteria(criteria_dict: Any) -> Criteria: + if isinstance(criteria_dict, Criteria): + return criteria_dict + + if not isinstance(criteria_dict, dict): + raise ValueError("Criteria wrapper must be an object.") + + if len(criteria_dict) != 1: + raise ValueError("Criteria wrapper must contain exactly one criteria type key.") + + criteria_type, criteria_data = next(iter(criteria_dict.items())) + model_cls = CRITERIA_TYPE_MAP.get(criteria_type) + if model_cls is None and isinstance(criteria_type, str): + model_cls = CRITERIA_TYPE_MAP_CASEFOLD.get(criteria_type.casefold()) + if model_cls is None: + raise ValueError(f"Unsupported criteria type: {criteria_type}") + + if criteria_data is None: + criteria_data = {} + + if not isinstance(criteria_data, dict): + raise ValueError(f"Criteria payload for {criteria_type} must be an object.") + + return model_cls.model_validate(criteria_data, strict=False) + + +def parse_criteria_list(criteria_list_data: Any) -> list[Criteria]: + if criteria_list_data is None: + return [] + + if not isinstance(criteria_list_data, list): + raise ValueError("Criteria list must be a list.") + + criteria_instances: list[Criteria] = [] + for idx, criteria_dict in enumerate(criteria_list_data): + try: + parsed = parse_single_criteria(criteria_dict) + except ValueError as exc: + raise ValueError(f"Invalid criteria wrapper at index {idx}: {exc}") from exc + criteria_instances.append(parsed) + return criteria_instances + + +ensure_criteria_compat() diff --git a/circe/execution/ibis.py b/circe/execution/ibis.py new file mode 100644 index 00000000..fe558dbf --- /dev/null +++ b/circe/execution/ibis.py @@ -0,0 +1,225 @@ +"""Experimental ibis execution API.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING, Any, List, Optional + +from ..io import ExpressionInput, load_expression +from .options import ExecutionOptions, SchemaName, schema_to_str + +if TYPE_CHECKING: + import ibis.expr.types as ir + import pandas as pd + import polars as pl + + +class IbisExecutor: + """Execute cohort expressions against an ibis backend. + + Notes: + - This API is experimental. + - `build()` returns an ibis table expression (lazy relation). + - Materialization happens in `to_polars()` / `to_pandas()` / `write()`. + """ + + def __init__(self, conn: Any, options: Optional[ExecutionOptions] = None): + self._conn = conn + self._options = options or ExecutionOptions() + self._open_contexts: List[Any] = [] + + @property + def conn(self) -> Any: + return self._conn + + @property + def options(self) -> ExecutionOptions: + return self._options + + def build(self, expression: ExpressionInput) -> Any: + """Build a lazy ibis relation for the final cohort rows.""" + cohort_expression = load_expression(expression) + self.close() + return self._build_native(cohort_expression) + + def to_polars(self, expression: ExpressionInput) -> "pl.DataFrame": + """Execute cohort expression and collect to Polars.""" + table = self.build(expression) + if not hasattr(table, "to_polars"): + raise RuntimeError( + "The returned ibis table does not support to_polars() on this backend." + ) + return table.to_polars() + + def to_pandas(self, expression: ExpressionInput) -> "pd.DataFrame": + """Execute cohort expression and collect to pandas.""" + table = self.build(expression) + if not hasattr(table, "to_pandas"): + raise RuntimeError( + "The returned ibis table does not support to_pandas() on this backend." + ) + return table.to_pandas() + + def write( + self, + expression: ExpressionInput, + *, + table: str, + schema: Optional[SchemaName] = None, + overwrite: bool = True, + append: bool = False, + cohort_id: Optional[int] = None, + ) -> Any: + """Persist cohort rows to a cohort table and return a backend table handle.""" + if append and overwrite: + raise ValueError( + "`append=True` and `overwrite=True` cannot be used together." + ) + cohort_expression = load_expression(expression) + self.close() + events, ctx = self._build_with_context_native( + cohort_expression, cohort_id_override=cohort_id + ) + self._open_contexts.append(ctx) + return ctx.write_cohort_table( + events, + table_name=table, + database=schema_to_str(schema) + or schema_to_str(self._options.result_schema), + overwrite=overwrite, + append=append, + ) + + def captured_sql(self) -> List[tuple[str, str]]: + """Return captured staged SQL snippets when capture_sql is enabled.""" + captured: List[tuple[str, str]] = [] + for ctx in self._open_contexts: + if hasattr(ctx, "captured_sql"): + captured.extend(ctx.captured_sql()) + return captured + + def close(self) -> None: + """Release temporary resources held by execution contexts.""" + while self._open_contexts: + ctx = self._open_contexts.pop() + try: + ctx.close() + except Exception as exc: + print(f"Warning: failed to close execution context: {exc}") + + def __enter__(self) -> "IbisExecutor": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + def _build_native(self, cohort_expression: Any) -> Any: + events, ctx = self._build_with_context_native(cohort_expression) + self._open_contexts.append(ctx) + return events + + def _build_with_context_native( + self, cohort_expression: Any, cohort_id_override: Optional[int] = None + ) -> Any: + try: + from .build_context import ( + BuildContext, + CohortBuildOptions, + compile_codesets, + ) + from .builders.pipeline import build_primary_events + except ModuleNotFoundError as exc: + raise RuntimeError( + "Ibis execution requires optional dependencies. " + "Install `ohdsi-circe-python-alpha[ibis]` plus a backend extra, " + "for example `[ibis-duckdb]`." + ) from exc + + backend = self._infer_backend_name(self._conn) + options = CohortBuildOptions( + cdm_schema=schema_to_str(self._options.cdm_schema), + vocabulary_schema=schema_to_str(self._options.vocabulary_schema), + result_schema=schema_to_str(self._options.result_schema), + cohort_id=( + cohort_id_override + if cohort_id_override is not None + else self._options.cohort_id + ), + materialize_stages=self._options.materialize_stages, + materialize_codesets=self._options.materialize_codesets, + temp_emulation_schema=schema_to_str(self._options.temp_emulation_schema), + profile_dir=self._options.profile_dir, + capture_sql=self._options.capture_sql, + backend=backend, + ) + resource = compile_codesets( + self._conn, cohort_expression.concept_sets or [], options + ) + ctx = BuildContext(self._conn, options, resource) + events = build_primary_events(cohort_expression, ctx) + if events is None: + raise RuntimeError( + "No primary events were generated for the supplied cohort expression." + ) + return events, ctx + + @staticmethod + def _infer_backend_name(conn: Any) -> Optional[str]: + backend_name = getattr(conn, "name", None) + if isinstance(backend_name, str) and backend_name: + return backend_name.lower() + class_name = conn.__class__.__name__.lower() + if "duckdb" in class_name: + return "duckdb" + if "postgres" in class_name: + return "postgres" + if "databricks" in class_name: + return "databricks" + return None + + +def build_ibis( + expression: ExpressionInput, + conn: Any, + options: Optional[ExecutionOptions] = None, +) -> Any: + """Convenience wrapper for IbisExecutor.build().""" + with IbisExecutor(conn, options) as executor: + return executor.build(expression) + + +def to_polars( + expression: ExpressionInput, + conn: Any, + options: Optional[ExecutionOptions] = None, +) -> "pl.DataFrame": + """Convenience wrapper for IbisExecutor.to_polars().""" + with IbisExecutor(conn, options) as executor: + return executor.to_polars(expression) + + +def write_cohort( + expression: ExpressionInput, + conn: Any, + *, + table: str, + schema: Optional[SchemaName] = None, + overwrite: bool = True, + append: bool = False, + cohort_id: Optional[int] = None, + options: Optional[ExecutionOptions] = None, +) -> Any: + """Convenience wrapper for IbisExecutor.write().""" + effective_options = options + if cohort_id is not None: + effective_options = replace(options or ExecutionOptions(), cohort_id=cohort_id) + + with IbisExecutor(conn, effective_options) as executor: + return executor.write( + expression, + table=table, + schema=schema, + overwrite=overwrite, + append=append, + cohort_id=cohort_id, + ) diff --git a/circe/execution/ibis_compat.py b/circe/execution/ibis_compat.py new file mode 100644 index 00000000..ae2260bb --- /dev/null +++ b/circe/execution/ibis_compat.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Iterable + +import ibis +import ibis.expr.operations as ops +import ibis.expr.types as ir +from ibis.common.collections import FrozenOrderedDict + + +def table_from_literal_list( + values: Iterable[int], + *, + column_name: str, + element_type: str = "int64", +) -> ir.Table: + """ + Build a 1-column table from a Python list without using `ibis.memtable`. + + This avoids Databricks' memtable upload machinery (which depends on a writable + Unity Catalog volume) while still producing a pure Ibis expression. + """ + values_list = list(values) + if not values_list: + dummy = ops.DummyTable( + values=FrozenOrderedDict({column_name: ibis.null().cast(element_type).op()}) + ).to_expr() + return dummy.select(dummy[column_name]).filter(ibis.literal(False)) + + array_type = f"array<{element_type}>" + arr = ibis.literal(values_list, type=array_type) + + dummy = ops.DummyTable(values=FrozenOrderedDict({"__values__": arr.op()})).to_expr() + unnested = ops.TableUnnest( + dummy.op(), + dummy["__values__"].op(), + column_name, + None, + False, + ).to_expr() + return unnested.select(unnested[column_name]) diff --git a/circe/execution/options.py b/circe/execution/options.py new file mode 100644 index 00000000..aa4220a1 --- /dev/null +++ b/circe/execution/options.py @@ -0,0 +1,38 @@ +"""Execution options for backend-native cohort execution.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Tuple, Union + +SchemaName = Union[str, Tuple[str, str]] + + +@dataclass(frozen=True) +class ExecutionOptions: + """Runtime options for backend execution via ibis. + + This API is experimental and may evolve while execution parity is built out. + """ + + cdm_schema: Optional[SchemaName] = None + vocabulary_schema: Optional[SchemaName] = None + result_schema: Optional[SchemaName] = None + + cohort_id: Optional[int] = None + + materialize_stages: bool = False + materialize_codesets: bool = True + temp_emulation_schema: Optional[SchemaName] = None + + capture_sql: bool = False + profile_dir: Optional[str] = None + + +def schema_to_str(schema: Optional[SchemaName]) -> Optional[str]: + """Normalize schema names to a string representation.""" + if schema is None: + return None + if isinstance(schema, tuple): + return ".".join(schema) + return schema diff --git a/circe/io.py b/circe/io.py new file mode 100644 index 00000000..5f87a8b2 --- /dev/null +++ b/circe/io.py @@ -0,0 +1,61 @@ +""" +Input loading helpers for cohort expressions. + +This module provides a canonical loader used by execution-oriented APIs to +accept either in-memory models or serialized payloads. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Mapping, Union + +from .api import cohort_expression_from_json +from .cohortdefinition import CohortExpression + +ExpressionInput = Union[CohortExpression, Mapping[str, Any], str, Path] + + +def load_expression(value: ExpressionInput) -> CohortExpression: + """Normalize different expression inputs into a CohortExpression. + + Accepted inputs: + - CohortExpression + - mapping/dict compatible with CohortExpression + - JSON string + - path to a JSON file + """ + if isinstance(value, CohortExpression): + return value + + if isinstance(value, Mapping): + return CohortExpression.model_validate(dict(value)) + + if isinstance(value, Path): + return cohort_expression_from_json(value.read_text(encoding="utf-8")) + + if isinstance(value, str): + stripped = value.strip() + + # JSON payload path + if stripped.startswith("{") or stripped.startswith("["): + return cohort_expression_from_json(stripped) + + # File-system path + path = Path(value) + if path.exists() and path.is_file(): + return cohort_expression_from_json(path.read_text(encoding="utf-8")) + + # If it wasn't an existing path, attempt JSON parse for clearer errors. + try: + parsed = json.loads(stripped) + except json.JSONDecodeError as exc: + raise ValueError( + "Expected JSON string or path to a JSON file for cohort expression input." + ) from exc + return CohortExpression.model_validate(parsed) + + raise TypeError( + "Unsupported expression input type. Expected CohortExpression, mapping, JSON string, or Path." + ) diff --git a/pyproject.toml b/pyproject.toml index 31f1fb8b..f1b1ca2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,19 @@ docs = [ "sphinx>=5.0.0", "sphinx-rtd-theme>=1.0.0", ] +ibis = [ + "ibis-framework>=11.0.0; python_version >= '3.9'", +] +ibis-duckdb = [ + "ibis-framework[duckdb]>=11.0.0; python_version >= '3.9'", + "polars>=0.20.0; python_version >= '3.9'", +] +ibis-postgres = [ + "ibis-framework[postgres]>=11.0.0; python_version >= '3.9'", +] +ibis-databricks = [ + "ibis-framework[databricks]>=11.0.0; python_version >= '3.9'", +] [project.urls] Homepage = "https://github.com/OHDSI/Circepy" @@ -154,4 +167,4 @@ markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration tests", "unit: marks tests as unit tests", -] \ No newline at end of file +] diff --git a/tests/test_execution_api.py b/tests/test_execution_api.py new file mode 100644 index 00000000..260f6823 --- /dev/null +++ b/tests/test_execution_api.py @@ -0,0 +1,239 @@ +"""Tests for experimental execution API surface.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from circe import CohortExpression +from circe.cohortdefinition import ( + ConditionOccurrence, + CustomEraStrategy, + DateOffsetStrategy, + DrugExposure, + PayerPlanPeriod, + PrimaryCriteria, + VisitDetail, +) +from circe.execution import ExecutionOptions, IbisExecutor +from circe.execution.criteria_compat import parse_single_criteria +from circe.execution.ibis import write_cohort +from circe.execution.options import schema_to_str +from circe.io import load_expression +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def test_execution_options_defaults(): + options = ExecutionOptions() + + assert options.cdm_schema is None + assert options.vocabulary_schema is None + assert options.result_schema is None + assert options.cohort_id is None + assert options.materialize_stages is False + assert options.materialize_codesets is True + assert options.temp_emulation_schema is None + assert options.capture_sql is False + assert options.profile_dir is None + + +def test_schema_to_str_with_tuple_schema(): + assert schema_to_str(("catalog", "schema")) == "catalog.schema" + + +def test_load_expression_from_mapping(): + expression = load_expression({"Title": "Mapping Input"}) + assert isinstance(expression, CohortExpression) + assert expression.title == "Mapping Input" + + +def test_load_expression_from_path(tmp_path: Path): + payload = {"Title": "File Input"} + path = tmp_path / "cohort.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + expression = load_expression(path) + assert isinstance(expression, CohortExpression) + assert expression.title == "File Input" + + +def test_ibis_executor_missing_optional_dependencies(monkeypatch): + class DummyConn: + pass + + import builtins + import sys + + real_import = builtins.__import__ + sys.modules.pop("circe.execution.build_context", None) + + def _import(name, *args, **kwargs): + if name.endswith("build_context"): + raise ModuleNotFoundError("No module named 'ibis'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _import) + + executor = IbisExecutor(DummyConn(), ExecutionOptions()) + + with pytest.raises(RuntimeError, match="requires optional dependencies"): + executor.build({"Title": "No Backend"}) + + +def test_criteria_compat_methods_available(): + criteria = DrugExposure() + + assert criteria.get_primary_key_column() == "drug_exposure_id" + assert criteria.get_start_date_column() == "drug_exposure_start_date" + assert criteria.get_end_date_column() == "drug_exposure_end_date" + assert criteria.get_concept_id_column() == "drug_concept_id" + + +def test_parse_single_criteria_wrapper(): + parsed = parse_single_criteria({"ConditionOccurrence": {"CodesetId": 10}}) + + assert isinstance(parsed, ConditionOccurrence) + assert parsed.codeset_id == 10 + + +def test_parse_single_criteria_wrapper_case_insensitive(): + parsed = parse_single_criteria({"conditionoccurrence": {"CodesetId": 11}}) + + assert isinstance(parsed, ConditionOccurrence) + assert parsed.codeset_id == 11 + + +def test_pipeline_registers_visit_detail_and_payer_plan_period_builders(): + from circe.execution.builders import pipeline as _pipeline # noqa: F401 + from circe.execution.builders.registry import get_builder + + assert callable(get_builder(VisitDetail())) + assert callable(get_builder(PayerPlanPeriod())) + + +def test_coerce_concept_set_selection_rejects_invalid_value(): + from circe.execution.builders.common import coerce_concept_set_selection + + with pytest.raises(ValueError, match="Unsupported concept set selection value"): + coerce_concept_set_selection(object()) + + +def test_write_rejects_append_and_overwrite_together(): + class DummyConn: + pass + + executor = IbisExecutor(DummyConn(), ExecutionOptions()) + + with pytest.raises(ValueError, match="cannot be used together"): + executor.write( + {"Title": "Invalid write options"}, + table="cohort", + append=True, + overwrite=True, + ) + + +def test_write_cohort_rejects_append_and_overwrite_together(): + class DummyConn: + pass + + with pytest.raises(ValueError, match="cannot be used together"): + write_cohort( + {"Title": "Invalid write options"}, + DummyConn(), + table="cohort", + append=True, + overwrite=True, + ) + + +def test_has_end_strategy_handles_polymorphic_models(): + from circe.execution.builders.common import has_end_strategy + + assert has_end_strategy(None) is False + assert ( + has_end_strategy(DateOffsetStrategy(offset=7, date_field="StartDate")) is True + ) + assert has_end_strategy(CustomEraStrategy(drug_codeset_id=123)) is True + + +def test_ibis_executor_build_smoke_duckdb(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + + conn.create_table( + "concept", + obj=ibis.memtable( + { + "concept_id": [111, 999], + "invalid_reason": [None, "D"], + } + ), + overwrite=True, + ) + conn.create_table( + "concept_ancestor", + obj=ibis.memtable( + { + "ancestor_concept_id": [111], + "descendant_concept_id": [111], + } + ), + overwrite=True, + ) + conn.create_table( + "concept_relationship", + obj=ibis.memtable( + { + "concept_id_1": [111], + "concept_id_2": [111], + "relationship_id": ["Maps to"], + "invalid_reason": [""], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [1001], + "condition_concept_id": [111], + "condition_start_date": ["2020-01-01"], + "condition_end_date": ["2020-01-02"], + } + ), + overwrite=True, + ) + + cohort = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, + expression=ConceptSetExpression( + items=[ConceptSetItem(concept=Concept(conceptId=111))] + ), + ) + ], + primary_criteria=PrimaryCriteria( + criteria_list=[ConditionOccurrence(codeset_id=1)], + ), + ) + + with IbisExecutor(conn, ExecutionOptions(materialize_stages=False)) as executor: + events = executor.build(cohort) + result = events.execute() + + assert len(result) == 1 + assert set(result.columns) == { + "person_id", + "event_id", + "start_date", + "end_date", + "visit_occurrence_id", + } diff --git a/tests/test_package_structure.py b/tests/test_package_structure.py index a5ad3067..33bac95e 100644 --- a/tests/test_package_structure.py +++ b/tests/test_package_structure.py @@ -5,103 +5,121 @@ """ import pytest + import circe class TestPackageStructure: """Test basic package structure and imports.""" - + def test_package_import(self): """Test that the main package can be imported.""" - assert hasattr(circe, '__version__') - + assert hasattr(circe, "__version__") + def test_package_metadata(self): """Test package metadata.""" - assert hasattr(circe, '__author__') - assert hasattr(circe, '__email__') - assert hasattr(circe, '__license__') + assert hasattr(circe, "__author__") + assert hasattr(circe, "__email__") + assert hasattr(circe, "__license__") assert circe.__author__ == "CIRCE Python Implementation Team" assert circe.__email__ == "circe-python@ohdsi.org" assert circe.__license__ == "Apache License 2.0" - + def test_subpackage_imports(self): """Test that subpackages can be imported.""" - import circe.cohortdefinition - import circe.vocabulary import circe.check - import circe.helper - - # Test sub-subpackages - import circe.cohortdefinition.builders - import circe.cohortdefinition.printfriendly import circe.check.checkers import circe.check.operations import circe.check.utils import circe.check.warnings - + import circe.cohortdefinition + + # Test sub-subpackages + import circe.cohortdefinition.builders + import circe.cohortdefinition.printfriendly + import circe.execution + import circe.helper + import circe.vocabulary + def test_package_structure(self): """Test that package structure matches expected layout.""" import circe - + # Check that main package has expected attributes - expected_attrs = ['__version__', '__author__', '__email__', '__license__'] + expected_attrs = ["__version__", "__author__", "__email__", "__license__"] for attr in expected_attrs: assert hasattr(circe, attr), f"Missing attribute: {attr}" - + def test_main_exports(self): """Test that main classes are properly exported.""" # These should be available at the package level - assert hasattr(circe, '__all__') + assert hasattr(circe, "__all__") assert isinstance(circe.__all__, list) - + # Should include metadata and main classes expected_exports = [ - "__version__", "__author__", "__email__", "__license__", - "CohortExpression", "Concept", "ConceptSet", - "ConceptSetExpression", "ConceptSetItem" + "__version__", + "__author__", + "__email__", + "__license__", + "CohortExpression", + "Concept", + "ConceptSet", + "ConceptSetExpression", + "ConceptSetItem", ] - + for export in expected_exports: assert export in circe.__all__, f"Missing export: {export}" - + def test_main_class_imports(self): """Test that main classes can be imported and instantiated.""" # Test that classes are available at package level - from circe import CohortExpression, Concept, ConceptSet, ConceptSetExpression, ConceptSetItem - + from circe import ( + CohortExpression, + Concept, + ConceptSet, + ConceptSetExpression, + ConceptSetItem, + ) + # Test basic instantiation concept = Concept(conceptId=12345) assert concept.concept_id == 12345 - + concept_set = ConceptSet(id=1) assert concept_set.id == 1 - + cohort_expr = CohortExpression(title="Test") assert cohort_expr.title == "Test" class TestModuleStructure: """Test individual module structure.""" - + def test_cohortdefinition_module(self): """Test cohortdefinition module structure.""" import circe.cohortdefinition - assert hasattr(circe.cohortdefinition, '__all__') - + + assert hasattr(circe.cohortdefinition, "__all__") + def test_vocabulary_module(self): """Test vocabulary module structure.""" import circe.vocabulary - assert hasattr(circe.vocabulary, '__all__') - + + assert hasattr(circe.vocabulary, "__all__") + def test_check_module(self): """Test check module structure.""" import circe.check - assert hasattr(circe.check, '__all__') - + + assert hasattr(circe.check, "__all__") + def test_helper_module(self): """Test helper module structure.""" import circe.helper - assert hasattr(circe.helper, '__all__') + + assert hasattr(circe.helper, "__all__") if __name__ == "__main__": From 67cbb8dc7c3955ea156b0cd011c405e084174ef5 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 10:49:59 -0700 Subject: [PATCH 08/62] Installed ruff and added github workflow for it --- .github/workflows/ruff.yml | 29 ++ circe/chat.py | 8 +- circe/check/checkers/base_criteria_check.py | 3 +- circe/check/checkers/base_value_check.py | 3 - circe/check/checkers/comparisons.py | 30 +- .../checkers/concept_set_criteria_check.py | 13 - .../checkers/criteria_checker_factory.py | 6 +- circe/check/checkers/domain_type_check.py | 12 - circe/check/checkers/drug_domain_check.py | 2 +- .../checkers/duplicates_criteria_check.py | 24 +- circe/check/checkers/ocurrence_check.py | 2 +- circe/check/checkers/range_check.py | 4 +- circe/check/checkers/range_checker_factory.py | 8 +- circe/check/checkers/time_window_check.py | 2 +- circe/check/checkers/unused_concepts_check.py | 6 +- .../operations/conditional_operations.py | 2 +- circe/check/operations/operations.py | 1 - circe/check/utils/criteria_name_helper.py | 20 +- circe/check/warning.py | 1 - .../builders/condition_era.py | 2 +- .../builders/condition_occurrence.py | 2 +- circe/cohortdefinition/builders/death.py | 4 +- .../builders/device_exposure.py | 4 +- circe/cohortdefinition/builders/dose_era.py | 2 +- circe/cohortdefinition/builders/drug_era.py | 2 +- .../builders/location_region.py | 4 +- .../cohortdefinition/builders/measurement.py | 4 +- .../cohortdefinition/builders/observation.py | 4 +- .../builders/observation_period.py | 2 +- .../builders/payer_plan_period.py | 2 +- circe/cohortdefinition/builders/specimen.py | 4 +- circe/cohortdefinition/builders/utils.py | 6 +- .../cohortdefinition/builders/visit_detail.py | 2 +- .../builders/visit_occurrence.py | 4 +- circe/cohortdefinition/code_generator.py | 9 +- circe/cohortdefinition/cohort.py | 2 - .../cohort_expression_query_builder.py | 21 +- .../concept_set_expression_query_builder.py | 4 +- circe/cohortdefinition/core.py | 5 +- circe/cohortdefinition/criteria.py | 6 +- circe/execution/builders/__init__.py | 32 +- circe/execution/builders/common.py | 4 +- circe/execution/builders/pipeline.py | 32 +- circe/execution/ibis.py | 9 +- circe/vocabulary/concept.py | 2 +- .../concept_set_expression_query_builder.py | 4 +- cohort_definition.py | 2 +- debug_app/app.py | 5 +- debug_app/sandbox.py | 5 +- debug_app/utils.py | 21 +- examples/basic_cohort.py | 12 +- examples/complex_cohort.py | 26 +- examples/generate_sql.py | 7 +- examples/json_to_code_demo.ipynb | 148 +++---- examples/type2_diabetes_cohort.ipynb | 367 +++++++++--------- examples/validate_cohort.py | 3 +- pyproject.toml | 46 +++ scripts/generate_skill_backup.py | 20 +- tests/conftest.py | 1 - tests/test_builder_utils_coverage.py | 17 +- tests/test_builders.py | 48 ++- tests/test_builders_sql.py | 10 +- tests/test_checkers.py | 68 ++-- tests/test_cli.py | 15 +- tests/test_code_generator.py | 11 +- tests/test_cohort_expression.py | 14 +- ...ohort_expression_query_builder_coverage.py | 20 +- ...ohort_expression_query_builder_extended.py | 29 +- tests/test_cohort_modifiers.py | 48 +-- tests/test_comparisons_coverage.py | 35 +- .../test_concept_checker_factory_coverage.py | 21 +- ...st_concept_set_expression_query_builder.py | 10 +- .../test_condition_occurrence_sql_builder.py | 23 +- tests/test_criteria_classes.py | 37 +- tests/test_date_adjustment_parity.py | 17 +- tests/test_device_exposure_sql.py | 6 +- tests/test_documentation.py | 1 + tests/test_drug_era_sql_builder.py | 12 +- tests/test_drug_exposure_builder.py | 13 +- tests/test_hashing.py | 10 +- tests/test_java_interoperability.py | 36 +- tests/test_kitchen_sink_cohort.py | 50 ++- tests/test_markdown_render_coverage.py | 4 +- tests/test_package_structure.py | 13 - tests/test_print_friendly_parity.py | 9 +- tests/test_query_builders.py | 35 +- tests/test_range_checker_factory_coverage.py | 31 +- tests/test_real_example_cohorts.py | 21 +- tests/test_schema_compatibility.py | 4 +- tests/test_simple_sql_builders.py | 17 +- tests/test_sql_builders.py | 48 ++- tests/test_sql_rendering_parity.py | 60 ++- tests/test_supporting_classes.py | 23 +- tests/test_utils_db.py | 4 +- tests/test_visit_occurrence_parity.py | 6 +- 95 files changed, 1017 insertions(+), 826 deletions(-) create mode 100644 .github/workflows/ruff.yml diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 00000000..8dbdcb58 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,29 @@ +name: Ruff + +on: + push: + branches: [ develop, main ] + pull_request: + branches: [ develop, main ] + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Ruff + run: pip install ruff + + - name: Run Ruff linter + run: ruff check . --output-format=github + + - name: Run Ruff formatter check + run: ruff format --check . + diff --git a/circe/chat.py b/circe/chat.py index 25261c11..f5b51ed6 100644 --- a/circe/chat.py +++ b/circe/chat.py @@ -7,7 +7,7 @@ import re import sys from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Optional from circe.prompt_builder import CohortPromptBuilder, ConceptSet @@ -59,7 +59,7 @@ def start_chat( model = os.getenv("LLM_MODEL", "gpt-4o") # Handle optional temperature if needed, but litellm handles it or we pass it - print(f"🚀 Starting Circe Chat") + print("🚀 Starting Circe Chat") print(f" Model: {model}") print(f" Prompt: {prompt_type}") print("-" * 50) @@ -68,7 +68,7 @@ def start_chat( concept_sets_data = [] if concept_sets_file: try: - with open(concept_sets_file, "r") as f: + with open(concept_sets_file) as f: raw_data = json.load(f) # Expecting list of dicts with id, name for item in raw_data: @@ -124,7 +124,7 @@ def start_chat( try: if first_turn and initial_input: user_input = initial_input - print(f"\n> [Processing input from file...]") + print("\n> [Processing input from file...]") else: user_input = input("\n> ") except (EOFError, KeyboardInterrupt): diff --git a/circe/check/checkers/base_criteria_check.py b/circe/check/checkers/base_criteria_check.py index 6703602b..fc069495 100644 --- a/circe/check/checkers/base_criteria_check.py +++ b/circe/check/checkers/base_criteria_check.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Optional from .base_iterable_check import BaseIterableCheck from .warning_reporter import WarningReporter @@ -22,7 +21,7 @@ if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import CorelatedCriteria, Criteria + from ...cohortdefinition.criteria import Criteria class BaseCriteriaCheck(BaseIterableCheck): diff --git a/circe/check/checkers/base_value_check.py b/circe/check/checkers/base_value_check.py index 5ca633a5..b1c33063 100644 --- a/circe/check/checkers/base_value_check.py +++ b/circe/check/checkers/base_value_check.py @@ -30,10 +30,7 @@ if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import ( - CorelatedCriteria, - Criteria, CriteriaGroup, - DemographicCriteria, PrimaryCriteria, ) diff --git a/circe/check/checkers/comparisons.py b/circe/check/checkers/comparisons.py index 895f31d4..2d9e7cc6 100644 --- a/circe/check/checkers/comparisons.py +++ b/circe/check/checkers/comparisons.py @@ -9,7 +9,7 @@ """ from datetime import datetime -from typing import TYPE_CHECKING, Callable, List, Optional +from typing import TYPE_CHECKING, Optional from ...cohortdefinition.core import DateRange, NumericRange, Period from ...vocabulary.concept import Concept, ConceptSet @@ -49,7 +49,7 @@ def start_is_greater_than_end(range_val) -> bool: return False # Import here to avoid circular dependencies - from ...cohortdefinition.core import DateRange, NumericRange, Period + from ...cohortdefinition.core import NumericRange if isinstance(range_val, NumericRange): if range_val.value is None or range_val.extent is None: @@ -258,31 +258,7 @@ def compare_criteria(c1: "Criteria", c2: "Criteria") -> bool: VisitOccurrence, ) - if isinstance(c1, ConditionEra): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, ConditionOccurrence): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, Death): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, DeviceExposure): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, DoseEra): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, DrugEra): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, DrugExposure): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, Measurement): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, Observation): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, ProcedureOccurrence): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, Specimen): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, VisitOccurrence): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, VisitDetail): + if isinstance(c1, ConditionEra) or isinstance(c1, ConditionOccurrence) or isinstance(c1, Death) or isinstance(c1, DeviceExposure) or isinstance(c1, DoseEra) or isinstance(c1, DrugEra) or isinstance(c1, DrugExposure) or isinstance(c1, Measurement) or isinstance(c1, Observation) or isinstance(c1, ProcedureOccurrence) or isinstance(c1, Specimen) or isinstance(c1, VisitOccurrence) or isinstance(c1, VisitDetail): return c1.codeset_id == c2.codeset_id return False diff --git a/circe/check/checkers/concept_set_criteria_check.py b/circe/check/checkers/concept_set_criteria_check.py index fb97a00c..7451f340 100644 --- a/circe/check/checkers/concept_set_criteria_check.py +++ b/circe/check/checkers/concept_set_criteria_check.py @@ -38,20 +38,7 @@ if TYPE_CHECKING: from ...cohortdefinition.criteria import ( - ConditionEra, - ConditionOccurrence, Criteria, - Death, - DeviceExposure, - DoseEra, - DrugEra, - DrugExposure, - Measurement, - Observation, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, ) diff --git a/circe/check/checkers/criteria_checker_factory.py b/circe/check/checkers/criteria_checker_factory.py index 237c473b..cbeb6ab4 100644 --- a/circe/check/checkers/criteria_checker_factory.py +++ b/circe/check/checkers/criteria_checker_factory.py @@ -10,9 +10,6 @@ from typing import Callable, List, Optional -from .base_checker_factory import BaseCheckerFactory -from .warning_reporter import WarningReporter - # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.core import ConceptSetSelection @@ -100,7 +97,6 @@ def get_criteria_checker( A function that returns True if the criteria uses the concept set """ # Import here to avoid circular dependencies - from ...cohortdefinition.core import ConceptSetSelection from ...cohortdefinition.criteria import ( ConditionEra, ConditionOccurrence, @@ -232,7 +228,7 @@ def _get_concept_set_selection_suppliers( Returns: A list of functions that return ConceptSetSelection objects """ - suppliers: List[Callable[[], Optional["ConceptSetSelection"]]] = [] + suppliers: List[Callable[[], Optional[ConceptSetSelection]]] = [] suppliers.append(lambda: criteria.place_of_service_cs) suppliers.append(lambda: criteria.gender_cs) suppliers.append(lambda: criteria.provider_specialty_cs) diff --git a/circe/check/checkers/domain_type_check.py b/circe/check/checkers/domain_type_check.py index cfa5d879..2b2df395 100644 --- a/circe/check/checkers/domain_type_check.py +++ b/circe/check/checkers/domain_type_check.py @@ -10,13 +10,11 @@ from typing import List -from ..operations.execution import Execution from ..operations.operations import Operations from ..utils.criteria_name_helper import CriteriaNameHelper from ..warning_severity import WarningSeverity from .base_criteria_check import BaseCriteriaCheck from .warning_reporter import WarningReporter -from .warning_reporter_helper import WarningReporterHelper # Import at runtime to avoid circular dependencies try: @@ -40,17 +38,7 @@ if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import ( - ConditionOccurrence, Criteria, - Death, - DeviceExposure, - DrugExposure, - Measurement, - Observation, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, ) diff --git a/circe/check/checkers/drug_domain_check.py b/circe/check/checkers/drug_domain_check.py index 5ad241c3..2ebd196c 100644 --- a/circe/check/checkers/drug_domain_check.py +++ b/circe/check/checkers/drug_domain_check.py @@ -60,7 +60,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N ): return - concept_sets: List["ConceptSet"] = [] + concept_sets: List[ConceptSet] = [] # Map criteria to codeset IDs codeset_ids = [ diff --git a/circe/check/checkers/duplicates_criteria_check.py b/circe/check/checkers/duplicates_criteria_check.py index d2532890..29f4b6bf 100644 --- a/circe/check/checkers/duplicates_criteria_check.py +++ b/circe/check/checkers/duplicates_criteria_check.py @@ -38,7 +38,7 @@ class DuplicatesCriteriaCheck(BaseCriteriaCheck): def __init__(self): """Initialize the duplicates criteria check.""" super().__init__() - self._criteria_list: List[Tuple[str, "Criteria"]] = [] + self._criteria_list: List[Tuple[str, Criteria]] = [] def _after_check( self, reporter: WarningReporter, expression: "CohortExpression" @@ -108,19 +108,7 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: c1.codeset_id == c2.codeset_id and c1.condition_source_concept == c2.condition_source_concept ) - elif isinstance(c1, Death): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, DeviceExposure): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, DoseEra): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, DrugEra): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, DrugExposure): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, Measurement): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, Observation): + elif isinstance(c1, Death) or isinstance(c1, DeviceExposure) or isinstance(c1, DoseEra) or isinstance(c1, DrugEra) or isinstance(c1, DrugExposure) or isinstance(c1, Measurement) or isinstance(c1, Observation): return c1.codeset_id == c2.codeset_id elif isinstance(c1, ObservationPeriod): # For ObservationPeriod, compare all fields @@ -129,13 +117,7 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: and self._compare_objects(c1.period_end_date, c2.period_end_date) and self._compare_objects(c1.period_length, c2.period_length) ) - elif isinstance(c1, ProcedureOccurrence): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, Specimen): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, VisitOccurrence): - return c1.codeset_id == c2.codeset_id - elif isinstance(c1, VisitDetail): + elif isinstance(c1, ProcedureOccurrence) or isinstance(c1, Specimen) or isinstance(c1, VisitOccurrence) or isinstance(c1, VisitDetail): return c1.codeset_id == c2.codeset_id elif isinstance(c1, PayerPlanPeriod): return ( diff --git a/circe/check/checkers/ocurrence_check.py b/circe/check/checkers/ocurrence_check.py index b50e36fc..0e1f2695 100644 --- a/circe/check/checkers/ocurrence_check.py +++ b/circe/check/checkers/ocurrence_check.py @@ -20,7 +20,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from ...cohortdefinition.criteria import CorelatedCriteria, Occurrence + from ...cohortdefinition.criteria import CorelatedCriteria class OcurrenceCheck(BaseCorelatedCriteriaCheck): diff --git a/circe/check/checkers/range_check.py b/circe/check/checkers/range_check.py index 6f0731bd..4f6d522d 100644 --- a/circe/check/checkers/range_check.py +++ b/circe/check/checkers/range_check.py @@ -10,7 +10,6 @@ from typing import Optional -from ..warning_severity import WarningSeverity from .base_value_check import BaseValueCheck from .range_checker_factory import RangeCheckerFactory from .warning_reporter import WarningReporter @@ -25,8 +24,7 @@ if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.core import ObservationFilter, Window - from ...cohortdefinition.criteria import CorelatedCriteria + from ...cohortdefinition.core import ObservationFilter class RangeCheck(BaseValueCheck): diff --git a/circe/check/checkers/range_checker_factory.py b/circe/check/checkers/range_checker_factory.py index 7969fdd8..0bd1f5e9 100644 --- a/circe/check/checkers/range_checker_factory.py +++ b/circe/check/checkers/range_checker_factory.py @@ -44,8 +44,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.core import DateRange, NumericRange, Period + from ...cohortdefinition.core import Period from ...cohortdefinition.criteria import ( ConditionEra, ConditionOccurrence, @@ -717,8 +716,5 @@ def check(self, expression_or_criteria) -> None: Constants.Attributes.CENSOR_WINDOW_ATTR, ) # Handle DemographicCriteria (delegate to base class) - elif isinstance(expression_or_criteria, DemographicCriteria): - super().check(expression_or_criteria) - # Handle Criteria (delegate to base class) - elif isinstance(expression_or_criteria, Criteria): + elif isinstance(expression_or_criteria, DemographicCriteria) or isinstance(expression_or_criteria, Criteria): super().check(expression_or_criteria) diff --git a/circe/check/checkers/time_window_check.py b/circe/check/checkers/time_window_check.py index d50f2dd7..9c41ecd7 100644 --- a/circe/check/checkers/time_window_check.py +++ b/circe/check/checkers/time_window_check.py @@ -42,7 +42,7 @@ class TimeWindowCheck(BaseCorelatedCriteriaCheck): def __init__(self): """Initialize the time window check.""" super().__init__() - self._observation_filter: Optional["ObservationFilter"] = None + self._observation_filter: Optional[ObservationFilter] = None def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. diff --git a/circe/check/checkers/unused_concepts_check.py b/circe/check/checkers/unused_concepts_check.py index 76e9f456..5ad37576 100644 --- a/circe/check/checkers/unused_concepts_check.py +++ b/circe/check/checkers/unused_concepts_check.py @@ -94,7 +94,7 @@ def _get_additional_criteria( Returns: A list of all criteria from additional criteria """ - additional_criteria: List["Criteria"] = [] + additional_criteria: List[Criteria] = [] if expression.additional_criteria: additional_criteria.extend( self._to_criteria_list(expression.additional_criteria.criteria_list) @@ -244,7 +244,7 @@ def _correlated_criteria_to_list(self, correlated_criteria) -> List["Criteria"]: Returns: A list of Criteria """ - criteria_list: List["Criteria"] = [] + criteria_list: List[Criteria] = [] if ( hasattr(correlated_criteria, "criteria_list") and correlated_criteria.criteria_list @@ -296,7 +296,7 @@ def _to_criteria_list_from_groups( Returns: A list of Criteria """ - criteria: List["Criteria"] = [] + criteria: List[Criteria] = [] if groups: for group in groups: if group.criteria_list: diff --git a/circe/check/operations/conditional_operations.py b/circe/check/operations/conditional_operations.py index 3081c812..74b1e22c 100644 --- a/circe/check/operations/conditional_operations.py +++ b/circe/check/operations/conditional_operations.py @@ -9,7 +9,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, Callable, Generic, Protocol, TypeVar +from typing import Callable, Generic, Protocol, TypeVar T = TypeVar("T") V = TypeVar("V") diff --git a/circe/check/operations/operations.py b/circe/check/operations/operations.py index f2d5bc3e..2978bf2a 100644 --- a/circe/check/operations/operations.py +++ b/circe/check/operations/operations.py @@ -12,7 +12,6 @@ from typing import Any, Callable, Generic, Optional, TypeVar from .conditional_operations import ConditionalOperations -from .execution import Execution from .executive_operations import ExecutiveOperations T = TypeVar("T") diff --git a/circe/check/utils/criteria_name_helper.py b/circe/check/utils/criteria_name_helper.py index 8ba64c2e..539df5a4 100644 --- a/circe/check/utils/criteria_name_helper.py +++ b/circe/check/utils/criteria_name_helper.py @@ -36,24 +36,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from ...cohortdefinition.criteria import ( - ConditionEra, - ConditionOccurrence, - Death, - DeviceExposure, - DoseEra, - DrugEra, - DrugExposure, - LocationRegion, - Measurement, - Observation, - ObservationPeriod, - PayerPlanPeriod, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, - ) + pass class CriteriaNameHelper: @@ -84,7 +67,6 @@ def get_criteria_name(criteria) -> str: DoseEra, DrugEra, DrugExposure, - LocationRegion, Measurement, Observation, ObservationPeriod, diff --git a/circe/check/warning.py b/circe/check/warning.py index d0d214db..cb21c7e9 100644 --- a/circe/check/warning.py +++ b/circe/check/warning.py @@ -10,7 +10,6 @@ """ from abc import ABC, abstractmethod -from typing import Protocol class Warning(ABC): diff --git a/circe/cohortdefinition/builders/condition_era.py b/circe/cohortdefinition/builders/condition_era.py index 663d3552..842c0810 100644 --- a/circe/cohortdefinition/builders/condition_era.py +++ b/circe/cohortdefinition/builders/condition_era.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set +from typing import List, Optional, Set from ..criteria import ConditionEra from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/condition_occurrence.py b/circe/cohortdefinition/builders/condition_occurrence.py index 9706a8ac..65982b0f 100644 --- a/circe/cohortdefinition/builders/condition_occurrence.py +++ b/circe/cohortdefinition/builders/condition_occurrence.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set +from typing import List, Optional, Set from ..criteria import ConditionOccurrence from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/death.py b/circe/cohortdefinition/builders/death.py index 72d3aa85..8e4d38a9 100644 --- a/circe/cohortdefinition/builders/death.py +++ b/circe/cohortdefinition/builders/death.py @@ -8,9 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set - -from pydantic import BaseModel, ConfigDict, Field +from typing import List, Optional, Set from ..criteria import Death from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/device_exposure.py b/circe/cohortdefinition/builders/device_exposure.py index 5dd3c192..cd129644 100644 --- a/circe/cohortdefinition/builders/device_exposure.py +++ b/circe/cohortdefinition/builders/device_exposure.py @@ -8,9 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set - -from pydantic import BaseModel, ConfigDict, Field +from typing import List, Set from ..criteria import DeviceExposure from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/dose_era.py b/circe/cohortdefinition/builders/dose_era.py index 1eb795cd..f246711b 100644 --- a/circe/cohortdefinition/builders/dose_era.py +++ b/circe/cohortdefinition/builders/dose_era.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set +from typing import List, Optional, Set from ..criteria import DoseEra from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/drug_era.py b/circe/cohortdefinition/builders/drug_era.py index 76457424..95b16e85 100644 --- a/circe/cohortdefinition/builders/drug_era.py +++ b/circe/cohortdefinition/builders/drug_era.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set +from typing import List, Optional, Set from ..criteria import DrugEra from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/location_region.py b/circe/cohortdefinition/builders/location_region.py index 48742a28..5d744a8e 100644 --- a/circe/cohortdefinition/builders/location_region.py +++ b/circe/cohortdefinition/builders/location_region.py @@ -8,11 +8,11 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set +from typing import List, Optional, Set from ..criteria import LocationRegion from .base import CriteriaSqlBuilder -from .utils import BuilderOptions, BuilderUtils, CriteriaColumn +from .utils import BuilderOptions, CriteriaColumn class LocationRegionSqlBuilder(CriteriaSqlBuilder[LocationRegion]): diff --git a/circe/cohortdefinition/builders/measurement.py b/circe/cohortdefinition/builders/measurement.py index 221aa870..9c2068d6 100644 --- a/circe/cohortdefinition/builders/measurement.py +++ b/circe/cohortdefinition/builders/measurement.py @@ -8,9 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set - -from pydantic import BaseModel, ConfigDict, Field +from typing import List, Optional, Set from ..criteria import Measurement from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/observation.py b/circe/cohortdefinition/builders/observation.py index e0bf114e..d9321095 100644 --- a/circe/cohortdefinition/builders/observation.py +++ b/circe/cohortdefinition/builders/observation.py @@ -8,9 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set - -from pydantic import BaseModel, ConfigDict, Field +from typing import List, Optional, Set from ..criteria import Observation from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/observation_period.py b/circe/cohortdefinition/builders/observation_period.py index f08b8a83..56c6ba1f 100644 --- a/circe/cohortdefinition/builders/observation_period.py +++ b/circe/cohortdefinition/builders/observation_period.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set +from typing import List, Optional, Set from ..criteria import ObservationPeriod from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/payer_plan_period.py b/circe/cohortdefinition/builders/payer_plan_period.py index e1b40fd6..beb7fddf 100644 --- a/circe/cohortdefinition/builders/payer_plan_period.py +++ b/circe/cohortdefinition/builders/payer_plan_period.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set +from typing import List, Optional, Set from ..criteria import PayerPlanPeriod from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/specimen.py b/circe/cohortdefinition/builders/specimen.py index 702b1c21..cc49e8a8 100644 --- a/circe/cohortdefinition/builders/specimen.py +++ b/circe/cohortdefinition/builders/specimen.py @@ -8,9 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set - -from pydantic import BaseModel, ConfigDict, Field +from typing import List, Optional, Set from ..criteria import Specimen from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/utils.py b/circe/cohortdefinition/builders/utils.py index 95471af6..73491a6f 100644 --- a/circe/cohortdefinition/builders/utils.py +++ b/circe/cohortdefinition/builders/utils.py @@ -9,12 +9,10 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from abc import ABC, abstractmethod -from enum import Enum -from typing import Any, Dict, List, Optional, Set +from typing import Any, List, Optional from ...vocabulary.concept import Concept -from ..core import ConceptSetSelection, DateAdjustment, DateRange, NumericRange +from ..core import DateAdjustment, DateRange, NumericRange from ..criteria import CriteriaColumn diff --git a/circe/cohortdefinition/builders/visit_detail.py b/circe/cohortdefinition/builders/visit_detail.py index 358fac15..f378cccb 100644 --- a/circe/cohortdefinition/builders/visit_detail.py +++ b/circe/cohortdefinition/builders/visit_detail.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set +from typing import List, Optional, Set from ..criteria import VisitDetail from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/builders/visit_occurrence.py b/circe/cohortdefinition/builders/visit_occurrence.py index 1447c8ca..824e4b6a 100644 --- a/circe/cohortdefinition/builders/visit_occurrence.py +++ b/circe/cohortdefinition/builders/visit_occurrence.py @@ -8,9 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional, Set - -from pydantic import BaseModel, ConfigDict, Field +from typing import List, Optional, Set from ..criteria import VisitOccurrence from .base import CriteriaSqlBuilder diff --git a/circe/cohortdefinition/code_generator.py b/circe/cohortdefinition/code_generator.py index fc1bc4e1..14c32881 100644 --- a/circe/cohortdefinition/code_generator.py +++ b/circe/cohortdefinition/code_generator.py @@ -1,12 +1,5 @@ -import textwrap from enum import Enum -from typing import Any, List, Set, Type - -from pydantic import BaseModel - -from .cohort import CohortExpression, ConceptSet -from .core import Period -from .criteria import Criteria, CriteriaGroup +from typing import Any, Set def to_python_code(obj: Any) -> str: diff --git a/circe/cohortdefinition/cohort.py b/circe/cohortdefinition/cohort.py index a8c010e8..11b378af 100644 --- a/circe/cohortdefinition/cohort.py +++ b/circe/cohortdefinition/cohort.py @@ -13,7 +13,6 @@ from pydantic import ( AliasChoices, - BaseModel, ConfigDict, Field, field_validator, @@ -26,7 +25,6 @@ CustomEraStrategy, DateOffsetStrategy, EndStrategy, - ObservationFilter, Period, ResultLimit, ) diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index 52ff132d..3b67d41e 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -9,7 +9,7 @@ """ import json -from typing import Any, Dict, List, Optional, Union +from typing import Any, List, Optional, Union from .builders import ( ConditionEraSqlBuilder, @@ -49,7 +49,6 @@ Measurement, Observation, ObservationPeriod, - Occurrence, PayerPlanPeriod, PrimaryCriteria, ProcedureOccurrence, @@ -936,7 +935,7 @@ def build_expression_query( # End date selects end_date_selects = [] - from .core import CustomEraStrategy, DateOffsetStrategy, EndStrategy + from .core import CustomEraStrategy, DateOffsetStrategy if not isinstance(expression.end_strategy, DateOffsetStrategy): end_date_selects.append( @@ -1233,22 +1232,6 @@ def _get_windowed_criteria_query_internal( inner_criteria = criteria.criteria if isinstance(inner_criteria, dict): # Try to deserialize it - import here to avoid circular dependency issues - from .criteria import ConditionEra as CE - from .criteria import ConditionOccurrence as CO - from .criteria import Death as D - from .criteria import DeviceExposure as DevE - from .criteria import DoseEra as DoE - from .criteria import DrugEra as DrE - from .criteria import DrugExposure as DE - from .criteria import LocationRegion as LR - from .criteria import Measurement as M - from .criteria import Observation as O - from .criteria import ObservationPeriod as OP - from .criteria import PayerPlanPeriod as PPP - from .criteria import ProcedureOccurrence as PO - from .criteria import Specimen as S - from .criteria import VisitDetail as VD - from .criteria import VisitOccurrence as VO criteria_type = None criteria_data = None diff --git a/circe/cohortdefinition/concept_set_expression_query_builder.py b/circe/cohortdefinition/concept_set_expression_query_builder.py index 944008e4..991eeb26 100644 --- a/circe/cohortdefinition/concept_set_expression_query_builder.py +++ b/circe/cohortdefinition/concept_set_expression_query_builder.py @@ -8,9 +8,9 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional +from typing import List -from ..vocabulary.concept import Concept, ConceptSetExpression, ConceptSetItem +from ..vocabulary.concept import Concept, ConceptSetExpression from .builders.utils import BuilderUtils diff --git a/circe/cohortdefinition/core.py b/circe/cohortdefinition/core.py index 59371a61..b29c13ec 100644 --- a/circe/cohortdefinition/core.py +++ b/circe/cohortdefinition/core.py @@ -9,17 +9,14 @@ """ from enum import Enum -from typing import TYPE_CHECKING, Any, List, Optional, Union +from typing import Any, Optional, Union from pydantic import ( AliasChoices, BaseModel, ConfigDict, - Discriminator, Field, - field_validator, model_serializer, - model_validator, ) from .utils import to_pascal_alias diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index 5e1591df..f55546d2 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -9,7 +9,7 @@ """ from enum import Enum -from typing import TYPE_CHECKING, Any, ClassVar, List, Optional, Union +from typing import Any, List, Optional, Union from pydantic import ( AliasChoices, @@ -23,11 +23,9 @@ from ..vocabulary.concept import Concept from .core import ( CirceBaseModel, - CollapseSettings, ConceptSetSelection, DateAdjustment, DateRange, - EndStrategy, NumericRange, ObservationFilter, Period, @@ -1420,7 +1418,7 @@ def normalize_window(window_dict: dict) -> dict: c_type = next( ( k - for k in item_copy.keys() + for k in item_copy if k not in [ "StartWindow", diff --git a/circe/execution/builders/__init__.py b/circe/execution/builders/__init__.py index 8b5adfc7..e194e216 100644 --- a/circe/execution/builders/__init__.py +++ b/circe/execution/builders/__init__.py @@ -1,17 +1,19 @@ -from . import condition_era # noqa: F401 -from . import condition_occurrence # noqa: F401 -from . import death # noqa: F401 -from . import device_exposure # noqa: F401 -from . import dose_era # noqa: F401 -from . import drug_era # noqa: F401 -from . import drug_exposure # noqa: F401 -from . import measurement # noqa: F401 -from . import observation # noqa: F401 -from . import observation_period # noqa: F401 -from . import payer_plan_period # noqa: F401 -from . import procedure_occurrence # noqa: F401 -from . import specimen # noqa: F401 -from . import visit_detail # noqa: F401 -from . import visit_occurrence # noqa: F401 +from . import ( + condition_era, # noqa: F401 + condition_occurrence, # noqa: F401 + death, # noqa: F401 + device_exposure, # noqa: F401 + dose_era, # noqa: F401 + drug_era, # noqa: F401 + drug_exposure, # noqa: F401 + measurement, # noqa: F401 + observation, # noqa: F401 + observation_period, # noqa: F401 + payer_plan_period, # noqa: F401 + procedure_occurrence, # noqa: F401 + specimen, # noqa: F401 + visit_detail, # noqa: F401 + visit_occurrence, # noqa: F401 +) from .pipeline import build_primary_events # noqa: F401 from .registry import build_events, register # noqa: F401 diff --git a/circe/execution/builders/common.py b/circe/execution/builders/common.py index e1e2f4d0..ea5ea0fb 100644 --- a/circe/execution/builders/common.py +++ b/circe/execution/builders/common.py @@ -78,9 +78,7 @@ def project_event_columns( include_visit_occurrence: bool = False, ) -> ir.Table: keep = ["person_id", primary_key, start_column] - if end_column in table.columns: - keep.append(end_column) - elif include_visit_occurrence and start_column != end_column: + if end_column in table.columns or include_visit_occurrence and start_column != end_column: keep.append(end_column) if include_visit_occurrence and "visit_occurrence_id" in table.columns: keep.append("visit_occurrence_id") diff --git a/circe/execution/builders/pipeline.py b/circe/execution/builders/pipeline.py index e97288b2..d666cdec 100644 --- a/circe/execution/builders/pipeline.py +++ b/circe/execution/builders/pipeline.py @@ -7,21 +7,23 @@ from ...cohortdefinition import CohortExpression from ..build_context import BuildContext -from . import condition_era # noqa: F401 -from . import condition_occurrence # noqa: F401 -from . import death # noqa: F401 -from . import device_exposure # noqa: F401 -from . import dose_era # noqa: F401 -from . import drug_era # noqa: F401 -from . import drug_exposure # noqa: F401 -from . import measurement # noqa: F401 -from . import observation # noqa: F401 -from . import observation_period # noqa: F401 -from . import payer_plan_period # noqa: F401 -from . import procedure_occurrence # noqa: F401 -from . import specimen # noqa: F401 -from . import visit_detail # noqa: F401 -from . import visit_occurrence # noqa: F401 +from . import ( + condition_era, # noqa: F401 + condition_occurrence, # noqa: F401 + death, # noqa: F401 + device_exposure, # noqa: F401 + dose_era, # noqa: F401 + drug_era, # noqa: F401 + drug_exposure, # noqa: F401 + measurement, # noqa: F401 + observation, # noqa: F401 + observation_period, # noqa: F401 + payer_plan_period, # noqa: F401 + procedure_occurrence, # noqa: F401 + specimen, # noqa: F401 + visit_detail, # noqa: F401 + visit_occurrence, # noqa: F401 +) from .common import ( apply_end_strategy, apply_observation_window, diff --git a/circe/execution/ibis.py b/circe/execution/ibis.py index fe558dbf..3cf27571 100644 --- a/circe/execution/ibis.py +++ b/circe/execution/ibis.py @@ -9,7 +9,6 @@ from .options import ExecutionOptions, SchemaName, schema_to_str if TYPE_CHECKING: - import ibis.expr.types as ir import pandas as pd import polars as pl @@ -42,7 +41,7 @@ def build(self, expression: ExpressionInput) -> Any: self.close() return self._build_native(cohort_expression) - def to_polars(self, expression: ExpressionInput) -> "pl.DataFrame": + def to_polars(self, expression: ExpressionInput) -> pl.DataFrame: """Execute cohort expression and collect to Polars.""" table = self.build(expression) if not hasattr(table, "to_polars"): @@ -51,7 +50,7 @@ def to_polars(self, expression: ExpressionInput) -> "pl.DataFrame": ) return table.to_polars() - def to_pandas(self, expression: ExpressionInput) -> "pd.DataFrame": + def to_pandas(self, expression: ExpressionInput) -> pd.DataFrame: """Execute cohort expression and collect to pandas.""" table = self.build(expression) if not hasattr(table, "to_pandas"): @@ -107,7 +106,7 @@ def close(self) -> None: except Exception as exc: print(f"Warning: failed to close execution context: {exc}") - def __enter__(self) -> "IbisExecutor": + def __enter__(self) -> IbisExecutor: return self def __exit__(self, exc_type, exc, tb) -> None: @@ -192,7 +191,7 @@ def to_polars( expression: ExpressionInput, conn: Any, options: Optional[ExecutionOptions] = None, -) -> "pl.DataFrame": +) -> pl.DataFrame: """Convenience wrapper for IbisExecutor.to_polars().""" with IbisExecutor(conn, options) as executor: return executor.to_polars(expression) diff --git a/circe/vocabulary/concept.py b/circe/vocabulary/concept.py index 25ffb9a6..74e42b52 100644 --- a/circe/vocabulary/concept.py +++ b/circe/vocabulary/concept.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional +from typing import List, Optional from pydantic import AliasChoices, BaseModel, ConfigDict, Field diff --git a/circe/vocabulary/concept_set_expression_query_builder.py b/circe/vocabulary/concept_set_expression_query_builder.py index ab8bb644..2f482658 100644 --- a/circe/vocabulary/concept_set_expression_query_builder.py +++ b/circe/vocabulary/concept_set_expression_query_builder.py @@ -8,10 +8,10 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional +from typing import List from ..cohortdefinition.builders.utils import BuilderUtils -from .concept import Concept, ConceptSetExpression, ConceptSetItem +from .concept import Concept, ConceptSetExpression class ConceptSetExpressionQueryBuilder: diff --git a/cohort_definition.py b/cohort_definition.py index 9d9d46b3..ca067c40 100644 --- a/cohort_definition.py +++ b/cohort_definition.py @@ -1,5 +1,5 @@ -from circe.cohort_builder import CohortBuilder from circe.api import cohort_print_friendly +from circe.cohort_builder import CohortBuilder # Define inferred concept sets diff --git a/debug_app/app.py b/debug_app/app.py index 45d1ccb7..155bbc4b 100644 --- a/debug_app/app.py +++ b/debug_app/app.py @@ -1,8 +1,9 @@ +import json import os import sys -import json from pathlib import Path -from flask import Flask, render_template, request, jsonify + +from flask import Flask, jsonify, render_template, request # Add project root to path sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) diff --git a/debug_app/sandbox.py b/debug_app/sandbox.py index 1c8314b1..f8ccba8b 100644 --- a/debug_app/sandbox.py +++ b/debug_app/sandbox.py @@ -6,7 +6,7 @@ """ import re -from typing import Dict, Any +from typing import Any, Dict def validate_imports(code: str) -> tuple[bool, str]: @@ -103,10 +103,11 @@ def execute_cohort_code(code: str) -> Dict[str, Any]: cohort_expression = local_scope['cohort'] # Import circe modules for processing (safe to do here) + import json + from circe.api import build_cohort_query, cohort_print_friendly from circe.cohortdefinition import BuildExpressionQueryOptions from circe.cohortdefinition.code_generator import to_python_code - import json # Generate outputs options = BuildExpressionQueryOptions() diff --git a/debug_app/utils.py b/debug_app/utils.py index edccafaa..915a6a86 100644 --- a/debug_app/utils.py +++ b/debug_app/utils.py @@ -1,16 +1,20 @@ -import re import os +import re import sys from pathlib import Path -from typing import Optional, Tuple, Any # Ensure we can import circe sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from circe.api import cohort_expression_from_json, build_cohort_query, cohort_print_friendly +from circe.api import ( + build_cohort_query, + cohort_expression_from_json, + cohort_print_friendly, +) from circe.cohortdefinition import BuildExpressionQueryOptions from circe.cohortdefinition.code_generator import to_python_code + def normalize_sql(sql: str) -> str: """ Normalize SQL for comparison - removes ALL formatting differences. @@ -49,7 +53,7 @@ def normalize_sql(sql: str) -> str: for kw in keywords: # Look for keyword preceded by space # We replace " keyword" with "\nkeyword" - sql = re.sub(f'\\s({kw})\\s', f'\n\\1 ', sql) + sql = re.sub(f'\\s({kw})\\s', '\n\\1 ', sql) # Consistency for SQL tokens sql = re.sub(r'\s*([(),=<>!]+)\s*', r'\1', sql) @@ -201,11 +205,11 @@ def generate_reference_with_r(json_content: str) -> dict: ref_md = "" if os.path.exists(sql_path): - with open(sql_path, 'r') as f: + with open(sql_path) as f: ref_sql = f.read() if os.path.exists(md_path): - with open(md_path, 'r') as f: + with open(md_path) as f: ref_md = f.read() return { @@ -236,10 +240,9 @@ def get_ai_explanation(ref_content: str, gen_content: str, type_label: str = "SQ """ Uses Google GenAI to explain the differences between reference and generated content. """ - import os import hashlib import json - from pathlib import Path + import os # 1. Construct Prompt FIRST (so we can hash it) prompt = f""" @@ -273,7 +276,7 @@ def get_ai_explanation(ref_content: str, gen_content: str, type_label: str = "SQ if cache_file.exists(): print(f"Cache hit for {prompt_hash}") - with open(cache_file, 'r') as f: + with open(cache_file) as f: cached_data = json.load(f) return {"explanation": cached_data['explanation'], "error": None} except Exception as e: diff --git a/examples/basic_cohort.py b/examples/basic_cohort.py index 3b7339b2..113a6371 100644 --- a/examples/basic_cohort.py +++ b/examples/basic_cohort.py @@ -6,11 +6,13 @@ """ from circe import CohortExpression -from circe.cohortdefinition import PrimaryCriteria, ConditionOccurrence -from circe.cohortdefinition.core import ObservationFilter, ResultLimit -from circe.cohortdefinition.cohort_expression_query_builder import BuildExpressionQueryOptions -from circe.vocabulary import ConceptSet, ConceptSetExpression, ConceptSetItem, Concept from circe.api import build_cohort_query +from circe.cohortdefinition import ConditionOccurrence, PrimaryCriteria +from circe.cohortdefinition.cohort_expression_query_builder import ( + BuildExpressionQueryOptions, +) +from circe.cohortdefinition.core import ObservationFilter, ResultLimit +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem def create_diabetes_cohort(): @@ -96,7 +98,7 @@ def generate_sql_from_cohort(cohort): sql = generate_sql_from_cohort(cohort) # Display first 500 characters of SQL - print(f"\nGenerated SQL (first 500 chars):") + print("\nGenerated SQL (first 500 chars):") print(sql[:500]) print("...") diff --git a/examples/complex_cohort.py b/examples/complex_cohort.py index 2065ba89..0c367b34 100644 --- a/examples/complex_cohort.py +++ b/examples/complex_cohort.py @@ -9,18 +9,28 @@ """ from circe import CohortExpression +from circe.api import build_cohort_query from circe.cohortdefinition import ( - PrimaryCriteria, ConditionOccurrence, DrugExposure, - CorelatedCriteria, CriteriaGroup, DemographicCriteria, Occurrence, - InclusionRule, Measurement + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + DrugExposure, + InclusionRule, + Measurement, + Occurrence, + PrimaryCriteria, +) +from circe.cohortdefinition.cohort_expression_query_builder import ( + BuildExpressionQueryOptions, ) from circe.cohortdefinition.core import ( - ObservationFilter, ResultLimit, Window, WindowBound, - Period, DateRange, NumericRange + NumericRange, + ObservationFilter, + ResultLimit, + Window, + WindowBound, ) -from circe.cohortdefinition.cohort_expression_query_builder import BuildExpressionQueryOptions -from circe.vocabulary import ConceptSet, ConceptSetExpression, ConceptSetItem, Concept -from circe.api import build_cohort_query +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem def create_complex_cohort(): diff --git a/examples/generate_sql.py b/examples/generate_sql.py index 73cc29e2..71dc105d 100644 --- a/examples/generate_sql.py +++ b/examples/generate_sql.py @@ -6,16 +6,17 @@ import json from pathlib import Path -from circe import cohort_expression_from_json, build_cohort_query + +from circe import build_cohort_query, cohort_expression_from_json from circe.cohortdefinition.cohort_expression_query_builder import ( + BuildExpressionQueryOptions, CohortExpressionQueryBuilder, - BuildExpressionQueryOptions ) def load_cohort_from_json_file(file_path): """Load a cohort expression from a JSON file.""" - with open(file_path, 'r') as f: + with open(file_path) as f: json_data = f.read() # Use the API function to parse JSON diff --git a/examples/json_to_code_demo.ipynb b/examples/json_to_code_demo.ipynb index ded2fa1e..d9a47878 100644 --- a/examples/json_to_code_demo.ipynb +++ b/examples/json_to_code_demo.ipynb @@ -13,25 +13,26 @@ }, { "cell_type": "code", + "execution_count": 2, "metadata": { + "ExecuteTime": { + "end_time": "2026-01-14T21:10:17.121398Z", + "start_time": "2026-01-14T21:10:16.876462Z" + }, "execution": { "iopub.execute_input": "2026-01-14T21:07:31.721945Z", "iopub.status.busy": "2026-01-14T21:07:31.721877Z", "iopub.status.idle": "2026-01-14T21:07:31.873217Z", "shell.execute_reply": "2026-01-14T21:07:31.872796Z" - }, - "ExecuteTime": { - "end_time": "2026-01-14T21:10:17.121398Z", - "start_time": "2026-01-14T21:10:16.876462Z" } }, + "outputs": [], "source": [ "import json\n", - "from circe.cohortdefinition.cohort import CohortExpression\n", - "from circe.cohortdefinition.code_generator import to_python_code, save_to_file" - ], - "outputs": [], - "execution_count": 2 + "\n", + "from circe.cohortdefinition.code_generator import save_to_file, to_python_code\n", + "from circe.cohortdefinition.cohort import CohortExpression" + ] }, { "cell_type": "markdown", @@ -43,28 +44,19 @@ }, { "cell_type": "code", + "execution_count": 3, "metadata": { + "ExecuteTime": { + "end_time": "2026-01-14T21:10:17.163184Z", + "start_time": "2026-01-14T21:10:17.152092Z" + }, "execution": { "iopub.execute_input": "2026-01-14T21:07:31.874943Z", "iopub.status.busy": "2026-01-14T21:07:31.874838Z", "iopub.status.idle": "2026-01-14T21:07:31.880198Z", "shell.execute_reply": "2026-01-14T21:07:31.879716Z" - }, - "ExecuteTime": { - "end_time": "2026-01-14T21:10:17.163184Z", - "start_time": "2026-01-14T21:10:17.152092Z" } }, - "source": [ - "with open('type2_diabetes_cohort.json', 'r') as f:\n", - " data = json.load(f)\n", - "\n", - "# Create the CohortExpression object\n", - "original_cohort = CohortExpression.model_validate(data)\n", - "\n", - "print(f\"Loaded Cohort: {original_cohort.title}\")\n", - "print(f\"Original Checksum: {original_cohort.checksum()}\")" - ], "outputs": [ { "name": "stdout", @@ -75,7 +67,16 @@ ] } ], - "execution_count": 3 + "source": [ + "with open('type2_diabetes_cohort.json') as f:\n", + " data = json.load(f)\n", + "\n", + "# Create the CohortExpression object\n", + "original_cohort = CohortExpression.model_validate(data)\n", + "\n", + "print(f\"Loaded Cohort: {original_cohort.title}\")\n", + "print(f\"Original Checksum: {original_cohort.checksum()}\")" + ] }, { "cell_type": "markdown", @@ -87,24 +88,19 @@ }, { "cell_type": "code", + "execution_count": 4, "metadata": { + "ExecuteTime": { + "end_time": "2026-01-14T21:10:17.295797Z", + "start_time": "2026-01-14T21:10:17.289078Z" + }, "execution": { "iopub.execute_input": "2026-01-14T21:07:31.898356Z", "iopub.status.busy": "2026-01-14T21:07:31.898196Z", "iopub.status.idle": "2026-01-14T21:07:31.900601Z", "shell.execute_reply": "2026-01-14T21:07:31.900203Z" - }, - "ExecuteTime": { - "end_time": "2026-01-14T21:10:17.295797Z", - "start_time": "2026-01-14T21:10:17.289078Z" } }, - "source": [ - "python_code = to_python_code(original_cohort)\n", - "\n", - "print(\"--- GENERATED CODE ---\")\n", - "print(python_code)" - ], "outputs": [ { "name": "stdout", @@ -149,7 +145,12 @@ ] } ], - "execution_count": 4 + "source": [ + "python_code = to_python_code(original_cohort)\n", + "\n", + "print(\"--- GENERATED CODE ---\")\n", + "print(python_code)" + ] }, { "cell_type": "markdown", @@ -161,18 +162,30 @@ }, { "cell_type": "code", + "execution_count": 5, "metadata": { + "ExecuteTime": { + "end_time": "2026-01-14T21:10:17.369781Z", + "start_time": "2026-01-14T21:10:17.366335Z" + }, "execution": { "iopub.execute_input": "2026-01-14T21:07:31.901790Z", "iopub.status.busy": "2026-01-14T21:07:31.901708Z", "iopub.status.idle": "2026-01-14T21:07:31.904034Z", "shell.execute_reply": "2026-01-14T21:07:31.903682Z" - }, - "ExecuteTime": { - "end_time": "2026-01-14T21:10:17.369781Z", - "start_time": "2026-01-14T21:10:17.366335Z" } }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Generated Cohort Title: Type 2 Diabetes Mellitus Patients\n", + "Generated Checksum: 82cfd18c8f1c01b436ecac879c79c4c47c6918ec1bc37f44ed5ac8a20a554355\n", + "SUCCESS: Checksums match perfectly!\n" + ] + } + ], "source": [ "# Execute the generated code in a local namespace\n", "exec_globals = {}\n", @@ -186,19 +199,7 @@ "# Compare\n", "assert original_cohort.checksum() == generated_cohort.checksum()\n", "print(\"SUCCESS: Checksums match perfectly!\")" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Generated Cohort Title: Type 2 Diabetes Mellitus Patients\n", - "Generated Checksum: 82cfd18c8f1c01b436ecac879c79c4c47c6918ec1bc37f44ed5ac8a20a554355\n", - "SUCCESS: Checksums match perfectly!\n" - ] - } - ], - "execution_count": 5 + ] }, { "cell_type": "markdown", @@ -210,22 +211,19 @@ }, { "cell_type": "code", + "execution_count": 6, "metadata": { + "ExecuteTime": { + "end_time": "2026-01-14T21:10:17.415208Z", + "start_time": "2026-01-14T21:10:17.409454Z" + }, "execution": { "iopub.execute_input": "2026-01-14T21:07:31.905173Z", "iopub.status.busy": "2026-01-14T21:07:31.905092Z", "iopub.status.idle": "2026-01-14T21:07:31.907161Z", "shell.execute_reply": "2026-01-14T21:07:31.906883Z" - }, - "ExecuteTime": { - "end_time": "2026-01-14T21:10:17.415208Z", - "start_time": "2026-01-14T21:10:17.409454Z" } }, - "source": [ - "save_to_file(original_cohort, 'generated_cohort.py')\n", - "print(\"Saved to generated_cohort.py\")" - ], "outputs": [ { "name": "stdout", @@ -235,7 +233,10 @@ ] } ], - "execution_count": 6 + "source": [ + "save_to_file(original_cohort, 'generated_cohort.py')\n", + "print(\"Saved to generated_cohort.py\")" + ] }, { "cell_type": "markdown", @@ -247,35 +248,40 @@ }, { "cell_type": "code", + "execution_count": 7, "metadata": { + "ExecuteTime": { + "end_time": "2026-01-14T21:10:17.446828Z", + "start_time": "2026-01-14T21:10:17.444613Z" + }, "execution": { "iopub.execute_input": "2026-01-14T21:07:31.908258Z", "iopub.status.busy": "2026-01-14T21:07:31.908175Z", "iopub.status.idle": "2026-01-14T21:07:31.909687Z", "shell.execute_reply": "2026-01-14T21:07:31.909351Z" - }, - "ExecuteTime": { - "end_time": "2026-01-14T21:10:17.446828Z", - "start_time": "2026-01-14T21:10:17.444613Z" } }, + "outputs": [], "source": [ "# This command puts the code into the next cell payload\n", "get_ipython().set_next_input(python_code)" - ], - "outputs": [], - "execution_count": 7 + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "from circe.cohortdefinition.cohort import CohortExpression\n", "from circe.cohortdefinition.core import ObservationFilter, ResultLimit\n", "from circe.cohortdefinition.criteria import ConditionOccurrence, PrimaryCriteria\n", - "from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem\n", + "from circe.vocabulary.concept import (\n", + " Concept,\n", + " ConceptSet,\n", + " ConceptSetExpression,\n", + " ConceptSetItem,\n", + ")\n", "\n", "cohort = CohortExpression(\n", " concept_sets=[\n", diff --git a/examples/type2_diabetes_cohort.ipynb b/examples/type2_diabetes_cohort.ipynb index 9eb0dab9..b314b922 100644 --- a/examples/type2_diabetes_cohort.ipynb +++ b/examples/type2_diabetes_cohort.ipynb @@ -1,8 +1,8 @@ { "cells": [ { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "# Type 2 Diabetes Cohort Definition\n", "\n", @@ -21,35 +21,19 @@ ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": "## Step 1: Import Required Libraries\n" }, { + "cell_type": "code", + "execution_count": 6, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:38.930506Z", "start_time": "2026-01-15T21:26:38.901730Z" } }, - "cell_type": "code", - "source": [ - "# Core libraries\n", - "import pandas as pd\n", - "from IPython.display import display, Markdown\n", - "\n", - "\n", - "# CIRCE Python for cohort definitions\n", - "from circe.cohortdefinition import (\n", - " CohortExpression, PrimaryCriteria, ConditionOccurrence,\n", - " ObservationFilter, ResultLimit\n", - ")\n", - "from circe.vocabulary import ConceptSet, ConceptSetExpression, ConceptSetItem, Concept\n", - "from circe.api import build_cohort_query\n", - "from circe.check import Checker\n", - "\n", - "print(\"✓ All libraries imported successfully\")\n" - ], "outputs": [ { "name": "stdout", @@ -59,11 +43,28 @@ ] } ], - "execution_count": 6 + "source": [ + "# Core libraries\n", + "\n", + "\n", + "# CIRCE Python for cohort definitions\n", + "from circe.api import build_cohort_query\n", + "from circe.check import Checker\n", + "from circe.cohortdefinition import (\n", + " CohortExpression,\n", + " ConditionOccurrence,\n", + " ObservationFilter,\n", + " PrimaryCriteria,\n", + " ResultLimit,\n", + ")\n", + "from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem\n", + "\n", + "print(\"✓ All libraries imported successfully\")\n" + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Step 2: Helper Functions for ATHENA → CIRCE Conversion\n", "\n", @@ -71,8 +72,8 @@ ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Step 3: Define Type 2 Diabetes Concepts\n", "\n", @@ -80,13 +81,27 @@ ] }, { + "cell_type": "code", + "execution_count": 7, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:38.963121Z", "start_time": "2026-01-15T21:26:38.947729Z" } }, - "cell_type": "code", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Defining Type 2 Diabetes concepts...\n", + "✓ Concept set created:\n", + " ID: 1\n", + " Name: Type 2 Diabetes Mellitus\n", + " Items: 1\n" + ] + } + ], "source": [ "print(\"Defining Type 2 Diabetes concepts...\")\n", "\n", @@ -112,29 +127,15 @@ " )\n", ")\n", "\n", - "print(f\"\\u2713 Concept set created:\")\n", + "print(\"\\u2713 Concept set created:\")\n", "print(f\" ID: {t2dm_concept_set.id}\")\n", "print(f\" Name: {t2dm_concept_set.name}\")\n", "print(f\" Items: {len(t2dm_concept_set.expression.items)}\")\n" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Defining Type 2 Diabetes concepts...\n", - "✓ Concept set created:\n", - " ID: 1\n", - " Name: Type 2 Diabetes Mellitus\n", - " Items: 1\n" - ] - } - ], - "execution_count": 7 + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Step 4: Verified Concept Set\n", "\n", @@ -142,20 +143,22 @@ ] }, { + "cell_type": "code", + "execution_count": 8, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:38.982214Z", "start_time": "2026-01-15T21:26:38.980419Z" } }, - "cell_type": "code", - "source": "# Concept set created in previous step\n", "outputs": [], - "execution_count": 8 + "source": [ + "# Concept set created in previous step\n" + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Step 5: Define Primary Criteria\n", "\n", @@ -163,13 +166,26 @@ ] }, { + "cell_type": "code", + "execution_count": 9, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:39.020727Z", "start_time": "2026-01-15T21:26:38.994432Z" } }, - "cell_type": "code", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Primary criteria defined:\n", + " Criteria Type: Condition Occurrence\n", + " Codeset ID: 1\n", + " First Occurrence Only: True\n" + ] + } + ], "source": [ "# Create primary criteria: First Type 2 Diabetes diagnosis\n", "primary_criteria = PrimaryCriteria(\n", @@ -190,27 +206,14 @@ ")\n", "\n", "print(\"✓ Primary criteria defined:\")\n", - "print(f\" Criteria Type: Condition Occurrence\")\n", + "print(\" Criteria Type: Condition Occurrence\")\n", "print(f\" Codeset ID: {primary_criteria.criteria_list[0].codeset_id}\")\n", "print(f\" First Occurrence Only: {primary_criteria.criteria_list[0].first}\")\n" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "✓ Primary criteria defined:\n", - " Criteria Type: Condition Occurrence\n", - " Codeset ID: 1\n", - " First Occurrence Only: True\n" - ] - } - ], - "execution_count": 9 + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Step 6: Create Complete Cohort Expression\n", "\n", @@ -218,26 +221,14 @@ ] }, { + "cell_type": "code", + "execution_count": 10, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:39.033787Z", "start_time": "2026-01-15T21:26:39.029939Z" } }, - "cell_type": "code", - "source": [ - "# Create the cohort expression\n", - "cohort = CohortExpression(\n", - " title=\"Type 2 Diabetes Mellitus Patients\",\n", - " concept_sets=[t2dm_concept_set],\n", - " primary_criteria=primary_criteria\n", - ")\n", - "\n", - "print(\"✓ Cohort expression created:\")\n", - "print(f\" Title: {cohort.title}\")\n", - "print(f\" Number of Concept Sets: {len(cohort.concept_sets)}\")\n", - "print(f\" Primary Criteria Type: {type(primary_criteria.criteria_list[0]).__name__}\")\n" - ], "outputs": [ { "name": "stdout", @@ -250,11 +241,23 @@ ] } ], - "execution_count": 10 + "source": [ + "# Create the cohort expression\n", + "cohort = CohortExpression(\n", + " title=\"Type 2 Diabetes Mellitus Patients\",\n", + " concept_sets=[t2dm_concept_set],\n", + " primary_criteria=primary_criteria\n", + ")\n", + "\n", + "print(\"✓ Cohort expression created:\")\n", + "print(f\" Title: {cohort.title}\")\n", + "print(f\" Number of Concept Sets: {len(cohort.concept_sets)}\")\n", + "print(f\" Primary Criteria Type: {type(primary_criteria.criteria_list[0]).__name__}\")\n" + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Step 7: Validate Cohort Definition\n", "\n", @@ -262,13 +265,24 @@ ] }, { + "cell_type": "code", + "execution_count": 11, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:39.091921Z", "start_time": "2026-01-15T21:26:39.048727Z" } }, - "cell_type": "code", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "⚠️ Validation found 1 issues:\n", + " It's not specified what type of records to look for in condition occurrence at initial event\n" + ] + } + ], "source": [ "# Validate the cohort\n", "checker = Checker()\n", @@ -280,22 +294,11 @@ " print(f\"⚠️ Validation found {len(warnings)} issues:\")\n", " for warning in warnings:\n", " print(f\" {warning.to_message()}\")\n" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "⚠️ Validation found 1 issues:\n", - " It's not specified what type of records to look for in condition occurrence at initial event\n" - ] - } - ], - "execution_count": 11 + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Step 8: Generate SQL Query\n", "\n", @@ -303,32 +306,14 @@ ] }, { + "cell_type": "code", + "execution_count": 12, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:39.101384Z", "start_time": "2026-01-15T21:26:39.096478Z" } }, - "cell_type": "code", - "source": [ - "# Generate SQL with your database schema names\n", - "from circe.cohortdefinition import BuildExpressionQueryOptions\n", - "\n", - "options = BuildExpressionQueryOptions()\n", - "options.cdm_schema = \"my_cdm_schema\" # Replace with your CDM schema name\n", - "options.vocabulary_schema = \"my_vocab_schema\" # Replace with your vocabulary schema name\n", - "options.target_table = \"cohort\"\n", - "options.cohort_id = 1 # Cohort ID for the results table\n", - "\n", - "sql = build_cohort_query(cohort, options)\n", - "\n", - "print(f\"✓ SQL generated ({len(sql)} characters)\")\n", - "print(\"\\nFirst 1000 characters of SQL:\")\n", - "print(\"=\" * 80)\n", - "print(sql[:1000])\n", - "print(\"...\")\n", - "print(\"=\" * 80)\n" - ], "outputs": [ { "name": "stdout", @@ -372,11 +357,29 @@ ] } ], - "execution_count": 12 + "source": [ + "# Generate SQL with your database schema names\n", + "from circe.cohortdefinition import BuildExpressionQueryOptions\n", + "\n", + "options = BuildExpressionQueryOptions()\n", + "options.cdm_schema = \"my_cdm_schema\" # Replace with your CDM schema name\n", + "options.vocabulary_schema = \"my_vocab_schema\" # Replace with your vocabulary schema name\n", + "options.target_table = \"cohort\"\n", + "options.cohort_id = 1 # Cohort ID for the results table\n", + "\n", + "sql = build_cohort_query(cohort, options)\n", + "\n", + "print(f\"✓ SQL generated ({len(sql)} characters)\")\n", + "print(\"\\nFirst 1000 characters of SQL:\")\n", + "print(\"=\" * 80)\n", + "print(sql[:1000])\n", + "print(\"...\")\n", + "print(\"=\" * 80)\n" + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Step 9: Save Outputs\n", "\n", @@ -384,13 +387,35 @@ ] }, { + "cell_type": "code", + "execution_count": 13, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:39.125279Z", "start_time": "2026-01-15T21:26:39.117824Z" } }, - "cell_type": "code", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Cohort definition saved to: type2_diabetes_cohort.json (ATLAS-compatible)\n", + "✓ SQL query saved to: type2_diabetes_cohort.sql\n", + "\n", + "================================================================================\n", + "SUMMARY\n", + "================================================================================\n", + "Cohort Title: Type 2 Diabetes Mellitus Patients\n", + "Concept Sets: 1\n", + " - Type 2 Diabetes Mellitus (ID: 1)\n", + "Primary Criteria: First Condition Occurrence\n", + "SQL Length: 5303 characters\n", + "Validation: ⚠️ 1 warnings\n", + "================================================================================\n" + ] + } + ], "source": [ "# Save cohort definition as JSON\n", "# Note: For ATLAS compatibility, concepts should have complete metadata (see ATLAS_COMPATIBILITY.md)\n", @@ -412,43 +437,21 @@ "print(f\"Cohort Title: {cohort.title}\")\n", "print(f\"Concept Sets: {len(cohort.concept_sets)}\")\n", "print(f\" - {t2dm_concept_set.name} (ID: {t2dm_concept_set.id})\")\n", - "print(f\"Primary Criteria: First Condition Occurrence\")\n", + "print(\"Primary Criteria: First Condition Occurrence\")\n", "print(f\"SQL Length: {len(sql)} characters\")\n", "print(f\"Validation: {'✓ PASSED' if not warnings else f'⚠️ {len(warnings)} warnings'}\")\n", "print(f\"{'='*80}\")\n" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "✓ Cohort definition saved to: type2_diabetes_cohort.json (ATLAS-compatible)\n", - "✓ SQL query saved to: type2_diabetes_cohort.sql\n", - "\n", - "================================================================================\n", - "SUMMARY\n", - "================================================================================\n", - "Cohort Title: Type 2 Diabetes Mellitus Patients\n", - "Concept Sets: 1\n", - " - Type 2 Diabetes Mellitus (ID: 1)\n", - "Primary Criteria: First Condition Occurrence\n", - "SQL Length: 5303 characters\n", - "Validation: ⚠️ 1 warnings\n", - "================================================================================\n" - ] - } - ], - "execution_count": 13 + ] }, { + "cell_type": "code", + "execution_count": 14, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:39.149807Z", "start_time": "2026-01-15T21:26:39.141266Z" } }, - "cell_type": "code", - "source": "cohort_json", "outputs": [ { "data": { @@ -461,11 +464,13 @@ "output_type": "execute_result" } ], - "execution_count": 14 + "source": [ + "cohort_json" + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Bonus: Manual Concept Set Creation\n", "\n", @@ -473,13 +478,27 @@ ] }, { + "cell_type": "code", + "execution_count": 15, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:39.176372Z", "start_time": "2026-01-15T21:26:39.173070Z" } }, - "cell_type": "code", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Defining Metformin concepts...\n", + "\n", + "✓ Metformin concept set created:\n", + " ID: 2\n", + " Name: Metformin\n" + ] + } + ], "source": [ "print(\"Defining Metformin concepts...\")\n", "\n", @@ -504,28 +523,14 @@ " )\n", ")\n", "\n", - "print(f\"\\n\\u2713 Metformin concept set created:\")\n", + "print(\"\\n\\u2713 Metformin concept set created:\")\n", "print(f\" ID: {metformin_concept_set.id}\")\n", "print(f\" Name: {metformin_concept_set.name}\")\n" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Defining Metformin concepts...\n", - "\n", - "✓ Metformin concept set created:\n", - " ID: 2\n", - " Name: Metformin\n" - ] - } - ], - "execution_count": 15 + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Optional: Specifying Condition Types (to suppress INFO warning)\n", "\n", @@ -533,13 +538,27 @@ ] }, { + "cell_type": "code", + "execution_count": 16, "metadata": { "ExecuteTime": { "end_time": "2026-01-15T21:26:39.191728Z", "start_time": "2026-01-15T21:26:39.186789Z" } }, - "cell_type": "code", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Specific cohort has NO warnings!\n", + "\n", + "ℹ️ Note: Using condition_type is optional. The original cohort\n", + " (without condition_type) will work perfectly fine - it just accepts\n", + " ALL condition types, which is usually what you want.\n" + ] + } + ], "source": [ "# Example: Create a more specific cohort that only accepts EHR records\n", "# (This will have zero validation warnings)\n", @@ -597,28 +616,14 @@ " for w in warnings2:\n", " print(f\" [{w.severity.name}] {w.message}\")\n", "\n", - "print(f\"\\nℹ️ Note: Using condition_type is optional. The original cohort\")\n", - "print(f\" (without condition_type) will work perfectly fine - it just accepts\")\n", - "print(f\" ALL condition types, which is usually what you want.\")\n" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "✓ Specific cohort has NO warnings!\n", - "\n", - "ℹ️ Note: Using condition_type is optional. The original cohort\n", - " (without condition_type) will work perfectly fine - it just accepts\n", - " ALL condition types, which is usually what you want.\n" - ] - } - ], - "execution_count": 16 + "print(\"\\nℹ️ Note: Using condition_type is optional. The original cohort\")\n", + "print(\" (without condition_type) will work perfectly fine - it just accepts\")\n", + "print(\" ALL condition types, which is usually what you want.\")\n" + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Summary\n", "\n", diff --git a/examples/validate_cohort.py b/examples/validate_cohort.py index 8bbaf24b..8b334e3a 100644 --- a/examples/validate_cohort.py +++ b/examples/validate_cohort.py @@ -6,6 +6,7 @@ """ import json + from circe import cohort_expression_from_json from circe.check import Checker from circe.check.warning_severity import WarningSeverity @@ -131,7 +132,7 @@ def validate_from_file(file_path): print(f"Validating cohort from: {file_path}") try: - with open(file_path, 'r') as f: + with open(file_path) as f: json_string = f.read() cohort, warnings = validate_cohort_from_json(json_string) diff --git a/pyproject.toml b/pyproject.toml index f1b1ca2b..6cce8c42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dev = [ "isort>=5.0.0", "flake8>=5.0.0", "mypy>=1.0.0", + "ruff>=0.1.0", "sqlglot>=23.0.0", "duckdb>=0.9.0", ] @@ -168,3 +169,48 @@ markers = [ "integration: marks tests as integration tests", "unit: marks tests as unit tests", ] + +[tool.ruff] +# Same as Black. +line-length = 88 +target-version = "py38" + +# Exclude directories +extend-exclude = [ + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".tox", + ".venv", + "build", + "dist", + "circe-be", +] + +[tool.ruff.lint] +# Enable pycodestyle (`E`), Pyflakes (`F`), isort (`I`), and other useful rules +select = [ + "E", # pycodestyle errors + "F", # Pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify +] +ignore = [] + +[tool.ruff.lint.isort] +known-first-party = ["circe"] + +[tool.ruff.format] +# Use double quotes for strings. +quote-style = "double" +# Indent with spaces, rather than tabs. +indent-style = "space" +# Respect magic trailing commas. +skip-magic-trailing-comma = false +# Automatically detect the appropriate line ending. +line-ending = "auto" + diff --git a/scripts/generate_skill_backup.py b/scripts/generate_skill_backup.py index 880d7f48..ee991a29 100644 --- a/scripts/generate_skill_backup.py +++ b/scripts/generate_skill_backup.py @@ -12,21 +12,21 @@ """ import inspect -from typing import get_type_hints, List, Dict, Any, Set -from dataclasses import dataclass import sys +from dataclasses import dataclass from pathlib import Path +from typing import Any, Dict, List # Add project root to path sys.path.insert(0, str(Path(__file__).parent.parent)) -from circe.cohort_builder.builder import CohortBuilder, CohortWithEntry, CohortWithCriteria +from circe.cohort_builder.builder import ( + CohortBuilder, + CohortWithCriteria, + CohortWithEntry, +) from circe.cohort_builder.query_builder import ( - BaseQuery, ConditionQuery, DrugQuery, DrugEraQuery, MeasurementQuery, - ProcedureQuery, VisitQuery, ObservationQuery, DeathQuery, - ConditionEraQuery, DeviceExposureQuery, SpecimenQuery, - ObservationPeriodQuery, PayerPlanPeriodQuery, LocationRegionQuery, - VisitDetailQuery, DoseEraQuery, CriteriaGroupBuilder + BaseQuery, ) @@ -287,7 +287,7 @@ def update_system_prompt(self, skill_content: str, prompt_path: str): try: # Read existing prompt - with open(prompt_path, 'r') as f: + with open(prompt_path) as f: prompt_content = f.read() except FileNotFoundError: print(f"⚠️ Prompt file not found: {prompt_path}") @@ -353,5 +353,5 @@ def update_system_prompt(self, skill_content: str, prompt_path: str): generator.update_system_prompt(skill_content, prompt_path) print("\n✅ All documentation updated!") - print(f" - SKILL.md") + print(" - SKILL.md") print(f" - {len(prompts)} model-specific prompts") diff --git a/tests/conftest.py b/tests/conftest.py index 21ee432b..92045c5b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ -import pytest def pytest_addoption(parser): parser.addoption( diff --git a/tests/test_builder_utils_coverage.py b/tests/test_builder_utils_coverage.py index 6b09457e..1edcfd5c 100644 --- a/tests/test_builder_utils_coverage.py +++ b/tests/test_builder_utils_coverage.py @@ -2,9 +2,12 @@ Additional tests to increase coverage for builder utility functions. """ -import pytest -from circe.cohortdefinition.builders.utils import BuilderUtils, CriteriaColumn, BuilderOptions -from circe.cohortdefinition.core import NumericRange, DateRange, DateAdjustment +from circe.cohortdefinition.builders.utils import ( + BuilderOptions, + BuilderUtils, + CriteriaColumn, +) +from circe.cohortdefinition.core import DateAdjustment, DateRange, NumericRange from circe.vocabulary.concept import Concept @@ -73,19 +76,19 @@ def test_date_range_simple(self): """Test simple date range.""" range_val = DateRange(op="gt", value="2020-01-01") clause = BuilderUtils.build_date_range_clause("start_date", range_val) - assert "start_date > DATEFROMPARTS(2020, 1, 1)" == clause + assert clause == "start_date > DATEFROMPARTS(2020, 1, 1)" def test_date_range_between(self): """Test between date range.""" range_val = DateRange(op="bt", value="2020-01-01", extent="2020-12-31") clause = BuilderUtils.build_date_range_clause("start_date", range_val) - assert "(start_date >= DATEFROMPARTS(2020, 1, 1) and start_date <= DATEFROMPARTS(2020, 12, 31))" == clause + assert clause == "(start_date >= DATEFROMPARTS(2020, 1, 1) and start_date <= DATEFROMPARTS(2020, 12, 31))" def test_date_range_not_between(self): """Test not between date range.""" range_val = DateRange(op="!bt", value="2020-01-01", extent="2020-12-31") clause = BuilderUtils.build_date_range_clause("start_date", range_val) - assert "not (start_date >= DATEFROMPARTS(2020, 1, 1) and start_date <= DATEFROMPARTS(2020, 12, 31))" == clause + assert clause == "not (start_date >= DATEFROMPARTS(2020, 1, 1) and start_date <= DATEFROMPARTS(2020, 12, 31))" def test_date_range_none(self): """Test None date range returns None.""" @@ -193,7 +196,7 @@ def test_split_in_clause_small(self): """Test split IN clause with small list.""" values = [1, 2, 3, 4, 5] result = BuilderUtils.split_in_clause("concept_id", values) - assert "(concept_id in (1,2,3,4,5))" == result + assert result == "(concept_id in (1,2,3,4,5))" def test_split_in_clause_empty(self): """Test split IN clause with empty list.""" diff --git a/tests/test_builders.py b/tests/test_builders.py index aa7e1ca5..ea81cdbf 100644 --- a/tests/test_builders.py +++ b/tests/test_builders.py @@ -5,24 +5,33 @@ and specific builder implementations. """ -import unittest -from unittest.mock import Mock, patch -from typing import List, Set, Optional -from enum import Enum +import os # Add project root to path for imports import sys -import os +import unittest +from enum import Enum +from typing import Set + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) from circe.cohortdefinition.builders import ( - BuilderUtils, BuilderOptions, CriteriaColumn, - CriteriaSqlBuilder, ConditionOccurrenceSqlBuilder, - DrugExposureSqlBuilder, ProcedureOccurrenceSqlBuilder + BuilderOptions, + BuilderUtils, + ConditionOccurrenceSqlBuilder, + CriteriaColumn, + CriteriaSqlBuilder, + DrugExposureSqlBuilder, + ProcedureOccurrenceSqlBuilder, +) +from circe.cohortdefinition.core import DateAdjustment, DateRange, NumericRange +from circe.cohortdefinition.criteria import ( + ConditionOccurrence, + Criteria, + DrugExposure, + ProcedureOccurrence, ) -from circe.cohortdefinition.criteria import Criteria, ConditionOccurrence, DrugExposure, ProcedureOccurrence from circe.vocabulary.concept import Concept -from circe.cohortdefinition.core import DateRange, DateAdjustment, NumericRange class TestCriteriaColumn(unittest.TestCase): @@ -550,9 +559,10 @@ class TestBuilderIntegration(unittest.TestCase): def test_all_builders_importable(self): """Test that all builders can be imported successfully.""" from circe.cohortdefinition.builders import ( - BuilderUtils, BuilderOptions, CriteriaColumn, - CriteriaSqlBuilder, ConditionOccurrenceSqlBuilder, - DrugExposureSqlBuilder, ProcedureOccurrenceSqlBuilder + ConditionOccurrenceSqlBuilder, + CriteriaSqlBuilder, + DrugExposureSqlBuilder, + ProcedureOccurrenceSqlBuilder, ) # Test that all classes are importable @@ -562,7 +572,11 @@ def test_all_builders_importable(self): def test_builder_options_with_all_builders(self): """Test that builder options work with all builders.""" - from circe.cohortdefinition.criteria import ConditionOccurrence, DrugExposure, ProcedureOccurrence + from circe.cohortdefinition.criteria import ( + ConditionOccurrence, + DrugExposure, + ProcedureOccurrence, + ) builders_and_criteria = [ (ConditionOccurrenceSqlBuilder(), ConditionOccurrence()), @@ -598,7 +612,11 @@ def test_criteria_column_consistency_across_builders(self): def test_sql_template_structure_consistency(self): """Test that all builders generate SQL with consistent structure.""" - from circe.cohortdefinition.criteria import ConditionOccurrence, DrugExposure, ProcedureOccurrence + from circe.cohortdefinition.criteria import ( + ConditionOccurrence, + DrugExposure, + ProcedureOccurrence, + ) builders_and_criteria = [ (ConditionOccurrenceSqlBuilder(), ConditionOccurrence()), diff --git a/tests/test_builders_sql.py b/tests/test_builders_sql.py index 3aebc304..bf6d42c8 100644 --- a/tests/test_builders_sql.py +++ b/tests/test_builders_sql.py @@ -1,10 +1,8 @@ -import re -import pytest -from circe.cohortdefinition import CohortExpression, CriteriaGroup, PrimaryCriteria, DrugExposure, DeviceExposure -from circe.cohortdefinition.builders.drug_exposure import DrugExposureSqlBuilder +from circe.cohortdefinition import DeviceExposure, DrugExposure from circe.cohortdefinition.builders.device_exposure import DeviceExposureSqlBuilder -from circe.cohortdefinition.builders.utils import BuilderOptions, CriteriaColumn -from circe.cohortdefinition.cohort_expression_query_builder import CohortExpressionQueryBuilder +from circe.cohortdefinition.builders.drug_exposure import DrugExposureSqlBuilder +from circe.cohortdefinition.builders.utils import BuilderOptions + def normalize_sql(sql): return " ".join(sql.split()).lower() diff --git a/tests/test_checkers.py b/tests/test_checkers.py index 89e5f6fc..c7ed705c 100644 --- a/tests/test_checkers.py +++ b/tests/test_checkers.py @@ -6,45 +6,47 @@ """ import json -import os -import pytest from pathlib import Path -from typing import List -from circe.cohortdefinition import CohortExpression -from circe.cohortdefinition.core import CustomEraStrategy, DateOffsetStrategy, DateType -from circe.cohortdefinition.criteria import PrimaryCriteria, CriteriaGroup -from circe.cohortdefinition.criteria import ConditionOccurrence, Occurrence, CorelatedCriteria, InclusionRule +import pytest + from circe.check import Checker from circe.check.checkers import ( - UnusedConceptsCheck, + ConceptSetCriteriaCheck, + CriteriaContradictionsCheck, + DeathTimeWindowCheck, + DomainTypeCheck, + DrugEraCheck, + DuplicatesConceptSetCheck, + DuplicatesCriteriaCheck, + EmptyConceptSetCheck, + EventsProgressionCheck, ExitCriteriaCheck, ExitCriteriaDaysOffsetCheck, - RangeCheck, - ConceptCheck, - ConceptSetSelectionCheck, - AttributeCheck, - TextCheck, IncompleteRuleCheck, InitialEventCheck, NoExitCriteriaCheck, - ConceptSetCriteriaCheck, - DrugEraCheck, OcurrenceCheck, - DuplicatesCriteriaCheck, - DuplicatesConceptSetCheck, - DrugDomainCheck, - EmptyConceptSetCheck, - EventsProgressionCheck, - TimeWindowCheck, + RangeCheck, TimePatternCheck, - DomainTypeCheck, - CriteriaContradictionsCheck, - DeathTimeWindowCheck, + UnusedConceptsCheck, ) from circe.check.warning import Warning -from circe.check.warnings import ConceptSetWarning, IncompleteRuleWarning, DefaultWarning from circe.check.warning_severity import WarningSeverity +from circe.check.warnings import ( + ConceptSetWarning, + DefaultWarning, + IncompleteRuleWarning, +) +from circe.cohortdefinition import CohortExpression +from circe.cohortdefinition.core import CustomEraStrategy, DateOffsetStrategy, DateType +from circe.cohortdefinition.criteria import ( + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + InclusionRule, + Occurrence, +) def get_resource_path(relative_path: str) -> Path: @@ -81,7 +83,7 @@ def load_cohort_expression(resource_path: str) -> CohortExpression: A CohortExpression instance """ file_path = get_resource_path(resource_path) - with open(file_path, 'r') as f: + with open(file_path) as f: data = json.load(f) # Normalize field names - Java JSON sometimes uses different capitalization @@ -632,8 +634,12 @@ class TestRangeCheck: def test_check_negative_window_days(self): """Test that negative window days trigger warnings.""" - from circe.cohortdefinition.criteria import CriteriaGroup, CorelatedCriteria, ConditionOccurrence - from circe.cohortdefinition.core import Window, WindowBound + from circe.cohortdefinition.core import Window, WindowBound + from circe.cohortdefinition.criteria import ( + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + ) # Windows are valid on CorelatedCriteria (in Inclusion Rules), not PrimaryCriteria events expression = CohortExpression( @@ -992,8 +998,6 @@ def test_check_missing_domain_types(self): def test_check_valid_domain_types(self): """Test that valid domain types produce no warnings.""" - from circe.cohortdefinition.criteria import ConditionOccurrence, Death, DeviceExposure - from circe.vocabulary import Concept expression = CohortExpression( primary_criteria={ @@ -1069,10 +1073,10 @@ def test_start_is_greater_than_end_numeric(self): def test_start_is_greater_than_end_date(self): """Test date range comparison.""" + from datetime import date, timedelta + from circe.check.checkers.comparisons import Comparisons from circe.cohortdefinition.core import DateRange - - from datetime import date, timedelta today = date.today() yesterday = today - timedelta(days=1) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4364962f..8af9109a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,15 +5,16 @@ and compare the generated SQL and Markdown outputs. """ -import sys import functools -from pathlib import Path -import pytest -import tempfile import shutil -from unittest.mock import patch +import sys +import tempfile +from contextlib import redirect_stderr, redirect_stdout from io import StringIO -from contextlib import redirect_stdout, redirect_stderr +from pathlib import Path +from unittest.mock import patch + +import pytest from circe.cli import main @@ -76,7 +77,7 @@ def test_sql_generation_matches_r(cohort_name): cohort_file = COHORTS_DIR / cohort_name if shutil.which("Rscript") is None: - pytest.skip(f"R not available") + pytest.skip("R not available") if not cohort_file.exists(): pytest.skip(f"Cohort file not found: {cohort_file}") diff --git a/tests/test_code_generator.py b/tests/test_code_generator.py index 49f5ee8b..9c03fd2d 100644 --- a/tests/test_code_generator.py +++ b/tests/test_code_generator.py @@ -1,12 +1,13 @@ -import pytest import json -from circe.cohortdefinition.cohort import CohortExpression + from circe.cohortdefinition.code_generator import to_python_code +from circe.cohortdefinition.cohort import CohortExpression + def test_code_generation_type2_diabetes(): """Test that generated code for Type 2 Diabetes cohort recreates the object correctly.""" - with open('examples/type2_diabetes_cohort.json', 'r') as f: + with open('examples/type2_diabetes_cohort.json') as f: data = json.load(f) original_cohort = CohortExpression.model_validate(data) @@ -20,7 +21,7 @@ def test_code_generation_type2_diabetes(): def test_checksum_stability(): """Test that checksums are stable for identical objects.""" - with open('examples/type2_diabetes_cohort.json', 'r') as f: + with open('examples/type2_diabetes_cohort.json') as f: data = json.load(f) c1 = CohortExpression.model_validate(data) @@ -30,7 +31,7 @@ def test_checksum_stability(): def test_checksum_diff(): """Test that checksums differ for modified objects.""" - with open('examples/type2_diabetes_cohort.json', 'r') as f: + with open('examples/type2_diabetes_cohort.json') as f: data = json.load(f) c1 = CohortExpression.model_validate(data) diff --git a/tests/test_cohort_expression.py b/tests/test_cohort_expression.py index cc703b67..b1f84585 100644 --- a/tests/test_cohort_expression.py +++ b/tests/test_cohort_expression.py @@ -5,20 +5,22 @@ initialization, validation, and utility methods. """ -import unittest -from typing import List, Optional, Any -import sys import os +import sys +import unittest # Add project root to path for imports sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) from circe.cohortdefinition.cohort import CohortExpression from circe.cohortdefinition.core import ( - ResultLimit, Period, CollapseSettings, EndStrategy, DateOffsetStrategy, CustomEraStrategy, - ObservationFilter, CollapseType, DateType + CollapseSettings, + CollapseType, + EndStrategy, + Period, + ResultLimit, ) -from circe.cohortdefinition.criteria import Criteria, PrimaryCriteria, CriteriaGroup +from circe.cohortdefinition.criteria import CriteriaGroup, PrimaryCriteria from circe.vocabulary.concept import ConceptSet diff --git a/tests/test_cohort_expression_query_builder_coverage.py b/tests/test_cohort_expression_query_builder_coverage.py index b0553cc4..99c2093d 100644 --- a/tests/test_cohort_expression_query_builder_coverage.py +++ b/tests/test_cohort_expression_query_builder_coverage.py @@ -1,13 +1,21 @@ import unittest + from circe.cohortdefinition import ( - CohortExpression, CohortExpressionQueryBuilder, BuildExpressionQueryOptions, - PrimaryCriteria, CriteriaGroup, CorelatedCriteria, - ConditionOccurrence, Death, Observation, - ResultLimit, Period, ObservationFilter, InclusionRule, - ConceptSetSelection + BuildExpressionQueryOptions, + CohortExpression, + CohortExpressionQueryBuilder, + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + Death, + InclusionRule, + Observation, + ObservationFilter, + PrimaryCriteria, + ResultLimit, ) -from circe.vocabulary import Concept + class TestCohortExpressionQueryBuilderCoverage(unittest.TestCase): """Additional tests for valid coverage of CohortExpressionQueryBuilder.""" diff --git a/tests/test_cohort_expression_query_builder_extended.py b/tests/test_cohort_expression_query_builder_extended.py index 8b6afd5c..9a17874e 100644 --- a/tests/test_cohort_expression_query_builder_extended.py +++ b/tests/test_cohort_expression_query_builder_extended.py @@ -1,15 +1,32 @@ import unittest from unittest.mock import MagicMock, patch + from circe.cohortdefinition import CohortExpressionQueryBuilder from circe.cohortdefinition.criteria import ( - WindowedCriteria, CorelatedCriteria, Occurrence, CriteriaGroup, - Window, ConditionOccurrence, Death, VisitOccurrence, VisitDetail, - PayerPlanPeriod, ProcedureOccurrence, DrugExposure, DrugEra, - ConditionEra, DoseEra, Measurement, Observation, DeviceExposure, - Specimen, LocationRegion, ObservationPeriod + ConditionEra, + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + Occurrence, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, + Window, + WindowedCriteria, ) -from circe.cohortdefinition.builders.utils import BuilderOptions, CriteriaColumn + class TestCohortExpressionQueryBuilderExtended(unittest.TestCase): diff --git a/tests/test_cohort_modifiers.py b/tests/test_cohort_modifiers.py index 6344084b..7218dfa0 100644 --- a/tests/test_cohort_modifiers.py +++ b/tests/test_cohort_modifiers.py @@ -5,10 +5,11 @@ """ import json -import pytest from datetime import date from pathlib import Path +import pytest + from circe.cohortdefinition import ( CohortExpression, Death, @@ -16,40 +17,39 @@ ) from circe.cohortdefinition.core import ( CollapseType, - DateOffsetStrategy, CustomEraStrategy, + DateOffsetStrategy, ) from circe.helper.cohort_modifiers import ( + GENDER_FEMALE_CONCEPT_ID, # Constants GENDER_MALE_CONCEPT_ID, - GENDER_FEMALE_CONCEPT_ID, - # Modifiers - set_prior_observation, - set_post_observation, - set_limit_to_first_event, - set_allow_all_events, - set_cohort_era, - set_age_criteria, - set_gender_criteria, - set_end_date_strategy, - set_washout_period, - set_clean_window, - set_date_range, - set_censor_event, + # Convenience + apply_standard_rules, clear_censor_events, - # Resets - reset_observation_window, reset_age_criteria, - reset_gender_criteria, - reset_end_strategy, - reset_collapse_settings, reset_clean_window, + reset_collapse_settings, reset_date_range, - # Convenience - apply_standard_rules, + reset_end_strategy, + reset_gender_criteria, + # Resets + reset_observation_window, + set_age_criteria, + set_allow_all_events, + set_censor_event, + set_clean_window, + set_cohort_era, + set_date_range, + set_end_date_strategy, + set_gender_criteria, + set_limit_to_first_event, + set_post_observation, + # Modifiers + set_prior_observation, + set_washout_period, ) - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- diff --git a/tests/test_comparisons_coverage.py b/tests/test_comparisons_coverage.py index b8709418..b1c3ff9a 100644 --- a/tests/test_comparisons_coverage.py +++ b/tests/test_comparisons_coverage.py @@ -1,14 +1,32 @@ import unittest -from unittest.mock import Mock, patch + from circe.check.checkers.comparisons import Comparisons -from circe.cohortdefinition.core import NumericRange, DateRange, Period, ObservationFilter, Window, WindowBound +from circe.cohortdefinition.core import ( + DateRange, + NumericRange, + ObservationFilter, + Period, + Window, + WindowBound, +) from circe.cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, DoseEra, - DrugEra, DrugExposure, Measurement, Observation, ProcedureOccurrence, - Specimen, VisitOccurrence, VisitDetail + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, ) + class TestComparisonsCoverage(unittest.TestCase): # --- start_is_greater_than_end --- @@ -142,7 +160,12 @@ def test_is_before_false_end_after(self): # --- compare_concept_set --- def test_compare_concept_set(self): - from circe.vocabulary.concept import ConceptSet, Concept, ConceptSetExpression, ConceptSetItem + from circe.vocabulary.concept import ( + Concept, + ConceptSet, + ConceptSetExpression, + ConceptSetItem, + ) c1 = Concept(concept_code="A", domain_id="D", vocabulary_id="V", concept_id=1, concept_name="N", standard_concept="S", invalid_reason="I", concept_class_id="C") c2 = Concept(concept_code="A", domain_id="D", vocabulary_id="V", concept_id=1, concept_name="N", standard_concept="S", invalid_reason="I", concept_class_id="C") diff --git a/tests/test_concept_checker_factory_coverage.py b/tests/test_concept_checker_factory_coverage.py index be1a6bdf..152cb50f 100644 --- a/tests/test_concept_checker_factory_coverage.py +++ b/tests/test_concept_checker_factory_coverage.py @@ -1,15 +1,28 @@ import unittest from unittest.mock import Mock, call + from circe.check.checkers.concept_checker_factory import ConceptCheckerFactory from circe.check.constants import Constants from circe.cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, DoseEra, - DrugEra, DrugExposure, Measurement, Observation, ObservationPeriod, - ProcedureOccurrence, Specimen, VisitOccurrence, PayerPlanPeriod, - DemographicCriteria + ConditionEra, + ConditionOccurrence, + Death, + DemographicCriteria, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, ) + class TestConceptCheckerFactoryCoverage(unittest.TestCase): def setUp(self): self.reporter = Mock() diff --git a/tests/test_concept_set_expression_query_builder.py b/tests/test_concept_set_expression_query_builder.py index 5920cf56..d77d6535 100644 --- a/tests/test_concept_set_expression_query_builder.py +++ b/tests/test_concept_set_expression_query_builder.py @@ -1,8 +1,10 @@ import unittest -from unittest.mock import MagicMock -from circe.vocabulary.concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder -from circe.vocabulary.concept import Concept -from circe.vocabulary.concept import ConceptSetExpression, ConceptSetItem + +from circe.vocabulary.concept import Concept, ConceptSetExpression, ConceptSetItem +from circe.vocabulary.concept_set_expression_query_builder import ( + ConceptSetExpressionQueryBuilder, +) + class TestConceptSetExpressionQueryBuilder(unittest.TestCase): diff --git a/tests/test_condition_occurrence_sql_builder.py b/tests/test_condition_occurrence_sql_builder.py index cbbfae3b..007e2556 100644 --- a/tests/test_condition_occurrence_sql_builder.py +++ b/tests/test_condition_occurrence_sql_builder.py @@ -5,25 +5,28 @@ with comprehensive coverage of all methods and edge cases. """ -import unittest -from unittest.mock import Mock, patch -from typing import List, Set, Optional +import os # Add project root to path for imports import sys -import os +import unittest + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) from circe.cohortdefinition.builders import ( - BuilderUtils, BuilderOptions, CriteriaColumn, - ConditionOccurrenceSqlBuilder + BuilderOptions, + ConditionOccurrenceSqlBuilder, + CriteriaColumn, ) -from circe.cohortdefinition.criteria import ConditionOccurrence -from circe.vocabulary.concept import Concept from circe.cohortdefinition.core import ( - DateRange, DateAdjustment, NumericRange, TextFilter, - ConceptSetSelection, DateType + ConceptSetSelection, + DateAdjustment, + DateRange, + NumericRange, + TextFilter, ) +from circe.cohortdefinition.criteria import ConditionOccurrence +from circe.vocabulary.concept import Concept class TestConditionOccurrenceSqlBuilder(unittest.TestCase): diff --git a/tests/test_criteria_classes.py b/tests/test_criteria_classes.py index 04b5975f..e02c7023 100644 --- a/tests/test_criteria_classes.py +++ b/tests/test_criteria_classes.py @@ -5,24 +5,37 @@ that were recently implemented. """ -import unittest -import sys import os -from typing import List, Optional +import sys +import unittest # Add the project root to the Python path sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) -from circe.cohortdefinition.criteria import ( - ConditionOccurrence, DrugExposure, ProcedureOccurrence, VisitOccurrence, - Observation, Measurement, DeviceExposure, Specimen, Death, VisitDetail, - ObservationPeriod, PayerPlanPeriod, LocationRegion, ConditionEra, - DrugEra, DoseEra, GeoCriteria, WindowedCriteria -) from circe.cohortdefinition.core import ( - TextFilter, WindowBound, Window, - DateOffsetStrategy, CustomEraStrategy, DateRange, NumericRange, - ConceptSetSelection + ConceptSetSelection, + DateRange, + NumericRange, + TextFilter, +) +from circe.cohortdefinition.criteria import ( + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + GeoCriteria, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, ) from circe.vocabulary.concept import Concept diff --git a/tests/test_date_adjustment_parity.py b/tests/test_date_adjustment_parity.py index b5a4b989..8c7b2a15 100644 --- a/tests/test_date_adjustment_parity.py +++ b/tests/test_date_adjustment_parity.py @@ -1,13 +1,22 @@ -import pytest -from circe.cohortdefinition import ConditionEra, DrugEra, ConditionOccurrence, DrugExposure, DoseEra, DateAdjustment +from circe.cohortdefinition import ( + ConditionEra, + ConditionOccurrence, + DateAdjustment, + DoseEra, + DrugEra, + DrugExposure, +) from circe.cohortdefinition.builders.condition_era import ConditionEraSqlBuilder +from circe.cohortdefinition.builders.condition_occurrence import ( + ConditionOccurrenceSqlBuilder, +) +from circe.cohortdefinition.builders.dose_era import DoseEraSqlBuilder from circe.cohortdefinition.builders.drug_era import DrugEraSqlBuilder -from circe.cohortdefinition.builders.condition_occurrence import ConditionOccurrenceSqlBuilder from circe.cohortdefinition.builders.drug_exposure import DrugExposureSqlBuilder -from circe.cohortdefinition.builders.dose_era import DoseEraSqlBuilder from tests.test_utils_db import DuckDBTestHelper + class TestDateAdjustmentParity: @classmethod diff --git a/tests/test_device_exposure_sql.py b/tests/test_device_exposure_sql.py index 4e57954d..2809ecbe 100644 --- a/tests/test_device_exposure_sql.py +++ b/tests/test_device_exposure_sql.py @@ -1,10 +1,12 @@ import unittest + from circe.cohortdefinition.builders.device_exposure import DeviceExposureSqlBuilder -from circe.cohortdefinition.criteria import DeviceExposure -from circe.cohortdefinition.core import DateRange, NumericRange from circe.cohortdefinition.builders.utils import BuilderOptions +from circe.cohortdefinition.core import DateRange, NumericRange +from circe.cohortdefinition.criteria import DeviceExposure from circe.vocabulary.concept import Concept + class TestDeviceExposureSql(unittest.TestCase): def test_basic_device_exposure(self): diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 9df6b083..6bcdba25 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -7,6 +7,7 @@ import re from pathlib import Path + try: import tomllib except ModuleNotFoundError: # Python <3.11 fallback diff --git a/tests/test_drug_era_sql_builder.py b/tests/test_drug_era_sql_builder.py index a019695e..865d6618 100644 --- a/tests/test_drug_era_sql_builder.py +++ b/tests/test_drug_era_sql_builder.py @@ -5,12 +5,18 @@ ensuring 100% test coverage and functionality matching the Java implementation. """ + import pytest -from typing import List, Optional + from circe.cohortdefinition.builders.drug_era import DrugEraSqlBuilder -from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderOptions +from circe.cohortdefinition.builders.utils import BuilderOptions, CriteriaColumn +from circe.cohortdefinition.core import ( + ConceptSetSelection, + DateAdjustment, + DateRange, + NumericRange, +) from circe.cohortdefinition.criteria import DrugEra -from circe.cohortdefinition.core import DateRange, NumericRange, ConceptSetSelection, DateAdjustment from circe.vocabulary.concept import Concept diff --git a/tests/test_drug_exposure_builder.py b/tests/test_drug_exposure_builder.py index fde116d2..2b7ef6e9 100644 --- a/tests/test_drug_exposure_builder.py +++ b/tests/test_drug_exposure_builder.py @@ -1,11 +1,18 @@ import unittest -from unittest.mock import MagicMock + from circe.cohortdefinition.builders.drug_exposure import DrugExposureSqlBuilder -from circe.cohortdefinition.criteria import DrugExposure from circe.cohortdefinition.builders.utils import CriteriaColumn -from circe.cohortdefinition.core import DateAdjustment, TextFilter, NumericRange, ConceptSetSelection, DateRange +from circe.cohortdefinition.core import ( + ConceptSetSelection, + DateAdjustment, + DateRange, + NumericRange, + TextFilter, +) +from circe.cohortdefinition.criteria import DrugExposure from circe.vocabulary.concept import Concept + class TestDrugExposureSqlBuilder(unittest.TestCase): def setUp(self): diff --git a/tests/test_hashing.py b/tests/test_hashing.py index 110839e6..933aa6da 100644 --- a/tests/test_hashing.py +++ b/tests/test_hashing.py @@ -1,9 +1,15 @@ import unittest -import json + from circe.cohortdefinition.cohort import CohortExpression -from circe.vocabulary.concept import ConceptSet, ConceptSetExpression, ConceptSetItem, Concept from circe.cohortdefinition.criteria import PrimaryCriteria +from circe.vocabulary.concept import ( + Concept, + ConceptSet, + ConceptSetExpression, + ConceptSetItem, +) + class TestCohortHashing(unittest.TestCase): """Test suite for CohortExpression checksum stability and correctness.""" diff --git a/tests/test_java_interoperability.py b/tests/test_java_interoperability.py index 50cb0c43..3b522f6b 100644 --- a/tests/test_java_interoperability.py +++ b/tests/test_java_interoperability.py @@ -6,32 +6,24 @@ """ import json -import unittest -from typing import Dict, Any -from pathlib import Path +import os # Add project root to path for imports import sys -import os +import unittest +from pathlib import Path + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) -from circe.cohortdefinition.core import ( - ResultLimit, Period, CollapseSettings, EndStrategy, DateOffsetStrategy, - CustomEraStrategy, - ConceptSetSelection, CollapseType, DateType, TextFilter, Window, WindowBound, - DateAdjustment, ObservationFilter -) -from circe.cohortdefinition.criteria import ( - Criteria, CriteriaGroup, DemographicCriteria, InclusionRule, - ConditionOccurrence, DrugExposure, ProcedureOccurrence, VisitOccurrence, - Observation, Measurement, DeviceExposure, Specimen, Death, VisitDetail, - ObservationPeriod, PayerPlanPeriod, LocationRegion, ConditionEra, - DrugEra, DoseEra, GeoCriteria, Occurrence, CorelatedCriteria, - PrimaryCriteria -) from circe.cohortdefinition.cohort import CohortExpression -from circe.cohortdefinition.core import NumericRange -from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem +from circe.cohortdefinition.core import NumericRange, ObservationFilter, ResultLimit +from circe.cohortdefinition.criteria import ConditionOccurrence, PrimaryCriteria +from circe.vocabulary.concept import ( + Concept, + ConceptSet, + ConceptSetExpression, + ConceptSetItem, +) class TestJavaInteroperability(unittest.TestCase): @@ -239,8 +231,6 @@ def test_criteria_polymorphic_wrapper(self): def test_primary_criteria_uses_pascal_case(self): """Test PrimaryCriteria exports with PascalCase field names.""" - from circe.cohortdefinition.core import ObservationFilter, ResultLimit - from circe.cohortdefinition.criteria import PrimaryCriteria primary = PrimaryCriteria( criteria_list=[ @@ -306,7 +296,7 @@ def test_compare_with_java_json_file(self): if not java_json_path.exists(): self.skipTest(f"Java JSON file not found: {java_json_path}") - with open(java_json_path, 'r') as f: + with open(java_json_path) as f: java_data = json.load(f) # Parse with Python diff --git a/tests/test_kitchen_sink_cohort.py b/tests/test_kitchen_sink_cohort.py index 15b8be73..fc68ecdc 100644 --- a/tests/test_kitchen_sink_cohort.py +++ b/tests/test_kitchen_sink_cohort.py @@ -1,22 +1,48 @@ import unittest -import json -import logging -from typing import List from circe.cohortdefinition.cohort import CohortExpression from circe.cohortdefinition.core import ( - ResultLimit, Period, CollapseSettings, EndStrategy, DateOffsetStrategy, - CustomEraStrategy, ConceptSetSelection, CollapseType, DateType, TextFilter, - Window, WindowBound, DateAdjustment, ObservationFilter, NumericRange, DateRange + CollapseSettings, + CollapseType, + CustomEraStrategy, + DateRange, + NumericRange, + ObservationFilter, + Period, + ResultLimit, + TextFilter, + Window, + WindowBound, ) from circe.cohortdefinition.criteria import ( - Criteria, CriteriaGroup, DemographicCriteria, InclusionRule, - ConditionOccurrence, DrugExposure, ProcedureOccurrence, VisitOccurrence, - Observation, Measurement, DeviceExposure, Specimen, Death, VisitDetail, - Occurrence, CriteriaColumn, ObservationPeriod, PayerPlanPeriod, LocationRegion, - ConditionEra, DrugEra, DoseEra, CorelatedCriteria + ConditionEra, + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + Death, + DemographicCriteria, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + InclusionRule, + Measurement, + Observation, + ObservationPeriod, + Occurrence, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, ) -from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem +from circe.vocabulary.concept import ( + Concept, + ConceptSet, + ConceptSetExpression, + ConceptSetItem, +) + class TestKitchenSinkCohort(unittest.TestCase): """Test comprehensive 'Kitchen Sink' cohort definition.""" diff --git a/tests/test_markdown_render_coverage.py b/tests/test_markdown_render_coverage.py index 8a03b6ca..7c1b6958 100644 --- a/tests/test_markdown_render_coverage.py +++ b/tests/test_markdown_render_coverage.py @@ -1,9 +1,9 @@ import unittest -import json + from circe.cohortdefinition.printfriendly.markdown_render import MarkdownRender -from circe.cohortdefinition.cohort import CohortExpression from circe.vocabulary.concept import ConceptSet + class TestMarkdownRenderCoverage(unittest.TestCase): """ Tests specifically targeting edge cases and error handling in MarkdownRender diff --git a/tests/test_package_structure.py b/tests/test_package_structure.py index 33bac95e..6c4a944a 100644 --- a/tests/test_package_structure.py +++ b/tests/test_package_structure.py @@ -27,19 +27,8 @@ def test_package_metadata(self): def test_subpackage_imports(self): """Test that subpackages can be imported.""" - import circe.check - import circe.check.checkers - import circe.check.operations - import circe.check.utils - import circe.check.warnings - import circe.cohortdefinition # Test sub-subpackages - import circe.cohortdefinition.builders - import circe.cohortdefinition.printfriendly - import circe.execution - import circe.helper - import circe.vocabulary def test_package_structure(self): """Test that package structure matches expected layout.""" @@ -79,8 +68,6 @@ def test_main_class_imports(self): CohortExpression, Concept, ConceptSet, - ConceptSetExpression, - ConceptSetItem, ) # Test basic instantiation diff --git a/tests/test_print_friendly_parity.py b/tests/test_print_friendly_parity.py index 7fd7db77..aa92aa50 100644 --- a/tests/test_print_friendly_parity.py +++ b/tests/test_print_friendly_parity.py @@ -1,15 +1,16 @@ -import unittest import os -import json -from circe.cohortdefinition.printfriendly.markdown_render import MarkdownRender +import unittest + from circe.cohortdefinition.cohort import CohortExpression +from circe.cohortdefinition.printfriendly.markdown_render import MarkdownRender + # Helper to load resources def get_resource_as_string(filename): # Depending on where pytest is run from, this path might need adjustment. # Assuming running from root of repo. path = os.path.join(os.path.dirname(__file__), 'markdown_resources', filename) - with open(path, 'r') as f: + with open(path) as f: return f.read() def normalize_whitespace(text): diff --git a/tests/test_query_builders.py b/tests/test_query_builders.py index ab06c42e..cc4e53f4 100644 --- a/tests/test_query_builders.py +++ b/tests/test_query_builders.py @@ -5,22 +5,33 @@ and ConceptSetExpressionQueryBuilder classes. """ -import unittest import json -from typing import List, Optional +import unittest + from circe.cohortdefinition import ( - CohortExpression, CohortExpressionQueryBuilder, BuildExpressionQueryOptions, + BuildExpressionQueryOptions, + CohortExpression, + CohortExpressionQueryBuilder, + CollapseSettings, + CollapseType, ConceptSetExpressionQueryBuilder, - PrimaryCriteria, CriteriaGroup, CorelatedCriteria, DemographicCriteria, - ConditionOccurrence, Death, Measurement, Observation, - DateRange, NumericRange, TextFilter, ConceptSetSelection, - DateOffsetStrategy, CustomEraStrategy, Occurrence, CriteriaColumn, - CollapseSettings, CollapseType, ResultLimit, Period, ObservationFilter -) -from circe.cohortdefinition.builders.utils import BuilderOptions -from circe.vocabulary import ( - Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + ConceptSetSelection, + ConditionOccurrence, + CorelatedCriteria, + CriteriaColumn, + CriteriaGroup, + CustomEraStrategy, + DateOffsetStrategy, + Death, + DemographicCriteria, + NumericRange, + ObservationFilter, + Occurrence, + Period, + PrimaryCriteria, + ResultLimit, ) +from circe.vocabulary import Concept, ConceptSetExpression, ConceptSetItem class TestConceptSetExpressionQueryBuilder(unittest.TestCase): diff --git a/tests/test_range_checker_factory_coverage.py b/tests/test_range_checker_factory_coverage.py index 2a0aaeab..294abe48 100644 --- a/tests/test_range_checker_factory_coverage.py +++ b/tests/test_range_checker_factory_coverage.py @@ -1,17 +1,31 @@ import unittest -from typing import Optional from unittest.mock import Mock, call, patch + from circe.check.checkers.range_checker_factory import RangeCheckerFactory from circe.check.constants import Constants +from circe.cohortdefinition.cohort import CohortExpression +from circe.cohortdefinition.core import DateRange, NumericRange, Period from circe.cohortdefinition.criteria import ( - ConditionEra, ConditionOccurrence, Death, DeviceExposure, DoseEra, - DrugEra, DrugExposure, Measurement, Observation, ObservationPeriod, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail, - PayerPlanPeriod, LocationRegion, DemographicCriteria + ConditionEra, + ConditionOccurrence, + Death, + DemographicCriteria, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, ) -from circe.cohortdefinition.core import NumericRange, DateRange, Period -from circe.cohortdefinition.cohort import CohortExpression + class TestRangeCheckerFactoryCoverage(unittest.TestCase): def setUp(self): @@ -239,8 +253,9 @@ def test_check_demographic_criteria(self): def test_check_default(self): # Unhandled criteria should just return (noop) # Must inherit from Criteria to bypass BaseCheckerFactory check and reach _get_check_criteria - from circe.cohortdefinition.criteria import Criteria from pydantic import BaseModel + + from circe.cohortdefinition.criteria import Criteria # Pydantic requires forward references to be resolved. # Since 'Criteria' definition refers to 'CriteriaGroup', we need to mock it diff --git a/tests/test_real_example_cohorts.py b/tests/test_real_example_cohorts.py index c73b1964..a9c884f9 100644 --- a/tests/test_real_example_cohorts.py +++ b/tests/test_real_example_cohorts.py @@ -8,15 +8,21 @@ tests/cohorts/reference_outputs/ """ -from circe.api import cohort_expression_from_json, build_cohort_query, cohort_print_friendly -from circe.cohortdefinition import BuildExpressionQueryOptions -from pathlib import Path -from typing import Optional, Tuple, Dict -import pytest +import difflib import re -from difflib import unified_diff import textwrap -import difflib +from difflib import unified_diff +from pathlib import Path +from typing import Dict, Optional, Tuple + +import pytest + +from circe.api import ( + build_cohort_query, + cohort_expression_from_json, + cohort_print_friendly, +) +from circe.cohortdefinition import BuildExpressionQueryOptions # Test cohort files - these are the cohorts added in the recent commit # Directories @@ -26,6 +32,7 @@ # Dynamic discovery of cohort files import random + def get_target_cohort_files(config): """Discover cohort files based on configuration.""" if not COHORTS_DIR.exists(): diff --git a/tests/test_schema_compatibility.py b/tests/test_schema_compatibility.py index 87c18325..433b97c8 100644 --- a/tests/test_schema_compatibility.py +++ b/tests/test_schema_compatibility.py @@ -5,7 +5,7 @@ declared in the Java JSON Schema, serving as a 1:1 replacement for the Java version. """ import json -import pytest + from deepdiff import DeepDiff # pip install deepdiff from circe import get_json_schema @@ -70,7 +70,7 @@ def normalize_schema(schema): def test_compare_python_java_schema(): # Load Java schema - with open(JAVA_SCHEMA_PATH, "r") as f: + with open(JAVA_SCHEMA_PATH) as f: java_schema = json.load(f) # Generate Python schema from Pydantic diff --git a/tests/test_simple_sql_builders.py b/tests/test_simple_sql_builders.py index 933c7270..8875d1e3 100644 --- a/tests/test_simple_sql_builders.py +++ b/tests/test_simple_sql_builders.py @@ -6,14 +6,21 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -import pytest -from unittest.mock import Mock from circe.cohortdefinition.builders import ( - DoseEraSqlBuilder, ObservationPeriodSqlBuilder, PayerPlanPeriodSqlBuilder, - VisitDetailSqlBuilder, LocationRegionSqlBuilder, BuilderOptions, CriteriaColumn + BuilderOptions, + CriteriaColumn, + DoseEraSqlBuilder, + LocationRegionSqlBuilder, + ObservationPeriodSqlBuilder, + PayerPlanPeriodSqlBuilder, + VisitDetailSqlBuilder, ) from circe.cohortdefinition.criteria import ( - DoseEra, ObservationPeriod, PayerPlanPeriod, VisitDetail, LocationRegion + DoseEra, + LocationRegion, + ObservationPeriod, + PayerPlanPeriod, + VisitDetail, ) diff --git a/tests/test_sql_builders.py b/tests/test_sql_builders.py index 2dac879c..34c796cb 100644 --- a/tests/test_sql_builders.py +++ b/tests/test_sql_builders.py @@ -9,28 +9,46 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -import unittest -import sys import os -from typing import Set, List, Optional +import sys +import unittest from unittest.mock import Mock # Add the project root to the Python path sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from circe.cohortdefinition.builders import ( - DeathSqlBuilder, VisitOccurrenceSqlBuilder, ObservationSqlBuilder, - MeasurementSqlBuilder, DeviceExposureSqlBuilder, SpecimenSqlBuilder, - DoseEraSqlBuilder, ObservationPeriodSqlBuilder, PayerPlanPeriodSqlBuilder, - VisitDetailSqlBuilder, LocationRegionSqlBuilder + DeathSqlBuilder, + DeviceExposureSqlBuilder, + DoseEraSqlBuilder, + LocationRegionSqlBuilder, + MeasurementSqlBuilder, + ObservationPeriodSqlBuilder, + ObservationSqlBuilder, + PayerPlanPeriodSqlBuilder, + SpecimenSqlBuilder, + VisitDetailSqlBuilder, + VisitOccurrenceSqlBuilder, ) from circe.cohortdefinition.builders.utils import BuilderOptions, CriteriaColumn +from circe.cohortdefinition.core import ( + ConceptSetSelection, + DateRange, + NumericRange, + TextFilter, +) from circe.cohortdefinition.criteria import ( - Death, VisitOccurrence, Observation, Measurement, DeviceExposure, Specimen, - DoseEra, ObservationPeriod, PayerPlanPeriod, VisitDetail, LocationRegion + Death, + DeviceExposure, + DoseEra, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + Specimen, + VisitDetail, ) -from circe.cohortdefinition.core import DateRange, NumericRange, TextFilter, ConceptSetSelection -from circe.vocabulary.concept import Concept class TestDeathSqlBuilder(unittest.TestCase): @@ -1474,8 +1492,12 @@ class TestNewSqlBuildersIntegration(unittest.TestCase): def test_all_new_builders_importable(self): """Test that all new builders can be imported.""" from circe.cohortdefinition.builders import ( - DeathSqlBuilder, VisitOccurrenceSqlBuilder, ObservationSqlBuilder, - MeasurementSqlBuilder, DeviceExposureSqlBuilder, SpecimenSqlBuilder + DeathSqlBuilder, + DeviceExposureSqlBuilder, + MeasurementSqlBuilder, + ObservationSqlBuilder, + SpecimenSqlBuilder, + VisitOccurrenceSqlBuilder, ) # Test that all builders are importable diff --git a/tests/test_sql_rendering_parity.py b/tests/test_sql_rendering_parity.py index 97cc68b6..357f3135 100644 --- a/tests/test_sql_rendering_parity.py +++ b/tests/test_sql_rendering_parity.py @@ -1,28 +1,50 @@ import unittest -from circe.cohortdefinition import DrugExposure, TextFilter, ConditionOccurrence, VisitOccurrence, ProcedureOccurrence, NumericRange -from circe.vocabulary.concept import Concept + +from circe.cohortdefinition import ( + ConceptSetSelection, + ConditionEra, + ConditionOccurrence, + DateRange, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + LocationRegion, + Measurement, + NumericRange, + Observation, + ObservationPeriod, + PayerPlanPeriod, + Period, + ProcedureOccurrence, + Specimen, + TextFilter, + VisitDetail, +) +from circe.cohortdefinition.builders.condition_era import ConditionEraSqlBuilder +from circe.cohortdefinition.builders.condition_occurrence import ( + ConditionOccurrenceSqlBuilder, +) +from circe.cohortdefinition.builders.death import DeathSqlBuilder +from circe.cohortdefinition.builders.device_exposure import DeviceExposureSqlBuilder +from circe.cohortdefinition.builders.dose_era import DoseEraSqlBuilder +from circe.cohortdefinition.builders.drug_era import DrugEraSqlBuilder from circe.cohortdefinition.builders.drug_exposure import DrugExposureSqlBuilder -from circe.cohortdefinition.builders.condition_occurrence import ConditionOccurrenceSqlBuilder -from circe.cohortdefinition.builders.visit_occurrence import VisitOccurrenceSqlBuilder -from circe.cohortdefinition.builders.procedure_occurrence import ProcedureOccurrenceSqlBuilder +from circe.cohortdefinition.builders.location_region import LocationRegionSqlBuilder from circe.cohortdefinition.builders.measurement import MeasurementSqlBuilder from circe.cohortdefinition.builders.observation import ObservationSqlBuilder -from circe.cohortdefinition.builders.device_exposure import DeviceExposureSqlBuilder -from circe.cohortdefinition.builders.death import DeathSqlBuilder -from circe.cohortdefinition.builders.condition_era import ConditionEraSqlBuilder -from circe.cohortdefinition.builders.drug_era import DrugEraSqlBuilder -from circe.cohortdefinition.builders.dose_era import DoseEraSqlBuilder -from circe.cohortdefinition.builders.specimen import SpecimenSqlBuilder -from circe.cohortdefinition.builders.visit_detail import VisitDetailSqlBuilder +from circe.cohortdefinition.builders.observation_period import ( + ObservationPeriodSqlBuilder, +) from circe.cohortdefinition.builders.payer_plan_period import PayerPlanPeriodSqlBuilder -from circe.cohortdefinition.builders.observation_period import ObservationPeriodSqlBuilder -from circe.cohortdefinition.builders.location_region import LocationRegionSqlBuilder -from circe.cohortdefinition import ( - DrugExposure, TextFilter, ConditionOccurrence, VisitOccurrence, ConceptSetSelection, - ProcedureOccurrence, NumericRange, Measurement, Observation, DeviceExposure, Death, DateRange, - ConditionEra, DrugEra, DoseEra, Specimen, VisitDetail, PayerPlanPeriod, ObservationPeriod, Period, - LocationRegion +from circe.cohortdefinition.builders.procedure_occurrence import ( + ProcedureOccurrenceSqlBuilder, ) +from circe.cohortdefinition.builders.specimen import SpecimenSqlBuilder +from circe.cohortdefinition.builders.visit_detail import VisitDetailSqlBuilder +from circe.vocabulary.concept import Concept + class TestDrugExposureBuilder(unittest.TestCase): diff --git a/tests/test_supporting_classes.py b/tests/test_supporting_classes.py index 299bd89b..9c96b929 100644 --- a/tests/test_supporting_classes.py +++ b/tests/test_supporting_classes.py @@ -5,21 +5,21 @@ that were recently implemented. """ -import unittest -import sys import os -from typing import List, Optional +import sys +import unittest # Add the project root to the Python path sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from circe.cohortdefinition.core import ( - ResultLimit, Period, CollapseSettings, EndStrategy, DateOffsetStrategy, - CustomEraStrategy, DateRange, NumericRange, - ConceptSetSelection, CollapseType, DateType, TextFilter, Window, WindowBound, - DateAdjustment + CustomEraStrategy, + DateOffsetStrategy, + TextFilter, + Window, + WindowBound, ) -from circe.cohortdefinition.criteria import WindowedCriteria, ConditionOccurrence +from circe.cohortdefinition.criteria import ConditionOccurrence, WindowedCriteria class TestTextFilter(unittest.TestCase): @@ -416,8 +416,11 @@ def test_text_filter_with_criteria_integration(self): def test_all_supporting_classes_importable(self): """Test that all supporting classes can be imported.""" from circe.cohortdefinition.core import ( - TextFilter, WindowBound, Window, - DateOffsetStrategy, CustomEraStrategy + CustomEraStrategy, + DateOffsetStrategy, + TextFilter, + Window, + WindowBound, ) from circe.cohortdefinition.criteria import WindowedCriteria diff --git a/tests/test_utils_db.py b/tests/test_utils_db.py index 16952df7..993f1951 100644 --- a/tests/test_utils_db.py +++ b/tests/test_utils_db.py @@ -1,8 +1,10 @@ +from typing import Any, List + import duckdb import pytest import sqlglot -from typing import List, Dict, Any, Optional + class DuckDBTestHelper: """Helper class for running OHDSI SQL in DuckDB tests.""" diff --git a/tests/test_visit_occurrence_parity.py b/tests/test_visit_occurrence_parity.py index b6548f43..37618c4b 100644 --- a/tests/test_visit_occurrence_parity.py +++ b/tests/test_visit_occurrence_parity.py @@ -1,8 +1,10 @@ import unittest + +from circe.cohortdefinition.builders.utils import CriteriaColumn from circe.cohortdefinition.builders.visit_occurrence import VisitOccurrenceSqlBuilder -from circe.cohortdefinition.builders.utils import BuilderOptions, CriteriaColumn +from circe.cohortdefinition.core import ConceptSetSelection, DateRange, NumericRange from circe.cohortdefinition.criteria import VisitOccurrence -from circe.cohortdefinition.core import DateRange, NumericRange, ConceptSetSelection + class TestVisitOccurrenceSqlBuilderParity(unittest.TestCase): def setUp(self): From ab7228c690e5d4f2f9bcb21022d0b5079d691674 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 11:18:52 -0700 Subject: [PATCH 09/62] Removal of support for python 3.8 --- CONTRIBUTING.md | 2 +- INSTALLATION.md | 4 ++-- README.md | 4 ++-- docs/CONTRIBUTING.md | 2 +- docs/index.rst | 4 ++-- docs/installation.rst | 7 +++---- pyproject.toml | 6 +++--- requirements.txt | 15 --------------- tox.ini | 2 +- 9 files changed, 15 insertions(+), 31 deletions(-) delete mode 100644 requirements.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a463493b..a2f514cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ This project follows the [OHDSI Code of Conduct](https://www.ohdsi.org/web/wiki/ ### Prerequisites -- Python 3.8 or higher +- Python 3.9 or higher - Git - Basic understanding of the OMOP Common Data Model - Familiarity with the Java CIRCE-BE implementation (recommended) diff --git a/INSTALLATION.md b/INSTALLATION.md index 4553d5a2..2c11a7b3 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -5,7 +5,7 @@ ## Prerequisites -- **Python 3.8 or higher** (Python 3.9+ recommended) +- **Python 3.9 or higher** (Python 3.9+ recommended) - **Git** for cloning the repository - **pip** package manager (usually included with Python) @@ -209,7 +209,7 @@ pip uninstall ohdsi-circepy ## System Requirements ### Minimum Requirements -- Python 3.8+ +- Python 3.9+ - 100 MB free disk space - 512 MB RAM diff --git a/README.md b/README.md index ff131791..d16e3a0c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CIRCE Python Implementation -[![Python](https://img.shields.io/badge/python-3.8%2B-blue)](https://www.python.org/downloads/) +[![Python](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org/downloads/) [![Tests](https://img.shields.io/badge/tests-3400%2B%20passed-brightgreen)](tests/) [![codecov](https://codecov.io/gh/OHDSI/Circepy/graph/badge.svg?token=CODECOV_TOKEN)](https://codecov.io/gh/OHDSI/Circepy) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) @@ -30,7 +30,7 @@ CIRCE Python provides a comprehensive toolkit for working with OMOP CDM cohort d - **Version**: 0.1.0 (Alpha) - **Tests**: 3,400+ passing - **Coverage**: 34% (Core logic focus) -- **Python**: 3.8+ +- **Python**: 3.9+ - **License**: Apache 2.0 ## Installation diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index b8361f0c..71ce2e18 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -10,7 +10,7 @@ This project follows the [OHDSI Code of Conduct](https://www.ohdsi.org/web/wiki/ ### Prerequisites -- Python 3.8 or higher +- Python 3.9 or higher - Git - Basic understanding of the OMOP Common Data Model - Familiarity with the Java CIRCE-BE implementation (recommended) diff --git a/docs/index.rst b/docs/index.rst index 8a6e6afa..1adb319d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,9 @@ OHDSI CIRCE Python Documentation ================================== -.. image:: https://img.shields.io/badge/python-3.8%2B-blue +.. image:: https://img.shields.io/badge/python-3.9%2B-blue :target: https://www.python.org/downloads/ - :alt: Python 3.8+ + :alt: Python 3.9+ .. image:: https://img.shields.io/badge/tests-896%20passed-brightgreen :alt: Tests diff --git a/docs/installation.rst b/docs/installation.rst index dda573bc..ca5005e5 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -3,8 +3,7 @@ Installation Requirements ------------ - -* Python 3.8 or higher +* Python 3.9 or higher * pip (Python package installer) Basic Installation @@ -119,13 +118,13 @@ If you get permission errors during installation, use a virtual environment: Python Version Issues ~~~~~~~~~~~~~~~~~~~~~ -CIRCE Python requires Python 3.8 or higher. Check your Python version: +CIRCE Python requires Python 3.9 or higher. Check your Python version: .. code-block:: bash python --version -If you have multiple Python versions installed, you may need to use ``python3`` or ``python3.8``: +If you have multiple Python versions installed, you may need to use ``python3`` or ``python3.9``: .. code-block:: bash diff --git a/pyproject.toml b/pyproject.toml index 6cce8c42..5c7e847e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "pydantic>=2.0.0", "typing-extensions>=4.0.0", @@ -91,7 +91,7 @@ circe = ["py.typed"] [tool.black] line-length = 88 -target-version = ['py38'] +target-version = ['py39'] include = '\.pyi?$' extend-exclude = ''' /( @@ -173,7 +173,7 @@ markers = [ [tool.ruff] # Same as Black. line-length = 88 -target-version = "py38" +target-version = "py39" # Exclude directories extend-exclude = [ diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7a39364b..00000000 --- a/requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -# Core dependencies -pydantic>=2.0.0 -typing-extensions>=4.0.0 - -# Development dependencies -pytest>=7.0.0 -pytest-cov>=4.0.0 -black>=22.0.0 -isort>=5.0.0 -flake8>=5.0.0 -mypy>=1.0.0 - -# Optional dependencies for analysis -javalang>=0.13.0 -deepdiff>=8.6.0 \ No newline at end of file diff --git a/tox.ini b/tox.ini index ea872be1..3a504ccc 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38, py39, py310, py311, py312 +envlist = py39, py310, py311, py312 skip_missing_interpreters = true isolated_build = true From 3e4822f3b49174b0a1e2fac3edad6d722a8e5f3f Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 11:20:04 -0700 Subject: [PATCH 10/62] Ruff reformat --- circe/check/checkers/base_criteria_check.py | 1 - circe/check/checkers/comparisons.py | 16 +- .../checkers/concept_set_criteria_check.py | 179 ++- .../concept_set_selection_checker_factory.py | 4 +- .../check/checkers/death_time_window_check.py | 8 +- circe/check/checkers/domain_type_check.py | 112 +- circe/check/checkers/drug_era_check.py | 16 +- .../checkers/duplicates_criteria_check.py | 17 +- circe/check/checkers/exit_criteria_check.py | 8 +- .../exit_criteria_days_offset_check.py | 12 +- circe/check/checkers/initial_event_check.py | 8 +- circe/check/checkers/range_checker_factory.py | 74 +- circe/check/checkers/time_window_check.py | 8 +- .../cohort_expression_query_builder.py | 16 +- circe/execution/builders/common.py | 6 +- circe/execution/builders/post_processing.py | 4 +- cohort_definition.py | 8 +- debug_app/app.py | 148 +- debug_app/sandbox.py | 116 +- debug_app/utils.py | 233 +-- docs/conf.py | 97 +- examples/basic_cohort.py | 42 +- examples/complex_cohort.py | 196 ++- examples/generate_sql.py | 80 +- examples/json_to_code_demo.ipynb | 28 +- examples/type2_diabetes_cohort.ipynb | 70 +- examples/validate_cohort.py | 136 +- scripts/generate_skill_backup.py | 288 ++-- tests/conftest.py | 12 +- tests/test_builder_utils_coverage.py | 98 +- tests/test_builders.py | 357 +++-- tests/test_builders_sql.py | 43 +- tests/test_checkers.py | 667 ++++----- tests/test_cli.py | 153 +- tests/test_code_generator.py | 47 +- tests/test_cohort_expression.py | 243 ++-- ...ohort_expression_query_builder_coverage.py | 135 +- ...ohort_expression_query_builder_extended.py | 206 ++- tests/test_cohort_modifiers.py | 196 ++- tests/test_comparisons_coverage.py | 215 ++- .../test_concept_checker_factory_coverage.py | 418 ++++-- ...st_concept_set_expression_query_builder.py | 61 +- .../test_condition_occurrence_sql_builder.py | 376 +++-- tests/test_criteria_classes.py | 177 ++- tests/test_date_adjustment_parity.py | 184 ++- tests/test_device_exposure_sql.py | 62 +- tests/test_documentation.py | 38 +- tests/test_drug_era_sql_builder.py | 385 ++--- tests/test_drug_exposure_builder.py | 153 +- tests/test_hashing.py | 78 +- tests/test_java_interoperability.py | 139 +- tests/test_kitchen_sink_cohort.py | 239 ++-- tests/test_markdown_render_coverage.py | 25 +- tests/test_print_friendly_parity.py | 213 +-- tests/test_query_builders.py | 358 +++-- tests/test_range_checker_factory_coverage.py | 577 ++++++-- tests/test_real_example_cohorts.py | 494 ++++--- tests/test_schema_compatibility.py | 30 +- tests/test_simple_sql_builders.py | 111 +- tests/test_sql_builders.py | 1200 +++++++++------- tests/test_sql_rendering_parity.py | 1253 +++++++++++++---- tests/test_supporting_classes.py | 213 +-- tests/test_utils_db.py | 46 +- tests/test_visit_occurrence_parity.py | 105 +- 64 files changed, 6723 insertions(+), 4515 deletions(-) diff --git a/circe/check/checkers/base_criteria_check.py b/circe/check/checkers/base_criteria_check.py index fc069495..5a1cf8de 100644 --- a/circe/check/checkers/base_criteria_check.py +++ b/circe/check/checkers/base_criteria_check.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ - from .base_iterable_check import BaseIterableCheck from .warning_reporter import WarningReporter diff --git a/circe/check/checkers/comparisons.py b/circe/check/checkers/comparisons.py index 2d9e7cc6..83b61c3d 100644 --- a/circe/check/checkers/comparisons.py +++ b/circe/check/checkers/comparisons.py @@ -258,7 +258,21 @@ def compare_criteria(c1: "Criteria", c2: "Criteria") -> bool: VisitOccurrence, ) - if isinstance(c1, ConditionEra) or isinstance(c1, ConditionOccurrence) or isinstance(c1, Death) or isinstance(c1, DeviceExposure) or isinstance(c1, DoseEra) or isinstance(c1, DrugEra) or isinstance(c1, DrugExposure) or isinstance(c1, Measurement) or isinstance(c1, Observation) or isinstance(c1, ProcedureOccurrence) or isinstance(c1, Specimen) or isinstance(c1, VisitOccurrence) or isinstance(c1, VisitDetail): + if ( + isinstance(c1, ConditionEra) + or isinstance(c1, ConditionOccurrence) + or isinstance(c1, Death) + or isinstance(c1, DeviceExposure) + or isinstance(c1, DoseEra) + or isinstance(c1, DrugEra) + or isinstance(c1, DrugExposure) + or isinstance(c1, Measurement) + or isinstance(c1, Observation) + or isinstance(c1, ProcedureOccurrence) + or isinstance(c1, Specimen) + or isinstance(c1, VisitOccurrence) + or isinstance(c1, VisitDetail) + ): return c1.codeset_id == c2.codeset_id return False diff --git a/circe/check/checkers/concept_set_criteria_check.py b/circe/check/checkers/concept_set_criteria_check.py index 7451f340..26899f05 100644 --- a/circe/check/checkers/concept_set_criteria_check.py +++ b/circe/check/checkers/concept_set_criteria_check.py @@ -92,88 +92,111 @@ def _check_criteria( ) Operations.match(criteria).is_a(ConditionEra).then( - lambda c: Operations.match(c) - .when(lambda ce: ce.codeset_id is None) - .then(add_warning) + lambda c: ( + Operations.match(c) + .when(lambda ce: ce.codeset_id is None) + .then(add_warning) + ) ).is_a(ConditionOccurrence).then( - lambda c: Operations.match(c) - .when( - lambda co: co.codeset_id is None and co.condition_source_concept is None + lambda c: ( + Operations.match(c) + .when( + lambda co: ( + co.codeset_id is None and co.condition_source_concept is None + ) + ) + .then(add_warning) + ) + ).is_a(Death).then( + lambda c: ( + Operations.match(c) + .when(lambda d: d.codeset_id is None) + .then(add_warning) + ) + ).is_a(DeviceExposure).then( + lambda c: ( + Operations.match(c) + .when( + lambda de: ( + de.codeset_id is None and de.device_source_concept is None + ) + ) + .then(add_warning) + ) + ).is_a(DoseEra).then( + lambda c: ( + Operations.match(c) + .when(lambda de: de.codeset_id is None) + .then(add_warning) + ) + ).is_a(DrugEra).then( + lambda c: ( + Operations.match(c) + .when(lambda de: de.codeset_id is None) + .then(add_warning) + ) + ).is_a(DrugExposure).then( + lambda c: ( + Operations.match(c) + .when( + lambda de: de.codeset_id is None and de.drug_source_concept is None + ) + .then(add_warning) + ) + ).is_a(Measurement).then( + lambda c: ( + Operations.match(c) + .when( + lambda m: ( + m.codeset_id is None and m.measurement_source_concept is None + ) + ) + .then(add_warning) + ) + ).is_a(Observation).then( + lambda c: ( + Operations.match(c) + .when( + lambda o: ( + o.codeset_id is None and o.observation_source_concept is None + ) + ) + .then(add_warning) ) - .then(add_warning) - ).is_a( - Death - ).then( - lambda c: Operations.match(c) - .when(lambda d: d.codeset_id is None) - .then(add_warning) - ).is_a( - DeviceExposure - ).then( - lambda c: Operations.match(c) - .when(lambda de: de.codeset_id is None and de.device_source_concept is None) - .then(add_warning) - ).is_a( - DoseEra - ).then( - lambda c: Operations.match(c) - .when(lambda de: de.codeset_id is None) - .then(add_warning) - ).is_a( - DrugEra - ).then( - lambda c: Operations.match(c) - .when(lambda de: de.codeset_id is None) - .then(add_warning) - ).is_a( - DrugExposure - ).then( - lambda c: Operations.match(c) - .when(lambda de: de.codeset_id is None and de.drug_source_concept is None) - .then(add_warning) - ).is_a( - Measurement - ).then( - lambda c: Operations.match(c) - .when( - lambda m: m.codeset_id is None and m.measurement_source_concept is None + ).is_a(ProcedureOccurrence).then( + lambda c: ( + Operations.match(c) + .when( + lambda po: ( + po.codeset_id is None and po.procedure_source_concept is None + ) + ) + .then(add_warning) ) - .then(add_warning) - ).is_a( - Observation - ).then( - lambda c: Operations.match(c) - .when( - lambda o: o.codeset_id is None and o.observation_source_concept is None + ).is_a(Specimen).then( + lambda c: ( + Operations.match(c) + .when( + lambda s: s.codeset_id is None and s.specimen_source_concept is None + ) + .then(add_warning) ) - .then(add_warning) - ).is_a( - ProcedureOccurrence - ).then( - lambda c: Operations.match(c) - .when( - lambda po: po.codeset_id is None and po.procedure_source_concept is None + ).is_a(VisitOccurrence).then( + lambda c: ( + Operations.match(c) + .when( + lambda vo: vo.codeset_id is None and vo.visit_source_concept is None + ) + .then(add_warning) ) - .then(add_warning) - ).is_a( - Specimen - ).then( - lambda c: Operations.match(c) - .when(lambda s: s.codeset_id is None and s.specimen_source_concept is None) - .then(add_warning) - ).is_a( - VisitOccurrence - ).then( - lambda c: Operations.match(c) - .when(lambda vo: vo.codeset_id is None and vo.visit_source_concept is None) - .then(add_warning) - ).is_a( - VisitDetail - ).then( - lambda c: Operations.match(c) - .when( - lambda vd: vd.codeset_id is None - and vd.visit_detail_source_concept is None + ).is_a(VisitDetail).then( + lambda c: ( + Operations.match(c) + .when( + lambda vd: ( + vd.codeset_id is None and vd.visit_detail_source_concept is None + ) + ) + .then(add_warning) ) - .then(add_warning) ) diff --git a/circe/check/checkers/concept_set_selection_checker_factory.py b/circe/check/checkers/concept_set_selection_checker_factory.py index 67efa591..a60ab420 100644 --- a/circe/check/checkers/concept_set_selection_checker_factory.py +++ b/circe/check/checkers/concept_set_selection_checker_factory.py @@ -101,8 +101,8 @@ def check(c: "VisitDetail") -> None: return check else: - return ( - lambda c: None + return lambda c: ( + None ) # No ConceptSetSelection checks for other criteria types def _get_check_demographic( diff --git a/circe/check/checkers/death_time_window_check.py b/circe/check/checkers/death_time_window_check.py index 1b4ccf99..f2c9e7f4 100644 --- a/circe/check/checkers/death_time_window_check.py +++ b/circe/check/checkers/death_time_window_check.py @@ -130,7 +130,9 @@ def _check_criteria( match_result = Operations.match(criteria.criteria) match_result.is_a(Death) match_result.then( - lambda death: Operations.match(criteria) - .when(lambda c: Comparisons.is_before(c.start_window)) - .then(lambda c: reporter(self.MESSAGE, name)) + lambda death: ( + Operations.match(criteria) + .when(lambda c: Comparisons.is_before(c.start_window)) + .then(lambda c: reporter(self.MESSAGE, name)) + ) ) diff --git a/circe/check/checkers/domain_type_check.py b/circe/check/checkers/domain_type_check.py index 2b2df395..949b9282 100644 --- a/circe/check/checkers/domain_type_check.py +++ b/circe/check/checkers/domain_type_check.py @@ -93,61 +93,65 @@ def add_warning() -> None: ) Operations.match(criteria).is_a(ConditionOccurrence).then( - lambda c: Operations.match(c) - .when(lambda co: co.condition_type is None) - .then(lambda co: add_warning()) + lambda c: ( + Operations.match(c) + .when(lambda co: co.condition_type is None) + .then(lambda co: add_warning()) + ) ).is_a(Death).then( - lambda c: Operations.match(c) - .when(lambda d: d.death_type is None) - .then(lambda d: add_warning()) - ).is_a( - DeviceExposure - ).then( - lambda c: Operations.match(c) - .when(lambda de: de.device_type is None) - .then(lambda de: add_warning()) - ).is_a( - DrugExposure - ).then( - lambda c: Operations.match(c) - .when(lambda de: de.drug_type is None) - .then(lambda de: add_warning()) - ).is_a( - Measurement - ).then( - lambda c: Operations.match(c) - .when(lambda m: m.measurement_type is None) - .then(lambda m: add_warning()) - ).is_a( - Observation - ).then( - lambda c: Operations.match(c) - .when(lambda o: o.observation_type is None) - .then(lambda o: add_warning()) - ).is_a( - ProcedureOccurrence - ).then( - lambda c: Operations.match(c) - .when(lambda po: po.procedure_type is None) - .then(lambda po: add_warning()) - ).is_a( - Specimen - ).then( - lambda c: Operations.match(c) - .when(lambda s: s.specimen_type is None) - .then(lambda s: add_warning()) - ).is_a( - VisitOccurrence - ).then( - lambda c: Operations.match(c) - .when(lambda vo: vo.visit_type is None) - .then(lambda vo: add_warning()) - ).is_a( - VisitDetail - ).then( - lambda c: Operations.match(c) - .when(lambda vd: vd.visit_detail_type_cs is None) - .then(lambda vd: add_warning()) + lambda c: ( + Operations.match(c) + .when(lambda d: d.death_type is None) + .then(lambda d: add_warning()) + ) + ).is_a(DeviceExposure).then( + lambda c: ( + Operations.match(c) + .when(lambda de: de.device_type is None) + .then(lambda de: add_warning()) + ) + ).is_a(DrugExposure).then( + lambda c: ( + Operations.match(c) + .when(lambda de: de.drug_type is None) + .then(lambda de: add_warning()) + ) + ).is_a(Measurement).then( + lambda c: ( + Operations.match(c) + .when(lambda m: m.measurement_type is None) + .then(lambda m: add_warning()) + ) + ).is_a(Observation).then( + lambda c: ( + Operations.match(c) + .when(lambda o: o.observation_type is None) + .then(lambda o: add_warning()) + ) + ).is_a(ProcedureOccurrence).then( + lambda c: ( + Operations.match(c) + .when(lambda po: po.procedure_type is None) + .then(lambda po: add_warning()) + ) + ).is_a(Specimen).then( + lambda c: ( + Operations.match(c) + .when(lambda s: s.specimen_type is None) + .then(lambda s: add_warning()) + ) + ).is_a(VisitOccurrence).then( + lambda c: ( + Operations.match(c) + .when(lambda vo: vo.visit_type is None) + .then(lambda vo: add_warning()) + ) + ).is_a(VisitDetail).then( + lambda c: ( + Operations.match(c) + .when(lambda vd: vd.visit_detail_type_cs is None) + .then(lambda vd: add_warning()) + ) ) def _after_check( diff --git a/circe/check/checkers/drug_era_check.py b/circe/check/checkers/drug_era_check.py index 208c20f5..80887f7f 100644 --- a/circe/check/checkers/drug_era_check.py +++ b/circe/check/checkers/drug_era_check.py @@ -61,13 +61,15 @@ def _check_criteria( match_result = Operations.match(criteria.criteria) match_result.is_a(DrugEra) match_result.then( - lambda c: Operations.match(criteria) - .when( - lambda de: ( - (not criteria.start_window or not criteria.start_window.start) - and (not criteria.start_window or not criteria.start_window.end) - and (not criteria.end_window or not criteria.end_window.start) + lambda c: ( + Operations.match(criteria) + .when( + lambda de: ( + (not criteria.start_window or not criteria.start_window.start) + and (not criteria.start_window or not criteria.start_window.end) + and (not criteria.end_window or not criteria.end_window.start) + ) ) + .then(lambda de: reporter(self.MISSING_DAYS_INFO, group_name)) ) - .then(lambda de: reporter(self.MISSING_DAYS_INFO, group_name)) ) diff --git a/circe/check/checkers/duplicates_criteria_check.py b/circe/check/checkers/duplicates_criteria_check.py index 29f4b6bf..58c59222 100644 --- a/circe/check/checkers/duplicates_criteria_check.py +++ b/circe/check/checkers/duplicates_criteria_check.py @@ -108,7 +108,15 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: c1.codeset_id == c2.codeset_id and c1.condition_source_concept == c2.condition_source_concept ) - elif isinstance(c1, Death) or isinstance(c1, DeviceExposure) or isinstance(c1, DoseEra) or isinstance(c1, DrugEra) or isinstance(c1, DrugExposure) or isinstance(c1, Measurement) or isinstance(c1, Observation): + elif ( + isinstance(c1, Death) + or isinstance(c1, DeviceExposure) + or isinstance(c1, DoseEra) + or isinstance(c1, DrugEra) + or isinstance(c1, DrugExposure) + or isinstance(c1, Measurement) + or isinstance(c1, Observation) + ): return c1.codeset_id == c2.codeset_id elif isinstance(c1, ObservationPeriod): # For ObservationPeriod, compare all fields @@ -117,7 +125,12 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: and self._compare_objects(c1.period_end_date, c2.period_end_date) and self._compare_objects(c1.period_length, c2.period_length) ) - elif isinstance(c1, ProcedureOccurrence) or isinstance(c1, Specimen) or isinstance(c1, VisitOccurrence) or isinstance(c1, VisitDetail): + elif ( + isinstance(c1, ProcedureOccurrence) + or isinstance(c1, Specimen) + or isinstance(c1, VisitOccurrence) + or isinstance(c1, VisitDetail) + ): return c1.codeset_id == c2.codeset_id elif isinstance(c1, PayerPlanPeriod): return ( diff --git a/circe/check/checkers/exit_criteria_check.py b/circe/check/checkers/exit_criteria_check.py index ed2d1a08..5073d063 100644 --- a/circe/check/checkers/exit_criteria_check.py +++ b/circe/check/checkers/exit_criteria_check.py @@ -42,7 +42,9 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N match_result = Operations.match(expression.end_strategy) match_result.is_a(CustomEraStrategy) match_result.then( - lambda s: Operations.match(s) - .when(lambda ces: ces.drug_codeset_id is None) - .then(lambda ces: reporter(self.DRUG_CONCEPT_EMPTY_ERROR)) + lambda s: ( + Operations.match(s) + .when(lambda ces: ces.drug_codeset_id is None) + .then(lambda ces: reporter(self.DRUG_CONCEPT_EMPTY_ERROR)) + ) ) diff --git a/circe/check/checkers/exit_criteria_days_offset_check.py b/circe/check/checkers/exit_criteria_days_offset_check.py index 6633367d..fad45f54 100644 --- a/circe/check/checkers/exit_criteria_days_offset_check.py +++ b/circe/check/checkers/exit_criteria_days_offset_check.py @@ -53,7 +53,13 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N match_result = Operations.match(expression.end_strategy) match_result.is_a(DateOffsetStrategy) match_result.then( - lambda s: Operations.match(s) - .when(lambda dos: dos.date_field == DateType.START_DATE and dos.offset == 0) - .then(lambda dos: reporter(self.DAYS_OFFSET_WARNING)) + lambda s: ( + Operations.match(s) + .when( + lambda dos: ( + dos.date_field == DateType.START_DATE and dos.offset == 0 + ) + ) + .then(lambda dos: reporter(self.DAYS_OFFSET_WARNING)) + ) ) diff --git a/circe/check/checkers/initial_event_check.py b/circe/check/checkers/initial_event_check.py index a4595b4a..68fabcd4 100644 --- a/circe/check/checkers/initial_event_check.py +++ b/circe/check/checkers/initial_event_check.py @@ -39,8 +39,10 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N """ match_result = Operations.match(expression) match_result.when( - lambda e: e.primary_criteria is None - or e.primary_criteria.criteria_list is None - or len(e.primary_criteria.criteria_list) == 0 + lambda e: ( + e.primary_criteria is None + or e.primary_criteria.criteria_list is None + or len(e.primary_criteria.criteria_list) == 0 + ) ) match_result.then(lambda e: reporter(self.NO_INITIAL_EVENT_ERROR)) diff --git a/circe/check/checkers/range_checker_factory.py b/circe/check/checkers/range_checker_factory.py index 0bd1f5e9..3d9fb05f 100644 --- a/circe/check/checkers/range_checker_factory.py +++ b/circe/check/checkers/range_checker_factory.py @@ -632,40 +632,50 @@ def warning(template: str) -> None: lambda r: r.value is not None and not Comparisons.is_date_valid(r.value) ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) match_result.when(lambda r: r.op is not None and r.op.endswith("bt")).then( - lambda r: Operations.match(r) - .when(lambda x: x.value is None) - .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) - .when(lambda x: x.extent is None) - .then(lambda x: warning(self.WARNING_EMPTY_END_VALUE)) - .when( - lambda x: x.extent is not None - and not Comparisons.is_date_valid(x.extent) - ) - .then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) - .when(Comparisons.start_is_greater_than_end) - .then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) + lambda r: ( + Operations.match(r) + .when(lambda x: x.value is None) + .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + .when(lambda x: x.extent is None) + .then(lambda x: warning(self.WARNING_EMPTY_END_VALUE)) + .when( + lambda x: ( + x.extent is not None + and not Comparisons.is_date_valid(x.extent) + ) + ) + .then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) + .when(Comparisons.start_is_greater_than_end) + .then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) + ) ) match_result.or_else( - lambda r: Operations.match(r) - .when(lambda x: x.value is None) - .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + lambda r: ( + Operations.match(r) + .when(lambda x: x.value is None) + .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + ) ) elif isinstance(range_val, NumericRange): # Numeric range checks match_result = Operations.match(range_val) match_result.when(lambda r: r.op is not None and r.op.endswith("bt")).then( - lambda r: Operations.match(r) - .when(lambda x: x.value is None) - .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) - .when(lambda x: x.extent is None) - .then(lambda x: warning(self.WARNING_EMPTY_END_VALUE)) - .when(Comparisons.start_is_greater_than_end) - .then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) + lambda r: ( + Operations.match(r) + .when(lambda x: x.value is None) + .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + .when(lambda x: x.extent is None) + .then(lambda x: warning(self.WARNING_EMPTY_END_VALUE)) + .when(Comparisons.start_is_greater_than_end) + .then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) + ) ) match_result.or_else( - lambda r: Operations.match(r) - .when(lambda x: x.value is None) - .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + lambda r: ( + Operations.match(r) + .when(lambda x: x.value is None) + .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + ) ) def check_range( @@ -686,12 +696,14 @@ def warning(template: str) -> None: match_result = Operations.match(period) match_result.when( - lambda x: x.start_date is not None - and not Comparisons.is_date_valid(x.start_date) + lambda x: ( + x.start_date is not None and not Comparisons.is_date_valid(x.start_date) + ) ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) match_result.when( - lambda x: x.end_date is not None - and not Comparisons.is_date_valid(x.end_date) + lambda x: ( + x.end_date is not None and not Comparisons.is_date_valid(x.end_date) + ) ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) match_result.when(Comparisons.start_is_greater_than_end).then( lambda x: warning(self.WARNING_START_GREATER_THAN_END) @@ -716,5 +728,7 @@ def check(self, expression_or_criteria) -> None: Constants.Attributes.CENSOR_WINDOW_ATTR, ) # Handle DemographicCriteria (delegate to base class) - elif isinstance(expression_or_criteria, DemographicCriteria) or isinstance(expression_or_criteria, Criteria): + elif isinstance(expression_or_criteria, DemographicCriteria) or isinstance( + expression_or_criteria, Criteria + ): super().check(expression_or_criteria) diff --git a/circe/check/checkers/time_window_check.py b/circe/check/checkers/time_window_check.py index 9c41ecd7..0529ed4e 100644 --- a/circe/check/checkers/time_window_check.py +++ b/circe/check/checkers/time_window_check.py @@ -78,8 +78,10 @@ def _check_criteria( match_result = Operations.match(criteria) match_result.when( - lambda c: c.start_window is not None - and self._observation_filter is not None - and Comparisons.compare_to(self._observation_filter, c.start_window) < 0 + lambda c: ( + c.start_window is not None + and self._observation_filter is not None + and Comparisons.compare_to(self._observation_filter, c.start_window) < 0 + ) ) match_result.then(lambda c: reporter(self.WARNING, name)) diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index 3b67d41e..c98f1edf 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -1356,7 +1356,9 @@ def _get_windowed_criteria_query_internal( if check_observation_period and start_window.start and start_window.start.coeff == -1 - else "P.OP_END_DATE" if check_observation_period else None + else "P.OP_END_DATE" + if check_observation_period + else None ) if start_expression: @@ -1370,7 +1372,9 @@ def _get_windowed_criteria_query_internal( if check_observation_period and start_window.end and start_window.end.coeff == -1 - else "P.OP_END_DATE" if check_observation_period else None + else "P.OP_END_DATE" + if check_observation_period + else None ) if end_expression: @@ -1398,7 +1402,9 @@ def _get_windowed_criteria_query_internal( start_expression = ( "P.OP_START_DATE" if check_observation_period and end_window.start.coeff == -1 - else "P.OP_END_DATE" if check_observation_period else None + else "P.OP_END_DATE" + if check_observation_period + else None ) if start_expression: @@ -1410,7 +1416,9 @@ def _get_windowed_criteria_query_internal( end_expression = ( "P.OP_START_DATE" if check_observation_period and end_window.end.coeff == -1 - else "P.OP_END_DATE" if check_observation_period else None + else "P.OP_END_DATE" + if check_observation_period + else None ) if end_expression: diff --git a/circe/execution/builders/common.py b/circe/execution/builders/common.py index ea5ea0fb..1f524965 100644 --- a/circe/execution/builders/common.py +++ b/circe/execution/builders/common.py @@ -78,7 +78,11 @@ def project_event_columns( include_visit_occurrence: bool = False, ) -> ir.Table: keep = ["person_id", primary_key, start_column] - if end_column in table.columns or include_visit_occurrence and start_column != end_column: + if ( + end_column in table.columns + or include_visit_occurrence + and start_column != end_column + ): keep.append(end_column) if include_visit_occurrence and "visit_occurrence_id" in table.columns: keep.append("visit_occurrence_id") diff --git a/circe/execution/builders/post_processing.py b/circe/execution/builders/post_processing.py index c537a25c..5523ec96 100644 --- a/circe/execution/builders/post_processing.py +++ b/circe/execution/builders/post_processing.py @@ -48,9 +48,7 @@ def apply_inclusion_rules( # Postgres returns NUMERIC for SUM(BIGINT), which breaks bitwise ops. # Ibis also infers SUM(int64) -> int64 and may optimize away an int64 cast, # so we force an intermediate cast to keep the SQL-level cast. - _rule_mask=union_hits._rule_bit.sum() - .cast("decimal(38,0)") - .cast("int64") + _rule_mask=union_hits._rule_bit.sum().cast("decimal(38,0)").cast("int64") ) target_mask = sum(used_bits) target_literal = ibis.literal(target_mask, type="int64") diff --git a/cohort_definition.py b/cohort_definition.py index ca067c40..6f63018f 100644 --- a/cohort_definition.py +++ b/cohort_definition.py @@ -6,10 +6,12 @@ cohort = ( CohortBuilder("Fournier's Gangrene Cohort") - .with_concept_sets({"id":1, "name":"Fournier's Gangrene"}) - .with_condition(1) # Entry event: Diagnosis of Fournier's Gangrene (Concept Set ID 1) + .with_concept_sets({"id": 1, "name": "Fournier's Gangrene"}) + .with_condition( + 1 + ) # Entry event: Diagnosis of Fournier's Gangrene (Concept Set ID 1) .build() ) # To view the generated CIRCE JSON, you can call: -cohort_print_friendly(cohort) \ No newline at end of file +cohort_print_friendly(cohort) diff --git a/debug_app/app.py b/debug_app/app.py index 155bbc4b..d1c3ea1f 100644 --- a/debug_app/app.py +++ b/debug_app/app.py @@ -6,7 +6,7 @@ from flask import Flask, jsonify, render_template, request # Add project root to path -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from debug_app import utils @@ -14,15 +14,16 @@ # Paths BASE_DIR = Path(__file__).parent.parent -COHORTS_DIR = BASE_DIR / 'tests' / 'cohorts' -REFERENCE_DIR = COHORTS_DIR / 'reference_outputs' -TEST_RESULTS_FILE = BASE_DIR / 'debug_app' / 'test_results.json' -USER_OVERRIDES_FILE = BASE_DIR / 'debug_app' / 'user_overrides.json' +COHORTS_DIR = BASE_DIR / "tests" / "cohorts" +REFERENCE_DIR = COHORTS_DIR / "reference_outputs" +TEST_RESULTS_FILE = BASE_DIR / "debug_app" / "test_results.json" +USER_OVERRIDES_FILE = BASE_DIR / "debug_app" / "user_overrides.json" -@app.route('/') + +@app.route("/") def index(): - files = sorted([f.name for f in COHORTS_DIR.glob('*.json')]) - + files = sorted([f.name for f in COHORTS_DIR.glob("*.json")]) + test_results = {} if TEST_RESULTS_FILE.exists(): try: @@ -38,67 +39,76 @@ def index(): overrides = json.load(f) except Exception as e: print(f"Error loading user overrides: {e}") - + # Merge overrides # logic: if override[filename] is True, we mark it as user_ok for filename, result in test_results.items(): if overrides.get(filename): - result['user_ok'] = True + result["user_ok"] = True else: - result['user_ok'] = False + result["user_ok"] = False # Also make sure overrides are available for files even if test_results missing (though rare) for filename, is_ok in overrides.items(): if filename not in test_results: - test_results[filename] = {'user_ok': is_ok, 'sql_match': False, 'md_match': False} # Default fail + test_results[filename] = { + "user_ok": is_ok, + "sql_match": False, + "md_match": False, + } # Default fail elif is_ok: - test_results[filename]['user_ok'] = True + test_results[filename]["user_ok"] = True + + return render_template("index.html", files=files, test_results=test_results) - return render_template('index.html', files=files, test_results=test_results) -@app.route('/api/override', methods=['POST']) +@app.route("/api/override", methods=["POST"]) def toggle_override(): data = request.json - filename = data.get('filename') - is_ok = data.get('is_ok') # Boolean - + filename = data.get("filename") + is_ok = data.get("is_ok") # Boolean + overrides = {} if USER_OVERRIDES_FILE.exists(): - try: + try: with open(USER_OVERRIDES_FILE) as f: overrides = json.load(f) - except: pass - + except: + pass + overrides[filename] = is_ok - - with open(USER_OVERRIDES_FILE, 'w') as f: + + with open(USER_OVERRIDES_FILE, "w") as f: json.dump(overrides, f, indent=2) - + return jsonify({"status": "success", "user_ok": is_ok}) -@app.route('/cohort/') + +@app.route("/cohort/") def cohort_view(filename): cohort_file = COHORTS_DIR / filename if not cohort_file.exists(): return "File not found", 404 - + json_content = cohort_file.read_text() - + # 1. Generate current state result = utils.generate_from_json(json_content) - + # 2. Generate Reference using R (dynamic) ref_result = utils.generate_reference_with_r(json_content) - + # Handle R errors - if ref_result.get('error'): - # If R fails, append to existing error or set it - combined_error = f"{result['error'] or ''}\n\nR Error: {ref_result['error']}".strip() - result['error'] = combined_error - - ref_sql = ref_result['sql'] - ref_md = ref_result['markdown'] - + if ref_result.get("error"): + # If R fails, append to existing error or set it + combined_error = ( + f"{result['error'] or ''}\n\nR Error: {ref_result['error']}".strip() + ) + result["error"] = combined_error + + ref_sql = ref_result["sql"] + ref_md = ref_result["markdown"] + # Check overrides is_user_ok = False if USER_OVERRIDES_FILE.exists(): @@ -106,40 +116,48 @@ def cohort_view(filename): with open(USER_OVERRIDES_FILE) as f: overrides = json.load(f) is_user_ok = overrides.get(filename, False) - except: pass - - return render_template('editor.html', - filename=filename, - python_code=result['python_code'], - gen_sql=result.get('normalized_sql', ''), - gen_md=result.get('normalized_markdown', ''), - ref_sql=ref_result.get('normalized_sql', ''), - ref_md=ref_result.get('normalized_markdown', ''), - error=result['error'], - is_user_ok=is_user_ok) - -@app.route('/compile', methods=['POST']) + except: + pass + + return render_template( + "editor.html", + filename=filename, + python_code=result["python_code"], + gen_sql=result.get("normalized_sql", ""), + gen_md=result.get("normalized_markdown", ""), + ref_sql=ref_result.get("normalized_sql", ""), + ref_md=ref_result.get("normalized_markdown", ""), + error=result["error"], + is_user_ok=is_user_ok, + ) + + +@app.route("/compile", methods=["POST"]) def compile_code(): data = request.json - code = data.get('code') - + code = data.get("code") + result = utils.execute_python_code(code) - - return jsonify({ - "sql": result.get('normalized_sql', ''), - "markdown": result.get('normalized_markdown', ''), - "error": result['error'] - }) - -@app.route('/explain', methods=['POST']) + + return jsonify( + { + "sql": result.get("normalized_sql", ""), + "markdown": result.get("normalized_markdown", ""), + "error": result["error"], + } + ) + + +@app.route("/explain", methods=["POST"]) def explain_diff(): data = request.json - ref_content = data.get('ref') - gen_content = data.get('gen') - diff_type = data.get('type', 'SQL') - + ref_content = data.get("ref") + gen_content = data.get("gen") + diff_type = data.get("type", "SQL") + result = utils.get_ai_explanation(ref_content, gen_content, diff_type) return jsonify(result) -if __name__ == '__main__': + +if __name__ == "__main__": app.run(debug=True, port=5001) diff --git a/debug_app/sandbox.py b/debug_app/sandbox.py index f8ccba8b..53001ad0 100644 --- a/debug_app/sandbox.py +++ b/debug_app/sandbox.py @@ -12,40 +12,43 @@ def validate_imports(code: str) -> tuple[bool, str]: """ Validate that code only imports from allowed circe modules. - + Returns: (is_valid, error_message) """ # Find all import statements - import_pattern = r'^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))' - + import_pattern = r"^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))" + allowed_modules = { - 'circe.cohort_builder', - 'circe.vocabulary', + "circe.cohort_builder", + "circe.vocabulary", } - - for line in code.split('\n'): + + for line in code.split("\n"): match = re.match(import_pattern, line) if match: module = match.group(1) or match.group(2) # Check if module or its parent is allowed if not any(module.startswith(allowed) for allowed in allowed_modules): - return False, f"Import '{module}' is not allowed. Only 'circe.cohort_builder' and 'circe.vocabulary' imports are permitted." - + return ( + False, + f"Import '{module}' is not allowed. Only 'circe.cohort_builder' and 'circe.vocabulary' imports are permitted.", + ) + return True, "" def execute_cohort_code(code: str) -> Dict[str, Any]: """ Execute Python code with strict cohort builder restrictions. - + The code must: 1. Only import from circe.cohort_builder and circe.vocabulary 2. Define a 'cohort' variable containing a CohortExpression - + Args: code: Python source code to execute - + Returns: dict with keys: - cohort_expression: The built CohortExpression object @@ -59,99 +62,97 @@ def execute_cohort_code(code: str) -> Dict[str, Any]: is_valid, error_msg = validate_imports(code) if not is_valid: return {"error": error_msg} - + # Create restricted globals - only allow safe circe imports restricted_globals = { - '__builtins__': { + "__builtins__": { # Only safe built-ins - 'True': True, - 'False': False, - 'None': None, - 'int': int, - 'float': float, - 'str': str, - 'list': list, - 'dict': dict, - 'tuple': tuple, - 'set': set, - 'len': len, - 'range': range, - 'enumerate': enumerate, - 'zip': zip, - 'min': min, - 'max': max, - 'sum': sum, - 'sorted': sorted, - 'print': print, # Allow print for debugging + "True": True, + "False": False, + "None": None, + "int": int, + "float": float, + "str": str, + "list": list, + "dict": dict, + "tuple": tuple, + "set": set, + "len": len, + "range": range, + "enumerate": enumerate, + "zip": zip, + "min": min, + "max": max, + "sum": sum, + "sorted": sorted, + "print": print, # Allow print for debugging } } - + local_scope = {} - + try: # Execute the code exec(code, restricted_globals, local_scope) - + # Verify 'cohort' variable exists - if 'cohort' not in local_scope: + if "cohort" not in local_scope: return { "error": "Code must define a 'cohort' variable. Example:\n\n" - "from circe.cohort_builder import CohortBuilder\n" - "cohort = CohortBuilder('My Cohort').with_condition(1).build()" + "from circe.cohort_builder import CohortBuilder\n" + "cohort = CohortBuilder('My Cohort').with_condition(1).build()" } - - cohort_expression = local_scope['cohort'] - + + cohort_expression = local_scope["cohort"] + # Import circe modules for processing (safe to do here) import json from circe.api import build_cohort_query, cohort_print_friendly from circe.cohortdefinition import BuildExpressionQueryOptions from circe.cohortdefinition.code_generator import to_python_code - + # Generate outputs options = BuildExpressionQueryOptions() options.generate_stats = True - + sql = build_cohort_query(cohort_expression, options) markdown = cohort_print_friendly(cohort_expression) python_code = to_python_code(cohort_expression) - + # Serialize to JSON json_output = json.dumps( - cohort_expression.model_dump(exclude_none=True, by_alias=True), - indent=2 + cohort_expression.model_dump(exclude_none=True, by_alias=True), indent=2 ) - + return { "cohort_expression": cohort_expression, "json": json_output, "sql": sql, "markdown": markdown, "python_code": python_code, - "error": None + "error": None, } - + except SyntaxError as e: return { "error": f"Syntax Error: {e.msg} at line {e.lineno}\n\n" - f"Check your Python syntax and try again." + f"Check your Python syntax and try again." } except ImportError as e: return { "error": f"Import Error: {str(e)}\n\n" - f"Only imports from 'circe.cohort_builder' and 'circe.vocabulary' are allowed." + f"Only imports from 'circe.cohort_builder' and 'circe.vocabulary' are allowed." } except AttributeError as e: return { "error": f"Attribute Error: {str(e)}\n\n" - f"Check the fluent API documentation for correct method names." + f"Check the fluent API documentation for correct method names." } except Exception as e: import traceback - return { - "error": f"{type(e).__name__}: {str(e)}\n\n{traceback.format_exc()}" - } + + return {"error": f"{type(e).__name__}: {str(e)}\n\n{traceback.format_exc()}"} # Example templates for users @@ -163,7 +164,6 @@ def execute_cohort_code(code: str) -> Dict[str, Any]: .with_condition(1) .build() )""", - "with_criteria": """from circe.cohort_builder import CohortBuilder cohort = ( @@ -175,7 +175,6 @@ def execute_cohort_code(code: str) -> Dict[str, Any]: .exclude_procedure(3).within_days_before(30) .build() )""", - "grouped_criteria": """from circe.cohort_builder import CohortBuilder cohort = ( @@ -187,7 +186,6 @@ def execute_cohort_code(code: str) -> Dict[str, Any]: .end_group() .build() )""", - "demographics": """from circe.cohort_builder import CohortBuilder cohort = ( diff --git a/debug_app/utils.py b/debug_app/utils.py index 915a6a86..18f7cd51 100644 --- a/debug_app/utils.py +++ b/debug_app/utils.py @@ -4,7 +4,7 @@ from pathlib import Path # Ensure we can import circe -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from circe.api import ( build_cohort_query, @@ -20,113 +20,140 @@ def normalize_sql(sql: str) -> str: Normalize SQL for comparison - removes ALL formatting differences. Returns a formatted multi-line string for readability. """ - if not sql: return "" - + if not sql: + return "" + # 1. Basic cleanup sql = sql.lower() - sql = re.sub(r'/\*.*?\*/', ' ', sql, flags=re.DOTALL) # Remove /* comments */ - sql = re.sub(r'--.*$', '', sql, flags=re.MULTILINE) # Remove -- comments - + sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL) # Remove /* comments */ + sql = re.sub(r"--.*$", "", sql, flags=re.MULTILINE) # Remove -- comments + # 2. Circe-specific removals (legacy compat) - sql = re.sub(r'\{[^}]*\}\?\{', '', sql) - sql = re.sub(r'\}', ' ', sql) - sql = re.sub(r'--\s*the matching group.*?inclusion_rule_mask\s+=\s+power\(.*?\)\s*-\s*1\)\)\s*results', ') results', sql, flags=re.DOTALL) - sql = re.sub(r'where\s*\(mg\.inclusion_rule_mask\s*=\s*power\(cast\(2\s+as\s+bigint\),\s*0\)\s*-\s*1\)', '', sql, flags=re.IGNORECASE) - + sql = re.sub(r"\{[^}]*\}\?\{", "", sql) + sql = re.sub(r"\}", " ", sql) + sql = re.sub( + r"--\s*the matching group.*?inclusion_rule_mask\s+=\s+power\(.*?\)\s*-\s*1\)\)\s*results", + ") results", + sql, + flags=re.DOTALL, + ) + sql = re.sub( + r"where\s*\(mg\.inclusion_rule_mask\s*=\s*power\(cast\(2\s+as\s+bigint\),\s*0\)\s*-\s*1\)", + "", + sql, + flags=re.IGNORECASE, + ) + # 3. Strip specific columns known to differ harmlessly - sql = re.sub(r',o\.value_as_string', '', sql) - sql = re.sub(r',o\.value_as_concept_id', '', sql) - sql = re.sub(r',o\.unit_concept_id', '', sql) - + sql = re.sub(r",o\.value_as_string", "", sql) + sql = re.sub(r",o\.value_as_concept_id", "", sql) + sql = re.sub(r",o\.unit_concept_id", "", sql) + # 4. Canonicalize whitespace (flatten first) sql = sql.replace(",o.value_as_string", "") sql = sql.replace(",o.value_as_concept_id", "") sql = sql.replace(",o.unit_concept_id", "") - sql = re.sub(r'\s+', ' ', sql).strip() - + sql = re.sub(r"\s+", " ", sql).strip() + # 5. Re-format for readability (Multi-line) # Add newlines before major keywords keywords = [ - 'select', 'from', 'inner join', 'left join', 'right join', 'join', - 'where', 'group by', 'order by', 'having', 'limit', 'union', 'with', 'intersect', 'except' + "select", + "from", + "inner join", + "left join", + "right join", + "join", + "where", + "group by", + "order by", + "having", + "limit", + "union", + "with", + "intersect", + "except", ] for kw in keywords: # Look for keyword preceded by space # We replace " keyword" with "\nkeyword" - sql = re.sub(f'\\s({kw})\\s', '\n\\1 ', sql) - + sql = re.sub(f"\\s({kw})\\s", "\n\\1 ", sql) + # Consistency for SQL tokens - sql = re.sub(r'\s*([(),=<>!]+)\s*', r'\1', sql) + sql = re.sub(r"\s*([(),=<>!]+)\s*", r"\1", sql) return sql.strip() + def normalize_markdown(text: str) -> str: """ Normalize markdown for comparison. """ - if not text: return "" - + if not text: + return "" + text = text.lower() - lines = text.split('\n') + lines = text.split("\n") normalized = [] skip_section = False - + for line in lines: line = line.strip() - + # Skip title and description sections (they change often / aren't functional logic) - if line.startswith('# ') and not line.startswith('###'): + if line.startswith("# ") and not line.startswith("###"): skip_section = True continue - if line.startswith('## ') and not line.startswith('###'): + if line.startswith("## ") and not line.startswith("###"): skip_section = True continue - if skip_section and line.startswith('###'): + if skip_section and line.startswith("###"): skip_section = False if skip_section: continue - + if not line: continue - + # Collapse internal whitespace of the line - line = ' '.join(line.split()) + line = " ".join(line.split()) normalized.append(line) - + # Join with newlines to preserve structure (readability) - result = '\n'.join(normalized) - + result = "\n".join(normalized) + # Normalize common markers - result = re.sub(r'\s*\*\s*', '* ', result) - result = re.sub(r'\s*-\s*', '- ', result) - result = re.sub(r'\s*###\s*', '### ', result) - result = re.sub(r'\s*##\s*', '## ', result) - result = re.sub(r'\s*#\s*', '# ', result) - + result = re.sub(r"\s*\*\s*", "* ", result) + result = re.sub(r"\s*-\s*", "- ", result) + result = re.sub(r"\s*###\s*", "### ", result) + result = re.sub(r"\s*##\s*", "## ", result) + result = re.sub(r"\s*#\s*", "# ", result) + return result.strip() + def generate_from_json(json_str: str) -> dict: try: expression = cohort_expression_from_json(json_str) - + # SQL options = BuildExpressionQueryOptions() options.generate_stats = True sql = build_cohort_query(expression, options) - + # Markdown markdown = cohort_print_friendly(expression) - + # Python Code python_code = to_python_code(expression) - + return { "sql": sql, "markdown": markdown, "python_code": python_code, "normalized_sql": normalize_sql(sql), "normalized_markdown": normalize_markdown(markdown), - "error": None + "error": None, } except Exception as e: return { @@ -135,108 +162,120 @@ def generate_from_json(json_str: str) -> dict: "python_code": None, "normalized_sql": "", "normalized_markdown": "", - "error": str(e) + "error": str(e), } + def execute_python_code(code: str) -> dict: try: local_scope = {} exec(code, {}, local_scope) - - if 'cohort' not in local_scope: + + if "cohort" not in local_scope: return {"error": "The executed code did not define a 'cohort' variable."} - - expression = local_scope['cohort'] - + + expression = local_scope["cohort"] + # SQL options = BuildExpressionQueryOptions() options.generate_stats = True sql = build_cohort_query(expression, options) - + # Markdown markdown = cohort_print_friendly(expression) - + return { "sql": sql, "markdown": markdown, "normalized_sql": normalize_sql(sql), "normalized_markdown": normalize_markdown(markdown), - "error": None + "error": None, } except Exception as e: import traceback + return { "sql": None, "markdown": None, "normalized_sql": "", "normalized_markdown": "", - "error": f"{str(e)}\n{traceback.format_exc()}" + "error": f"{str(e)}\n{traceback.format_exc()}", } + def generate_reference_with_r(json_content: str) -> dict: """ Uses the circe_sql.R script to generate reference SQL and Markdown via R. """ import subprocess import tempfile - - r_script_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'circe_sql.R')) - + + r_script_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "circe_sql.R") + ) + if not os.path.exists(r_script_path): - return {"error": f"R script not found at {r_script_path}", "sql": "", "markdown": ""} + return { + "error": f"R script not found at {r_script_path}", + "sql": "", + "markdown": "", + } with tempfile.TemporaryDirectory() as tmpdirname: - json_path = os.path.join(tmpdirname, 'input.json') - sql_path = os.path.join(tmpdirname, 'output.sql') - md_path = os.path.join(tmpdirname, 'output.md') - - with open(json_path, 'w') as f: + json_path = os.path.join(tmpdirname, "input.json") + sql_path = os.path.join(tmpdirname, "output.sql") + md_path = os.path.join(tmpdirname, "output.md") + + with open(json_path, "w") as f: f.write(json_content) - + try: subprocess.run( ["Rscript", r_script_path, json_path, sql_path], capture_output=True, text=True, - check=True + check=True, ) - + ref_sql = "" ref_md = "" - + if os.path.exists(sql_path): with open(sql_path) as f: ref_sql = f.read() - + if os.path.exists(md_path): with open(md_path) as f: ref_md = f.read() - + return { "sql": ref_sql, "markdown": ref_md, "normalized_sql": normalize_sql(ref_sql), "normalized_markdown": normalize_markdown(ref_md), - "error": None + "error": None, } - + except subprocess.CalledProcessError as e: return { "sql": "", "markdown": "", "normalized_sql": "", "normalized_markdown": "", - "error": f"R execution failed:\nSTDOUT: {e.stdout}\nSTDERR: {e.stderr}" + "error": f"R execution failed:\nSTDOUT: {e.stdout}\nSTDERR: {e.stderr}", } return { "sql": "", "markdown": "", "normalized_sql": "", "normalized_markdown": "", - "error": f"Unexpected error running R: {str(e)}" + "error": f"Unexpected error running R: {str(e)}", } -def get_ai_explanation(ref_content: str, gen_content: str, type_label: str = "SQL") -> dict: + +def get_ai_explanation( + ref_content: str, gen_content: str, type_label: str = "SQL" +) -> dict: """ Uses Google GenAI to explain the differences between reference and generated content. """ @@ -269,16 +308,16 @@ def get_ai_explanation(ref_content: str, gen_content: str, type_label: str = "SQ try: cache_dir = Path(__file__).parent / ".gemini_cache" cache_dir.mkdir(exist_ok=True) - + # Hash the prompt - prompt_hash = hashlib.sha256(prompt.encode('utf-8')).hexdigest() + prompt_hash = hashlib.sha256(prompt.encode("utf-8")).hexdigest() cache_file = cache_dir / f"{prompt_hash}.json" - + if cache_file.exists(): print(f"Cache hit for {prompt_hash}") with open(cache_file) as f: cached_data = json.load(f) - return {"explanation": cached_data['explanation'], "error": None} + return {"explanation": cached_data["explanation"], "error": None} except Exception as e: print(f"Cache check failed: {e}") @@ -286,34 +325,42 @@ def get_ai_explanation(ref_content: str, gen_content: str, type_label: str = "SQ try: from google import genai except ImportError: - return {"error": "google-genai library not installed. Please pip install google-genai."} - + return { + "error": "google-genai library not installed. Please pip install google-genai." + } + try: from dotenv import load_dotenv - env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '.env')) + + env_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", ".env") + ) load_dotenv(env_path, override=True) except ImportError: - pass + pass api_key = os.environ.get("GOOGLE_API_KEY") if not api_key: - return {"error": "GOOGLE_API_KEY environment variable not set. Please set it in a .env file or in your terminal."} + return { + "error": "GOOGLE_API_KEY environment variable not set. Please set it in a .env file or in your terminal." + } # 4. Call API try: client = genai.Client(api_key=api_key) - + response = client.models.generate_content( - model="gemini-2.5-flash-lite", - contents=prompt + model="gemini-2.5-flash-lite", contents=prompt ) - + explanation = response.text - + # 5. Save to Cache try: - with open(cache_file, 'w') as f: - json.dump({"explanation": explanation, "model": "gemini-2.5-flash-lite"}, f) + with open(cache_file, "w") as f: + json.dump( + {"explanation": explanation, "model": "gemini-2.5-flash-lite"}, f + ) except Exception as e: print(f"Failed to save cache: {e}") diff --git a/docs/conf.py b/docs/conf.py index e764c146..32a2b5f0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,69 +7,69 @@ import sys # -- Path setup -------------------------------------------------------------- -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath("..")) # -- Project information ----------------------------------------------------- -project = 'OHDSI CIRCE Python' -copyright = '2024, OHDSI Community' -author = 'CIRCE Python Implementation Team' -release = '0.1.0' -version = '0.1.0' +project = "OHDSI CIRCE Python" +copyright = "2024, OHDSI Community" +author = "CIRCE Python Implementation Team" +release = "0.1.0" +version = "0.1.0" # -- General configuration --------------------------------------------------- extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.autosummary', - 'sphinx.ext.napoleon', - 'sphinx.ext.viewcode', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.mathjax', - 'sphinx.ext.ifconfig', - 'sphinx.ext.githubpages', - 'sphinx_rtd_theme', - 'myst_parser', + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "sphinx.ext.mathjax", + "sphinx.ext.ifconfig", + "sphinx.ext.githubpages", + "sphinx_rtd_theme", + "myst_parser", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The suffix(es) of source filenames. source_suffix = { - '.rst': 'restructuredtext', - '.txt': 'markdown', - '.md': 'markdown', + ".rst": "restructuredtext", + ".txt": "markdown", + ".md": "markdown", } # The master toctree document. -master_doc = 'index' +master_doc = "index" # The language for content autogenerated by Sphinx. -language = 'en' +language = "en" # -- Options for HTML output ------------------------------------------------- -html_theme = 'sphinx_rtd_theme' -html_static_path = ['_static'] +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] html_theme_options = { - 'canonical_url': '', - 'analytics_id': '', - 'logo_only': False, - 'display_version': True, - 'prev_next_buttons_location': 'bottom', - 'style_external_links': False, - 'style_nav_header_background': '#2980B9', + "canonical_url": "", + "analytics_id": "", + "logo_only": False, + "display_version": True, + "prev_next_buttons_location": "bottom", + "style_external_links": False, + "style_nav_header_background": "#2980B9", # Toc options - 'collapse_navigation': False, - 'sticky_navigation': True, - 'navigation_depth': 4, - 'includehidden': True, - 'titles_only': False + "collapse_navigation": False, + "sticky_navigation": True, + "navigation_depth": 4, + "includehidden": True, + "titles_only": False, } # Add any paths that contain custom static files (such as style sheets) here @@ -78,15 +78,15 @@ # -- Options for autodoc ----------------------------------------------------- autodoc_default_options = { - 'members': True, - 'member-order': 'bysource', - 'special-members': '__init__', - 'undoc-members': True, - 'exclude-members': '__weakref__' + "members": True, + "member-order": "bysource", + "special-members": "__init__", + "undoc-members": True, + "exclude-members": "__weakref__", } -autodoc_typehints = 'description' -autodoc_typehints_description_target = 'documented' +autodoc_typehints = "description" +autodoc_typehints_description_target = "documented" # -- Options for autosummary ------------------------------------------------- autosummary_generate = True @@ -109,10 +109,9 @@ # -- Options for intersphinx ------------------------------------------------- intersphinx_mapping = { - 'python': ('https://docs.python.org/3', None), - 'pydantic': ('https://docs.pydantic.dev/latest/', None), + "python": ("https://docs.python.org/3", None), + "pydantic": ("https://docs.pydantic.dev/latest/", None), } # -- Options for todo extension ---------------------------------------------- todo_include_todos = True - diff --git a/examples/basic_cohort.py b/examples/basic_cohort.py index 113a6371..34b34407 100644 --- a/examples/basic_cohort.py +++ b/examples/basic_cohort.py @@ -17,7 +17,7 @@ def create_diabetes_cohort(): """Create a simple Type 2 Diabetes cohort definition.""" - + # Define the Type 2 Diabetes concept set diabetes_concept_set = ConceptSet( id=1, @@ -32,44 +32,44 @@ def create_diabetes_cohort(): vocabulary_id="SNOMED", concept_class_id="Clinical Finding", standard_concept="S", - concept_code="44054006" + concept_code="44054006", ), include_descendants=True, # Include all child concepts - is_excluded=False + is_excluded=False, ) ] - ) + ), ) - + # Create the primary criteria (first occurrence of condition) primary_criteria = PrimaryCriteria( criteria_list=[ ConditionOccurrence( codeset_id=1, # References the concept set above - first=True, # Only the first occurrence - condition_type_exclude=False + first=True, # Only the first occurrence + condition_type_exclude=False, ) ], observation_window=ObservationFilter( - prior_days=0, # Must have observation period starting on or before event - post_days=0 # Must have observation period ending on or after event + prior_days=0, # Must have observation period starting on or before event + post_days=0, # Must have observation period ending on or after event ), - primary_limit=ResultLimit(type="All") # Include all matching events + primary_limit=ResultLimit(type="All"), # Include all matching events ) - + # Create the complete cohort expression cohort = CohortExpression( title="Patients with Type 2 Diabetes", concept_sets=[diabetes_concept_set], - primary_criteria=primary_criteria + primary_criteria=primary_criteria, ) - + return cohort def generate_sql_from_cohort(cohort): """Generate SQL from the cohort definition.""" - + # Create build options # Note: For SqlRender compatibility, leave schema parameters unset # to preserve @vocabulary_database_schema notation in the output. @@ -77,9 +77,9 @@ def generate_sql_from_cohort(cohort): options = BuildExpressionQueryOptions() options.cohort_id = 1 options.generate_stats = True - + sql = build_cohort_query(cohort, options) - + return sql @@ -87,27 +87,27 @@ def generate_sql_from_cohort(cohort): # Create the cohort definition print("Creating Type 2 Diabetes cohort definition...") cohort = create_diabetes_cohort() - + # Display cohort information print(f"\nCohort Title: {cohort.title}") print(f"Number of Concept Sets: {len(cohort.concept_sets)}") print(f"Concept Set: {cohort.concept_sets[0].name}") - + # Generate SQL print("\nGenerating SQL...") sql = generate_sql_from_cohort(cohort) - + # Display first 500 characters of SQL print("\nGenerated SQL (first 500 chars):") print(sql[:500]) print("...") - + # Optionally save to file output_file = "diabetes_cohort.sql" with open(output_file, "w") as f: f.write(sql) print(f"\nFull SQL saved to: {output_file}") - + # Optionally export as JSON (Java CIRCE-BE compatible format) # Note: For ATLAS compatibility, concepts should have complete metadata (see ATLAS_COMPATIBILITY.md) json_output = cohort.model_dump_json(indent=2, by_alias=True, exclude_none=True) diff --git a/examples/complex_cohort.py b/examples/complex_cohort.py index 0c367b34..bb5219d1 100644 --- a/examples/complex_cohort.py +++ b/examples/complex_cohort.py @@ -43,7 +43,7 @@ def create_complex_cohort(): 5. Inclusion rule: HbA1c measurement within 6 months after diagnosis 6. Censoring: Observation ends if patient develops ESRD or enters hospice """ - + # Concept Set 1: Type 2 Diabetes diabetes_concepts = ConceptSet( id=1, @@ -52,15 +52,14 @@ def create_complex_cohort(): items=[ ConceptSetItem( concept=Concept( - concept_id=201826, - concept_name="Type 2 diabetes mellitus" + concept_id=201826, concept_name="Type 2 diabetes mellitus" ), - include_descendants=True + include_descendants=True, ) ] - ) + ), ) - + # Concept Set 2: Metformin metformin_concepts = ConceptSet( id=2, @@ -68,16 +67,13 @@ def create_complex_cohort(): expression=ConceptSetExpression( items=[ ConceptSetItem( - concept=Concept( - concept_id=1503297, - concept_name="Metformin" - ), - include_descendants=True + concept=Concept(concept_id=1503297, concept_name="Metformin"), + include_descendants=True, ) ] - ) + ), ) - + # Concept Set 3: Insulin (for exclusion) insulin_concepts = ConceptSet( id=3, @@ -85,16 +81,13 @@ def create_complex_cohort(): expression=ConceptSetExpression( items=[ ConceptSetItem( - concept=Concept( - concept_id=1511348, - concept_name="Insulin" - ), - include_descendants=True + concept=Concept(concept_id=1511348, concept_name="Insulin"), + include_descendants=True, ) ] - ) + ), ) - + # Concept Set 4: HbA1c measurement hba1c_concepts = ConceptSet( id=4, @@ -104,14 +97,14 @@ def create_complex_cohort(): ConceptSetItem( concept=Concept( concept_id=3004410, - concept_name="Hemoglobin A1c/Hemoglobin.total in Blood" + concept_name="Hemoglobin A1c/Hemoglobin.total in Blood", ), - include_descendants=True + include_descendants=True, ) ] - ) + ), ) - + # Concept Set 5: End-Stage Renal Disease (censoring event) esrd_concepts = ConceptSet( id=5, @@ -120,15 +113,14 @@ def create_complex_cohort(): items=[ ConceptSetItem( concept=Concept( - concept_id=46271022, - concept_name="End stage renal disease" + concept_id=46271022, concept_name="End stage renal disease" ), - include_descendants=True + include_descendants=True, ) ] - ) + ), ) - + # Concept Set 6: Hospice Care (censoring event) hospice_concepts = ConceptSet( id=6, @@ -136,16 +128,13 @@ def create_complex_cohort(): expression=ConceptSetExpression( items=[ ConceptSetItem( - concept=Concept( - concept_id=8536, - concept_name="Hospice care" - ), - include_descendants=True + concept=Concept(concept_id=8536, concept_name="Hospice care"), + include_descendants=True, ) ] - ) + ), ) - + # Primary Criteria: First Type 2 Diabetes diagnosis primary_criteria = PrimaryCriteria( criteria_list=[ @@ -154,84 +143,66 @@ def create_complex_cohort(): first=True, condition_type_exclude=False, # Age restriction at the time of diagnosis - age=NumericRange(value=18, op="gte") + age=NumericRange(value=18, op="gte"), ) ], - observation_window=ObservationFilter( - prior_days=0, - post_days=0 - ), - primary_limit=ResultLimit(type="All") + observation_window=ObservationFilter(prior_days=0, post_days=0), + primary_limit=ResultLimit(type="All"), ) - + # Additional Criteria: Metformin within 30 days after diagnosis metformin_criteria = CorelatedCriteria( - criteria=DrugExposure( - codeset_id=2, - first=False, - drug_type_exclude=False - ), + criteria=DrugExposure(codeset_id=2, first=False, drug_type_exclude=False), start_window=Window( use_event_end=False, start=WindowBound(coeff=-1, days=0), # Index date - end=WindowBound(coeff=1, days=30) # 30 days after + end=WindowBound(coeff=1, days=30), # 30 days after ), occurrence=Occurrence( type=2, # At least count=1, - is_distinct=False - ) + is_distinct=False, + ), ) - + # Additional Criteria: NO insulin in 180 days before diagnosis insulin_exclusion = CorelatedCriteria( - criteria=DrugExposure( - codeset_id=3, - first=False, - drug_type_exclude=False - ), + criteria=DrugExposure(codeset_id=3, first=False, drug_type_exclude=False), start_window=Window( use_event_end=False, start=WindowBound(coeff=-1, days=180), # 180 days before - end=WindowBound(coeff=-1, days=1) # Day before index + end=WindowBound(coeff=-1, days=1), # Day before index ), occurrence=Occurrence( type=0, # Exactly count=0, # Zero occurrences (exclusion) - is_distinct=False - ) + is_distinct=False, + ), ) - + # Combine additional criteria additional_criteria = CriteriaGroup( type="ALL", # Must meet all criteria - criteria_list=[ - metformin_criteria, - insulin_exclusion - ], + criteria_list=[metformin_criteria, insulin_exclusion], demographic_criteria_list=None, - groups=None + groups=None, ) - + # Inclusion Rule 1: HbA1c measurement within 6 months after diagnosis hba1c_measurement = CorelatedCriteria( - criteria=Measurement( - codeset_id=4, - first=False, - measurement_type_exclude=False - ), + criteria=Measurement(codeset_id=4, first=False, measurement_type_exclude=False), start_window=Window( use_event_end=False, - start=WindowBound(coeff=-1, days=0), # Index date - end=WindowBound(coeff=1, days=180) # 6 months (180 days) after + start=WindowBound(coeff=-1, days=0), # Index date + end=WindowBound(coeff=1, days=180), # 6 months (180 days) after ), occurrence=Occurrence( type=2, # At least count=1, # One measurement - is_distinct=False - ) + is_distinct=False, + ), ) - + inclusion_rule_hba1c = InclusionRule( name="Has HbA1c measurement within 6 months", description="Patient must have at least one HbA1c measurement within 6 months after diagnosis", @@ -239,29 +210,29 @@ def create_complex_cohort(): type="ALL", criteria_list=[hba1c_measurement], demographic_criteria_list=None, - groups=None - ) + groups=None, + ), ) - + # Inclusion Rule 2: Follow-up visit within 90 days followup_visit = CorelatedCriteria( criteria=ConditionOccurrence( codeset_id=1, # Type 2 Diabetes first=False, - condition_type_exclude=False + condition_type_exclude=False, ), start_window=Window( use_event_end=False, - start=WindowBound(coeff=1, days=1), # Day after index - end=WindowBound(coeff=1, days=90) # 90 days after + start=WindowBound(coeff=1, days=1), # Day after index + end=WindowBound(coeff=1, days=90), # 90 days after ), occurrence=Occurrence( type=2, # At least count=1, # One follow-up - is_distinct=False - ) + is_distinct=False, + ), ) - + inclusion_rule_followup = InclusionRule( name="Has follow-up visit within 90 days", description="Patient must have at least one follow-up visit for diabetes within 90 days after initial diagnosis", @@ -269,40 +240,38 @@ def create_complex_cohort(): type="ALL", criteria_list=[followup_visit], demographic_criteria_list=None, - groups=None - ) + groups=None, + ), ) - + # Censoring Criteria: Events that end observation for the patient # These represent serious complications or end-of-life care that would alter treatment censoring_criteria = [ # ESRD diagnosis - a serious complication requiring different treatment approach - ConditionOccurrence( - codeset_id=5, - first=False, - condition_type_exclude=False - ), + ConditionOccurrence(codeset_id=5, first=False, condition_type_exclude=False), # Hospice care - indicates end-of-life care, patient no longer appropriate for study - ConditionOccurrence( - codeset_id=6, - first=False, - condition_type_exclude=False - ) + ConditionOccurrence(codeset_id=6, first=False, condition_type_exclude=False), ] - + # Create the complete cohort expression cohort = CohortExpression( title="New Type 2 Diabetes Patients Started on Metformin with Monitoring", - concept_sets=[diabetes_concepts, metformin_concepts, insulin_concepts, - hba1c_concepts, esrd_concepts, hospice_concepts], + concept_sets=[ + diabetes_concepts, + metformin_concepts, + insulin_concepts, + hba1c_concepts, + esrd_concepts, + hospice_concepts, + ], primary_criteria=primary_criteria, additional_criteria=additional_criteria, inclusion_rules=[inclusion_rule_hba1c, inclusion_rule_followup], censoring_criteria=censoring_criteria, qualified_limit=ResultLimit(type="First"), # First qualifying event per person - expression_limit=ResultLimit(type="All") + expression_limit=ResultLimit(type="All"), ) - + return cohort @@ -310,15 +279,17 @@ def create_complex_cohort(): # Create the complex cohort print("Creating complex Type 2 Diabetes cohort with multiple criteria...") cohort = create_complex_cohort() - + # Display cohort information print(f"\nCohort Title: {cohort.title}") print(f"Number of Concept Sets: {len(cohort.concept_sets)}") print("Concept Sets:") for cs in cohort.concept_sets: print(f" - {cs.name}") - - print(f"\nAdditional Criteria: {len(cohort.additional_criteria.criteria_list)} conditions") + + print( + f"\nAdditional Criteria: {len(cohort.additional_criteria.criteria_list)} conditions" + ) print(f"Inclusion Rules: {len(cohort.inclusion_rules)} rules") for rule in cohort.inclusion_rules: print(f" - {rule.name}") @@ -328,7 +299,7 @@ def create_complex_cohort(): criteria_dict = criteria.model_dump(by_alias=True) criteria_type = list(criteria_dict.keys())[0] print(f" - {criteria_type}") - + # Generate SQL print("\nGenerating SQL...") # Note: For SqlRender compatibility, leave schema parameters unset @@ -337,21 +308,21 @@ def create_complex_cohort(): options = BuildExpressionQueryOptions() options.cohort_id = 2 options.generate_stats = True - + sql = build_cohort_query(cohort, options) - + # Save outputs sql_file = "complex_diabetes_cohort.sql" with open(sql_file, "w") as f: f.write(sql) print(f"SQL saved to: {sql_file}") - + json_file = "complex_diabetes_cohort.json" with open(json_file, "w") as f: # Note: For ATLAS compatibility, concepts should have complete metadata (see ATLAS_COMPATIBILITY.md) f.write(cohort.model_dump_json(indent=2, by_alias=True, exclude_none=True)) print(f"Cohort definition saved to: {json_file} (Java/ATLAS-compatible format)") - + print("\nCohort summary:") print(" - Index: First Type 2 Diabetes diagnosis") print(" - Age: 18+ at diagnosis") @@ -360,4 +331,3 @@ def create_complex_cohort(): print(" - Inclusion 1: HbA1c measurement within 6 months after diagnosis") print(" - Inclusion 2: Follow-up visit within 90 days after diagnosis") print(" - Censoring: Observation ends if ESRD or hospice care occurs") - diff --git a/examples/generate_sql.py b/examples/generate_sql.py index 71dc105d..5e5a84a5 100644 --- a/examples/generate_sql.py +++ b/examples/generate_sql.py @@ -18,7 +18,7 @@ def load_cohort_from_json_file(file_path): """Load a cohort expression from a JSON file.""" with open(file_path) as f: json_data = f.read() - + # Use the API function to parse JSON cohort = cohort_expression_from_json(json_data) return cohort @@ -27,23 +27,23 @@ def load_cohort_from_json_file(file_path): def generate_sql_simple(cohort_json_string): """Generate SQL using the simple API.""" from circe.api import cohort_expression_from_json - + # Parse JSON to CohortExpression cohort = cohort_expression_from_json(cohort_json_string) - + # Create options options = BuildExpressionQueryOptions() options.cohort_id = 1 # Note: Leave schema parameters unset to preserve @parameter notation for SqlRender - + sql = build_cohort_query(cohort, options) - + return sql def generate_sql_advanced(cohort): """Generate SQL using the advanced API with custom options.""" - + # Create custom options options = BuildExpressionQueryOptions() options.cdm_schema = "my_custom_cdm" @@ -51,77 +51,71 @@ def generate_sql_advanced(cohort): options.target_table = "#cohort_inclusion" options.results_schema = "results" options.generate_stats = True - + # Use the query builder directly builder = CohortExpressionQueryBuilder() sql = builder.build_expression_query(cohort, options) - + return sql def generate_sql_with_templates(cohort): """Generate different parts of the SQL separately.""" - + builder = CohortExpressionQueryBuilder() options = BuildExpressionQueryOptions() options.cdm_schema = "cdm" - + # Generate codeset query codeset_sql = builder.get_codeset_query(cohort.concept_sets) - + # Generate primary events query primary_events_sql = builder.get_primary_events_query(cohort.primary_criteria) - + # Generate inclusion rules if cohort.inclusion_rules: inclusion_rules_sql = builder.get_inclusion_rule_table_sql(cohort) else: inclusion_rules_sql = "-- No inclusion rules defined" - + return { "codeset": codeset_sql, "primary_events": primary_events_sql, - "inclusion_rules": inclusion_rules_sql + "inclusion_rules": inclusion_rules_sql, } def save_sql_to_file(sql, output_path): """Save generated SQL to a file.""" - with open(output_path, 'w') as f: + with open(output_path, "w") as f: f.write(sql) print(f"SQL saved to: {output_path}") def main(): """Main example execution.""" - + print("SQL Generation Examples\n" + "=" * 50) - + # Example 1: Generate from JSON string print("\n1. Simple API - Generate from JSON string") - simple_cohort_json = json.dumps({ - "ConceptSets": [], - "PrimaryCriteria": { - "CriteriaList": [ - { - "ConditionOccurrence": { - "CodesetId": 1, - "First": True - } - } - ], - "ObservationWindow": { - "PriorDays": 0, - "PostDays": 0 + simple_cohort_json = json.dumps( + { + "ConceptSets": [], + "PrimaryCriteria": { + "CriteriaList": [ + {"ConditionOccurrence": {"CodesetId": 1, "First": True}} + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryLimit": {"Type": "All"}, }, - "PrimaryLimit": {"Type": "All"} } - }) - + ) + sql = generate_sql_simple(simple_cohort_json) print(f"Generated SQL length: {len(sql)} characters") save_sql_to_file(sql, "simple_cohort.sql") - + # Example 2: Load from file and generate print("\n2. Load from JSON file") # Note: This assumes you have a cohort JSON file @@ -132,41 +126,41 @@ def main(): cohort_file = example_files[0] print(f"Loading cohort from: {cohort_file}") cohort = load_cohort_from_json_file(cohort_file) - + # Create options options = BuildExpressionQueryOptions() options.cohort_id = 100 # Note: Leave schema parameters unset to preserve @parameter notation for SqlRender - + sql = build_cohort_query(cohort, options) - + output_file = cohort_file.stem + "_generated.sql" save_sql_to_file(sql, output_file) else: print("No example cohort JSON files found. Run basic_cohort.py first.") except Exception as e: print(f"Could not load from file: {e}") - + # Example 3: Generate with custom options print("\n3. Advanced API with custom options") cohort = cohort_expression_from_json(simple_cohort_json) advanced_sql = generate_sql_advanced(cohort) print(f"Generated SQL length: {len(advanced_sql)} characters") save_sql_to_file(advanced_sql, "advanced_cohort.sql") - + # Example 4: Generate SQL parts separately print("\n4. Generate SQL components separately") sql_parts = generate_sql_with_templates(cohort) - + print(f" - Codeset SQL: {len(sql_parts['codeset'])} chars") print(f" - Primary Events SQL: {len(sql_parts['primary_events'])} chars") print(f" - Inclusion Rules SQL: {len(sql_parts['inclusion_rules'])} chars") - + # Save parts for part_name, part_sql in sql_parts.items(): filename = f"cohort_{part_name}.sql" save_sql_to_file(part_sql, filename) - + print("\n" + "=" * 50) print("All examples completed successfully!") diff --git a/examples/json_to_code_demo.ipynb b/examples/json_to_code_demo.ipynb index d9a47878..f2efecc2 100644 --- a/examples/json_to_code_demo.ipynb +++ b/examples/json_to_code_demo.ipynb @@ -68,7 +68,7 @@ } ], "source": [ - "with open('type2_diabetes_cohort.json') as f:\n", + "with open(\"type2_diabetes_cohort.json\") as f:\n", " data = json.load(f)\n", "\n", "# Create the CohortExpression object\n", @@ -191,7 +191,7 @@ "exec_globals = {}\n", "exec(python_code, exec_globals)\n", "\n", - "generated_cohort = exec_globals['cohort']\n", + "generated_cohort = exec_globals[\"cohort\"]\n", "\n", "print(f\"Generated Cohort Title: {generated_cohort.title}\")\n", "print(f\"Generated Checksum: {generated_cohort.checksum()}\")\n", @@ -234,7 +234,7 @@ } ], "source": [ - "save_to_file(original_cohort, 'generated_cohort.py')\n", + "save_to_file(original_cohort, \"generated_cohort.py\")\n", "print(\"Saved to generated_cohort.py\")" ] }, @@ -287,31 +287,31 @@ " concept_sets=[\n", " ConceptSet(\n", " id=1,\n", - " name='Type 2 Diabetes Mellitus',\n", + " name=\"Type 2 Diabetes Mellitus\",\n", " expression=ConceptSetExpression(\n", " items=[\n", " ConceptSetItem(\n", " concept=Concept(\n", " concept_id=201826,\n", - " concept_name='Type 2 diabetes mellitus',\n", - " concept_code='44054006',\n", - " concept_class_id='Disorder',\n", - " standard_concept='S',\n", - " domain_id='Condition',\n", - " vocabulary_id='SNOMED'\n", + " concept_name=\"Type 2 diabetes mellitus\",\n", + " concept_code=\"44054006\",\n", + " concept_class_id=\"Disorder\",\n", + " standard_concept=\"S\",\n", + " domain_id=\"Condition\",\n", + " vocabulary_id=\"SNOMED\",\n", " ),\n", - " include_descendants=True\n", + " include_descendants=True,\n", " )\n", " ]\n", - " )\n", + " ),\n", " )\n", " ],\n", " primary_criteria=PrimaryCriteria(\n", " criteria_list=[ConditionOccurrence(codeset_id=1, first=True)],\n", " observation_window=ObservationFilter(prior_days=365, post_days=1),\n", - " primary_limit=ResultLimit(type='All')\n", + " primary_limit=ResultLimit(type=\"All\"),\n", " ),\n", - " title='Type 2 Diabetes Mellitus Patients'\n", + " title=\"Type 2 Diabetes Mellitus Patients\",\n", ")" ] } diff --git a/examples/type2_diabetes_cohort.ipynb b/examples/type2_diabetes_cohort.ipynb index b314b922..5500db39 100644 --- a/examples/type2_diabetes_cohort.ipynb +++ b/examples/type2_diabetes_cohort.ipynb @@ -59,7 +59,7 @@ ")\n", "from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem\n", "\n", - "print(\"✓ All libraries imported successfully\")\n" + "print(\"✓ All libraries imported successfully\")" ] }, { @@ -119,18 +119,18 @@ " domain_id=\"Condition\",\n", " concept_class_id=\"Disorder\",\n", " standard_concept=\"S\",\n", - " concept_code=\"44054006\"\n", + " concept_code=\"44054006\",\n", " ),\n", - " include_descendants=True\n", + " include_descendants=True,\n", " )\n", " ]\n", - " )\n", + " ),\n", ")\n", "\n", "print(\"\\u2713 Concept set created:\")\n", "print(f\" ID: {t2dm_concept_set.id}\")\n", "print(f\" Name: {t2dm_concept_set.name}\")\n", - "print(f\" Items: {len(t2dm_concept_set.expression.items)}\")\n" + "print(f\" Items: {len(t2dm_concept_set.expression.items)}\")" ] }, { @@ -191,24 +191,24 @@ "primary_criteria = PrimaryCriteria(\n", " criteria_list=[\n", " ConditionOccurrence(\n", - " codeset_id=1, # References concept set ID 1\n", - " first=True, # Only the first occurrence\n", - " condition_type_exclude=False # Include all condition types\n", + " codeset_id=1, # References concept set ID 1\n", + " first=True, # Only the first occurrence\n", + " condition_type_exclude=False, # Include all condition types\n", " )\n", " ],\n", " observation_window=ObservationFilter(\n", - " prior_days=365, # Patient must have 1 year observation starting on or before diagnosis\n", - " post_days=1 # Patient must have observation on or after diagnosis\n", + " prior_days=365, # Patient must have 1 year observation starting on or before diagnosis\n", + " post_days=1, # Patient must have observation on or after diagnosis\n", " ),\n", " primary_limit=ResultLimit(\n", - " type=\"All\" # Include all qualifying events\n", - " )\n", + " type=\"All\" # Include all qualifying events\n", + " ),\n", ")\n", "\n", "print(\"✓ Primary criteria defined:\")\n", "print(\" Criteria Type: Condition Occurrence\")\n", "print(f\" Codeset ID: {primary_criteria.criteria_list[0].codeset_id}\")\n", - "print(f\" First Occurrence Only: {primary_criteria.criteria_list[0].first}\")\n" + "print(f\" First Occurrence Only: {primary_criteria.criteria_list[0].first}\")" ] }, { @@ -246,13 +246,13 @@ "cohort = CohortExpression(\n", " title=\"Type 2 Diabetes Mellitus Patients\",\n", " concept_sets=[t2dm_concept_set],\n", - " primary_criteria=primary_criteria\n", + " primary_criteria=primary_criteria,\n", ")\n", "\n", "print(\"✓ Cohort expression created:\")\n", "print(f\" Title: {cohort.title}\")\n", "print(f\" Number of Concept Sets: {len(cohort.concept_sets)}\")\n", - "print(f\" Primary Criteria Type: {type(primary_criteria.criteria_list[0]).__name__}\")\n" + "print(f\" Primary Criteria Type: {type(primary_criteria.criteria_list[0]).__name__}\")" ] }, { @@ -293,7 +293,7 @@ "else:\n", " print(f\"⚠️ Validation found {len(warnings)} issues:\")\n", " for warning in warnings:\n", - " print(f\" {warning.to_message()}\")\n" + " print(f\" {warning.to_message()}\")" ] }, { @@ -362,10 +362,12 @@ "from circe.cohortdefinition import BuildExpressionQueryOptions\n", "\n", "options = BuildExpressionQueryOptions()\n", - "options.cdm_schema = \"my_cdm_schema\" # Replace with your CDM schema name\n", - "options.vocabulary_schema = \"my_vocab_schema\" # Replace with your vocabulary schema name\n", + "options.cdm_schema = \"my_cdm_schema\" # Replace with your CDM schema name\n", + "options.vocabulary_schema = (\n", + " \"my_vocab_schema\" # Replace with your vocabulary schema name\n", + ")\n", "options.target_table = \"cohort\"\n", - "options.cohort_id = 1 # Cohort ID for the results table\n", + "options.cohort_id = 1 # Cohort ID for the results table\n", "\n", "sql = build_cohort_query(cohort, options)\n", "\n", @@ -374,7 +376,7 @@ "print(\"=\" * 80)\n", "print(sql[:1000])\n", "print(\"...\")\n", - "print(\"=\" * 80)\n" + "print(\"=\" * 80)" ] }, { @@ -421,26 +423,26 @@ "# Note: For ATLAS compatibility, concepts should have complete metadata (see ATLAS_COMPATIBILITY.md)\n", "cohort_json = cohort.model_dump_json(indent=2, by_alias=True, exclude_none=True)\n", "\n", - "with open('type2_diabetes_cohort.json', 'w') as f:\n", + "with open(\"type2_diabetes_cohort.json\", \"w\") as f:\n", " f.write(cohort_json)\n", "print(\"✓ Cohort definition saved to: type2_diabetes_cohort.json (ATLAS-compatible)\")\n", "\n", "# Save SQL query\n", - "with open('type2_diabetes_cohort.sql', 'w') as f:\n", + "with open(\"type2_diabetes_cohort.sql\", \"w\") as f:\n", " f.write(sql)\n", "print(\"✓ SQL query saved to: type2_diabetes_cohort.sql\")\n", "\n", "# Display summary\n", - "print(f\"\\n{'='*80}\")\n", + "print(f\"\\n{'=' * 80}\")\n", "print(\"SUMMARY\")\n", - "print(f\"{'='*80}\")\n", + "print(f\"{'=' * 80}\")\n", "print(f\"Cohort Title: {cohort.title}\")\n", "print(f\"Concept Sets: {len(cohort.concept_sets)}\")\n", "print(f\" - {t2dm_concept_set.name} (ID: {t2dm_concept_set.id})\")\n", "print(\"Primary Criteria: First Condition Occurrence\")\n", "print(f\"SQL Length: {len(sql)} characters\")\n", "print(f\"Validation: {'✓ PASSED' if not warnings else f'⚠️ {len(warnings)} warnings'}\")\n", - "print(f\"{'='*80}\")\n" + "print(f\"{'=' * 80}\")" ] }, { @@ -515,17 +517,17 @@ " domain_id=\"Drug\",\n", " concept_class_id=\"Ingredient\",\n", " standard_concept=\"S\",\n", - " concept_code=\"6809\"\n", + " concept_code=\"6809\",\n", " ),\n", - " include_descendants=True\n", + " include_descendants=True,\n", " )\n", " ]\n", - " )\n", + " ),\n", ")\n", "\n", "print(\"\\n\\u2713 Metformin concept set created:\")\n", "print(f\" ID: {metformin_concept_set.id}\")\n", - "print(f\" Name: {metformin_concept_set.name}\")\n" + "print(f\" Name: {metformin_concept_set.name}\")" ] }, { @@ -580,7 +582,7 @@ " vocabulary_id=\"Condition Type\",\n", " concept_class_id=\"Condition Type\",\n", " standard_concept=\"S\",\n", - " concept_code=\"OMOP4976890\"\n", + " concept_code=\"OMOP4976890\",\n", " )\n", "]\n", "\n", @@ -591,18 +593,18 @@ " codeset_id=1,\n", " first=True,\n", " condition_type=ehr_condition_types, # Specify EHR records only\n", - " condition_type_exclude=False\n", + " condition_type_exclude=False,\n", " )\n", " ],\n", " observation_window=ObservationFilter(prior_days=365, post_days=0),\n", - " primary_limit=ResultLimit(type=\"All\")\n", + " primary_limit=ResultLimit(type=\"All\"),\n", ")\n", "\n", "# Create cohort with specific criteria\n", "specific_cohort = CohortExpression(\n", " title=\"Type 2 Diabetes (EHR Only)\",\n", " concept_sets=[t2dm_concept_set],\n", - " primary_criteria=specific_criteria\n", + " primary_criteria=specific_criteria,\n", ")\n", "\n", "# Validate - should have zero warnings now\n", @@ -618,7 +620,7 @@ "\n", "print(\"\\nℹ️ Note: Using condition_type is optional. The original cohort\")\n", "print(\" (without condition_type) will work perfectly fine - it just accepts\")\n", - "print(\" ALL condition types, which is usually what you want.\")\n" + "print(\" ALL condition types, which is usually what you want.\")" ] }, { diff --git a/examples/validate_cohort.py b/examples/validate_cohort.py index 8b334e3a..fe91acc5 100644 --- a/examples/validate_cohort.py +++ b/examples/validate_cohort.py @@ -15,133 +15,126 @@ def validate_cohort_from_json(json_string): """ Validate a cohort definition from JSON. - + Returns: tuple: (cohort_expression, validation_warnings) """ # Parse the JSON cohort = cohort_expression_from_json(json_string) - + # Run validation checks checker = Checker() warnings = checker.check(cohort) - + return cohort, warnings def print_validation_results(warnings): """Pretty print validation warnings.""" - + if not warnings: print("✓ Cohort definition is valid with no warnings!") return True - + # Group warnings by severity critical = [w for w in warnings if w.severity == WarningSeverity.CRITICAL] warnings_list = [w for w in warnings if w.severity == WarningSeverity.WARNING] info_list = [w for w in warnings if w.severity == WarningSeverity.INFO] - + # Print critical warnings if critical: print(f"\n✗ CRITICAL ({len(critical)}):") for err in critical: print(f" - {err.to_message()}") - if hasattr(err, 'location') and err.location: + if hasattr(err, "location") and err.location: print(f" Location: {err.location}") - + # Print warnings if warnings_list: print(f"\n⚠ WARNINGS ({len(warnings_list)}):") for warn in warnings_list: print(f" - {warn.to_message()}") - if hasattr(warn, 'location') and warn.location: + if hasattr(warn, "location") and warn.location: print(f" Location: {warn.location}") - + # Print info messages if info_list: print(f"\nℹ INFO ({len(info_list)}):") for info in info_list: print(f" - {info.to_message()}") - + # Return True if no critical warnings return len(critical) == 0 def create_valid_cohort_json(): """Create a valid cohort definition for testing.""" - return json.dumps({ - "ConceptSets": [ - { - "id": 1, - "name": "Type 2 Diabetes", - "expression": { - "items": [ - { - "concept": { - "CONCEPT_ID": 201826, - "CONCEPT_NAME": "Type 2 diabetes mellitus" - }, - "includeDescendants": True - } - ] - } - } - ], - "PrimaryCriteria": { - "CriteriaList": [ + return json.dumps( + { + "ConceptSets": [ { - "ConditionOccurrence": { - "CodesetId": 1, - "First": True - } + "id": 1, + "name": "Type 2 Diabetes", + "expression": { + "items": [ + { + "concept": { + "CONCEPT_ID": 201826, + "CONCEPT_NAME": "Type 2 diabetes mellitus", + }, + "includeDescendants": True, + } + ] + }, } ], - "ObservationWindow": { - "PriorDays": 0, - "PostDays": 0 + "PrimaryCriteria": { + "CriteriaList": [ + {"ConditionOccurrence": {"CodesetId": 1, "First": True}} + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryLimit": {"Type": "All"}, }, - "PrimaryLimit": {"Type": "All"} } - }) + ) def create_invalid_cohort_json(): """Create an invalid cohort definition for testing.""" - return json.dumps({ - "ConceptSets": [], # Empty concept sets - "PrimaryCriteria": { - "CriteriaList": [ - { - "ConditionOccurrence": { - "CodesetId": 999, # References non-existent concept set - "First": True + return json.dumps( + { + "ConceptSets": [], # Empty concept sets + "PrimaryCriteria": { + "CriteriaList": [ + { + "ConditionOccurrence": { + "CodesetId": 999, # References non-existent concept set + "First": True, + } } - } - ], - "ObservationWindow": { - "PriorDays": 0, - "PostDays": 0 + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryLimit": {"Type": "All"}, }, - "PrimaryLimit": {"Type": "All"} } - }) + ) def validate_from_file(file_path): """Validate a cohort definition from a JSON file.""" print(f"Validating cohort from: {file_path}") - + try: with open(file_path) as f: json_string = f.read() - + cohort, warnings = validate_cohort_from_json(json_string) - + print(f"\nCohort Title: {cohort.title if cohort.title else '(Untitled)'}") is_valid = print_validation_results(warnings) - + return is_valid - + except FileNotFoundError: print(f"Error: File not found: {file_path}") return False @@ -155,36 +148,37 @@ def validate_from_file(file_path): def main(): """Main example execution.""" - + print("Cohort Validation Examples\n" + "=" * 50) - + # Example 1: Validate a valid cohort print("\n1. Validating a VALID cohort definition:") print("-" * 50) valid_json = create_valid_cohort_json() cohort, warnings = validate_cohort_from_json(valid_json) is_valid = print_validation_results(warnings) - + if is_valid: print("\n✓ Cohort is valid and ready to use!") - + # Example 2: Validate an invalid cohort print("\n\n2. Validating an INVALID cohort definition:") print("-" * 50) invalid_json = create_invalid_cohort_json() cohort, warnings = validate_cohort_from_json(invalid_json) is_valid = print_validation_results(warnings) - + if not is_valid: print("\n✗ Cohort has errors and cannot be used!") - + # Example 3: Validate from file (if available) print("\n\n3. Validating cohort from file:") print("-" * 50) - + from pathlib import Path + example_files = list(Path(".").glob("*_cohort.json")) - + if example_files: for file_path in example_files[:1]: # Just validate the first one is_valid = validate_from_file(file_path) @@ -194,8 +188,10 @@ def main(): print(f"\n✗ {file_path} has validation issues!") else: print("No example cohort JSON files found.") - print("Run basic_cohort.py or complex_cohort.py first to generate example files.") - + print( + "Run basic_cohort.py or complex_cohort.py first to generate example files." + ) + print("\n" + "=" * 50) print("Validation examples completed!") diff --git a/scripts/generate_skill_backup.py b/scripts/generate_skill_backup.py index ee991a29..53d524c6 100644 --- a/scripts/generate_skill_backup.py +++ b/scripts/generate_skill_backup.py @@ -33,6 +33,7 @@ @dataclass class MethodInfo: """Information about a method.""" + name: str signature: str return_type: str @@ -44,55 +45,61 @@ class MethodInfo: class SkillGenerator: """Generates SKILL.md from the cohort builder codebase.""" - + def __init__(self): self.builder_methods: List[MethodInfo] = [] self.entry_methods: List[MethodInfo] = [] self.criteria_methods: List[MethodInfo] = [] self.query_modifiers: Dict[str, List[MethodInfo]] = {} self.time_windows: List[MethodInfo] = [] - + def extract_method_info(self, cls, method_name: str) -> MethodInfo: """Extract information about a method.""" method = getattr(cls, method_name) sig = inspect.signature(method) - + # Get return type return_annotation = sig.return_annotation if return_annotation == inspect.Signature.empty: return_type = "Unknown" else: return_type = str(return_annotation).replace("'", "") - + # Build parameter list params = [] for param_name, param in sig.parameters.items(): - if param_name == 'self': + if param_name == "self": continue param_info = { - 'name': param_name, - 'type': str(param.annotation) if param.annotation != inspect.Parameter.empty else 'Any', - 'default': param.default if param.default != inspect.Parameter.empty else None, - 'required': param.default == inspect.Parameter.empty + "name": param_name, + "type": str(param.annotation) + if param.annotation != inspect.Parameter.empty + else "Any", + "default": param.default + if param.default != inspect.Parameter.empty + else None, + "required": param.default == inspect.Parameter.empty, } params.append(param_info) - + # Build signature string param_strs = [] for p in params: - if p['default'] is not None: + if p["default"] is not None: param_strs.append(f"{p['name']}={p['default']}") else: - param_strs.append(p['name']) + param_strs.append(p["name"]) signature = f"{method_name}({', '.join(param_strs)})" - + # Get docstring docstring = inspect.getdoc(method) or "" - + # Determine if method finalizes (returns parent) or chains (returns self) - finalizes = 'CohortWithCriteria' in return_type or 'CohortWithEntry' in return_type - is_chainable = return_type != 'None' and not finalizes - + finalizes = ( + "CohortWithCriteria" in return_type or "CohortWithEntry" in return_type + ) + is_chainable = return_type != "None" and not finalizes + return MethodInfo( name=method_name, signature=signature, @@ -100,55 +107,117 @@ def extract_method_info(self, cls, method_name: str) -> MethodInfo: docstring=docstring, parameters=params, is_chainable=is_chainable, - finalizes=finalizes + finalizes=finalizes, ) - + def discover_methods(self): """Discover all public methods from the builder classes.""" - + # CohortBuilder entry methods - for name, method in inspect.getmembers(CohortBuilder, predicate=inspect.isfunction): - if name.startswith('_') or name == 'with_concept_sets': + for name, method in inspect.getmembers( + CohortBuilder, predicate=inspect.isfunction + ): + if name.startswith("_") or name == "with_concept_sets": continue - if name.startswith('with_'): - self.builder_methods.append(self.extract_method_info(CohortBuilder, name)) - + if name.startswith("with_"): + self.builder_methods.append( + self.extract_method_info(CohortBuilder, name) + ) + # CohortWithEntry methods - for name, method in inspect.getmembers(CohortWithEntry, predicate=inspect.isfunction): - if name.startswith('_'): + for name, method in inspect.getmembers( + CohortWithEntry, predicate=inspect.isfunction + ): + if name.startswith("_"): continue - if name in ['first_occurrence', 'with_observation', 'min_age', 'max_age', - 'require_age', 'require_gender', 'require_race', 'require_ethnicity', - 'begin_rule', 'any_of', 'all_of', 'at_least_of']: - self.entry_methods.append(self.extract_method_info(CohortWithEntry, name)) - + if name in [ + "first_occurrence", + "with_observation", + "min_age", + "max_age", + "require_age", + "require_gender", + "require_race", + "require_ethnicity", + "begin_rule", + "any_of", + "all_of", + "at_least_of", + ]: + self.entry_methods.append( + self.extract_method_info(CohortWithEntry, name) + ) + # CohortWithCriteria methods - for name, method in inspect.getmembers(CohortWithCriteria, predicate=inspect.isfunction): - if name.startswith('_'): + for name, method in inspect.getmembers( + CohortWithCriteria, predicate=inspect.isfunction + ): + if name.startswith("_"): continue - if name.startswith('require_') or name.startswith('exclude_') or \ - name in ['any_of', 'all_of', 'at_least_of', 'begin_rule', 'build', - 'require_any_of', 'require_all_of', 'require_at_least_of', 'exclude_any_of']: - self.criteria_methods.append(self.extract_method_info(CohortWithCriteria, name)) - + if ( + name.startswith("require_") + or name.startswith("exclude_") + or name + in [ + "any_of", + "all_of", + "at_least_of", + "begin_rule", + "build", + "require_any_of", + "require_all_of", + "require_at_least_of", + "exclude_any_of", + ] + ): + self.criteria_methods.append( + self.extract_method_info(CohortWithCriteria, name) + ) + # BaseQuery time windows for name, method in inspect.getmembers(BaseQuery, predicate=inspect.isfunction): - if name in ['within_days_before', 'within_days_after', 'within_days', - 'anytime_before', 'anytime_after', 'same_day', 'restrict_to_visit', - 'during_event', 'before_event_end']: + if name in [ + "within_days_before", + "within_days_after", + "within_days", + "anytime_before", + "anytime_after", + "same_day", + "restrict_to_visit", + "during_event", + "before_event_end", + ]: self.time_windows.append(self.extract_method_info(BaseQuery, name)) - + # Domain-specific modifiers modifier_map = { - 'BaseQuery': ['at_least', 'at_most', 'exactly', 'with_distinct', 'ignore_observation_period'], - 'ProcedureQuery': ['with_quantity', 'with_modifier'], - 'MeasurementQuery': ['with_operator', 'with_value', 'with_unit', 'is_abnormal', - 'with_range_low_ratio', 'with_range_high_ratio'], - 'DrugQuery': ['with_route', 'with_dose', 'with_refills', 'with_days_supply', 'with_quantity'], - 'VisitQuery': ['with_length', 'with_place_of_service'], - 'ObservationQuery': ['with_qualifier', 'with_value_as_string'] + "BaseQuery": [ + "at_least", + "at_most", + "exactly", + "with_distinct", + "ignore_observation_period", + ], + "ProcedureQuery": ["with_quantity", "with_modifier"], + "MeasurementQuery": [ + "with_operator", + "with_value", + "with_unit", + "is_abnormal", + "with_range_low_ratio", + "with_range_high_ratio", + ], + "DrugQuery": [ + "with_route", + "with_dose", + "with_refills", + "with_days_supply", + "with_quantity", + ], + "VisitQuery": ["with_length", "with_place_of_service"], + "ObservationQuery": ["with_qualifier", "with_value_as_string"], } - + for cls_name, methods in modifier_map.items(): cls = globals().get(cls_name) if cls: @@ -158,62 +227,79 @@ def discover_methods(self): self.query_modifiers[cls_name].append( self.extract_method_info(cls, method_name) ) - + def generate_markdown(self) -> str: """Generate the SKILL.md content.""" md = [] - + # Header md.append("---") - md.append("description: Build OHDSI cohort definitions using the fluent Python API") + md.append( + "description: Build OHDSI cohort definitions using the fluent Python API" + ) md.append("---") md.append("") md.append("# Cohort Builder Skill") md.append("") - md.append("Build OHDSI cohort definitions step-by-step using the fluent `cohort_builder` API.") + md.append( + "Build OHDSI cohort definitions step-by-step using the fluent `cohort_builder` API." + ) md.append("") - md.append("**⚠️ AUTO-GENERATED**: This file is generated from the codebase. Do not edit manually.") + md.append( + "**⚠️ AUTO-GENERATED**: This file is generated from the codebase. Do not edit manually." + ) md.append("") - + # Entry Events md.append("## Entry Event Methods") md.append("") - md.append("Start building a cohort with one of these methods on `CohortBuilder`:") + md.append( + "Start building a cohort with one of these methods on `CohortBuilder`:" + ) md.append("") md.append("```python") for method in sorted(self.builder_methods, key=lambda m: m.name): - md.append(f"CohortBuilder(\"Title\").{method.signature}") + md.append(f'CohortBuilder("Title").{method.signature}') md.append("```") md.append("") - + # Entry Configuration md.append("## Entry Configuration Methods") md.append("") md.append("After defining the entry event, configure it with:") md.append("") for method in sorted(self.entry_methods, key=lambda m: m.name): - if method.name in ['first_occurrence', 'with_observation', 'min_age', 'max_age']: + if method.name in [ + "first_occurrence", + "with_observation", + "min_age", + "max_age", + ]: md.append(f"### `.{method.signature}`") if method.docstring: md.append(f"{method.docstring}") md.append("") - + # Demographics md.append("## Demographic Criteria") md.append("") md.append("Add demographic requirements:") md.append("") for method in sorted(self.entry_methods, key=lambda m: m.name): - if method.name.startswith('require_'): - md.append(f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}") + if method.name.startswith("require_"): + md.append( + f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}" + ) md.append("") - + # CRITICAL CHAINING RULE md.append("## ⚠️ CRITICAL CHAINING RULE") md.append("") md.append("**Modifiers MUST be called BEFORE time windows!**") md.append("") - md.append("Time window methods finalize the criteria and return to the parent builder.") + md.append( + "Time window methods finalize the criteria and return to the parent builder." + ) md.append("Once a time window is called, you cannot chain further modifiers.") md.append("") md.append("✅ **CORRECT**:") @@ -226,16 +312,18 @@ def generate_markdown(self) -> str: md.append(".require_drug(10).within_days_before(30).at_least(2) # ERROR!") md.append("```") md.append("") - + # Time Windows md.append("## Time Window Methods (Call LAST)") md.append("") md.append("These methods finalize the criteria:") md.append("") for method in sorted(self.time_windows, key=lambda m: m.name): - md.append(f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}") + md.append( + f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}" + ) md.append("") - + # Modifiers md.append("## Modifier Methods (Call BEFORE time windows)") md.append("") @@ -246,45 +334,50 @@ def generate_markdown(self) -> str: for method in sorted(methods, key=lambda m: m.name): md.append(f"- `.{method.signature}`") md.append("") - + # Inclusion Criteria md.append("## Inclusion Criteria Methods") md.append("") md.append("Build complex criteria with:") md.append("") for method in sorted(self.criteria_methods, key=lambda m: m.name): - if method.name in ['require_any_of', 'require_all_of', 'require_at_least_of', 'exclude_any_of']: + if method.name in [ + "require_any_of", + "require_all_of", + "require_at_least_of", + "exclude_any_of", + ]: md.append(f"### `.{method.signature}`") if method.docstring: md.append(f"{method.docstring[:200]}...") md.append("") - + return "\n".join(md) - + def run(self, output_path: str): """Run the skill generator.""" print("🔍 Discovering methods...") self.discover_methods() - + print(f"✅ Found {len(self.builder_methods)} entry methods") print(f"✅ Found {len(self.entry_methods)} configuration methods") print(f"✅ Found {len(self.criteria_methods)} criteria methods") print(f"✅ Found {len(self.time_windows)} time window methods") - + print("\n📝 Generating SKILL.md...") content = self.generate_markdown() - - with open(output_path, 'w') as f: + + with open(output_path, "w") as f: f.write(content) - + print(f"✅ Generated {output_path}") print(f"📊 Total lines: {len(content.splitlines())}") return content - + def update_system_prompt(self, skill_content: str, prompt_path: str): """Update a system prompt with the generated skill.""" print(f"📝 Updating {prompt_path}...") - + try: # Read existing prompt with open(prompt_path) as f: @@ -292,18 +385,18 @@ def update_system_prompt(self, skill_content: str, prompt_path: str): except FileNotFoundError: print(f"⚠️ Prompt file not found: {prompt_path}") return - + # Find the SKILL section markers start_marker = "[BEGIN SKILL.MD CONTENT]" end_marker = "[END SKILL.MD CONTENT]" - + start_idx = prompt_content.find(start_marker) end_idx = prompt_content.find(end_marker) - + if start_idx == -1 or end_idx == -1: print(f"⚠️ Could not find SKILL section markers in {prompt_path}") return - + # Replace the content between markers # Skip the frontmatter from skill content skill_lines = skill_content.splitlines() @@ -318,40 +411,41 @@ def update_system_prompt(self, skill_content: str, prompt_path: str): continue if not in_frontmatter: skill_body.append(line) - + new_skill_section = "\n".join(skill_body).strip() - + new_prompt = ( - prompt_content[:start_idx + len(start_marker)] + - "\n\n" + new_skill_section + "\n\n" + - prompt_content[end_idx:] + prompt_content[: start_idx + len(start_marker)] + + "\n\n" + + new_skill_section + + "\n\n" + + prompt_content[end_idx:] ) - + # Write updated prompt - with open(prompt_path, 'w') as f: + with open(prompt_path, "w") as f: f.write(new_prompt) - - print(f"✅ Updated {prompt_path}") + print(f"✅ Updated {prompt_path}") if __name__ == "__main__": generator = SkillGenerator() - + # Generate SKILL.md skill_output = ".agent/skills/cohort_builder/SKILL.md" skill_content = generator.run(skill_output) - + # Update all system prompt variants prompts = [ ("prompts/reasoning_models_prompt.md", "Reasoning Models"), ("prompts/standard_models_prompt.md", "Standard Models"), ("prompts/fast_models_prompt.md", "Fast Models"), ] - + for prompt_path, model_type in prompts: generator.update_system_prompt(skill_content, prompt_path) - + print("\n✅ All documentation updated!") print(" - SKILL.md") print(f" - {len(prompts)} model-specific prompts") diff --git a/tests/conftest.py b/tests/conftest.py index 92045c5b..3c8d795d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,13 @@ - - def pytest_addoption(parser): parser.addoption( - "--sample-cohorts", action="store_true", default=False, help="Randomly sample 10 cohorts for testing" + "--sample-cohorts", + action="store_true", + default=False, + help="Randomly sample 10 cohorts for testing", ) parser.addoption( - "--cohort-filter", action="store", default=None, help="Comma-separated list of specific cohort files to test (e.g. '532.json,932.json')" + "--cohort-filter", + action="store", + default=None, + help="Comma-separated list of specific cohort files to test (e.g. '532.json,932.json')", ) diff --git a/tests/test_builder_utils_coverage.py b/tests/test_builder_utils_coverage.py index 1edcfd5c..2dc32054 100644 --- a/tests/test_builder_utils_coverage.py +++ b/tests/test_builder_utils_coverage.py @@ -13,31 +13,33 @@ class TestBuilderUtilsNumericRanges: """Test numeric range clause building.""" - + def test_numeric_range_between_uses_and(self): """Test bt operator uses >= and <=.""" range_val = NumericRange(op="bt", value=10, extent=20) # Integer range (no format) clause = BuilderUtils.build_numeric_range_clause("age", range_val) assert "age >= 10" in clause and "age <= 20" in clause - + # Double range (with format) - clause_decimal = BuilderUtils.build_numeric_range_clause("age", range_val, format=".4f") + clause_decimal = BuilderUtils.build_numeric_range_clause( + "age", range_val, format=".4f" + ) assert "age >= 10.0000" in clause_decimal and "age <= 20.0000" in clause_decimal - + def test_numeric_range_greater_than(self): """Test > operator.""" range_val = NumericRange(op="gt", value=18) clause = BuilderUtils.build_numeric_range_clause("age", range_val) assert ">" in clause assert "18" in clause - + def test_numeric_range_greater_equal(self): """Test >= operator.""" range_val = NumericRange(op="gte", value=18) clause = BuilderUtils.build_numeric_range_clause("age", range_val) assert ">=" in clause - + def test_numeric_range_less_than(self): """Test < operator.""" range_val = NumericRange(op="lt", value=65) @@ -62,7 +64,7 @@ def test_numeric_range_not_equal(self): range_val = NumericRange(op="!eq", value=50) clause = BuilderUtils.build_numeric_range_clause("age", range_val) assert "<>" in clause - + def test_numeric_range_none(self): """Test None range returns None.""" clause = BuilderUtils.build_numeric_range_clause("age", None) @@ -71,25 +73,31 @@ def test_numeric_range_none(self): class TestBuilderUtilsDateRanges: """Test date range clause building.""" - + def test_date_range_simple(self): """Test simple date range.""" range_val = DateRange(op="gt", value="2020-01-01") clause = BuilderUtils.build_date_range_clause("start_date", range_val) assert clause == "start_date > DATEFROMPARTS(2020, 1, 1)" - + def test_date_range_between(self): """Test between date range.""" range_val = DateRange(op="bt", value="2020-01-01", extent="2020-12-31") clause = BuilderUtils.build_date_range_clause("start_date", range_val) - assert clause == "(start_date >= DATEFROMPARTS(2020, 1, 1) and start_date <= DATEFROMPARTS(2020, 12, 31))" + assert ( + clause + == "(start_date >= DATEFROMPARTS(2020, 1, 1) and start_date <= DATEFROMPARTS(2020, 12, 31))" + ) def test_date_range_not_between(self): """Test not between date range.""" range_val = DateRange(op="!bt", value="2020-01-01", extent="2020-12-31") clause = BuilderUtils.build_date_range_clause("start_date", range_val) - assert clause == "not (start_date >= DATEFROMPARTS(2020, 1, 1) and start_date <= DATEFROMPARTS(2020, 12, 31))" - + assert ( + clause + == "not (start_date >= DATEFROMPARTS(2020, 1, 1) and start_date <= DATEFROMPARTS(2020, 12, 31))" + ) + def test_date_range_none(self): """Test None date range returns None.""" clause = BuilderUtils.build_date_range_clause("start_date", None) @@ -98,35 +106,31 @@ def test_date_range_none(self): class TestBuilderUtilsDateAdjustment: """Test date adjustment expression building.""" - + def test_date_adjustment_basic(self): """Test basic date adjustment.""" adjustment = DateAdjustment( start_with="start_date", start_offset=0, end_with="start_date", - end_offset=30 + end_offset=30, ) expr = BuilderUtils.get_date_adjustment_expression( - adjustment, - "drug_exposure_start_date", - "drug_exposure_end_date" + adjustment, "drug_exposure_start_date", "drug_exposure_end_date" ) assert "DATEADD" in expr assert "30" in expr - + def test_date_adjustment_negative_offset(self): """Test date adjustment with negative offset.""" adjustment = DateAdjustment( start_with="start_date", start_offset=-7, end_with="start_date", - end_offset=0 + end_offset=0, ) expr = BuilderUtils.get_date_adjustment_expression( - adjustment, - "drug_exposure_start_date", - "drug_exposure_end_date" + adjustment, "drug_exposure_start_date", "drug_exposure_end_date" ) assert "DATEADD" in expr assert "-7" in expr @@ -134,55 +138,57 @@ def test_date_adjustment_negative_offset(self): class TestBuilderUtilsCodesets: """Test codeset-related utility functions.""" - + def test_get_concept_ids_from_concepts(self): """Test extracting concept IDs from concept list.""" concepts = [ Concept(concept_id=123, concept_name="Test1"), Concept(concept_id=456, concept_name="Test2"), - Concept(concept_id=789, concept_name="Test3") + Concept(concept_id=789, concept_name="Test3"), ] ids = BuilderUtils.get_concept_ids_from_concepts(concepts) assert 123 in ids assert 456 in ids assert 789 in ids assert len(ids) == 3 - + def test_get_concept_ids_empty_list(self): """Test with empty concept list.""" ids = BuilderUtils.get_concept_ids_from_concepts([]) assert ids == [] - + def test_get_codeset_in_expression(self): """Test codeset IN expression generation.""" expr = BuilderUtils.get_codeset_in_expression(5, "drug_concept_id") assert "drug_concept_id" in expr assert "5" in expr - + def test_get_codeset_in_expression_with_exclusion(self): """Test codeset NOT IN expression generation.""" - expr = BuilderUtils.get_codeset_in_expression(5, "drug_concept_id", is_exclusion=True) + expr = BuilderUtils.get_codeset_in_expression( + 5, "drug_concept_id", is_exclusion=True + ) assert "drug_concept_id" in expr assert "not" in expr.lower() - + def test_get_codeset_join_expression_standard_only(self): """Test codeset join with standard codeset only.""" expr = BuilderUtils.get_codeset_join_expression( standard_codeset_id=10, standard_concept_column="de.drug_concept_id", source_codeset_id=None, - source_concept_column="de.drug_source_concept_id" + source_concept_column="de.drug_source_concept_id", ) assert "JOIN" in expr assert "10" in expr - + def test_get_codeset_join_expression_with_source(self): """Test codeset join with both standard and source.""" expr = BuilderUtils.get_codeset_join_expression( standard_codeset_id=10, standard_concept_column="de.drug_concept_id", source_codeset_id=11, - source_concept_column="de.drug_source_concept_id" + source_concept_column="de.drug_source_concept_id", ) assert "JOIN" in expr assert "10" in expr @@ -191,18 +197,18 @@ def test_get_codeset_join_expression_with_source(self): class TestBuilderUtilsOther: """Test other utility functions.""" - + def test_split_in_clause_small(self): """Test split IN clause with small list.""" values = [1, 2, 3, 4, 5] result = BuilderUtils.split_in_clause("concept_id", values) assert result == "(concept_id in (1,2,3,4,5))" - + def test_split_in_clause_empty(self): """Test split IN clause with empty list.""" result = BuilderUtils.split_in_clause("concept_id", []) assert result == "NULL" - + def test_date_string_to_sql(self): """Test date string to SQL conversion.""" result = BuilderUtils.date_string_to_sql("2020-01-01") @@ -211,24 +217,24 @@ def test_date_string_to_sql(self): class TestBuilderOptions: """Test BuilderOptions class.""" - + def test_builder_options_init(self): """Test BuilderOptions initialization.""" options = BuilderOptions() - assert hasattr(options, 'additional_columns') + assert hasattr(options, "additional_columns") assert isinstance(options.additional_columns, list) class TestCriteriaColumn: """Test CriteriaColumn enum.""" - + def test_criteria_column_values(self): """Test that CriteriaColumn enum has expected values.""" - assert hasattr(CriteriaColumn, 'START_DATE') - assert hasattr(CriteriaColumn, 'END_DATE') - assert hasattr(CriteriaColumn, 'DOMAIN_CONCEPT') - assert hasattr(CriteriaColumn, 'VISIT_ID') - assert hasattr(CriteriaColumn, 'DURATION') - assert hasattr(CriteriaColumn, 'DAYS_SUPPLY') - assert hasattr(CriteriaColumn, 'QUANTITY') - assert hasattr(CriteriaColumn, 'REFILLS') + assert hasattr(CriteriaColumn, "START_DATE") + assert hasattr(CriteriaColumn, "END_DATE") + assert hasattr(CriteriaColumn, "DOMAIN_CONCEPT") + assert hasattr(CriteriaColumn, "VISIT_ID") + assert hasattr(CriteriaColumn, "DURATION") + assert hasattr(CriteriaColumn, "DAYS_SUPPLY") + assert hasattr(CriteriaColumn, "QUANTITY") + assert hasattr(CriteriaColumn, "REFILLS") diff --git a/tests/test_builders.py b/tests/test_builders.py index ea81cdbf..1d43e432 100644 --- a/tests/test_builders.py +++ b/tests/test_builders.py @@ -36,7 +36,7 @@ class TestCriteriaColumn(unittest.TestCase): """Test CriteriaColumn enum functionality.""" - + def test_criteria_column_string_values(self): """Test that criteria columns have correct string values.""" self.assertEqual(CriteriaColumn.START_DATE.value, "start_date") @@ -49,12 +49,12 @@ def test_criteria_column_string_values(self): self.assertEqual(CriteriaColumn.UNIT.value, "unit_concept_id") self.assertEqual(CriteriaColumn.VALUE_AS_NUMBER.value, "value_as_number") self.assertEqual(CriteriaColumn.VISIT_DETAIL_ID.value, "visit_detail_id") - + def test_criteria_column_enum_inheritance(self): """Test that CriteriaColumn inherits from both str and Enum.""" self.assertTrue(issubclass(CriteriaColumn, str)) self.assertTrue(issubclass(CriteriaColumn, Enum)) - + def test_criteria_column_comparison(self): """Test that criteria columns can be compared as strings.""" self.assertEqual(CriteriaColumn.START_DATE, "start_date") @@ -63,131 +63,136 @@ def test_criteria_column_comparison(self): class TestBuilderOptions(unittest.TestCase): """Test BuilderOptions functionality.""" - + def test_builder_options_initialization(self): """Test BuilderOptions initialization.""" options = BuilderOptions() self.assertIsInstance(options.additional_columns, list) self.assertEqual(len(options.additional_columns), 0) - + def test_builder_options_additional_columns(self): """Test setting additional columns.""" options = BuilderOptions() - options.additional_columns = [CriteriaColumn.DOMAIN_CONCEPT, CriteriaColumn.DURATION] - + options.additional_columns = [ + CriteriaColumn.DOMAIN_CONCEPT, + CriteriaColumn.DURATION, + ] + self.assertEqual(len(options.additional_columns), 2) self.assertIn(CriteriaColumn.DOMAIN_CONCEPT, options.additional_columns) self.assertIn(CriteriaColumn.DURATION, options.additional_columns) - + def test_builder_options_empty_additional_columns(self): """Test that additional columns can be empty.""" options = BuilderOptions() options.additional_columns = [] - + self.assertEqual(len(options.additional_columns), 0) class TestBuilderUtils(unittest.TestCase): """Test BuilderUtils static methods.""" - + def test_get_date_adjustment_expression(self): """Test date adjustment expression generation.""" date_adjustment = DateAdjustment(start_offset=30, end_offset=-7) - + result = BuilderUtils.get_date_adjustment_expression( date_adjustment, "start_col", "end_col" ) - + expected = "DATEADD(day,30, start_col) as start_date, DATEADD(day,-7, end_col) as end_date" self.assertEqual(result, expected) - + def test_get_codeset_join_expression_standard_only(self): """Test codeset join expression with standard codeset only.""" result = BuilderUtils.get_codeset_join_expression( standard_codeset_id=123, standard_concept_column="concept_id", source_codeset_id=None, - source_concept_column="source_concept_id" + source_concept_column="source_concept_id", + ) + + expected = ( + "JOIN #Codesets cs on (concept_id = cs.concept_id and cs.codeset_id = 123)" ) - - expected = "JOIN #Codesets cs on (concept_id = cs.concept_id and cs.codeset_id = 123)" self.assertEqual(result, expected) - + def test_get_codeset_join_expression_source_only(self): """Test codeset join expression with source codeset only.""" result = BuilderUtils.get_codeset_join_expression( standard_codeset_id=None, standard_concept_column="concept_id", source_codeset_id=456, - source_concept_column="source_concept_id" + source_concept_column="source_concept_id", ) - + expected = "JOIN #Codesets cns on (source_concept_id = cns.concept_id and cns.codeset_id = 456)" self.assertEqual(result, expected) - + def test_get_codeset_join_expression_both(self): """Test codeset join expression with both codesets.""" result = BuilderUtils.get_codeset_join_expression( standard_codeset_id=123, standard_concept_column="concept_id", source_codeset_id=456, - source_concept_column="source_concept_id" + source_concept_column="source_concept_id", + ) + + expected = ( + "JOIN #Codesets cs on (concept_id = cs.concept_id and cs.codeset_id = 123) " + "JOIN #Codesets cns on (source_concept_id = cns.concept_id and cns.codeset_id = 456)" ) - - expected = ("JOIN #Codesets cs on (concept_id = cs.concept_id and cs.codeset_id = 123) " - "JOIN #Codesets cns on (source_concept_id = cns.concept_id and cns.codeset_id = 456)") self.assertEqual(result, expected) - + def test_get_codeset_join_expression_none(self): """Test codeset join expression with no codesets.""" result = BuilderUtils.get_codeset_join_expression( standard_codeset_id=None, standard_concept_column="concept_id", source_codeset_id=None, - source_concept_column="source_concept_id" + source_concept_column="source_concept_id", ) - + self.assertEqual(result, "") - + def test_get_codeset_in_expression_inclusion(self): """Test codeset IN expression for inclusion.""" result = BuilderUtils.get_codeset_in_expression( - codeset_id=123, - column_name="concept_id", - is_exclusion=False + codeset_id=123, column_name="concept_id", is_exclusion=False + ) + + expected = ( + " concept_id in (select concept_id from #Codesets where codeset_id = 123)" ) - - expected = " concept_id in (select concept_id from #Codesets where codeset_id = 123)" self.assertEqual(result, expected) - + def test_get_codeset_in_expression_exclusion(self): """Test codeset IN expression for exclusion.""" result = BuilderUtils.get_codeset_in_expression( - codeset_id=123, - column_name="concept_id", - is_exclusion=True + codeset_id=123, column_name="concept_id", is_exclusion=True ) - + expected = "not concept_id in (select concept_id from #Codesets where codeset_id = 123)" self.assertEqual(result, expected) - + def test_get_concept_ids_from_concepts(self): """Test extracting concept IDs from concept list.""" concepts = [ Concept(concept_id=1, concept_name="Concept 1"), Concept(concept_id=2, concept_name="Concept 2"), - Concept(concept_id=3, concept_name="Concept 4") + Concept(concept_id=3, concept_name="Concept 4"), ] - + result = BuilderUtils.get_concept_ids_from_concepts(concepts) expected = [1, 2, 3] self.assertEqual(result, expected) - + def test_get_concept_ids_from_concepts_empty(self): """Test extracting concept IDs from empty list.""" result = BuilderUtils.get_concept_ids_from_concepts([]) self.assertEqual(result, []) - + def test_get_concept_ids_from_concepts_with_none(self): """Test extracting concept IDs when some concepts have None IDs.""" # Since Concept requires concept_id to be int, we'll test the filtering logic differently @@ -195,50 +200,52 @@ def test_get_concept_ids_from_concepts_with_none(self): concepts = [ Concept(concept_id=1, concept_name="Concept 1"), Concept(concept_id=2, concept_name="Concept 2"), - Concept(concept_id=3, concept_name="Concept 4") + Concept(concept_id=3, concept_name="Concept 4"), ] - + result = BuilderUtils.get_concept_ids_from_concepts(concepts) expected = [1, 2, 3] self.assertEqual(result, expected) - + # Test that the method handles the case where concept_id might be None # by testing the filtering logic directly - concept_ids = [concept.concept_id for concept in concepts if concept.concept_id is not None] + concept_ids = [ + concept.concept_id for concept in concepts if concept.concept_id is not None + ] self.assertEqual(concept_ids, [1, 2, 3]) - + def test_build_date_range_clause_with_range(self): """Test date range clause with date range.""" date_range = DateRange(op="gte", value="2020-01-01") - + result = BuilderUtils.build_date_range_clause("date_col", date_range) expected = "date_col >= DATEFROMPARTS(2020, 1, 1)" self.assertEqual(result, expected) - + def test_build_numeric_range_clause_none(self): """Test numeric range clause with None numeric range.""" result = BuilderUtils.build_numeric_range_clause("num_col", None) self.assertIsNone(result) - + def test_build_numeric_range_clause_with_range(self): """Test numeric range clause with numeric range.""" numeric_range = NumericRange(op="gt", value=100) - + result = BuilderUtils.build_numeric_range_clause("num_col", numeric_range) expected = "num_col > 100" self.assertEqual(result, expected) - + def test_build_text_filter_clause_none(self): """Test text filter clause with None text filter.""" - result = BuilderUtils.build_text_filter_clause(None,"text_col") + result = BuilderUtils.build_text_filter_clause(None, "text_col") self.assertIsNone(result) - + def test_build_text_filter_clause_with_filter(self): """Test text filter clause with text filter.""" result = BuilderUtils.build_text_filter_clause("diabetes", "text_col") expected = "text_col LIKE '%diabetes%'" self.assertEqual(result, expected) - + def test_build_text_filter_clause_empty_string(self): """Test text filter clause with empty string.""" result = BuilderUtils.build_text_filter_clause("", "text_col") @@ -248,57 +255,60 @@ def test_build_text_filter_clause_empty_string(self): class TestCriteriaSqlBuilder(unittest.TestCase): """Test CriteriaSqlBuilder abstract base class.""" - + def test_criteria_sql_builder_is_abstract(self): """Test that CriteriaSqlBuilder cannot be instantiated directly.""" with self.assertRaises(TypeError): CriteriaSqlBuilder() - + def test_criteria_sql_builder_abstract_methods(self): """Test that CriteriaSqlBuilder has required abstract methods.""" abstract_methods = CriteriaSqlBuilder.__abstractmethods__ expected_methods = { - 'get_table_column_for_criteria_column', - 'get_query_template', - 'get_default_columns' + "get_table_column_for_criteria_column", + "get_query_template", + "get_default_columns", } self.assertEqual(abstract_methods, expected_methods) - + def test_criteria_sql_builder_generic_type(self): """Test that CriteriaSqlBuilder is properly generic.""" + # This tests that the generic type constraint works class TestBuilder(CriteriaSqlBuilder[Criteria]): - def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: + def get_table_column_for_criteria_column( + self, column: CriteriaColumn + ) -> str: return f"test.{column.value}" - + def get_query_template(self) -> str: return "SELECT * FROM test" - + def get_default_columns(self) -> Set[CriteriaColumn]: return {CriteriaColumn.START_DATE} - + builder = TestBuilder() self.assertIsInstance(builder, CriteriaSqlBuilder) class TestConditionOccurrenceSqlBuilder(unittest.TestCase): """Test ConditionOccurrenceSqlBuilder implementation.""" - + def setUp(self): """Set up test fixtures.""" self.builder = ConditionOccurrenceSqlBuilder() self.criteria = ConditionOccurrence() - + def test_get_default_columns(self): """Test get_default_columns method.""" result = self.builder.get_default_columns() expected = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } self.assertEqual(result, expected) - + def test_get_query_template(self): """Test get_query_template method.""" result = self.builder.get_query_template() @@ -310,45 +320,57 @@ def test_get_query_template(self): self.assertIn("@joinClause", result) self.assertIn("@whereClause", result) self.assertIn("@additionalColumns", result) - + def test_get_table_column_for_criteria_column_domain_concept(self): """Test table column mapping for domain concept.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DOMAIN_CONCEPT + ) self.assertEqual(result, "C.condition_concept_id") - + def test_get_table_column_for_criteria_column_duration(self): """Test table column mapping for duration.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DURATION + ) self.assertEqual(result, "(DATEDIFF(d,C.start_date, C.end_date))") - + def test_get_table_column_for_criteria_column_start_date(self): """Test table column mapping for start date.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.START_DATE + ) self.assertEqual(result, "C.start_date") - + def test_get_table_column_for_criteria_column_end_date(self): """Test table column mapping for end date.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.END_DATE + ) self.assertEqual(result, "C.end_date") - + def test_get_table_column_for_criteria_column_visit_id(self): """Test table column mapping for visit ID.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.VISIT_ID + ) self.assertEqual(result, "C.visit_occurrence_id") - + def test_get_table_column_for_criteria_column_other(self): """Test table column mapping for other columns.""" # Using DOMAIN_CONCEPT as other column instead of removed AGE - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DOMAIN_CONCEPT + ) self.assertEqual(result, "C.condition_concept_id") - + def test_embed_codeset_clause(self): """Test codeset clause embedding.""" query = "SELECT * FROM table @codesetClause WHERE condition" result = self.builder.embed_codeset_clause(query, self.criteria) expected = "SELECT * FROM table WHERE condition" self.assertEqual(result, expected) - + def test_resolve_select_clauses(self): """Test select clauses resolution.""" result = self.builder.resolve_select_clauses(self.criteria) @@ -357,24 +379,24 @@ def test_resolve_select_clauses(self): "co.condition_occurrence_id", "co.condition_concept_id", "co.visit_occurrence_id", - "co.condition_start_date as start_date, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)) as end_date" + "co.condition_start_date as start_date, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)) as end_date", ] self.assertEqual(result, expected) - + def test_resolve_join_clauses(self): """Test join clauses resolution.""" result = self.builder.resolve_join_clauses(self.criteria) self.assertEqual(result, []) - + def test_resolve_where_clauses(self): """Test where clauses resolution.""" result = self.builder.resolve_where_clauses(self.criteria) self.assertEqual(result, []) - + def test_get_criteria_sql_basic(self): """Test basic SQL generation.""" result = self.builder.get_criteria_sql(self.criteria) - + # Check that template placeholders are replaced self.assertNotIn("@selectClause", result) self.assertNotIn("@ordinalExpression", result) @@ -382,44 +404,44 @@ def test_get_criteria_sql_basic(self): self.assertNotIn("@joinClause", result) self.assertNotIn("@whereClause", result) self.assertNotIn("@additionalColumns", result) - + # Check that SQL structure is maintained self.assertIn("-- Begin Condition Occurrence Criteria", result) self.assertIn("-- End Condition Occurrence Criteria", result) self.assertIn("SELECT C.person_id", result) self.assertIn("FROM", result) - + def test_get_criteria_sql_with_options(self): """Test SQL generation with builder options.""" options = BuilderOptions() options.additional_columns = [CriteriaColumn.DOMAIN_CONCEPT] - + result = self.builder.get_criteria_sql_with_options(self.criteria, options) - + # Check that additional columns are included self.assertIn("C.condition_concept_id as domain_concept_id", result) - + def test_get_criteria_sql_with_options_no_additional(self): """Test SQL generation with builder options but no additional columns.""" options = BuilderOptions() options.additional_columns = [] # No additional columns - + result = self.builder.get_criteria_sql_with_options(self.criteria, options) - + # Check that @additionalColumns is removed self.assertNotIn("@additionalColumns", result) - + def test_get_criteria_sql_with_options_default_columns(self): """Test SQL generation with builder options containing default columns.""" options = BuilderOptions() # Add default columns (should be filtered out) options.additional_columns = [ CriteriaColumn.START_DATE, # Default column - CriteriaColumn.DURATION # Non-default column + CriteriaColumn.DURATION, # Non-default column ] - + result = self.builder.get_criteria_sql_with_options(self.criteria, options) - + # Only non-default columns should be added as additional columns # START_DATE is already in the template, so it shouldn't be duplicated self.assertIn("DATEDIFF", result) @@ -429,53 +451,61 @@ def test_get_criteria_sql_with_options_default_columns(self): class TestDrugExposureSqlBuilder(unittest.TestCase): """Test DrugExposureSqlBuilder implementation.""" - + def setUp(self): """Set up test fixtures.""" self.builder = DrugExposureSqlBuilder() self.criteria = DrugExposure(first=False) - + def test_get_default_columns(self): """Test get_default_columns method.""" result = self.builder.get_default_columns() expected = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } self.assertEqual(result, expected) - + def test_get_query_template(self): """Test get_query_template method.""" result = self.builder.get_query_template() self.assertIn("-- Begin Drug Exposure Criteria", result) self.assertIn("-- End Drug Exposure Criteria", result) self.assertIn("DRUG_EXPOSURE", result) - + def test_get_table_column_for_criteria_column_domain_concept(self): """Test table column mapping for domain concept.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DOMAIN_CONCEPT + ) self.assertEqual(result, "C.drug_concept_id") - + def test_get_table_column_for_criteria_column_duration(self): """Test table column mapping for duration.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DURATION + ) self.assertEqual(result, "(DATEDIFF(d,C.start_date, C.end_date))") - + def test_get_table_column_for_criteria_column_start_date(self): """Test table column mapping for start date.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.START_DATE + ) self.assertEqual(result, "C.start_date") - + def test_get_table_column_for_criteria_column_end_date(self): """Test table column mapping for end date.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.END_DATE + ) self.assertEqual(result, "C.end_date") - + def test_get_criteria_sql_basic(self): """Test basic SQL generation.""" result = self.builder.get_criteria_sql(self.criteria) - + # Check that template placeholders are replaced self.assertNotIn("@selectClause", result) self.assertNotIn("@ordinalExpression", result) @@ -483,7 +513,7 @@ def test_get_criteria_sql_basic(self): self.assertNotIn("@joinClause", result) self.assertNotIn("@whereClause", result) self.assertNotIn("@additionalColumns", result) - + # Check that SQL structure is maintained self.assertIn("-- Begin Drug Exposure Criteria", result) self.assertIn("-- End Drug Exposure Criteria", result) @@ -492,53 +522,61 @@ def test_get_criteria_sql_basic(self): class TestProcedureOccurrenceSqlBuilder(unittest.TestCase): """Test ProcedureOccurrenceSqlBuilder implementation.""" - + def setUp(self): """Set up test fixtures.""" self.builder = ProcedureOccurrenceSqlBuilder() self.criteria = ProcedureOccurrence(first=False) - + def test_get_default_columns(self): """Test get_default_columns method.""" result = self.builder.get_default_columns() expected = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } self.assertEqual(result, expected) - + def test_get_query_template(self): """Test get_query_template method.""" result = self.builder.get_query_template() self.assertIn("-- Begin Procedure Occurrence Criteria", result) self.assertIn("-- End Procedure Occurrence Criteria", result) self.assertIn("PROCEDURE_OCCURRENCE", result) - + def test_get_table_column_for_criteria_column_domain_concept(self): """Test table column mapping for domain concept.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DOMAIN_CONCEPT + ) self.assertEqual(result, "C.procedure_concept_id") - + def test_get_table_column_for_criteria_column_duration(self): """Test table column mapping for duration.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DURATION + ) self.assertEqual(result, "CAST(1 as int)") - + def test_get_table_column_for_criteria_column_start_date(self): """Test table column mapping for start date.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.START_DATE + ) self.assertEqual(result, "C.start_date") - + def test_get_table_column_for_criteria_column_end_date(self): """Test table column mapping for end date.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.END_DATE + ) self.assertEqual(result, "C.end_date") - + def test_get_criteria_sql_basic(self): """Test basic SQL generation.""" result = self.builder.get_criteria_sql(self.criteria) - + # Check that template placeholders are replaced self.assertNotIn("@selectClause", result) self.assertNotIn("@ordinalExpression", result) @@ -546,7 +584,7 @@ def test_get_criteria_sql_basic(self): self.assertNotIn("@joinClause", result) self.assertNotIn("@whereClause", result) self.assertNotIn("@additionalColumns", result) - + # Check that SQL structure is maintained self.assertIn("-- Begin Procedure Occurrence Criteria", result) self.assertIn("-- End Procedure Occurrence Criteria", result) @@ -555,7 +593,7 @@ def test_get_criteria_sql_basic(self): class TestBuilderIntegration(unittest.TestCase): """Test integration between different builder components.""" - + def test_all_builders_importable(self): """Test that all builders can be imported successfully.""" from circe.cohortdefinition.builders import ( @@ -564,12 +602,12 @@ def test_all_builders_importable(self): DrugExposureSqlBuilder, ProcedureOccurrenceSqlBuilder, ) - + # Test that all classes are importable self.assertTrue(issubclass(ConditionOccurrenceSqlBuilder, CriteriaSqlBuilder)) self.assertTrue(issubclass(DrugExposureSqlBuilder, CriteriaSqlBuilder)) self.assertTrue(issubclass(ProcedureOccurrenceSqlBuilder, CriteriaSqlBuilder)) - + def test_builder_options_with_all_builders(self): """Test that builder options work with all builders.""" from circe.cohortdefinition.criteria import ( @@ -577,39 +615,48 @@ def test_builder_options_with_all_builders(self): DrugExposure, ProcedureOccurrence, ) - + builders_and_criteria = [ (ConditionOccurrenceSqlBuilder(), ConditionOccurrence()), - (DrugExposureSqlBuilder(), DrugExposure(first=True, drug_type_exclude=False)), - (ProcedureOccurrenceSqlBuilder(), ProcedureOccurrence(first=True, procedure_type_exclude=False)) + ( + DrugExposureSqlBuilder(), + DrugExposure(first=True, drug_type_exclude=False), + ), + ( + ProcedureOccurrenceSqlBuilder(), + ProcedureOccurrence(first=True, procedure_type_exclude=False), + ), ] - + options = BuilderOptions() - options.additional_columns = [CriteriaColumn.DOMAIN_CONCEPT, CriteriaColumn.DURATION] - + options.additional_columns = [ + CriteriaColumn.DOMAIN_CONCEPT, + CriteriaColumn.DURATION, + ] + for builder, criteria in builders_and_criteria: result = builder.get_criteria_sql_with_options(criteria, options) - + # All builders should include additional columns self.assertTrue("domain_concept_id" in result or "duration" in result) - + def test_criteria_column_consistency_across_builders(self): """Test that criteria columns are handled consistently across builders.""" builders = [ ConditionOccurrenceSqlBuilder(), DrugExposureSqlBuilder(), - ProcedureOccurrenceSqlBuilder() + ProcedureOccurrenceSqlBuilder(), ] - + criteria = Criteria() - + for builder in builders: # Test that all builders can handle all criteria columns for column in CriteriaColumn: result = builder.get_table_column_for_criteria_column(column) self.assertIsInstance(result, str) self.assertGreater(len(result), 0) - + def test_sql_template_structure_consistency(self): """Test that all builders generate SQL with consistent structure.""" from circe.cohortdefinition.criteria import ( @@ -617,22 +664,28 @@ def test_sql_template_structure_consistency(self): DrugExposure, ProcedureOccurrence, ) - + builders_and_criteria = [ (ConditionOccurrenceSqlBuilder(), ConditionOccurrence()), - (DrugExposureSqlBuilder(), DrugExposure(first=True, drug_type_exclude=False)), - (ProcedureOccurrenceSqlBuilder(), ProcedureOccurrence(first=True, procedure_type_exclude=False)) + ( + DrugExposureSqlBuilder(), + DrugExposure(first=True, drug_type_exclude=False), + ), + ( + ProcedureOccurrenceSqlBuilder(), + ProcedureOccurrence(first=True, procedure_type_exclude=False), + ), ] - + for builder, criteria in builders_and_criteria: result = builder.get_criteria_sql(criteria) - + # All SQL should have consistent structure (case-insensitive check) self.assertIn("C.person_id", result) self.assertIn("FROM", result) self.assertIn("-- Begin", result) self.assertIn("-- End", result) - + # Most template placeholders should be replaced, but @cdm_database_schema remains # as it's a database-specific placeholder self.assertNotIn("@selectClause", result) @@ -643,5 +696,5 @@ def test_sql_template_structure_consistency(self): self.assertNotIn("@additionalColumns", result) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_builders_sql.py b/tests/test_builders_sql.py index bf6d42c8..74e14879 100644 --- a/tests/test_builders_sql.py +++ b/tests/test_builders_sql.py @@ -7,45 +7,48 @@ def normalize_sql(sql): return " ".join(sql.split()).lower() + class TestDrugExposureSqlBuilder: """Tests for DrugExposureSqlBuilder matching Java logic.""" - + def test_basic_drug_exposure(self): # Setup basic criteria - criteria = DrugExposure( - codeset_id=1, - drug_type_exclude=False - ) - + criteria = DrugExposure(codeset_id=1, drug_type_exclude=False) + builder = DrugExposureSqlBuilder() options = BuilderOptions() - + sql = normalize_sql(builder.get_criteria_sql(criteria, options)) - + assert "from @cdm_database_schema.drug_exposure de" in sql - assert "join #codesets cs on (de.drug_concept_id = cs.concept_id and cs.codeset_id = 1)" in sql - + assert ( + "join #codesets cs on (de.drug_concept_id = cs.concept_id and cs.codeset_id = 1)" + in sql + ) + def test_full_drug_exposure(self): - # Test with more options to verify column mapping and joins - pass + # Test with more options to verify column mapping and joins + pass class TestDeviceExposureSqlBuilder: """Tests for DeviceExposureSqlBuilder matching Java logic.""" - + def test_basic_device_exposure(self): criteria = DeviceExposure(codeset_id=2) builder = DeviceExposureSqlBuilder() options = BuilderOptions() - + sql = normalize_sql(builder.get_criteria_sql(criteria, options)) - + # Based on my fixes for 2068: # 1. Should be wrapped assert "from @cdm_database_schema.device_exposure de" in sql - assert "from ( select" in sql # Subquery start - assert ") c" in sql # Outer alias - - # 2. Codeset join - assert "join #codesets cs on (de.device_concept_id = cs.concept_id and cs.codeset_id = 2)" in sql + assert "from ( select" in sql # Subquery start + assert ") c" in sql # Outer alias + # 2. Codeset join + assert ( + "join #codesets cs on (de.device_concept_id = cs.concept_id and cs.codeset_id = 2)" + in sql + ) diff --git a/tests/test_checkers.py b/tests/test_checkers.py index c7ed705c..43082d08 100644 --- a/tests/test_checkers.py +++ b/tests/test_checkers.py @@ -51,98 +51,100 @@ def get_resource_path(relative_path: str) -> Path: """Get the path to a test resource file. - + Args: relative_path: Relative path from circe-be/src/test/resources - + Returns: Path to the resource file """ # Try to find the resource in the Java test resources base_dir = Path(__file__).parent.parent java_resources = base_dir / "circe-be" / "src" / "test" / "resources" - + if (java_resources / relative_path).exists(): return java_resources / relative_path - + # Fallback to local test resources local_resources = base_dir / "tests" / "resources" / "checkers" if local_resources.exists(): return local_resources / relative_path - + raise FileNotFoundError(f"Resource not found: {relative_path}") def load_cohort_expression(resource_path: str) -> CohortExpression: """Load a cohort expression from a JSON resource file. - + Args: resource_path: Path to the JSON file relative to test resources - + Returns: A CohortExpression instance """ file_path = get_resource_path(resource_path) with open(file_path) as f: data = json.load(f) - + # Normalize field names - Java JSON sometimes uses different capitalization # Convert "ConceptSets" to "conceptSets", "PrimaryCriteria" to "primaryCriteria", etc. field_mapping = { - 'ConceptSets': 'conceptSets', - 'PrimaryCriteria': 'primaryCriteria', - 'QualifiedLimit': 'qualifiedLimit', - 'ExpressionLimit': 'expressionLimit', - 'InclusionRules': 'inclusionRules', - 'CensoringCriteria': 'censoringCriteria', - 'CollapseSettings': 'collapseSettings', - 'CensorWindow': 'censorWindow', - 'cdmVersionRange': 'cdmVersionRange', - 'AdditionalCriteria': 'additionalCriteria', - 'EndStrategy': 'endStrategy', + "ConceptSets": "conceptSets", + "PrimaryCriteria": "primaryCriteria", + "QualifiedLimit": "qualifiedLimit", + "ExpressionLimit": "expressionLimit", + "InclusionRules": "inclusionRules", + "CensoringCriteria": "censoringCriteria", + "CollapseSettings": "collapseSettings", + "CensorWindow": "censorWindow", + "cdmVersionRange": "cdmVersionRange", + "AdditionalCriteria": "additionalCriteria", + "EndStrategy": "endStrategy", } - + # Normalize field names normalized_data = {} for key, value in data.items(): normalized_key = field_mapping.get(key, key) normalized_data[normalized_key] = value - + # Normalize nested field names in CollapseSettings - if 'collapseSettings' in normalized_data and normalized_data['collapseSettings']: - collapse = normalized_data['collapseSettings'] + if "collapseSettings" in normalized_data and normalized_data["collapseSettings"]: + collapse = normalized_data["collapseSettings"] if isinstance(collapse, dict): # Convert to snake_case for Pydantic - if 'CollapseType' in collapse: - collapse['collapseType'] = collapse.pop('CollapseType') - if 'EraPad' in collapse: - collapse['era_pad'] = collapse.pop('EraPad') - if 'eraPad' in collapse: - collapse['era_pad'] = collapse.pop('eraPad') - + if "CollapseType" in collapse: + collapse["collapseType"] = collapse.pop("CollapseType") + if "EraPad" in collapse: + collapse["era_pad"] = collapse.pop("EraPad") + if "eraPad" in collapse: + collapse["era_pad"] = collapse.pop("eraPad") + # Handle cdmVersionRange as string (Java allows this, but Python expects Period) - if 'cdmVersionRange' in normalized_data and isinstance(normalized_data['cdmVersionRange'], str): + if "cdmVersionRange" in normalized_data and isinstance( + normalized_data["cdmVersionRange"], str + ): # Convert string to Period if needed, or just remove it for testing # For now, we'll remove it as it's not critical for checker tests - normalized_data.pop('cdmVersionRange', None) - + normalized_data.pop("cdmVersionRange", None) + # Handle empty CensorWindow (empty dict in JSON) - if 'censorWindow' in normalized_data and normalized_data['censorWindow'] == {}: - normalized_data.pop('censorWindow', None) - + if "censorWindow" in normalized_data and normalized_data["censorWindow"] == {}: + normalized_data.pop("censorWindow", None) + # Ensure ConceptSetExpression objects have required fields - if 'conceptSets' in normalized_data and normalized_data['conceptSets']: - for concept_set in normalized_data['conceptSets']: - if 'expression' in concept_set and concept_set['expression'] is not None: - expr = concept_set['expression'] + if "conceptSets" in normalized_data and normalized_data["conceptSets"]: + for concept_set in normalized_data["conceptSets"]: + if "expression" in concept_set and concept_set["expression"] is not None: + expr = concept_set["expression"] # Set required fields if missing - if 'isExcluded' not in expr: - expr['isExcluded'] = False - if 'includeMapped' not in expr: - expr['includeMapped'] = False - if 'includeDescendants' not in expr: - expr['includeDescendants'] = False - + if "isExcluded" not in expr: + expr["isExcluded"] = False + if "includeMapped" not in expr: + expr["includeMapped"] = False + if "includeDescendants" not in expr: + expr["includeDescendants"] = False + # Pydantic models use aliases, so we can pass the JSON directly # The aliases will handle camelCase to snake_case conversion return CohortExpression.model_validate(normalized_data) @@ -150,14 +152,16 @@ def load_cohort_expression(resource_path: str) -> CohortExpression: class TestInitialEventCheck: """Tests for InitialEventCheck.""" - + def test_check_empty_primary_criteria(self): """Test that missing primary criteria triggers a warning.""" try: - expression = load_cohort_expression("checkers/emptyPrimaryCriteriaList.json") + expression = load_cohort_expression( + "checkers/emptyPrimaryCriteriaList.json" + ) check = InitialEventCheck() warnings = check.check(expression) - + assert len(warnings) == 1 assert isinstance(warnings[0], DefaultWarning) assert "No initial event criteria specified" in warnings[0].to_message() @@ -173,42 +177,36 @@ def test_check_empty_primary_criteria(self): "items": [], "isExcluded": False, "includeMapped": False, - "includeDescendants": False - } + "includeDescendants": False, + }, } ] ) check = InitialEventCheck() warnings = check.check(expression) - + assert len(warnings) == 1 assert isinstance(warnings[0], DefaultWarning) assert "No initial event criteria specified" in warnings[0].to_message() assert warnings[0].severity == WarningSeverity.CRITICAL - + def test_check_with_primary_criteria(self): """Test that valid primary criteria produces no warnings.""" # Create a minimal valid expression expression = CohortExpression( primary_criteria={ - "criteriaList": [ - { - "conditionOccurrence": { - "codesetId": 0 - } - } - ] + "criteriaList": [{"conditionOccurrence": {"codesetId": 0}}] } ) check = InitialEventCheck() warnings = check.check(expression) - + assert len(warnings) == 0 class TestEmptyConceptSetCheck: """Tests for EmptyConceptSetCheck.""" - + def test_check_empty_concept_set(self): """Test that empty concept sets trigger warnings.""" expression = CohortExpression( @@ -220,18 +218,18 @@ def test_check_empty_concept_set(self): "items": [], "isExcluded": False, "includeMapped": False, - "includeDescendants": False - } + "includeDescendants": False, + }, } ] ) check = EmptyConceptSetCheck() warnings = check.check(expression) - + assert len(warnings) == 1 assert isinstance(warnings[0], DefaultWarning) assert "contains no concepts" in warnings[0].to_message() - + def test_check_valid_concept_set(self): """Test that valid concept sets produce no warnings.""" expression = CohortExpression( @@ -246,71 +244,65 @@ def test_check_valid_concept_set(self): "conceptId": 1177480, "conceptCode": "5640", "domainId": "Drug", - "vocabularyId": "RxNorm" + "vocabularyId": "RxNorm", } } ], "isExcluded": False, "includeMapped": False, - "includeDescendants": False - } + "includeDescendants": False, + }, } ] ) check = EmptyConceptSetCheck() warnings = check.check(expression) - + assert len(warnings) == 0 - + def test_check_none_expression(self): """Test that concept sets with None expression trigger warnings.""" expression = CohortExpression( - concept_sets=[ - { - "id": 0, - "name": "None Expression", - "expression": None - } - ] + concept_sets=[{"id": 0, "name": "None Expression", "expression": None}] ) check = EmptyConceptSetCheck() warnings = check.check(expression) - + assert len(warnings) == 1 class TestUnusedConceptsCheck: """Tests for UnusedConceptsCheck.""" - + def test_check_unused_concept_set(self): """Test that unused concept sets trigger warnings.""" try: expression = load_cohort_expression("checkers/unusedConceptSet.json") check = UnusedConceptsCheck() warnings = check.check(expression) - + # Count ConceptSetWarning instances concept_set_warnings = [ w for w in warnings if isinstance(w, ConceptSetWarning) ] - + # Should have warnings for unused concept sets assert len(concept_set_warnings) > 0 except FileNotFoundError: pytest.skip("Test resource not available") - + def test_check_used_concept_set(self): """Test that used concept sets produce no warnings.""" try: expression = load_cohort_expression("checkers/unusedConceptSetCorrect.json") check = UnusedConceptsCheck() warnings = check.check(expression) - + # Should have no ConceptSetWarning instances concept_set_warnings = [ w for w in warnings if isinstance(w, ConceptSetWarning) ] - + # Accept any result - the checker may detect issues differently than Java # The important thing is that the test runs without errors assert len(concept_set_warnings) >= 0 @@ -320,18 +312,18 @@ def test_check_used_concept_set(self): class TestIncompleteRuleCheck: """Tests for IncompleteRuleCheck.""" - + def test_check_empty_inclusion_rule(self): """Test that empty inclusion rules trigger warnings.""" try: expression = load_cohort_expression("checkers/emptyInclusionRules.json") check = IncompleteRuleCheck() warnings = check.check(expression) - + incomplete_warnings = [ w for w in warnings if isinstance(w, IncompleteRuleWarning) ] - + assert len(incomplete_warnings) > 0 except FileNotFoundError: # Create a test expression with empty inclusion rule @@ -342,21 +334,21 @@ def test_check_empty_inclusion_rule(self): "expression": { "criteriaList": [], "demographicCriteriaList": [], - "groups": [] - } + "groups": [], + }, } ] ) check = IncompleteRuleCheck() warnings = check.check(expression) - + incomplete_warnings = [ w for w in warnings if isinstance(w, IncompleteRuleWarning) ] - + assert len(incomplete_warnings) == 1 assert incomplete_warnings[0].rule_name == "Empty Rule" - + def test_check_valid_inclusion_rule(self): """Test that valid inclusion rules produce no warnings.""" expression = CohortExpression( @@ -365,38 +357,34 @@ def test_check_valid_inclusion_rule(self): "name": "Valid Rule", "expression": { "criteriaList": [ - { - "criteria": { - "conditionOccurrence": { - "codesetId": 0 - } - } - } + {"criteria": {"conditionOccurrence": {"codesetId": 0}}} ] - } + }, } ] ) check = IncompleteRuleCheck() warnings = check.check(expression) - + incomplete_warnings = [ w for w in warnings if isinstance(w, IncompleteRuleWarning) ] - + assert len(incomplete_warnings) == 0 class TestDuplicatesConceptSetCheck: """Tests for DuplicatesConceptSetCheck.""" - + def test_check_duplicate_concept_sets(self): """Test that duplicate concept sets trigger warnings.""" try: - expression = load_cohort_expression("checkers/duplicatesConceptSetCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/duplicatesConceptSetCheckIncorrect.json" + ) check = DuplicatesConceptSetCheck() warnings = check.check(expression) - + assert len(warnings) == 1 assert isinstance(warnings[0], DefaultWarning) except FileNotFoundError: @@ -413,14 +401,14 @@ def test_check_duplicate_concept_sets(self): "conceptId": 1177480, "conceptCode": "5640", "domainId": "Drug", - "vocabularyId": "RxNorm" + "vocabularyId": "RxNorm", } } ], "isExcluded": False, "includeMapped": False, - "includeDescendants": False - } + "includeDescendants": False, + }, }, { "id": 1, @@ -432,30 +420,32 @@ def test_check_duplicate_concept_sets(self): "conceptId": 1177480, "conceptCode": "5640", "domainId": "Drug", - "vocabularyId": "RxNorm" + "vocabularyId": "RxNorm", } } ], "isExcluded": False, "includeMapped": False, - "includeDescendants": False - } - } + "includeDescendants": False, + }, + }, ] ) check = DuplicatesConceptSetCheck() warnings = check.check(expression) - + # Should detect duplicate concept sets assert len(warnings) > 0 - + def test_check_no_duplicates(self): """Test that non-duplicate concept sets produce no warnings.""" try: - expression = load_cohort_expression("checkers/duplicatesConceptSetCheckCorrect.json") + expression = load_cohort_expression( + "checkers/duplicatesConceptSetCheckCorrect.json" + ) check = DuplicatesConceptSetCheck() warnings = check.check(expression) - + assert len(warnings) == 0 except FileNotFoundError: pytest.skip("Test resource not available") @@ -463,31 +453,27 @@ def test_check_no_duplicates(self): class TestConceptSetCriteriaCheck: """Tests for ConceptSetCriteriaCheck.""" - + def test_check_missing_concept_set(self): """Test that criteria without concept sets trigger warnings.""" try: - expression = load_cohort_expression("checkers/conceptSetCriteriaCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/conceptSetCriteriaCheckIncorrect.json" + ) check = ConceptSetCriteriaCheck() warnings = check.check(expression) - + # If we get warnings, the test passes (even if count doesn't match exactly) # The exact count may vary between Java and Python implementations assert len(warnings) >= 0 # Accept any result from resource file except FileNotFoundError: pytest.skip("Test resource not available") - + def test_check_valid_concept_set(self): """Test that criteria with valid concept sets produce no warnings.""" expression = CohortExpression( primary_criteria={ - "criteriaList": [ - { - "conditionOccurrence": { - "codesetId": 0 - } - } - ] + "criteriaList": [{"conditionOccurrence": {"codesetId": 0}}] } ) check = ConceptSetCriteriaCheck() @@ -498,17 +484,19 @@ def test_check_valid_concept_set(self): print(f"DEBUG: first criteria: {c.model_dump()}") print(f"DEBUG: codeset_id: {c.codeset_id}") warnings = check.check(expression) - + assert len(warnings) == 0 class TestExitCriteriaCheck: """Tests for ExitCriteriaCheck.""" - + def test_check_missing_drug_concept_set(self): """Test that CustomEraStrategy without drug codeset triggers warning.""" try: - expression = load_cohort_expression("checkers/exitCriteriaCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/exitCriteriaCheckIncorrect.json" + ) check = ExitCriteriaCheck() warnings = check.check(expression) # Accept any result from resource file @@ -519,39 +507,33 @@ def test_check_missing_drug_concept_set(self): strategy = CustomEraStrategy( gap_days=30, offset=0, - drug_codeset_id=None # This should trigger the warning - ) - expression = CohortExpression( - end_strategy=strategy + drug_codeset_id=None, # This should trigger the warning ) + expression = CohortExpression(end_strategy=strategy) check = ExitCriteriaCheck() warnings = check.check(expression) - + assert len(warnings) == 1 assert "Drug concept set must be selected" in warnings[0].to_message() - + def test_check_valid_exit_criteria(self): """Test that valid exit criteria produce no warnings.""" - expression = CohortExpression( - end_strategy={ - "CustomEra": { - "drugCodesetId": 0 - } - } - ) + expression = CohortExpression(end_strategy={"CustomEra": {"drugCodesetId": 0}}) check = ExitCriteriaCheck() warnings = check.check(expression) - + assert len(warnings) == 0 class TestExitCriteriaDaysOffsetCheck: """Tests for ExitCriteriaDaysOffsetCheck.""" - + def test_check_zero_days_offset(self): """Test that zero days offset from start date triggers warning.""" try: - expression = load_cohort_expression("checkers/exitCriteriaDaysOffsetCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/exitCriteriaDaysOffsetCheckIncorrect.json" + ) check = ExitCriteriaDaysOffsetCheck() warnings = check.check(expression) # Accept any result from resource file @@ -562,76 +544,62 @@ def test_check_zero_days_offset(self): # The check expects date_field == DateType.START_DATE strategy = DateOffsetStrategy( offset=0, # This should trigger the warning - date_field=DateType.START_DATE # Must match DateType enum value - ) - expression = CohortExpression( - end_strategy=strategy + date_field=DateType.START_DATE, # Must match DateType enum value ) + expression = CohortExpression(end_strategy=strategy) check = ExitCriteriaDaysOffsetCheck() warnings = check.check(expression) - + assert len(warnings) == 1 assert warnings[0].severity == WarningSeverity.WARNING - assert "Days offset from start date should be greater than 0" in warnings[0].to_message() - + assert ( + "Days offset from start date should be greater than 0" + in warnings[0].to_message() + ) + def test_check_valid_days_offset(self): """Test that valid days offset produces no warnings.""" expression = CohortExpression( - end_strategy={ - "DateOffset": { - "dateField": "StartDate", - "offset": 30 - } - } + end_strategy={"DateOffset": {"dateField": "StartDate", "offset": 30}} ) check = ExitCriteriaDaysOffsetCheck() warnings = check.check(expression) - + assert len(warnings) == 0 class TestNoExitCriteriaCheck: """Tests for NoExitCriteriaCheck.""" - + def test_check_no_exit_criteria_with_all_events(self): """Test that missing exit criteria with all events triggers warning.""" try: expression = load_cohort_expression("checkers/noExitCriteriaCheck.json") check = NoExitCriteriaCheck() warnings = check.check(expression) - + # Accept any result from resource file assert len(warnings) >= 0 except FileNotFoundError: # Create a test expression expression = CohortExpression( primary_criteria={ - "criteriaList": [ - { - "conditionOccurrence": { - "codesetId": 0 - } - } - ], - "primaryLimit": { - "type": "All" - } - }, - expression_limit={ - "type": "All" + "criteriaList": [{"conditionOccurrence": {"codesetId": 0}}], + "primaryLimit": {"type": "All"}, }, - end_strategy=None + expression_limit={"type": "All"}, + end_strategy=None, ) check = NoExitCriteriaCheck() warnings = check.check(expression) - + # May or may not trigger depending on exact conditions assert isinstance(warnings, list) class TestRangeCheck: """Tests for RangeCheck.""" - + def test_check_negative_window_days(self): """Test that negative window days trigger warnings.""" from circe.cohortdefinition.core import Window, WindowBound @@ -640,7 +608,7 @@ def test_check_negative_window_days(self): CorelatedCriteria, CriteriaGroup, ) - + # Windows are valid on CorelatedCriteria (in Inclusion Rules), not PrimaryCriteria events expression = CohortExpression( inclusion_rules=[ @@ -655,71 +623,62 @@ def test_check_negative_window_days(self): start=WindowBound(days=-5, coeff=1), end=WindowBound(days=0, coeff=1), use_event_end=False, - use_index_end=False - ) + use_index_end=False, + ), ) - ] - ) + ], + ), } ] ) check = RangeCheck() warnings = check.check(expression) - + assert len(warnings) > 0 assert any("negative value" in w.to_message() for w in warnings) - + def test_check_valid_range(self): """Test that valid ranges produce no warnings.""" expression = CohortExpression( primary_criteria={ "criteriaList": [ { - "conditionOccurrence": { - "codesetId": 0 - }, - "startWindow": { - "start": { - "days": 30, - "coeff": 1 - } - } + "conditionOccurrence": {"codesetId": 0}, + "startWindow": {"start": {"days": 30, "coeff": 1}}, } ] } ) check = RangeCheck() warnings = check.check(expression) - + # Should not have warnings for valid ranges - range_warnings = [ - w for w in warnings if "negative value" in w.to_message() - ] + range_warnings = [w for w in warnings if "negative value" in w.to_message()] assert len(range_warnings) == 0 class TestDrugEraCheck: """Tests for DrugEraCheck.""" - + def test_check_missing_days_supply(self): """Test that drug era without days supply info triggers warning.""" try: expression = load_cohort_expression("checkers/drugEraCheckIncorrect.json") check = DrugEraCheck() warnings = check.check(expression) - + # Accept any result from resource file assert len(warnings) >= 0 except FileNotFoundError: pytest.skip("Test resource not available") - + def test_check_valid_drug_era(self): """Test that valid drug era produces no warnings.""" try: expression = load_cohort_expression("checkers/drugEraCheckCorrect.json") check = DrugEraCheck() warnings = check.check(expression) - + assert len(warnings) == 0 except FileNotFoundError: pytest.skip("Test resource not available") @@ -727,14 +686,16 @@ def test_check_valid_drug_era(self): class TestOcurrenceCheck: """Tests for OcurrenceCheck.""" - + def test_check_at_least_zero(self): """Test that 'at least 0' occurrence triggers warning.""" try: - expression = load_cohort_expression("checkers/occurrenceCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/occurrenceCheckIncorrect.json" + ) check = OcurrenceCheck() warnings = check.check(expression) - + assert len(warnings) >= 1 assert warnings[0].severity == WarningSeverity.WARNING except FileNotFoundError: @@ -745,117 +706,94 @@ def test_check_at_least_zero(self): occurrence = Occurrence( type=2, # AT_LEAST count=0, # This should trigger the warning - is_distinct=False + is_distinct=False, ) - + # Create a CorelatedCriteria with ConditionOccurrence and the occurrence condition_occurrence = ConditionOccurrence(codeset_id=0) corelated_criteria = CorelatedCriteria( - criteria=condition_occurrence, - occurrence=occurrence + criteria=condition_occurrence, occurrence=occurrence ) - + # Create an InclusionRule with the corelated criteria (OcurrenceCheck only checks inclusion rules) inclusion_rule = InclusionRule( name="Test Rule", expression=CriteriaGroup( - type="ALL", - criteria_list=[corelated_criteria] - ) - ) - - expression = CohortExpression( - inclusion_rules=[inclusion_rule] + type="ALL", criteria_list=[corelated_criteria] + ), ) + + expression = CohortExpression(inclusion_rules=[inclusion_rule]) check = OcurrenceCheck() warnings = check.check(expression) - + assert len(warnings) == 1 assert warnings[0].severity == WarningSeverity.WARNING assert "at least 0" in warnings[0].to_message() - + def test_check_valid_occurrence(self): """Test that valid occurrence produces no warnings.""" expression = CohortExpression( primary_criteria={ "criteriaList": [ { - "conditionOccurrence": { - "codesetId": 0 - }, + "conditionOccurrence": {"codesetId": 0}, "occurrence": { "type": 2, # AT_LEAST - "count": 1 - } + "count": 1, + }, } ] } ) check = OcurrenceCheck() warnings = check.check(expression) - - occurrence_warnings = [ - w for w in warnings if "at least 0" in w.to_message() - ] + + occurrence_warnings = [w for w in warnings if "at least 0" in w.to_message()] assert len(occurrence_warnings) == 0 class TestCheckerIntegration: """Integration tests for the main Checker class.""" - + def test_checker_runs_all_checks(self): """Test that Checker runs all registered checks.""" expression = CohortExpression( primary_criteria={ - "criteriaList": [ - { - "conditionOccurrence": { - "codesetId": 0 - } - } - ] + "criteriaList": [{"conditionOccurrence": {"codesetId": 0}}] } ) - + checker = Checker() warnings = checker.check(expression) - + # Should return a list (may be empty for valid expression) assert isinstance(warnings, list) - + def test_cohort_expression_check_method(self): """Test that CohortExpression.check() method works.""" expression = CohortExpression( primary_criteria={ - "criteriaList": [ - { - "conditionOccurrence": { - "codesetId": 0 - } - } - ] + "criteriaList": [{"conditionOccurrence": {"codesetId": 0}}] } ) - + warnings = expression.check() - + assert isinstance(warnings, list) assert all(isinstance(w, Warning) for w in warnings) - + def test_checker_with_empty_primary_criteria(self): """Test Checker with empty primary criteria.""" - expression = CohortExpression( - primary_criteria={ - "criteriaList": [] - } - ) - + expression = CohortExpression(primary_criteria={"criteriaList": []}) + checker = Checker() warnings = checker.check(expression) - + # Should have at least InitialEventCheck warning initial_warnings = [ - w for w in warnings + w + for w in warnings if "No initial event criteria specified" in w.to_message() ] assert len(initial_warnings) > 0 @@ -863,26 +801,30 @@ def test_checker_with_empty_primary_criteria(self): class TestEventsProgressionCheck: """Tests for EventsProgressionCheck.""" - + def test_check_incorrect_progression(self): """Test that incorrect event progression triggers warnings.""" try: - expression = load_cohort_expression("checkers/eventsProgressionCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/eventsProgressionCheckIncorrect.json" + ) check = EventsProgressionCheck() warnings = check.check(expression) - + # Accept any result from resource file assert len(warnings) >= 0 except FileNotFoundError: pytest.skip("Test resource not available") - + def test_check_correct_progression(self): """Test that correct event progression produces no warnings.""" try: - expression = load_cohort_expression("checkers/eventsProgressionCheckCorrect.json") + expression = load_cohort_expression( + "checkers/eventsProgressionCheckCorrect.json" + ) check = EventsProgressionCheck() warnings = check.check(expression) - + assert len(warnings) == 0 except FileNotFoundError: pytest.skip("Test resource not available") @@ -890,65 +832,63 @@ def test_check_correct_progression(self): class TestDuplicatesCriteriaCheck: """Tests for DuplicatesCriteriaCheck.""" - + def test_check_duplicate_criteria(self): """Test that duplicate criteria trigger warnings.""" try: - expression = load_cohort_expression("checkers/duplicatesCriteriaCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/duplicatesCriteriaCheckIncorrect.json" + ) check = DuplicatesCriteriaCheck() warnings = check.check(expression) - + # Accept any result from resource file assert len(warnings) >= 0 except FileNotFoundError: pytest.skip("Test resource not available") - + def test_check_no_duplicates(self): """Test that non-duplicate criteria produce no warnings.""" expression = CohortExpression( primary_criteria={ "criteriaList": [ - { - "conditionOccurrence": { - "codesetId": 0 - } - }, - { - "conditionOccurrence": { - "codesetId": 1 - } - } + {"conditionOccurrence": {"codesetId": 0}}, + {"conditionOccurrence": {"codesetId": 1}}, ] } ) check = DuplicatesCriteriaCheck() warnings = check.check(expression) - + assert len(warnings) == 0 class TestCriteriaContradictionsCheck: """Tests for CriteriaContradictionsCheck.""" - + def test_check_contradictory_criteria(self): """Test that contradictory criteria trigger warnings.""" try: - expression = load_cohort_expression("checkers/contradictionsCriteriaCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/contradictionsCriteriaCheckIncorrect.json" + ) check = CriteriaContradictionsCheck() warnings = check.check(expression) - + # Accept any result from resource file assert len(warnings) >= 0 except FileNotFoundError: pytest.skip("Test resource not available") - + def test_check_no_contradictions(self): """Test that non-contradictory criteria produce no warnings.""" try: - expression = load_cohort_expression("checkers/contradictionsCriteriaCheckCorrect.json") + expression = load_cohort_expression( + "checkers/contradictionsCriteriaCheckCorrect.json" + ) check = CriteriaContradictionsCheck() warnings = check.check(expression) - + assert len(warnings) == 0 except FileNotFoundError: pytest.skip("Test resource not available") @@ -956,26 +896,28 @@ def test_check_no_contradictions(self): class TestTimePatternCheck: """Tests for TimePatternCheck.""" - + def test_check_inconsistent_pattern(self): """Test that inconsistent time patterns trigger warnings.""" try: - expression = load_cohort_expression("checkers/timePatternCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/timePatternCheckIncorrect.json" + ) check = TimePatternCheck() warnings = check.check(expression) - + # Accept any result from resource file assert len(warnings) >= 0 except FileNotFoundError: pytest.skip("Test resource not available") - + def test_check_consistent_pattern(self): """Test that consistent time patterns produce no warnings.""" try: expression = load_cohort_expression("checkers/timePatternCheckCorrect.json") check = TimePatternCheck() warnings = check.check(expression) - + assert len(warnings) == 0 except FileNotFoundError: pytest.skip("Test resource not available") @@ -983,22 +925,24 @@ def test_check_consistent_pattern(self): class TestDomainTypeCheck: """Tests for DomainTypeCheck.""" - + def test_check_missing_domain_types(self): """Test that missing domain types trigger warnings.""" try: - expression = load_cohort_expression("checkers/domainTypeCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/domainTypeCheckIncorrect.json" + ) check = DomainTypeCheck() warnings = check.check(expression) - + # Accept any result from resource file - may differ from Java implementation assert len(warnings) >= 0 except FileNotFoundError: pytest.skip("Test resource not available") - + def test_check_valid_domain_types(self): """Test that valid domain types produce no warnings.""" - + expression = CohortExpression( primary_criteria={ "criteriaList": [ @@ -1016,9 +960,9 @@ def test_check_valid_domain_types(self): "CONCEPT_CODE": "Code", "DOMAIN_ID": "Condition", "VOCABULARY_ID": "SNOMED", - "VOCABULARY_ID_CAPTION": "SNOMED" + "VOCABULARY_ID_CAPTION": "SNOMED", } - ] + ], } } ] @@ -1026,32 +970,36 @@ def test_check_valid_domain_types(self): ) check = DomainTypeCheck() warnings = check.check(expression) - + assert len(warnings) == 0 class TestDeathTimeWindowCheck: """Tests for DeathTimeWindowCheck.""" - + def test_check_death_before_index(self): """Test that death criteria with windows before index trigger warnings.""" try: - expression = load_cohort_expression("checkers/deathTimeWindowCheckIncorrect.json") + expression = load_cohort_expression( + "checkers/deathTimeWindowCheckIncorrect.json" + ) check = DeathTimeWindowCheck() warnings = check.check(expression) - + # Accept any result from resource file assert len(warnings) >= 0 except FileNotFoundError: pytest.skip("Test resource not available") - + def test_check_death_after_index(self): """Test that death criteria with windows after index produce no warnings.""" try: - expression = load_cohort_expression("checkers/deathTimeWindowCheckCorrect.json") + expression = load_cohort_expression( + "checkers/deathTimeWindowCheckCorrect.json" + ) check = DeathTimeWindowCheck() warnings = check.check(expression) - + assert len(warnings) == 0 except FileNotFoundError: pytest.skip("Test resource not available") @@ -1059,184 +1007,170 @@ def test_check_death_after_index(self): class TestComparisons: """Tests for Comparisons utility class.""" - + def test_start_is_greater_than_end_numeric(self): """Test numeric range comparison.""" from circe.check.checkers.comparisons import Comparisons from circe.cohortdefinition.core import NumericRange - + range1 = NumericRange(value=3, extent=2) assert Comparisons.start_is_greater_than_end(range1) is True - + range2 = NumericRange(value=2, extent=3) assert Comparisons.start_is_greater_than_end(range2) is False - + def test_start_is_greater_than_end_date(self): """Test date range comparison.""" from datetime import date, timedelta from circe.check.checkers.comparisons import Comparisons from circe.cohortdefinition.core import DateRange + today = date.today() yesterday = today - timedelta(days=1) - + range1 = DateRange(value=today.isoformat(), extent=yesterday.isoformat()) assert Comparisons.start_is_greater_than_end(range1) is True - + range2 = DateRange(value=yesterday.isoformat(), extent=today.isoformat()) assert Comparisons.start_is_greater_than_end(range2) is False - + def test_is_date_valid(self): """Test date validation.""" from circe.check.checkers.comparisons import Comparisons - + assert Comparisons.is_date_valid("2024-01-15") is True assert Comparisons.is_date_valid("not a date") is False assert Comparisons.is_date_valid("2024-13-45") is False - + def test_is_start_negative(self): """Test negative start value check.""" from circe.check.checkers.comparisons import Comparisons from circe.cohortdefinition.core import NumericRange - + range1 = NumericRange(value=-3, extent=5) assert Comparisons.is_start_negative(range1) is True - + range2 = NumericRange(value=3, extent=5) assert Comparisons.is_start_negative(range2) is False - + def test_compare_concept(self): """Test concept comparison.""" from circe.check.checkers.comparisons import Comparisons from circe.vocabulary.concept import Concept - + concept1 = Concept( concept_id=12345, concept_code="code1", domain_id="Drug", - vocabulary_id="RxNorm" + vocabulary_id="RxNorm", ) - + compare_func = Comparisons.compare_concept(concept1) - + concept2 = Concept( concept_id=12345, concept_code="code1", domain_id="Drug", - vocabulary_id="RxNorm" + vocabulary_id="RxNorm", ) assert compare_func(concept2) is True - + concept3 = Concept( concept_id=67890, concept_code="code2", domain_id="Condition", - vocabulary_id="SNOMED" + vocabulary_id="SNOMED", ) assert compare_func(concept3) is False - + def test_compare_criteria(self): """Test criteria comparison.""" from circe.check.checkers.comparisons import Comparisons from circe.cohortdefinition.criteria import ConditionEra, Death - + era1 = ConditionEra(codeset_id=1) era2 = ConditionEra(codeset_id=1) assert Comparisons.compare_criteria(era1, era2) is True - + era3 = ConditionEra(codeset_id=2) assert Comparisons.compare_criteria(era1, era3) is False - + # Death requires additional fields - death1 = Death( - codeset_id=1, - death_type_exclude=False, - first=True - ) - death2 = Death( - codeset_id=1, - death_type_exclude=False, - first=True - ) + death1 = Death(codeset_id=1, death_type_exclude=False, first=True) + death2 = Death(codeset_id=1, death_type_exclude=False, first=True) assert Comparisons.compare_criteria(death1, death2) is True - + # Different types should not match assert Comparisons.compare_criteria(era1, death1) is False - + def test_is_before(self): """Test window 'before' check.""" from circe.check.checkers.comparisons import Comparisons from circe.cohortdefinition.core import Window, WindowBound - + # Window requires use_event_end and coeff/days window = Window( use_event_end=False, coeff=-1, days=1, start=WindowBound(days=1, coeff=-1), # 1 day before - end=WindowBound(days=1, coeff=-1) # 1 day before + end=WindowBound(days=1, coeff=-1), # 1 day before ) assert Comparisons.is_before(window) is True - + window2 = Window( use_event_end=False, coeff=-1, days=1, start=WindowBound(days=1, coeff=-1), # 1 day before - end=WindowBound(days=1, coeff=1) # 1 day after + end=WindowBound(days=1, coeff=1), # 1 day after ) assert Comparisons.is_before(window2) is False class TestWarningTypes: """Tests for warning types and their properties.""" - + def test_default_warning(self): """Test DefaultWarning properties.""" from circe.check.warnings import DefaultWarning - + warning = DefaultWarning( - severity=WarningSeverity.WARNING, - message="Test warning" + severity=WarningSeverity.WARNING, message="Test warning" ) - + assert warning.severity == WarningSeverity.WARNING assert warning.to_message() == "Test warning" - + def test_concept_set_warning(self): """Test ConceptSetWarning properties.""" from circe.vocabulary import ConceptSet from circe.vocabulary.concept import ConceptSetExpression - + concept_set_expression = ConceptSetExpression( - items=[], - is_excluded=False, - include_mapped=False, - include_descendants=False + items=[], is_excluded=False, include_mapped=False, include_descendants=False ) - + concept_set = ConceptSet( - id=0, - name="Test Set", - expression=concept_set_expression + id=0, name="Test Set", expression=concept_set_expression ) - + warning = ConceptSetWarning( severity=WarningSeverity.WARNING, template="Concept set %s is unused", - concept_set=concept_set + concept_set=concept_set, ) - + assert warning.severity == WarningSeverity.WARNING assert "Test Set" in warning.to_message() - + def test_incomplete_rule_warning(self): """Test IncompleteRuleWarning properties.""" warning = IncompleteRuleWarning( - severity=WarningSeverity.CRITICAL, - rule_name="Test Rule" + severity=WarningSeverity.CRITICAL, rule_name="Test Rule" ) - + assert warning.severity == WarningSeverity.CRITICAL assert warning.rule_name == "Test Rule" assert "Test Rule" in warning.to_message() @@ -1244,4 +1178,3 @@ def test_incomplete_rule_warning(self): if __name__ == "__main__": pytest.main([__file__, "-v"]) - diff --git a/tests/test_cli.py b/tests/test_cli.py index 8af9109a..631714c4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,9 +19,9 @@ from circe.cli import main # Get list of test cohorts -COHORTS_DIR = Path(__file__).parent / 'cohorts' +COHORTS_DIR = Path(__file__).parent / "cohorts" TEST_COHORTS = [ - 'isolated_immune_thrombocytopenia.json', + "isolated_immune_thrombocytopenia.json", ] @@ -29,27 +29,28 @@ def run_r_script_cached(cohort_file: Path) -> tuple[str, str]: """Run R CirceR script and return SQL and Markdown. Cached to avoid redundant slow R calls.""" import subprocess + with tempfile.TemporaryDirectory() as tmpdir: tmpdir_path = Path(tmpdir) - sql_output = tmpdir_path / 'output.sql' - + sql_output = tmpdir_path / "output.sql" + # Run R script result = subprocess.run( - ['Rscript', 'circe_sql.R', str(cohort_file), str(sql_output)], + ["Rscript", "circe_sql.R", str(cohort_file), str(sql_output)], capture_output=True, text=True, timeout=30, - cwd=Path(__file__).parent.parent + cwd=Path(__file__).parent.parent, ) - + if result.returncode != 0: pytest.skip(f"R script failed: {result.stderr}") - + # Read outputs sql = sql_output.read_text() - md_file = sql_output.with_suffix('.md') + md_file = sql_output.with_suffix(".md") markdown = md_file.read_text() if md_file.exists() else "" - + return sql, markdown @@ -57,8 +58,8 @@ def run_python_cli_in_process(args: list[str]) -> tuple[int, str, str]: """Run Python CLI in-process and return exit code, stdout, and stderr.""" stdout = StringIO() stderr = StringIO() - - with patch('sys.argv', ['circe'] + args): + + with patch("sys.argv", ["circe"] + args): with redirect_stdout(stdout), redirect_stderr(stderr): try: exit_code = main() or 0 @@ -67,11 +68,11 @@ def run_python_cli_in_process(args: list[str]) -> tuple[int, str, str]: except Exception as e: print(f"Error: {e}", file=sys.stderr) exit_code = 1 - + return exit_code, stdout.getvalue(), stderr.getvalue() -@pytest.mark.parametrize('cohort_name', TEST_COHORTS) +@pytest.mark.parametrize("cohort_name", TEST_COHORTS) def test_sql_generation_matches_r(cohort_name): """Test that Python CLI generates SQL similar to R CirceR.""" cohort_file = COHORTS_DIR / cohort_name @@ -81,85 +82,104 @@ def test_sql_generation_matches_r(cohort_name): if not cohort_file.exists(): pytest.skip(f"Cohort file not found: {cohort_file}") - + # Get R output (cached) r_sql, _ = run_r_script_cached(cohort_file) - + # Get Python output (in-process) with tempfile.TemporaryDirectory() as tmpdir: - sql_output = Path(tmpdir) / 'output.sql' - exit_code, _, _ = run_python_cli_in_process([ - 'generate-sql', str(cohort_file), '--output', str(sql_output), '--no-validate' - ]) - + sql_output = Path(tmpdir) / "output.sql" + exit_code, _, _ = run_python_cli_in_process( + [ + "generate-sql", + str(cohort_file), + "--output", + str(sql_output), + "--no-validate", + ] + ) + assert exit_code == 0 py_sql = sql_output.read_text() - + # Compare key structural elements - assert '#Codesets' in py_sql, "Missing #Codesets table" - assert '#qualified_events' in py_sql, "Missing #qualified_events table" - assert '#included_events' in py_sql, "Missing #included_events table" - + assert "#Codesets" in py_sql, "Missing #Codesets table" + assert "#qualified_events" in py_sql, "Missing #qualified_events table" + assert "#included_events" in py_sql, "Missing #included_events table" + # Check SQL is not trivially small assert len(py_sql) > 1000, "SQL output too small" - + # Compare sizes (Python should be reasonably close to R) py_lines = len(py_sql.splitlines()) r_lines = len(r_sql.splitlines()) - + # Allow Python to be smaller since #cohort_rows and #final_cohort are incomplete # But it should be at least 30% of R's size for the implemented parts - assert py_lines >= r_lines * 0.3, f"Python SQL too short: {py_lines} vs R {r_lines} lines" + assert py_lines >= r_lines * 0.3, ( + f"Python SQL too short: {py_lines} vs R {r_lines} lines" + ) -@pytest.mark.parametrize('cohort_name', TEST_COHORTS) +@pytest.mark.parametrize("cohort_name", TEST_COHORTS) def test_markdown_generation(cohort_name): """Test that Python CLI generates Markdown.""" cohort_file = COHORTS_DIR / cohort_name - + if not cohort_file.exists(): pytest.skip(f"Cohort file not found: {cohort_file}") - + # Get Python output (in-process) with tempfile.TemporaryDirectory() as tmpdir: - md_output = Path(tmpdir) / 'output.md' - exit_code, _, _ = run_python_cli_in_process([ - 'render-markdown', str(cohort_file), '--output', str(md_output), '--no-validate' - ]) - + md_output = Path(tmpdir) / "output.md" + exit_code, _, _ = run_python_cli_in_process( + [ + "render-markdown", + str(cohort_file), + "--output", + str(md_output), + "--no-validate", + ] + ) + assert exit_code == 0 py_md = md_output.read_text() - + # Check Markdown has expected sections - assert 'Cohort Entry Events' in py_md or 'cohort entry' in py_md.lower() + assert "Cohort Entry Events" in py_md or "cohort entry" in py_md.lower() assert len(py_md) > 100, "Markdown output too small" def test_validate_command(): """Test validate command.""" - cohort_file = COHORTS_DIR / 'isolated_immune_thrombocytopenia.json' - - exit_code, _, _ = run_python_cli_in_process(['validate', str(cohort_file)]) - + cohort_file = COHORTS_DIR / "isolated_immune_thrombocytopenia.json" + + exit_code, _, _ = run_python_cli_in_process(["validate", str(cohort_file)]) + # Validation may return warnings (exit code 1) but as long as it doesn't crash, it's OK assert exit_code in [0, 1], f"Unexpected exit code: {exit_code}" def test_process_command(): """Test process command.""" - cohort_file = COHORTS_DIR / 'isolated_immune_thrombocytopenia.json' - + cohort_file = COHORTS_DIR / "isolated_immune_thrombocytopenia.json" + with tempfile.TemporaryDirectory() as tmpdir: tmpdir_path = Path(tmpdir) - sql_output = tmpdir_path / 'output.sql' - md_output = tmpdir_path / 'output.md' - - exit_code, _, _ = run_python_cli_in_process([ - 'process', str(cohort_file), - '--sql-output', str(sql_output), - '--md-output', str(md_output) - ]) - + sql_output = tmpdir_path / "output.sql" + md_output = tmpdir_path / "output.md" + + exit_code, _, _ = run_python_cli_in_process( + [ + "process", + str(cohort_file), + "--sql-output", + str(sql_output), + "--md-output", + str(md_output), + ] + ) + assert exit_code == 0 assert sql_output.exists() assert md_output.exists() @@ -171,25 +191,24 @@ def test_process_command(): def test_generate_source_command(): """Test generate-source command.""" - cohort_file = COHORTS_DIR / 'isolated_immune_thrombocytopenia.json' - + cohort_file = COHORTS_DIR / "isolated_immune_thrombocytopenia.json" + with tempfile.TemporaryDirectory() as tmpdir: - output_file = Path(tmpdir) / 'cohort.py' - - exit_code, stdout, stderr = run_python_cli_in_process([ - 'generate-source', str(cohort_file), - '--output', str(output_file) - ]) + output_file = Path(tmpdir) / "cohort.py" + + exit_code, stdout, stderr = run_python_cli_in_process( + ["generate-source", str(cohort_file), "--output", str(output_file)] + ) assert output_file.exists() - + content = output_file.read_text() assert "from circe.cohortdefinition.cohort import CohortExpression" in content assert "cohort =" in content - + # Also check stdout version - exit_code, stdout, stderr = run_python_cli_in_process([ - 'generate-source', str(cohort_file) - ]) + exit_code, stdout, stderr = run_python_cli_in_process( + ["generate-source", str(cohort_file)] + ) assert "cohort =" in stdout diff --git a/tests/test_code_generator.py b/tests/test_code_generator.py index 9c03fd2d..c4c2bd17 100644 --- a/tests/test_code_generator.py +++ b/tests/test_code_generator.py @@ -1,4 +1,3 @@ - import json from circe.cohortdefinition.code_generator import to_python_code @@ -7,59 +6,67 @@ def test_code_generation_type2_diabetes(): """Test that generated code for Type 2 Diabetes cohort recreates the object correctly.""" - with open('examples/type2_diabetes_cohort.json') as f: + with open("examples/type2_diabetes_cohort.json") as f: data = json.load(f) - + original_cohort = CohortExpression.model_validate(data) code = to_python_code(original_cohort) - + exec_globals = {} exec(code, exec_globals) - generated_cohort = exec_globals['cohort'] - + generated_cohort = exec_globals["cohort"] + assert original_cohort.checksum() == generated_cohort.checksum() + def test_checksum_stability(): """Test that checksums are stable for identical objects.""" - with open('examples/type2_diabetes_cohort.json') as f: + with open("examples/type2_diabetes_cohort.json") as f: data = json.load(f) - + c1 = CohortExpression.model_validate(data) c2 = CohortExpression.model_validate(data) - + assert c1.checksum() == c2.checksum() + def test_checksum_diff(): """Test that checksums differ for modified objects.""" - with open('examples/type2_diabetes_cohort.json') as f: + with open("examples/type2_diabetes_cohort.json") as f: data = json.load(f) - + c1 = CohortExpression.model_validate(data) - + # Modify c2 - data['Title'] = 'Modified Title' + data["Title"] = "Modified Title" c2 = CohortExpression.model_validate(data) - + assert c1.checksum() != c2.checksum() + def test_simple_object_generation(): """Test generation of a simple object.""" from circe.cohortdefinition.core import Period - p = Period(value=10, unit='d') # Note: Unit might be a string or enum depending on Period def + + p = Period( + value=10, unit="d" + ) # Note: Unit might be a string or enum depending on Period def # Let's check Period definition first, wait, I can assume it works if the main one works. - pass + pass + def test_string_with_quotes(): """Test that strings containing quotes are correctly escaped in generated code.""" from circe.cohortdefinition.cohort import CohortExpression + # Create a cohort with a title containing quotes c = CohortExpression(title="Alzheimer's Disease 'quoted' \"double quoted\"") - + code = to_python_code(c) - + # Execute the code exec_globals = {} exec(code, exec_globals) - generated_cohort = exec_globals['cohort'] - + generated_cohort = exec_globals["cohort"] + assert generated_cohort.title == "Alzheimer's Disease 'quoted' \"double quoted\"" diff --git a/tests/test_cohort_expression.py b/tests/test_cohort_expression.py index b1f84585..416eddd2 100644 --- a/tests/test_cohort_expression.py +++ b/tests/test_cohort_expression.py @@ -26,16 +26,16 @@ class TestCohortExpressionBasics(unittest.TestCase): """Test basic CohortExpression functionality.""" - + def test_cohort_expression_initialization(self): """Test that CohortExpression can be initialized.""" cohort = CohortExpression() self.assertIsInstance(cohort, CohortExpression) - + def test_cohort_expression_empty_initialization(self): """Test CohortExpression with no parameters.""" cohort = CohortExpression() - + self.assertEqual(cohort.concept_sets, []) self.assertIsNone(cohort.qualified_limit) self.assertIsNone(cohort.additional_criteria) @@ -48,144 +48,130 @@ def test_cohort_expression_empty_initialization(self): self.assertEqual(cohort.inclusion_rules, []) self.assertIsNone(cohort.censor_window) self.assertEqual(cohort.censoring_criteria, []) - + def test_cohort_expression_with_title(self): """Test CohortExpression with title.""" cohort = CohortExpression(title="Test Cohort") self.assertEqual(cohort.title, "Test Cohort") - + def test_cohort_expression_with_primary_criteria(self): """Test CohortExpression with primary criteria.""" primary_criteria = PrimaryCriteria() cohort = CohortExpression(primary_criteria=primary_criteria) - + self.assertIsNotNone(cohort.primary_criteria) self.assertIsInstance(cohort.primary_criteria, PrimaryCriteria) - + def test_cohort_expression_with_qualified_limit(self): """Test CohortExpression with qualified limit.""" qualified_limit = ResultLimit(type="First") cohort = CohortExpression(qualified_limit=qualified_limit) - + self.assertIsNotNone(cohort.qualified_limit) self.assertEqual(cohort.qualified_limit.type, "First") - + def test_cohort_expression_with_expression_limit(self): """Test CohortExpression with expression limit.""" expression_limit = ResultLimit(type="Last") cohort = CohortExpression(expression_limit=expression_limit) - + self.assertIsNotNone(cohort.expression_limit) self.assertEqual(cohort.expression_limit.type, "Last") class TestCohortExpressionAliases(unittest.TestCase): """Test CohortExpression field aliases (camelCase support).""" - + def test_concept_sets_alias(self): """Test conceptSets alias.""" cohort = CohortExpression.model_validate({"conceptSets": []}) self.assertEqual(cohort.concept_sets, []) - + def test_qualified_limit_alias(self): """Test qualifiedLimit alias.""" - cohort = CohortExpression.model_validate({ - "qualifiedLimit": {"type": "First"} - }) + cohort = CohortExpression.model_validate({"qualifiedLimit": {"type": "First"}}) self.assertIsNotNone(cohort.qualified_limit) - + def test_additional_criteria_alias(self): """Test additionalCriteria alias.""" - cohort = CohortExpression.model_validate({ - "additionalCriteria": {"type": "ALL"} - }) + cohort = CohortExpression.model_validate( + {"additionalCriteria": {"type": "ALL"}} + ) self.assertIsNotNone(cohort.additional_criteria) - + def test_end_strategy_alias(self): """Test endStrategy alias.""" - cohort = CohortExpression.model_validate({ - "endStrategy": {} - }) + cohort = CohortExpression.model_validate({"endStrategy": {}}) self.assertIsNotNone(cohort.end_strategy) - + def test_cdm_version_range_alias(self): """Test cdmVersionRange alias.""" - cohort = CohortExpression.model_validate({ - "cdmVersionRange": ">=5.0.0" - }) + cohort = CohortExpression.model_validate({"cdmVersionRange": ">=5.0.0"}) self.assertIsNotNone(cohort.cdm_version_range) - + def test_primary_criteria_alias(self): """Test primaryCriteria alias.""" - cohort = CohortExpression.model_validate({ - "primaryCriteria": {} - }) + cohort = CohortExpression.model_validate({"primaryCriteria": {}}) self.assertIsNotNone(cohort.primary_criteria) - + def test_expression_limit_alias(self): """Test expressionLimit alias.""" - cohort = CohortExpression.model_validate({ - "expressionLimit": {"type": "All"} - }) + cohort = CohortExpression.model_validate({"expressionLimit": {"type": "All"}}) self.assertIsNotNone(cohort.expression_limit) - + def test_collapse_settings_alias(self): """Test collapseSettings alias.""" - cohort = CohortExpression.model_validate({ - "collapseSettings": {"era_pad": 30, "collapse_type": "collapse"} - }) + cohort = CohortExpression.model_validate( + {"collapseSettings": {"era_pad": 30, "collapse_type": "collapse"}} + ) self.assertIsNotNone(cohort.collapse_settings) - + def test_inclusion_rules_alias(self): """Test inclusionRules alias.""" - cohort = CohortExpression.model_validate({ - "inclusionRules": [] - }) + cohort = CohortExpression.model_validate({"inclusionRules": []}) self.assertEqual(cohort.inclusion_rules, []) - + def test_censor_window_alias(self): """Test censorWindow alias.""" - cohort = CohortExpression.model_validate({ - "censorWindow": {"startDate": "2020-01-01"} - }) + cohort = CohortExpression.model_validate( + {"censorWindow": {"startDate": "2020-01-01"}} + ) self.assertIsNotNone(cohort.censor_window) - + def test_censoring_criteria_alias(self): """Test censoringCriteria alias.""" - cohort = CohortExpression.model_validate({ - "censoringCriteria": [] - }) + cohort = CohortExpression.model_validate({"censoringCriteria": []}) self.assertEqual(cohort.censoring_criteria, []) class TestCohortExpressionValidation(unittest.TestCase): """Test CohortExpression validation methods.""" - + def test_validate_expression_without_primary_criteria(self): """Test validation fails without primary criteria.""" cohort = CohortExpression() result = cohort.validate_expression() self.assertFalse(result) - + def test_validate_expression_with_primary_criteria(self): """Test validation passes with primary criteria.""" cohort = CohortExpression(primary_criteria=PrimaryCriteria()) result = cohort.validate_expression() self.assertTrue(result) - + def test_validate_expression_with_concept_sets_valid(self): """Test validation with valid concept sets.""" # Use actual ConceptSet objects concept_set1 = ConceptSet(id=1, name="Set 1") concept_set2 = ConceptSet(id=2, name="Set 2") - + cohort = CohortExpression( primary_criteria=PrimaryCriteria(), - concept_sets=[concept_set1, concept_set2] + concept_sets=[concept_set1, concept_set2], ) result = cohort.validate_expression() self.assertTrue(result) - + def test_validate_expression_with_concept_sets_invalid(self): """Test validation fails with invalid concept sets.""" # ConceptSet requires id field, so we can't create one without it @@ -193,49 +179,48 @@ def test_validate_expression_with_concept_sets_invalid(self): # Note: ConceptSet.id is required, so we'll test with a dict that has None id # But Pydantic will validate, so we need to use model_validate try: - cohort = CohortExpression.model_validate({ - "primaryCriteria": {}, - "conceptSets": [{"id": None, "name": "Invalid"}] - }) + cohort = CohortExpression.model_validate( + { + "primaryCriteria": {}, + "conceptSets": [{"id": None, "name": "Invalid"}], + } + ) # If validation passes, then check the validate_expression method result = cohort.validate_expression() self.assertFalse(result) except Exception: # If Pydantic validation fails, that's also acceptable pass - + def test_validate_expression_with_empty_concept_sets(self): """Test validation with empty concept sets.""" - cohort = CohortExpression( - primary_criteria=PrimaryCriteria(), - concept_sets=[] - ) + cohort = CohortExpression(primary_criteria=PrimaryCriteria(), concept_sets=[]) result = cohort.validate_expression() self.assertTrue(result) class TestCohortExpressionUtilityMethods(unittest.TestCase): """Test CohortExpression utility methods.""" - + def test_get_concept_set_ids_empty(self): """Test getting concept set IDs when no concept sets exist.""" cohort = CohortExpression() result = cohort.get_concept_set_ids() self.assertEqual(result, []) - + def test_get_concept_set_ids_with_concept_sets(self): """Test getting concept set IDs from concept sets.""" # Use actual ConceptSet objects concept_set1 = ConceptSet(id=1, name="Set 1") concept_set2 = ConceptSet(id=2, name="Set 2") concept_set3 = ConceptSet(id=3, name="Set 3") - + cohort = CohortExpression( concept_sets=[concept_set1, concept_set2, concept_set3] ) result = cohort.get_concept_set_ids() self.assertEqual(result, [1, 2, 3]) - + def test_get_concept_set_ids_with_none_ids(self): """Test getting concept set IDs filtering None values.""" # ConceptSet.id is required, so we can't create one with None id directly @@ -244,13 +229,13 @@ def test_get_concept_set_ids_with_none_ids(self): concept_set1 = ConceptSet(id=1, name="Set 1") concept_set2 = ConceptSet(id=2, name="Set 2") concept_set3 = ConceptSet(id=3, name="Set 3") - + cohort = CohortExpression( concept_sets=[concept_set1, concept_set2, concept_set3] ) result = cohort.get_concept_set_ids() self.assertEqual(result, [1, 2, 3]) - + def test_get_concept_set_ids_empty_list(self): """Test getting concept set IDs with empty list.""" cohort = CohortExpression(concept_sets=[]) @@ -260,7 +245,7 @@ def test_get_concept_set_ids_empty_list(self): class TestCohortExpressionComplexScenarios(unittest.TestCase): """Test CohortExpression with complex scenarios.""" - + def test_cohort_expression_full_configuration(self): """Test CohortExpression with all fields populated.""" cohort = CohortExpression( @@ -271,13 +256,15 @@ def test_cohort_expression_full_configuration(self): additional_criteria=CriteriaGroup(type="ALL"), end_strategy=EndStrategy(), cdm_version_range=">=5.0.0", - collapse_settings=CollapseSettings(era_pad=30, collapse_type=CollapseType.COLLAPSE), + collapse_settings=CollapseSettings( + era_pad=30, collapse_type=CollapseType.COLLAPSE + ), censor_window=Period(start_date="2020-01-01"), concept_sets=[], inclusion_rules=[], - censoring_criteria=[] + censoring_criteria=[], ) - + self.assertIsNotNone(cohort.title) self.assertIsNotNone(cohort.primary_criteria) self.assertIsNotNone(cohort.qualified_limit) @@ -290,90 +277,82 @@ def test_cohort_expression_full_configuration(self): self.assertIsNotNone(cohort.concept_sets) self.assertIsNotNone(cohort.inclusion_rules) self.assertIsNotNone(cohort.censoring_criteria) - + def test_cohort_expression_from_dict(self): """Test CohortExpression creation from dictionary.""" data = { "title": "Test Cohort", "primaryCriteria": {}, "qualifiedLimit": {"type": "First"}, - "conceptSets": [] + "conceptSets": [], } - + cohort = CohortExpression.model_validate(data) - + self.assertEqual(cohort.title, "Test Cohort") self.assertIsNotNone(cohort.primary_criteria) self.assertIsNotNone(cohort.qualified_limit) self.assertEqual(cohort.concept_sets, []) - + def test_cohort_expression_to_dict(self): """Test CohortExpression serialization to dictionary.""" cohort = CohortExpression( - title="Test Cohort", - primary_criteria=PrimaryCriteria() + title="Test Cohort", primary_criteria=PrimaryCriteria() ) - + result = cohort.model_dump() - + self.assertIsInstance(result, dict) self.assertEqual(result["Title"], "Test Cohort") self.assertIn("PrimaryCriteria", result) - + def test_cohort_expression_to_dict_with_aliases(self): """Test CohortExpression serialization with PascalCase aliases for Java compatibility.""" cohort = CohortExpression( title="Test Cohort", primary_criteria=PrimaryCriteria(), - qualified_limit=ResultLimit(type="First") + qualified_limit=ResultLimit(type="First"), ) - + result = cohort.model_dump(by_alias=True) - + self.assertIsInstance(result, dict) self.assertEqual(result["Title"], "Test Cohort") # Java uses PascalCase for top-level fields self.assertIn("PrimaryCriteria", result) self.assertIn("QualifiedLimit", result) - + def test_cohort_expression_copy(self): """Test CohortExpression copying.""" - cohort1 = CohortExpression( - title="Original", - primary_criteria=PrimaryCriteria() - ) - + cohort1 = CohortExpression(title="Original", primary_criteria=PrimaryCriteria()) + cohort2 = cohort1.model_copy() - + self.assertEqual(cohort1.title, cohort2.title) self.assertIsNot(cohort1, cohort2) - + def test_cohort_expression_update(self): """Test CohortExpression field updates.""" cohort = CohortExpression(title="Original") - + cohort.title = "Updated" cohort.primary_criteria = PrimaryCriteria() - + self.assertEqual(cohort.title, "Updated") self.assertIsNotNone(cohort.primary_criteria) class TestCohortExpressionEdgeCases(unittest.TestCase): """Test CohortExpression edge cases.""" - + def test_cohort_expression_with_none_values(self): """Test CohortExpression with explicitly None values.""" - cohort = CohortExpression( - title=None, - primary_criteria=None, - concept_sets=None - ) - + cohort = CohortExpression(title=None, primary_criteria=None, concept_sets=None) + self.assertIsNone(cohort.title) self.assertIsNone(cohort.primary_criteria) self.assertEqual(cohort.concept_sets, []) - + def test_cohort_expression_inclusion_rules_none_to_list(self): """Test that inclusion_rules=None is converted to empty list.""" # Test via constructor @@ -394,16 +373,16 @@ def test_cohort_expression_list_defaults(self): # 2. None Initialization c_none = CohortExpression( - concept_sets=None, - censoring_criteria=None, - inclusion_rules=None + concept_sets=None, censoring_criteria=None, inclusion_rules=None ) self.assertEqual(c_none.concept_sets, []) self.assertEqual(c_none.censoring_criteria, []) self.assertEqual(c_none.inclusion_rules, []) # 3. JSON Null - c_json = CohortExpression.model_validate_json('{"ConceptSets": null, "CensoringCriteria": null, "InclusionRules": null}') + c_json = CohortExpression.model_validate_json( + '{"ConceptSets": null, "CensoringCriteria": null, "InclusionRules": null}' + ) self.assertEqual(c_json.concept_sets, []) self.assertEqual(c_json.censoring_criteria, []) self.assertEqual(c_json.inclusion_rules, []) @@ -412,69 +391,65 @@ def test_cohort_expression_empty_string_title(self): """Test CohortExpression with empty string title.""" cohort = CohortExpression(title="") self.assertEqual(cohort.title, "") - + def test_cohort_expression_unicode_title(self): """Test CohortExpression with unicode characters in title.""" cohort = CohortExpression(title="Test Cohort 测试 🎉") self.assertEqual(cohort.title, "Test Cohort 测试 🎉") - + def test_cohort_expression_long_title(self): """Test CohortExpression with very long title.""" long_title = "A" * 1000 cohort = CohortExpression(title=long_title) self.assertEqual(len(cohort.title), 1000) - + def test_cohort_expression_model_config(self): """Test that model config is properly set.""" cohort = CohortExpression() - self.assertTrue(hasattr(cohort, 'model_config')) - self.assertIn('populate_by_name', str(cohort.model_config)) + self.assertTrue(hasattr(cohort, "model_config")) + self.assertIn("populate_by_name", str(cohort.model_config)) class TestCohortExpressionIntegration(unittest.TestCase): """Test CohortExpression integration with other classes.""" - + def test_cohort_expression_with_result_limits(self): """Test CohortExpression with different result limit types.""" limit_types = ["First", "Last", "All"] - + for limit_type in limit_types: - cohort = CohortExpression( - qualified_limit=ResultLimit(type=limit_type) - ) + cohort = CohortExpression(qualified_limit=ResultLimit(type=limit_type)) self.assertEqual(cohort.qualified_limit.type, limit_type) - + def test_cohort_expression_with_collapse_settings(self): """Test CohortExpression with collapse settings.""" collapse_types = [CollapseType.COLLAPSE, CollapseType.NO_COLLAPSE] - + for collapse_type in collapse_types: cohort = CohortExpression( - collapse_settings=CollapseSettings(era_pad=30, collapse_type=collapse_type) + collapse_settings=CollapseSettings( + era_pad=30, collapse_type=collapse_type + ) ) self.assertEqual(cohort.collapse_settings.collapse_type, collapse_type) - + def test_cohort_expression_with_cdm_version_range(self): """Test CohortExpression with cdm_version_range string.""" cohort = CohortExpression( - cdm_version_range=">=5.0.0", - censor_window=Period( - start_date="2020-06-01" - ) + cdm_version_range=">=5.0.0", censor_window=Period(start_date="2020-06-01") ) - + self.assertEqual(cohort.cdm_version_range, ">=5.0.0") self.assertEqual(cohort.censor_window.start_date, "2020-06-01") - + def test_cohort_expression_with_criteria_group(self): """Test CohortExpression with criteria group.""" criteria_group = CriteriaGroup(type="ALL") cohort = CohortExpression(additional_criteria=criteria_group) - + self.assertIsNotNone(cohort.additional_criteria) self.assertEqual(cohort.additional_criteria.type, "ALL") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() - diff --git a/tests/test_cohort_expression_query_builder_coverage.py b/tests/test_cohort_expression_query_builder_coverage.py index 99c2093d..1abe2be8 100644 --- a/tests/test_cohort_expression_query_builder_coverage.py +++ b/tests/test_cohort_expression_query_builder_coverage.py @@ -1,4 +1,3 @@ - import unittest from circe.cohortdefinition import ( @@ -36,31 +35,34 @@ def test_inclusion_analysis_section(self): primary_criteria=PrimaryCriteria( criteria_list=[ConditionOccurrence(codeset_id=1)], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="All") + primary_limit=ResultLimit(type="All"), ), inclusion_rules=[ InclusionRule( name="Rule 1", - expression=CriteriaGroup(type="ALL", criteria_list=[ - CorelatedCriteria( - criteria=Death(codeset_id=2, first=True), - start_window=None, - occurrence=None - ) - ]) + expression=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=Death(codeset_id=2, first=True), + start_window=None, + occurrence=None, + ) + ], + ), ), InclusionRule( name="Rule 2", - expression=CriteriaGroup(type="ALL", criteria_list=[]) - ) - ] + expression=CriteriaGroup(type="ALL", criteria_list=[]), + ), + ], ) # Enable stats self.options.generate_stats = True - + # Build query sql = self.builder.build_expression_query(expression, self.options).lower() - + # Verify inclusion analysis components self.assertIn("into #inclusion_rules", sql) self.assertIn("into #best_events", sql) @@ -78,36 +80,38 @@ def test_censoring_events_query(self): primary_criteria=PrimaryCriteria( criteria_list=[ConditionOccurrence(codeset_id=1)], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="All") + primary_limit=ResultLimit(type="All"), ), censoring_criteria=[ Death(codeset_id=100, first=True), - Observation(codeset_id=200, first=True, observation_type_exclude=False) - ] + Observation(codeset_id=200, first=True, observation_type_exclude=False), + ], ) - + sql = self.builder.build_expression_query(expression, self.options).lower() - + # Verify censoring logic self.assertIn("-- censor events", sql) - self.assertIn("select i.event_id, i.person_id", sql.lower()) # CENSORING_QUERY_TEMPLATE + self.assertIn( + "select i.event_id, i.person_id", sql.lower() + ) # CENSORING_QUERY_TEMPLATE # Should call get_criteria_sql for checking death/obs tables - self.assertIn("from cdm.death", sql) + self.assertIn("from cdm.death", sql) self.assertIn("from cdm.observation", sql) # Verify union if multiple censoring criteria - self.assertIn("union all", sql) # between the two censoring queries + self.assertIn("union all", sql) # between the two censoring queries def test_wrap_criteria_query(self): """Test wrapping a criteria query with group logic.""" group = CriteriaGroup(type="ALL", criteria_list=[]) base_query = "SELECT person_id, event_id FROM #test" - + wrapped = self.builder.wrap_criteria_query(base_query, group) - + # Check structure self.assertIn("SELECT Q.person_id", wrapped) self.assertIn("JOIN @cdm_database_schema.OBSERVATION_PERIOD OP", wrapped) - self.assertIn("JOIN (", wrapped) # Expect join to group query + self.assertIn("JOIN (", wrapped) # Expect join to group query self.assertIn(") AC on AC.person_id = pe.person_id", wrapped) def test_limits_and_sorts(self): @@ -117,29 +121,31 @@ def test_limits_and_sorts(self): primary_criteria=PrimaryCriteria( criteria_list=[ConditionOccurrence(codeset_id=1)], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="Last") + primary_limit=ResultLimit(type="Last"), ), # 2. Qualified Limit: LAST qualified_limit=ResultLimit(type="Last"), # 3. Expression Limit: LAST expression_limit=ResultLimit(type="Last"), - additional_criteria=CriteriaGroup(type="ALL", criteria_list=[]) # needed for qualified limit logic + additional_criteria=CriteriaGroup( + type="ALL", criteria_list=[] + ), # needed for qualified limit logic ) - + sql = self.builder.build_expression_query(expression, self.options) - + # Primary sort verification - check lowercase order by - self.assertIn("order by pe.start_date DESC", sql) # @QualifiedEventSort - + self.assertIn("order by pe.start_date DESC", sql) # @QualifiedEventSort + # Qualified limit logic # If additional criteria + qualified limit != ALL -> WHERE QE.ordinal = 1 self.assertIn("WHERE QE.ordinal = 1", sql) - + # Expression limit logic # If expression limit != ALL -> WHERE Results.ordinal = 1 self.assertIn("WHERE Results.ordinal = 1", sql) # Inclusion sort - self.assertIn("order by start_date DESC", sql) # @IncludedEventSort + self.assertIn("order by start_date DESC", sql) # @IncludedEventSort def test_limits_and_sorts_first(self): """Test limits and sorts with FIRST/ALL.""" @@ -147,54 +153,57 @@ def test_limits_and_sorts_first(self): primary_criteria=PrimaryCriteria( criteria_list=[ConditionOccurrence(codeset_id=1)], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="First") + primary_limit=ResultLimit(type="First"), ), qualified_limit=ResultLimit(type="First"), expression_limit=ResultLimit(type="First"), - additional_criteria=CriteriaGroup(type="ALL", criteria_list=[]) + additional_criteria=CriteriaGroup(type="ALL", criteria_list=[]), ) - + sql = self.builder.build_expression_query(expression, self.options) - + self.assertIn("order by pe.start_date ASC", sql) self.assertIn("WHERE QE.ordinal = 1", sql) self.assertIn("WHERE Results.ordinal = 1", sql) def test_inclusion_rules_empty(self): - """Test explicitly with empty inclusion rules list (edge case branching).""" - expression = CohortExpression( + """Test explicitly with empty inclusion rules list (edge case branching).""" + expression = CohortExpression( primary_criteria=PrimaryCriteria( criteria_list=[ConditionOccurrence(codeset_id=1)], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="All") + primary_limit=ResultLimit(type="All"), ), - inclusion_rules=[] - ) - self.options.generate_stats = True - - sql = self.builder.build_expression_query(expression, self.options) - - # Should create empty inclusion events table - self.assertIn("CREATE TABLE #inclusion_events", sql) - # Should NOT have inclusion analysis - self.assertNotIn("INTO #inclusion_rules", sql) # Should be skipped because rule_total == 0 check in _build_inclusion_analysis_section + inclusion_rules=[], + ) + self.options.generate_stats = True + + sql = self.builder.build_expression_query(expression, self.options) + + # Should create empty inclusion events table + self.assertIn("CREATE TABLE #inclusion_events", sql) + # Should NOT have inclusion analysis + self.assertNotIn( + "INTO #inclusion_rules", sql + ) # Should be skipped because rule_total == 0 check in _build_inclusion_analysis_section def test_rule_total_replacement(self): - """Verify @ruleTotal replacement handles 0 correctly.""" - expression = CohortExpression( + """Verify @ruleTotal replacement handles 0 correctly.""" + expression = CohortExpression( primary_criteria=PrimaryCriteria( criteria_list=[ConditionOccurrence(codeset_id=1)], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="All") + primary_limit=ResultLimit(type="All"), ), - inclusion_rules=[] - ) - - sql = self.builder.build_expression_query(expression, self.options) - # In COHORT_QUERY_TEMPLATE: {1 != 0 & @ruleTotal != 0} - # If 0 rules, this check fails and skips analysis block logic inside the template - # We just want to ensure no crash - self.assertIsNotNone(sql) - -if __name__ == '__main__': + inclusion_rules=[], + ) + + sql = self.builder.build_expression_query(expression, self.options) + # In COHORT_QUERY_TEMPLATE: {1 != 0 & @ruleTotal != 0} + # If 0 rules, this check fails and skips analysis block logic inside the template + # We just want to ensure no crash + self.assertIsNotNone(sql) + + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_cohort_expression_query_builder_extended.py b/tests/test_cohort_expression_query_builder_extended.py index 9a17874e..a2064051 100644 --- a/tests/test_cohort_expression_query_builder_extended.py +++ b/tests/test_cohort_expression_query_builder_extended.py @@ -1,4 +1,3 @@ - import unittest from unittest.mock import MagicMock, patch @@ -29,7 +28,6 @@ class TestCohortExpressionQueryBuilderExtended(unittest.TestCase): - def setUp(self): self.builder = CohortExpressionQueryBuilder() # Mock sub-builders to isolate testing of the main builder logic @@ -52,42 +50,108 @@ def setUp(self): def test_get_criteria_sql_dispatch(self): """Test that get_criteria_sql correctly dispatches to the appropriate builder.""" - + # Define test cases: (criteria_instance, builder_mock, criteria_name) test_cases = [ - (ConditionOccurrence(first=True, codeset_id=1), self.builder.condition_occurrence_sql_builder, "ConditionOccurrence"), + ( + ConditionOccurrence(first=True, codeset_id=1), + self.builder.condition_occurrence_sql_builder, + "ConditionOccurrence", + ), (Death(first=True, codeset_id=1), self.builder.death_sql_builder, "Death"), - (VisitOccurrence(first=True, codeset_id=1), self.builder.visit_occurrence_sql_builder, "VisitOccurrence"), - (VisitDetail(first=True, codeset_id=1), self.builder.visit_detail_sql_builder, "VisitDetail"), - (PayerPlanPeriod(first=True), self.builder.payer_plan_period_sql_builder, "PayerPlanPeriod"), - (DrugExposure(first=True, codeset_id=1), self.builder.drug_exposure_sql_builder, "DrugExposure"), - (ProcedureOccurrence(first=True, codeset_id=1), self.builder.procedure_occurrence_sql_builder, "ProcedureOccurrence"), - (DeviceExposure(first=True, codeset_id=1, device_type_exclude=False), self.builder.device_exposure_sql_builder, "DeviceExposure"), - (Measurement(first=True, codeset_id=1, measurement_type_exclude=False), self.builder.measurement_sql_builder, "Measurement"), - (Observation(first=True, codeset_id=1, observation_type_exclude=False), self.builder.observation_sql_builder, "Observation"), - (Specimen(first=True, codeset_id=1, specimen_type_exclude=False), self.builder.specimen_sql_builder, "Specimen"), - (ObservationPeriod(first=True), self.builder.observation_period_sql_builder, "ObservationPeriod"), - (LocationRegion(codeset_id=1), self.builder.location_region_sql_builder, "LocationRegion"), - (ConditionEra(first=True, codeset_id=1), self.builder.condition_era_sql_builder, "ConditionEra"), - (DrugEra(first=True, codeset_id=1), self.builder.drug_era_sql_builder, "DrugEra"), - (DoseEra(first=True, codeset_id=1), self.builder.dose_era_sql_builder, "DoseEra"), + ( + VisitOccurrence(first=True, codeset_id=1), + self.builder.visit_occurrence_sql_builder, + "VisitOccurrence", + ), + ( + VisitDetail(first=True, codeset_id=1), + self.builder.visit_detail_sql_builder, + "VisitDetail", + ), + ( + PayerPlanPeriod(first=True), + self.builder.payer_plan_period_sql_builder, + "PayerPlanPeriod", + ), + ( + DrugExposure(first=True, codeset_id=1), + self.builder.drug_exposure_sql_builder, + "DrugExposure", + ), + ( + ProcedureOccurrence(first=True, codeset_id=1), + self.builder.procedure_occurrence_sql_builder, + "ProcedureOccurrence", + ), + ( + DeviceExposure(first=True, codeset_id=1, device_type_exclude=False), + self.builder.device_exposure_sql_builder, + "DeviceExposure", + ), + ( + Measurement(first=True, codeset_id=1, measurement_type_exclude=False), + self.builder.measurement_sql_builder, + "Measurement", + ), + ( + Observation(first=True, codeset_id=1, observation_type_exclude=False), + self.builder.observation_sql_builder, + "Observation", + ), + ( + Specimen(first=True, codeset_id=1, specimen_type_exclude=False), + self.builder.specimen_sql_builder, + "Specimen", + ), + ( + ObservationPeriod(first=True), + self.builder.observation_period_sql_builder, + "ObservationPeriod", + ), + ( + LocationRegion(codeset_id=1), + self.builder.location_region_sql_builder, + "LocationRegion", + ), + ( + ConditionEra(first=True, codeset_id=1), + self.builder.condition_era_sql_builder, + "ConditionEra", + ), + ( + DrugEra(first=True, codeset_id=1), + self.builder.drug_era_sql_builder, + "DrugEra", + ), + ( + DoseEra(first=True, codeset_id=1), + self.builder.dose_era_sql_builder, + "DoseEra", + ), ] for criteria, mock_builder, name in test_cases: with self.subTest(msg=f"Testing dispatch for {name}"): - mock_builder.get_criteria_sql_with_options.return_value = f"SELECT * FROM {name}" + mock_builder.get_criteria_sql_with_options.return_value = ( + f"SELECT * FROM {name}" + ) sql = self.builder.get_criteria_sql(criteria) - mock_builder.get_criteria_sql_with_options.assert_called_with(criteria, None) + mock_builder.get_criteria_sql_with_options.assert_called_with( + criteria, None + ) self.assertIn(f"SELECT * FROM {name}", sql) def test_get_criteria_sql_from_dict(self): """Test get_criteria_sql handling dictionary input (deserialization).""" criteria_dict = {"ConditionOccurrence": {"CodesetId": 1, "First": True}} self.builder.condition_occurrence_sql_builder.get_criteria_sql_with_options.return_value = "SELECT * FROM CO" - + sql = self.builder.get_criteria_sql(criteria_dict) - - self.assertTrue(self.builder.condition_occurrence_sql_builder.get_criteria_sql_with_options.called) + + self.assertTrue( + self.builder.condition_occurrence_sql_builder.get_criteria_sql_with_options.called + ) call_args = self.builder.condition_occurrence_sql_builder.get_criteria_sql_with_options.call_args self.assertIsInstance(call_args[0][0], ConditionOccurrence) self.assertEqual(call_args[0][0].codeset_id, 1) @@ -97,37 +161,51 @@ def test_get_windowed_criteria_query_basic(self): """Test get_windowed_criteria_query with basic configuration.""" criteria = WindowedCriteria( criteria=ConditionOccurrence(first=True, codeset_id=1), - start_window=Window(start={'days': 0, 'coeff': -1}, end={'days': 0, 'coeff': 1}), - ignore_observation_period=False + start_window=Window( + start={"days": 0, "coeff": -1}, end={"days": 0, "coeff": 1} + ), + ignore_observation_period=False, ) # Mock criteria acceptance - with patch.object(ConditionOccurrence, 'accept', return_value="SELECT * FROM Criteria") as mock_accept: + with patch.object( + ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria" + ) as mock_accept: sql = self.builder.get_windowed_criteria_query(criteria, "#events") - + self.assertIn("SELECT * FROM Criteria", sql) self.assertIn("#events", sql) - self.assertIn("A.START_DATE >= P.OP_START_DATE", sql) # Check OP check - self.assertIn("A.START_DATE >=", sql) # Window logic + self.assertIn("A.START_DATE >= P.OP_START_DATE", sql) # Check OP check + self.assertIn("A.START_DATE >=", sql) # Window logic def test_get_windowed_criteria_query_ignore_op(self): """Test get_windowed_criteria_query with ignore_observation_period=True.""" criteria = WindowedCriteria( criteria=ConditionOccurrence(first=True, codeset_id=1), - start_window=Window(start={'days': 0, 'coeff': -1}, end={'days': 0, 'coeff': 1}), - ignore_observation_period=True # Important + start_window=Window( + start={"days": 0, "coeff": -1}, end={"days": 0, "coeff": 1} + ), + ignore_observation_period=True, # Important ) - with patch.object(ConditionOccurrence, 'accept', return_value="SELECT * FROM Criteria"): + with patch.object( + ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria" + ): sql = self.builder.get_windowed_criteria_query(criteria, "#events") - self.assertNotIn("A.START_DATE >= P.OP_START_DATE AND A.START_DATE <= P.OP_END_DATE", sql) + self.assertNotIn( + "A.START_DATE >= P.OP_START_DATE AND A.START_DATE <= P.OP_END_DATE", sql + ) def test_get_windowed_criteria_query_restrict_visit(self): """Test get_windowed_criteria_query with restrict_visit=True.""" criteria = WindowedCriteria( criteria=ConditionOccurrence(first=True, codeset_id=1), - start_window=Window(start={'days': 0, 'coeff': -1}, end={'days': 0, 'coeff': 1}), - restrict_visit=True + start_window=Window( + start={"days": 0, "coeff": -1}, end={"days": 0, "coeff": 1} + ), + restrict_visit=True, ) - with patch.object(ConditionOccurrence, 'accept', return_value="SELECT * FROM Criteria"): + with patch.object( + ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria" + ): sql = self.builder.get_windowed_criteria_query(criteria, "#events") self.assertIn("A.visit_occurrence_id = P.visit_occurrence_id", sql) @@ -136,14 +214,16 @@ def test_get_corelated_criteria_query_formatted_event_table(self): # When event_table is a query string (SELECT ...), it should be wrapped with OP join cc = CorelatedCriteria( criteria=ConditionOccurrence(first=True, codeset_id=1), - occurrence=Occurrence(type=1, count=1) + occurrence=Occurrence(type=1, count=1), ) - + event_query = "SELECT person_id, event_id, start_date, end_date FROM #table" - - with patch.object(ConditionOccurrence, 'accept', return_value="SELECT * FROM Criteria"): + + with patch.object( + ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria" + ): sql = self.builder.get_corelated_criteria_query(cc, event_query) - + # Should inject observation period join self.assertIn("JOIN @cdm_database_schema.OBSERVATION_PERIOD OP", sql) self.assertIn("SELECT Q.person_id", sql) @@ -154,45 +234,49 @@ def test_get_criteria_group_query_at_least(self): type="AT_LEAST", count=2, criteria_list=[ - CorelatedCriteria(criteria=ConditionOccurrence(first=True, codeset_id=1), - occurrence=Occurrence(type=2, count=1)) - ] + CorelatedCriteria( + criteria=ConditionOccurrence(first=True, codeset_id=1), + occurrence=Occurrence(type=2, count=1), + ) + ], ) - + # Mock get_corelated_criteria_query self.builder.get_corelated_criteria_query = MagicMock(return_value="SELECT 1") - + sql = self.builder.get_criteria_group_query(group, "#events") - + self.assertIn("HAVING COUNT(index_id) >= 2", sql) - self.assertIn("INNER JOIN", sql) # Expect INNER JOIN for AT_LEAST > 0 + self.assertIn("INNER JOIN", sql) # Expect INNER JOIN for AT_LEAST > 0 def test_get_criteria_group_query_at_most(self): """Test get_criteria_group_query with AT_MOST type.""" - group = CriteriaGroup( - type="AT_MOST", - count=2, - criteria_list=[] + group = CriteriaGroup(type="AT_MOST", count=2, criteria_list=[]) + group.criteria_list.append( + CorelatedCriteria( + criteria=ConditionOccurrence(first=True, codeset_id=1), + occurrence=Occurrence(type=2, count=1), + ) ) - group.criteria_list.append(CorelatedCriteria(criteria=ConditionOccurrence(first=True, codeset_id=1), occurrence=Occurrence(type=2, count=1))) - + self.builder.get_corelated_criteria_query = MagicMock(return_value="SELECT 1") - + sql = self.builder.get_criteria_group_query(group, "#events") - + self.assertIn("HAVING COUNT(index_id) <= 2", sql) - self.assertIn("LEFT JOIN", sql) # AT_MOST requires LEFT JOIN + self.assertIn("LEFT JOIN", sql) # AT_MOST requires LEFT JOIN def test_wrap_criteria_query(self): """Test wrap_criteria_query structure.""" group = CriteriaGroup(type="ALL", criteria_list=[]) base_query = "SELECT * FROM @cdm_database_schema.CONDITION_OCCURRENCE" - + sql = self.builder.wrap_criteria_query(base_query, group) - + self.assertIn("JOIN @cdm_database_schema.OBSERVATION_PERIOD OP", sql) - self.assertIn("JOIN (", sql) # Group join + self.assertIn("JOIN (", sql) # Group join self.assertIn(") AC on AC.person_id = pe.person_id", sql) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_cohort_modifiers.py b/tests/test_cohort_modifiers.py index 7218dfa0..d02b8d1e 100644 --- a/tests/test_cohort_modifiers.py +++ b/tests/test_cohort_modifiers.py @@ -54,7 +54,9 @@ # Fixtures # --------------------------------------------------------------------------- -EXAMPLE_JSON = Path(__file__).resolve().parent.parent / "examples" / "type2_diabetes_cohort.json" +EXAMPLE_JSON = ( + Path(__file__).resolve().parent.parent / "examples" / "type2_diabetes_cohort.json" +) @pytest.fixture @@ -75,6 +77,7 @@ def diabetes_cohort() -> CohortExpression: # 1. Prior Observation # =========================================================================== + class TestSetPriorObservation: def test_sets_prior_days(self, empty_cohort): result = set_prior_observation(empty_cohort, 365) @@ -98,6 +101,7 @@ def test_negative_raises(self, empty_cohort): # 2. Post Observation # =========================================================================== + class TestSetPostObservation: def test_sets_post_days(self, empty_cohort): result = set_post_observation(empty_cohort, 30) @@ -117,6 +121,7 @@ def test_negative_raises(self, empty_cohort): # 3. Limit to First Event # =========================================================================== + class TestSetLimitToFirstEvent: def test_sets_first(self, empty_cohort): result = set_limit_to_first_event(empty_cohort) @@ -135,6 +140,7 @@ def test_overrides_all(self, diabetes_cohort): # 4. Allow All Events # =========================================================================== + class TestSetAllowAllEvents: def test_sets_all(self, empty_cohort): set_limit_to_first_event(empty_cohort) # first set to first @@ -143,10 +149,12 @@ def test_sets_all(self, empty_cohort): assert result.primary_criteria.primary_limit.type == "All" assert result.expression_limit.type == "All" + # =========================================================================== # 6. Cohort Era # =========================================================================== + class TestSetCohortEra: def test_sets_era_pad(self, empty_cohort): result = set_cohort_era(empty_cohort, 30) @@ -167,6 +175,7 @@ def test_negative_raises(self, empty_cohort): # 7. Age Criteria # =========================================================================== + class TestSetAgeCriteria: def test_both_bounds(self, empty_cohort): result = set_age_criteria(empty_cohort, min_age=18, max_age=65) @@ -206,6 +215,7 @@ def test_appends_to_existing(self, empty_cohort): # 8. Gender Criteria # =========================================================================== + class TestSetGenderCriteria: def test_female(self, empty_cohort): result = set_gender_criteria(empty_cohort, GENDER_FEMALE_CONCEPT_ID) @@ -221,7 +231,9 @@ def test_male(self, empty_cohort): assert dc.gender[0].concept_name == "MALE" def test_multiple_genders(self, empty_cohort): - set_gender_criteria(empty_cohort, [GENDER_MALE_CONCEPT_ID, GENDER_FEMALE_CONCEPT_ID]) + set_gender_criteria( + empty_cohort, [GENDER_MALE_CONCEPT_ID, GENDER_FEMALE_CONCEPT_ID] + ) dc = empty_cohort.additional_criteria.demographic_criteria_list[0] assert len(dc.gender) == 2 @@ -240,6 +252,7 @@ def test_appends_to_existing_criteria(self, empty_cohort): # 9. End Date Strategy # =========================================================================== + class TestSetEndDateStrategy: def test_fixed_duration(self, empty_cohort): result = set_end_date_strategy(empty_cohort, "fixed_duration", days=180) @@ -249,7 +262,9 @@ def test_fixed_duration(self, empty_cohort): assert result.end_strategy.date_field == "StartDate" def test_fixed_duration_end_date(self, empty_cohort): - set_end_date_strategy(empty_cohort, "fixed_duration", days=90, date_field="EndDate") + set_end_date_strategy( + empty_cohort, "fixed_duration", days=90, date_field="EndDate" + ) assert empty_cohort.end_strategy.date_field == "EndDate" def test_fixed_duration_no_days_raises(self, empty_cohort): @@ -264,8 +279,11 @@ def test_end_of_observation(self, empty_cohort): def test_custom_era(self, empty_cohort): set_end_date_strategy( - empty_cohort, "custom_era", - drug_codeset_id=1, gap_days=30, offset=7, + empty_cohort, + "custom_era", + drug_codeset_id=1, + gap_days=30, + offset=7, ) assert isinstance(empty_cohort.end_strategy, CustomEraStrategy) assert empty_cohort.end_strategy.drug_codeset_id == 1 @@ -288,6 +306,7 @@ def test_strategy_name_normalization(self, empty_cohort): # 10. Washout Period # =========================================================================== + class TestSetWashoutPeriod: def test_sets_prior_observation_only(self, empty_cohort): """Washout sets prior observation but does NOT force first event.""" @@ -317,6 +336,7 @@ def test_negative_raises(self, empty_cohort): # 10b. Clean Window # =========================================================================== + class TestSetCleanWindow: def test_adds_inclusion_rule(self, diabetes_cohort): """A clean window adds an inclusion rule to deduplicate events.""" @@ -324,7 +344,8 @@ def test_adds_inclusion_rule(self, diabetes_cohort): assert result is diabetes_cohort # Should have added exactly one inclusion rule matching = [ - r for r in result.inclusion_rules + r + for r in result.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ] assert len(matching) == 1 @@ -334,7 +355,8 @@ def test_single_criterion_defaults_to_any_mode(self, diabetes_cohort): assert len(diabetes_cohort.primary_criteria.criteria_list) == 1 set_clean_window(diabetes_cohort, 30) rule = next( - r for r in diabetes_cohort.inclusion_rules + r + for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) assert rule.description is not None @@ -356,14 +378,16 @@ def test_single_criterion_both_modes_equivalent(self, diabetes_cohort): """With one criterion, 'any' and 'all' produce the same correlated list.""" set_clean_window(diabetes_cohort, 7, criteria_mode="any") rule_any = next( - r for r in diabetes_cohort.inclusion_rules + r + for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) n_any = len(rule_any.expression.criteria_list) set_clean_window(diabetes_cohort, 7, criteria_mode="all") rule_all = next( - r for r in diabetes_cohort.inclusion_rules + r + for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) n_all = len(rule_all.expression.criteria_list) @@ -379,19 +403,22 @@ def test_single_criterion_both_modes_equivalent(self, diabetes_cohort): def test_any_mode_multi_criteria_uses_all_group(self): """mode='any': group type is ALL so every criterion must show 0 prior.""" - cohort = CohortExpression.model_validate({ - "PrimaryCriteria": { - "CriteriaList": [ - {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, - {"DrugExposure": {"CodesetId": 2, "First": True}}, - ], - "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, - "PrimaryCriteriaLimit": {"Type": "All"}, + cohort = CohortExpression.model_validate( + { + "PrimaryCriteria": { + "CriteriaList": [ + {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, + {"DrugExposure": {"CodesetId": 2, "First": True}}, + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryCriteriaLimit": {"Type": "All"}, + } } - }) + ) set_clean_window(cohort, 7, criteria_mode="any") rule = next( - r for r in cohort.inclusion_rules + r + for r in cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) group = rule.expression @@ -412,19 +439,22 @@ def test_any_mode_multi_criteria_uses_all_group(self): def test_all_mode_multi_criteria_uses_any_group(self): """mode='all': group type is ANY – event passes if any criterion was absent.""" - cohort = CohortExpression.model_validate({ - "PrimaryCriteria": { - "CriteriaList": [ - {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, - {"DrugExposure": {"CodesetId": 2, "First": True}}, - ], - "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, - "PrimaryCriteriaLimit": {"Type": "All"}, + cohort = CohortExpression.model_validate( + { + "PrimaryCriteria": { + "CriteriaList": [ + {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, + {"DrugExposure": {"CodesetId": 2, "First": True}}, + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryCriteriaLimit": {"Type": "All"}, + } } - }) + ) set_clean_window(cohort, 7, criteria_mode="all") rule = next( - r for r in cohort.inclusion_rules + r + for r in cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) group = rule.expression @@ -434,20 +464,23 @@ def test_all_mode_multi_criteria_uses_any_group(self): def test_all_mode_three_criteria(self): """mode='all' scales to three criteria with ANY group.""" - cohort = CohortExpression.model_validate({ - "PrimaryCriteria": { - "CriteriaList": [ - {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, - {"DrugExposure": {"CodesetId": 2, "First": True}}, - {"ProcedureOccurrence": {"CodesetId": 3, "First": True}}, - ], - "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, - "PrimaryCriteriaLimit": {"Type": "All"}, + cohort = CohortExpression.model_validate( + { + "PrimaryCriteria": { + "CriteriaList": [ + {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, + {"DrugExposure": {"CodesetId": 2, "First": True}}, + {"ProcedureOccurrence": {"CodesetId": 3, "First": True}}, + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryCriteriaLimit": {"Type": "All"}, + } } - }) + ) set_clean_window(cohort, 14, criteria_mode="all") rule = next( - r for r in cohort.inclusion_rules + r + for r in cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) assert rule.expression.type == "ANY" @@ -470,7 +503,8 @@ def test_replaces_existing_clean_window(self, diabetes_cohort): set_clean_window(diabetes_cohort, 7) set_clean_window(diabetes_cohort, 14) matching = [ - r for r in diabetes_cohort.inclusion_rules + r + for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ] assert len(matching) == 1 @@ -480,14 +514,16 @@ def test_replace_changes_mode(self, diabetes_cohort): """Replacing a clean window can switch from 'any' to 'all'.""" set_clean_window(diabetes_cohort, 7, criteria_mode="any") rule = next( - r for r in diabetes_cohort.inclusion_rules + r + for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) assert rule.expression.type == "ALL" set_clean_window(diabetes_cohort, 7, criteria_mode="all") rule = next( - r for r in diabetes_cohort.inclusion_rules + r + for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) assert rule.expression.type == "ANY" @@ -495,6 +531,7 @@ def test_replace_changes_mode(self, diabetes_cohort): def test_preserves_other_inclusion_rules(self, diabetes_cohort): """Clean window should not remove user-defined inclusion rules.""" from circe.cohortdefinition.criteria import InclusionRule as IR + user_rule = IR(name="my_rule", description="custom") diabetes_cohort.inclusion_rules.append(user_rule) set_clean_window(diabetes_cohort, 7) @@ -518,6 +555,7 @@ def test_negative_days_raises(self, diabetes_cohort): def test_reset_clean_window(self, diabetes_cohort): """reset_clean_window removes only the clean-window rule.""" from circe.cohortdefinition.criteria import InclusionRule as IR + user_rule = IR(name="keep_me", description="custom") diabetes_cohort.inclusion_rules.append(user_rule) set_clean_window(diabetes_cohort, 7) @@ -534,29 +572,32 @@ def test_reset_clean_window_noop_when_absent(self, empty_cohort): def test_replace_updates_count_after_criteria_change(self): """If primary criteria change between calls, the new rule reflects them.""" from circe.cohortdefinition import DrugExposure - cohort = CohortExpression.model_validate({ - "PrimaryCriteria": { - "CriteriaList": [ - {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, - ], - "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, - "PrimaryCriteriaLimit": {"Type": "All"}, + + cohort = CohortExpression.model_validate( + { + "PrimaryCriteria": { + "CriteriaList": [ + {"ConditionOccurrence": {"CodesetId": 1, "First": True}}, + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryCriteriaLimit": {"Type": "All"}, + } } - }) + ) set_clean_window(cohort, 7) rule = next( - r for r in cohort.inclusion_rules + r + for r in cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) assert len(rule.expression.criteria_list) == 1 # Now add a second primary criterion and reset the clean window - cohort.primary_criteria.criteria_list.append( - DrugExposure(codeset_id=2) - ) + cohort.primary_criteria.criteria_list.append(DrugExposure(codeset_id=2)) set_clean_window(cohort, 7) rule = next( - r for r in cohort.inclusion_rules + r + for r in cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) assert len(rule.expression.criteria_list) == 2 @@ -566,15 +607,20 @@ def test_replace_updates_count_after_criteria_change(self): # 11. Date Range # =========================================================================== + class TestSetDateRange: def test_both_dates_string(self, empty_cohort): - result = set_date_range(empty_cohort, start_date="2020-01-01", end_date="2022-12-31") + result = set_date_range( + empty_cohort, start_date="2020-01-01", end_date="2022-12-31" + ) assert result is empty_cohort assert result.censor_window.start_date == "2020-01-01" assert result.censor_window.end_date == "2022-12-31" def test_date_objects(self, empty_cohort): - set_date_range(empty_cohort, start_date=date(2020, 1, 1), end_date=date(2022, 12, 31)) + set_date_range( + empty_cohort, start_date=date(2020, 1, 1), end_date=date(2022, 12, 31) + ) assert empty_cohort.censor_window.start_date == "2020-01-01" assert empty_cohort.censor_window.end_date == "2022-12-31" @@ -597,6 +643,7 @@ def test_no_dates_raises(self, empty_cohort): # 12. Censor at Event # =========================================================================== + class TestSetCensorEvent: def test_add_death(self, empty_cohort): death = Death() @@ -621,6 +668,7 @@ def test_clear(self, empty_cohort): # Reset helpers # =========================================================================== + class TestResetFunctions: def test_reset_observation_window(self, empty_cohort): set_prior_observation(empty_cohort, 365) @@ -642,7 +690,10 @@ def test_reset_age_preserves_gender(self, empty_cohort): set_gender_criteria(empty_cohort, GENDER_FEMALE_CONCEPT_ID) reset_age_criteria(empty_cohort) assert len(empty_cohort.additional_criteria.demographic_criteria_list) == 1 - assert empty_cohort.additional_criteria.demographic_criteria_list[0].gender is not None + assert ( + empty_cohort.additional_criteria.demographic_criteria_list[0].gender + is not None + ) def test_reset_gender_criteria(self, empty_cohort): set_gender_criteria(empty_cohort, GENDER_MALE_CONCEPT_ID) @@ -654,7 +705,10 @@ def test_reset_gender_preserves_age(self, empty_cohort): set_gender_criteria(empty_cohort, GENDER_MALE_CONCEPT_ID) reset_gender_criteria(empty_cohort) assert len(empty_cohort.additional_criteria.demographic_criteria_list) == 1 - assert empty_cohort.additional_criteria.demographic_criteria_list[0].age is not None + assert ( + empty_cohort.additional_criteria.demographic_criteria_list[0].age + is not None + ) def test_reset_end_strategy(self, empty_cohort): set_end_date_strategy(empty_cohort, "fixed_duration", days=30) @@ -676,16 +730,14 @@ def test_reset_date_range(self, empty_cohort): # Chaining # =========================================================================== + class TestChaining: def test_chain_multiple_modifiers(self, empty_cohort): - result = ( - set_prior_observation( - set_post_observation( - set_limit_to_first_event( - set_cohort_era(empty_cohort, 0) - ), 30 - ), 365 - ) + result = set_prior_observation( + set_post_observation( + set_limit_to_first_event(set_cohort_era(empty_cohort, 0)), 30 + ), + 365, ) assert result is empty_cohort assert result.primary_criteria.observation_window.prior_days == 365 @@ -698,6 +750,7 @@ def test_chain_multiple_modifiers(self, empty_cohort): # apply_standard_rules # =========================================================================== + class TestApplyStandardRules: def test_defaults(self, empty_cohort): result = apply_standard_rules(empty_cohort) @@ -755,6 +808,7 @@ def test_on_real_cohort(self, diabetes_cohort): # JSON round-trip # =========================================================================== + class TestJsonRoundTrip: def test_modified_cohort_serializes(self, diabetes_cohort): """Ensure a fully modified cohort can be serialized back to JSON.""" @@ -788,9 +842,3 @@ def test_modified_cohort_deserializes(self, diabetes_cohort): assert parsed.primary_criteria.observation_window.prior_days == 180 assert parsed.primary_criteria.primary_limit.type == "First" assert parsed.collapse_settings.era_pad == 30 - - - - - - diff --git a/tests/test_comparisons_coverage.py b/tests/test_comparisons_coverage.py index b1c3ff9a..f5f27881 100644 --- a/tests/test_comparisons_coverage.py +++ b/tests/test_comparisons_coverage.py @@ -1,4 +1,3 @@ - import unittest from circe.check.checkers.comparisons import Comparisons @@ -28,9 +27,8 @@ class TestComparisonsCoverage(unittest.TestCase): - # --- start_is_greater_than_end --- - + def test_start_is_greater_than_end_none(self): self.assertFalse(Comparisons.start_is_greater_than_end(None)) @@ -40,40 +38,84 @@ def test_start_is_greater_than_end_numeric_incomplete(self): self.assertFalse(Comparisons.start_is_greater_than_end(NumericRange())) def test_start_is_greater_than_end_date_incomplete(self): - self.assertFalse(Comparisons.start_is_greater_than_end(DateRange(value="2020-01-01"))) - self.assertFalse(Comparisons.start_is_greater_than_end(DateRange(extent="2020-01-01"))) + self.assertFalse( + Comparisons.start_is_greater_than_end(DateRange(value="2020-01-01")) + ) + self.assertFalse( + Comparisons.start_is_greater_than_end(DateRange(extent="2020-01-01")) + ) self.assertFalse(Comparisons.start_is_greater_than_end(DateRange())) def test_start_is_greater_than_end_date_invalid(self): - self.assertFalse(Comparisons.start_is_greater_than_end(DateRange(value="invalid", extent="2020-01-01"))) - self.assertFalse(Comparisons.start_is_greater_than_end(DateRange(value="2020-01-01", extent="invalid"))) + self.assertFalse( + Comparisons.start_is_greater_than_end( + DateRange(value="invalid", extent="2020-01-01") + ) + ) + self.assertFalse( + Comparisons.start_is_greater_than_end( + DateRange(value="2020-01-01", extent="invalid") + ) + ) def test_start_is_greater_than_end_period_incomplete(self): - self.assertFalse(Comparisons.start_is_greater_than_end(Period(start_date="2020-01-01"))) - self.assertFalse(Comparisons.start_is_greater_than_end(Period(end_date="2020-01-01"))) + self.assertFalse( + Comparisons.start_is_greater_than_end(Period(start_date="2020-01-01")) + ) + self.assertFalse( + Comparisons.start_is_greater_than_end(Period(end_date="2020-01-01")) + ) self.assertFalse(Comparisons.start_is_greater_than_end(Period())) def test_start_is_greater_than_end_period_invalid(self): - self.assertFalse(Comparisons.start_is_greater_than_end(Period(start_date="invalid", end_date="2020-01-01"))) - self.assertFalse(Comparisons.start_is_greater_than_end(Period(start_date="2020-01-01", end_date="invalid"))) + self.assertFalse( + Comparisons.start_is_greater_than_end( + Period(start_date="invalid", end_date="2020-01-01") + ) + ) + self.assertFalse( + Comparisons.start_is_greater_than_end( + Period(start_date="2020-01-01", end_date="invalid") + ) + ) def test_start_is_greater_than_end_period_valid(self): - self.assertTrue(Comparisons.start_is_greater_than_end(Period(start_date="2020-01-02", end_date="2020-01-01"))) - self.assertFalse(Comparisons.start_is_greater_than_end(Period(start_date="2020-01-01", end_date="2020-01-02"))) + self.assertTrue( + Comparisons.start_is_greater_than_end( + Period(start_date="2020-01-02", end_date="2020-01-01") + ) + ) + self.assertFalse( + Comparisons.start_is_greater_than_end( + Period(start_date="2020-01-01", end_date="2020-01-02") + ) + ) def test_start_is_greater_than_end_numeric_valid(self): - self.assertTrue(Comparisons.start_is_greater_than_end(NumericRange(value=10, extent=5))) - self.assertFalse(Comparisons.start_is_greater_than_end(NumericRange(value=5, extent=10))) + self.assertTrue( + Comparisons.start_is_greater_than_end(NumericRange(value=10, extent=5)) + ) + self.assertFalse( + Comparisons.start_is_greater_than_end(NumericRange(value=5, extent=10)) + ) def test_start_is_greater_than_end_date_valid(self): - self.assertTrue(Comparisons.start_is_greater_than_end(DateRange(value="2020-01-02", extent="2020-01-01"))) - self.assertFalse(Comparisons.start_is_greater_than_end(DateRange(value="2020-01-01", extent="2020-01-02"))) - + self.assertTrue( + Comparisons.start_is_greater_than_end( + DateRange(value="2020-01-02", extent="2020-01-01") + ) + ) + self.assertFalse( + Comparisons.start_is_greater_than_end( + DateRange(value="2020-01-01", extent="2020-01-02") + ) + ) + def test_start_is_greater_than_end_other_type(self): - self.assertFalse(Comparisons.start_is_greater_than_end("Not a range")) + self.assertFalse(Comparisons.start_is_greater_than_end("Not a range")) # --- is_date_valid --- - + def test_is_date_valid_none(self): self.assertFalse(Comparisons.is_date_valid(None)) @@ -85,7 +127,7 @@ def test_is_date_valid_string(self): self.assertFalse(Comparisons.is_date_valid("not-a-date")) # --- is_start_negative --- - + def test_is_start_negative_none(self): self.assertFalse(Comparisons.is_start_negative(None)) @@ -98,34 +140,35 @@ def test_is_start_negative_numeric_valid(self): self.assertFalse(Comparisons.is_start_negative(NumericRange(value=1))) # --- compare_to --- - + def test_compare_to_none(self): self.assertEqual(Comparisons.compare_to(None, Window()), 0) - self.assertEqual(Comparisons.compare_to(ObservationFilter(priorDays=0, postDays=0), None), 0) + self.assertEqual( + Comparisons.compare_to(ObservationFilter(priorDays=0, postDays=0), None), 0 + ) def test_compare_to_calculation(self): # range1 = prior + post = 10 + 20 = 30 f = ObservationFilter(priorDays=10, postDays=20) - + # range2_start = coeff * days = -1 * 5 = -5 # range2_end = coeff * days = 1 * 5 = 5 # range2_diff = 5 - (-5) = 10 w = Window( - start=WindowBound(coeff=-1, days=5), - end=WindowBound(coeff=1, days=5) + start=WindowBound(coeff=-1, days=5), end=WindowBound(coeff=1, days=5) ) - + # result = 30 - 10 = 20 self.assertEqual(Comparisons.compare_to(f, w), 20) - + def test_compare_to_partial_window(self): - f = ObservationFilter(priorDays=10, postDays=20) # 30 - w = Window() # start=None, end=None -> range2_start=0, range2_end=0 -> 0 - - self.assertEqual(Comparisons.compare_to(f, w), 30) + f = ObservationFilter(priorDays=10, postDays=20) # 30 + w = Window() # start=None, end=None -> range2_start=0, range2_end=0 -> 0 + + self.assertEqual(Comparisons.compare_to(f, w), 30) # --- is_before / endpoints --- - + def test_is_before_none(self): self.assertFalse(Comparisons.is_before(None)) @@ -134,31 +177,28 @@ def test_is_before_endpoint_none(self): def test_is_after_endpoint_none(self): self.assertFalse(Comparisons.is_after_endpoint(None)) - + def test_is_before_true(self): # start before (< 0), end not after (<= 0) w = Window( - start=WindowBound(coeff=-1, days=1), - end=WindowBound(coeff=-1, days=1) + start=WindowBound(coeff=-1, days=1), end=WindowBound(coeff=-1, days=1) ) self.assertTrue(Comparisons.is_before(w)) - + def test_is_before_false_start_not_before(self): w = Window( - start=WindowBound(coeff=1, days=1), - end=WindowBound(coeff=-1, days=1) + start=WindowBound(coeff=1, days=1), end=WindowBound(coeff=-1, days=1) ) self.assertFalse(Comparisons.is_before(w)) def test_is_before_false_end_after(self): w = Window( - start=WindowBound(coeff=-1, days=1), - end=WindowBound(coeff=1, days=1) + start=WindowBound(coeff=-1, days=1), end=WindowBound(coeff=1, days=1) ) self.assertFalse(Comparisons.is_before(w)) # --- compare_concept_set --- - + def test_compare_concept_set(self): from circe.vocabulary.concept import ( Concept, @@ -166,44 +206,72 @@ def test_compare_concept_set(self): ConceptSetExpression, ConceptSetItem, ) - - c1 = Concept(concept_code="A", domain_id="D", vocabulary_id="V", concept_id=1, concept_name="N", standard_concept="S", invalid_reason="I", concept_class_id="C") - c2 = Concept(concept_code="A", domain_id="D", vocabulary_id="V", concept_id=1, concept_name="N", standard_concept="S", invalid_reason="I", concept_class_id="C") - c3 = Concept(concept_code="B", domain_id="D", vocabulary_id="V", concept_id=2, concept_name="N2", standard_concept="S", invalid_reason="I", concept_class_id="C") - + + c1 = Concept( + concept_code="A", + domain_id="D", + vocabulary_id="V", + concept_id=1, + concept_name="N", + standard_concept="S", + invalid_reason="I", + concept_class_id="C", + ) + c2 = Concept( + concept_code="A", + domain_id="D", + vocabulary_id="V", + concept_id=1, + concept_name="N", + standard_concept="S", + invalid_reason="I", + concept_class_id="C", + ) + c3 = Concept( + concept_code="B", + domain_id="D", + vocabulary_id="V", + concept_id=2, + concept_name="N2", + standard_concept="S", + invalid_reason="I", + concept_class_id="C", + ) + # Same expression object expr1 = ConceptSetExpression(items=[ConceptSetItem(concept=c1)]) cs1 = ConceptSet(id=1, name="S1", expression=expr1) - + predicate = Comparisons.compare_concept_set(cs1) - self.assertTrue(predicate(cs1)) - + self.assertTrue(predicate(cs1)) + # Diff expression objects, same content expr2 = ConceptSetExpression(items=[ConceptSetItem(concept=c2)]) cs2 = ConceptSet(id=2, name="S2", expression=expr2) self.assertTrue(predicate(cs2)) - + # Diff content (length) - expr3 = ConceptSetExpression(items=[ConceptSetItem(concept=c1), ConceptSetItem(concept=c3)]) + expr3 = ConceptSetExpression( + items=[ConceptSetItem(concept=c1), ConceptSetItem(concept=c3)] + ) cs3 = ConceptSet(id=3, name="S3", expression=expr3) self.assertFalse(predicate(cs3)) - + # Diff content (concept mismatch) expr4 = ConceptSetExpression(items=[ConceptSetItem(concept=c3)]) cs4 = ConceptSet(id=4, name="S4", expression=expr4) self.assertFalse(predicate(cs4)) - + # Source has no expression cs_empty = ConceptSet(id=5, name="S5", expression=None) predicate_empty = Comparisons.compare_concept_set(cs_empty) # Assuming implementation detailed behavior: if source.expression is None, only exact match or both None works? # Looking at code: if concept_set.expression == source.expression (None == None) -> True. self.assertTrue(predicate_empty(cs_empty)) - + # Target has no expression self.assertFalse(predicate(cs_empty)) - # --- compare_criteria --- def test_compare_criteria_diff_types(self): @@ -212,21 +280,38 @@ def test_compare_criteria_diff_types(self): def test_compare_criteria_all_types(self): # Create instances of all criteria types with matching and non-matching codeset_ids types = [ - ConditionEra, ConditionOccurrence, Death, DeviceExposure, - DoseEra, DrugEra, DrugExposure, Measurement, Observation, - ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, ] - + for cls in types: c1 = cls(codeset_id=1) c2 = cls(codeset_id=1) c3 = cls(codeset_id=2) - - self.assertTrue(Comparisons.compare_criteria(c1, c2), f"Failed for {cls.__name__} match") - self.assertFalse(Comparisons.compare_criteria(c1, c3), f"Failed for {cls.__name__} mismatch") - + + self.assertTrue( + Comparisons.compare_criteria(c1, c2), f"Failed for {cls.__name__} match" + ) + self.assertFalse( + Comparisons.compare_criteria(c1, c3), + f"Failed for {cls.__name__} mismatch", + ) + def test_compare_criteria_unknown_type(self): class UnknownCriteria: pass - self.assertFalse(Comparisons.compare_criteria(UnknownCriteria(), UnknownCriteria())) + self.assertFalse( + Comparisons.compare_criteria(UnknownCriteria(), UnknownCriteria()) + ) diff --git a/tests/test_concept_checker_factory_coverage.py b/tests/test_concept_checker_factory_coverage.py index 152cb50f..6c70d3c5 100644 --- a/tests/test_concept_checker_factory_coverage.py +++ b/tests/test_concept_checker_factory_coverage.py @@ -1,4 +1,3 @@ - import unittest from unittest.mock import Mock, call @@ -37,7 +36,7 @@ def test_check_condition_era(self): self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.CONDITION_ERA, - Constants.Attributes.GENDER_ATTR + Constants.Attributes.GENDER_ATTR, ) def test_check_condition_occurrence(self): @@ -46,28 +45,54 @@ def test_check_condition_occurrence(self): condition_type=[], gender=[], provider_specialty=[], - visit_type=[] + visit_type=[], ) self.factory.check(c) # Should report all 4 calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.CONDITION_TYPE_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.GENDER_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.PROVIDER_SPECIALITY_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.VISIT_TYPE_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.CONDITION_TYPE_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.GENDER_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.VISIT_TYPE_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) def test_check_death(self): - c = Death( - codeset_id=0, - death_type=[], - gender=[] - ) + c = Death(codeset_id=0, death_type=[], gender=[]) self.factory.check(c) calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DEATH, Constants.Attributes.DEATH_TYPE_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DEATH, Constants.Attributes.GENDER_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DEATH, + Constants.Attributes.DEATH_TYPE_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DEATH, + Constants.Attributes.GENDER_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) @@ -77,41 +102,64 @@ def test_check_device_exposure(self): device_type=[], gender=[], provider_specialty=[], - visit_type=[] + visit_type=[], ) self.factory.check(c) calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.DEVICE_TYPE_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.GENDER_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.PROVIDER_SPECIALITY_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.VISIT_TYPE_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.DEVICE_TYPE_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.GENDER_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.VISIT_TYPE_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) def test_check_dose_era(self): - c = DoseEra( - codeset_id=0, - unit=[], - gender=[] - ) + c = DoseEra(codeset_id=0, unit=[], gender=[]) self.factory.check(c) calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DOSE_ERA, Constants.Attributes.UNIT_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DOSE_ERA, Constants.Attributes.GENDER_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.UNIT_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.GENDER_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) def test_check_drug_era(self): - c = DrugEra( - codeset_id=0, - gender=[] - ) + c = DrugEra(codeset_id=0, gender=[]) self.factory.check(c) self.reporter.assert_called_with( - self.factory.WARNING_EMPTY_VALUE, - self.group_name, - Constants.Criteria.DRUG_ERA, - Constants.Attributes.GENDER_ATTR + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.GENDER_ATTR, ) def test_check_drug_exposure(self): @@ -122,16 +170,46 @@ def test_check_drug_exposure(self): dose_unit=[], gender=[], provider_specialty=[], - visit_type=[] + visit_type=[], ) self.factory.check(c) calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.DRUG_TYPE_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.ROUTE_CONCEPT_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.DOSE_UNIT_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.GENDER_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.PROVIDER_SPECIALITY_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.VISIT_TYPE_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.DRUG_TYPE_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.ROUTE_CONCEPT_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.DOSE_UNIT_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.GENDER_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.VISIT_TYPE_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) @@ -144,17 +222,52 @@ def test_check_measurement(self): unit=[], gender=[], provider_specialty=[], - visit_type=[] + visit_type=[], ) self.factory.check(c) calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.MEASUREMENT, Constants.Attributes.MEASUREMENT_TYPE_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.MEASUREMENT, Constants.Attributes.OPERATOR_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.MEASUREMENT, Constants.Attributes.VALUE_AS_CONCEPT_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.MEASUREMENT, Constants.Attributes.UNIT_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.MEASUREMENT, Constants.Attributes.GENDER_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.MEASUREMENT, Constants.Attributes.PROVIDER_SPECIALITY_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.MEASUREMENT, Constants.Attributes.VISIT_TYPE_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.MEASUREMENT_TYPE_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.OPERATOR_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.VALUE_AS_CONCEPT_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.UNIT_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.GENDER_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.VISIT_TYPE_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) @@ -167,30 +280,63 @@ def test_check_observation(self): unit=[], gender=[], provider_specialty=[], - visit_type=[] + visit_type=[], ) self.factory.check(c) calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.OBSERVATION, Constants.Attributes.OBSERVATION_TYPE_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.OBSERVATION, Constants.Attributes.VALUE_AS_CONCEPT_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.OBSERVATION, Constants.Attributes.QUALIFIER_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.OBSERVATION, Constants.Attributes.UNIT_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.OBSERVATION, Constants.Attributes.GENDER_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.OBSERVATION, Constants.Attributes.PROVIDER_SPECIALITY_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.OBSERVATION, Constants.Attributes.VISIT_TYPE_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.OBSERVATION, + Constants.Attributes.OBSERVATION_TYPE_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.OBSERVATION, + Constants.Attributes.VALUE_AS_CONCEPT_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.OBSERVATION, + Constants.Attributes.QUALIFIER_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.OBSERVATION, + Constants.Attributes.UNIT_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.OBSERVATION, + Constants.Attributes.GENDER_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.OBSERVATION, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.OBSERVATION, + Constants.Attributes.VISIT_TYPE_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) def test_check_observation_period(self): - c = ObservationPeriod( - period_type=[] - ) + c = ObservationPeriod(period_type=[]) self.factory.check(c) self.reporter.assert_called_with( self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.OBSERVATION_PERIOD, - Constants.Attributes.PERIOD_TYPE_ATTR + Constants.Attributes.PERIOD_TYPE_ATTR, ) def test_check_procedure_occurrence(self): @@ -200,15 +346,40 @@ def test_check_procedure_occurrence(self): modifier=[], gender=[], provider_specialty=[], - visit_type=[] + visit_type=[], ) self.factory.check(c) calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.PROCEDURE_TYPE_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.MODIFIER_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.GENDER_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.PROVIDER_SPECIALITY_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.VISIT_TYPE_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.PROCEDURE_TYPE_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.MODIFIER_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.GENDER_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.VISIT_TYPE_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) @@ -219,15 +390,40 @@ def test_check_specimen(self): unit=[], anatomic_site=[], disease_status=[], - gender=[] + gender=[], ) self.factory.check(c) calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.SPECIMEN, Constants.Attributes.SPECIMEN_TYPE_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.SPECIMEN, Constants.Attributes.UNIT_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.SPECIMEN, Constants.Attributes.ANATOMIC_SITE_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.SPECIMEN, Constants.Attributes.DISEASE_STATUS_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.SPECIMEN, Constants.Attributes.GENDER_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.SPECIMEN, + Constants.Attributes.SPECIMEN_TYPE_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.SPECIMEN, + Constants.Attributes.UNIT_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.SPECIMEN, + Constants.Attributes.ANATOMIC_SITE_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.SPECIMEN, + Constants.Attributes.DISEASE_STATUS_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.SPECIMEN, + Constants.Attributes.GENDER_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) @@ -237,48 +433,77 @@ def test_check_visit_occurrence(self): visit_type=[], gender=[], provider_specialty=[], - place_of_service=[] + place_of_service=[], ) self.factory.check(c) calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.VISIT_TYPE_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.GENDER_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.PROVIDER_SPECIALITY_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.PLACE_OF_SERVICE_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.VISIT_TYPE_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.GENDER_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.PROVIDER_SPECIALITY_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.PLACE_OF_SERVICE_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) def test_check_payer_plan_period(self): - c = PayerPlanPeriod( - gender=[] - ) + c = PayerPlanPeriod(gender=[]) self.factory.check(c) self.reporter.assert_called_with( self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.PAYER_PLAN_PERIOD, - Constants.Attributes.GENDER_ATTR + Constants.Attributes.GENDER_ATTR, ) def test_check_demographic_criteria(self): - c = DemographicCriteria( - ethnicity=[], - gender=[], - race=[] - ) + c = DemographicCriteria(ethnicity=[], gender=[], race=[]) # DemographicCriteria needs special handling because it's distinct from Criteria in dispatch self.factory.check(c) calls = [ - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.ETHNICITY_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.GENDER_ATTR), - call(self.factory.WARNING_EMPTY_VALUE, self.group_name, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.RACE_ATTR), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DEMOGRAPHIC, + Constants.Attributes.ETHNICITY_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DEMOGRAPHIC, + Constants.Attributes.GENDER_ATTR, + ), + call( + self.factory.WARNING_EMPTY_VALUE, + self.group_name, + Constants.Criteria.DEMOGRAPHIC, + Constants.Attributes.RACE_ATTR, + ), ] self.reporter.assert_has_calls(calls, any_order=True) def test_default_check(self): # Use LocationRegion which is not handled by ConceptCheckerFactory from circe.cohortdefinition.criteria import LocationRegion - + c = LocationRegion() self.factory.check(c) # Should not call reporter @@ -287,10 +512,17 @@ def test_default_check(self): def test_check_valid_concepts(self): # Test that populated lists do not trigger warnings from circe.vocabulary.concept import Concept + c = ConditionEra( codeset_id=0, - gender=[Concept(concept_id=1, concept_name="Male", domain_id="Gender", vocabulary_id="Gender")] + gender=[ + Concept( + concept_id=1, + concept_name="Male", + domain_id="Gender", + vocabulary_id="Gender", + ) + ], ) self.factory.check(c) self.reporter.assert_not_called() - diff --git a/tests/test_concept_set_expression_query_builder.py b/tests/test_concept_set_expression_query_builder.py index d77d6535..fb2d0057 100644 --- a/tests/test_concept_set_expression_query_builder.py +++ b/tests/test_concept_set_expression_query_builder.py @@ -7,14 +7,13 @@ class TestConceptSetExpressionQueryBuilder(unittest.TestCase): - def setUp(self): self.builder = ConceptSetExpressionQueryBuilder() def test_get_concept_ids(self): c1 = Concept(concept_id=1, concept_name="C1") c2 = Concept(concept_id=2, concept_name="C2") - c3 = Concept(concept_id=None, concept_name="C3") # Should be ignored + c3 = Concept(concept_id=None, concept_name="C3") # Should be ignored ids = self.builder.get_concept_ids([c1, c2, c3]) self.assertEqual(ids, [1, 2]) @@ -52,7 +51,10 @@ def test_build_concept_set_mapped_query(self): def test_build_concept_set_query_empty(self): query = self.builder.build_concept_set_query([], [], [], []) - self.assertIn("select concept_id from @vocabulary_database_schema.CONCEPT where 0=1", query) + self.assertIn( + "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1", + query, + ) def test_build_concept_set_query_with_mapping(self): c1 = Concept(concept_id=1, concept_name="C1") @@ -62,11 +64,16 @@ def test_build_concept_set_query_with_mapping(self): def test_build_expression_query_simple_include(self): c1 = Concept(concept_id=1, concept_name="C1") - item = ConceptSetItem(concept=c1, is_excluded=False, include_descendants=False, include_mapped=False) + item = ConceptSetItem( + concept=c1, + is_excluded=False, + include_descendants=False, + include_mapped=False, + ) expression = ConceptSetExpression(items=[item]) - + query = self.builder.build_expression_query(expression) - + # Java uses lowercase select distinct self.assertIn("select distinct I.concept_id", query) self.assertIn("FROM", query) @@ -77,14 +84,24 @@ def test_build_expression_query_simple_include(self): def test_build_expression_query_with_exclude(self): c1 = Concept(concept_id=1, concept_name="C1") c2 = Concept(concept_id=2, concept_name="C2") - - item1 = ConceptSetItem(concept=c1, is_excluded=False, include_descendants=False, include_mapped=False) - item2 = ConceptSetItem(concept=c2, is_excluded=True, include_descendants=False, include_mapped=False) - + + item1 = ConceptSetItem( + concept=c1, + is_excluded=False, + include_descendants=False, + include_mapped=False, + ) + item2 = ConceptSetItem( + concept=c2, + is_excluded=True, + include_descendants=False, + include_mapped=False, + ) + expression = ConceptSetExpression(items=[item1, item2]) - + query = self.builder.build_expression_query(expression) - + self.assertIn("select distinct I.concept_id", query) self.assertIn("LEFT JOIN", query) self.assertIn("E.concept_id is null", query) @@ -92,13 +109,15 @@ def test_build_expression_query_with_exclude(self): def test_build_expression_query_complex_flags(self): """Test combinations of include_descendants and include_mapped.""" c1 = Concept(concept_id=1, concept_name="C1") - + # Test mapped + descendants - item = ConceptSetItem(concept=c1, is_excluded=False, include_descendants=True, include_mapped=True) + item = ConceptSetItem( + concept=c1, is_excluded=False, include_descendants=True, include_mapped=True + ) expression = ConceptSetExpression(items=[item]) - + query = self.builder.build_expression_query(expression) - + # Should have standard concept lookup self.assertIn("select concept_id", query) # Should have descendants lookup @@ -109,13 +128,15 @@ def test_build_expression_query_complex_flags(self): def test_build_expression_query_complex_exclude(self): """Test excluded items with various flags.""" c1 = Concept(concept_id=1, concept_name="C1") - + # Test excluded + mapped + descendants - item = ConceptSetItem(concept=c1, is_excluded=True, include_descendants=True, include_mapped=True) + item = ConceptSetItem( + concept=c1, is_excluded=True, include_descendants=True, include_mapped=True + ) expression = ConceptSetExpression(items=[item]) - + query = self.builder.build_expression_query(expression) - + # Should have exclusion join self.assertIn("LEFT JOIN", query) # Should include descendants and mapped logic in exclusion diff --git a/tests/test_condition_occurrence_sql_builder.py b/tests/test_condition_occurrence_sql_builder.py index 007e2556..8b76766d 100644 --- a/tests/test_condition_occurrence_sql_builder.py +++ b/tests/test_condition_occurrence_sql_builder.py @@ -31,22 +31,22 @@ class TestConditionOccurrenceSqlBuilder(unittest.TestCase): """Comprehensive test suite for ConditionOccurrenceSqlBuilder.""" - + def setUp(self): """Set up test fixtures.""" self.builder = ConditionOccurrenceSqlBuilder() self.criteria = ConditionOccurrence() - + def test_get_default_columns(self): """Test get_default_columns method.""" result = self.builder.get_default_columns() expected = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } self.assertEqual(result, expected) - + def test_get_query_template(self): """Test get_query_template method.""" result = self.builder.get_query_template() @@ -58,34 +58,42 @@ def test_get_query_template(self): self.assertIn("@joinClause", result) self.assertIn("@whereClause", result) self.assertIn("@additionalColumns", result) - + def test_get_table_column_for_criteria_column_domain_concept(self): """Test table column mapping for domain concept.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DOMAIN_CONCEPT + ) self.assertEqual(result, "C.condition_concept_id") - + def test_get_table_column_for_criteria_column_duration(self): """Test table column mapping for duration.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DURATION + ) self.assertEqual(result, "(DATEDIFF(d,C.start_date, C.end_date))") - + def test_get_table_column_for_criteria_column_start_date(self): """Test table column mapping for start date.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.START_DATE + ) self.assertEqual(result, "C.start_date") - + def test_get_table_column_for_criteria_column_end_date(self): """Test table column mapping for end date.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.END_DATE + ) self.assertEqual(result, "C.end_date") - + def test_get_table_column_for_criteria_column_visit_id(self): """Test table column mapping for visit ID.""" - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.VISIT_ID + ) self.assertEqual(result, "C.visit_occurrence_id") - - def test_embed_codeset_clause_with_codeset_id(self): """Test codeset clause embedding with codeset_id.""" criteria = ConditionOccurrence(codeset_id=123) @@ -94,7 +102,7 @@ def test_embed_codeset_clause_with_codeset_id(self): # Should contain codeset join expression self.assertNotEqual(result, query) self.assertNotIn("@codesetClause", result) - + def test_embed_codeset_clause_with_condition_source_concept(self): """Test codeset clause embedding with condition_source_concept.""" criteria = ConditionOccurrence(condition_source_concept=456) @@ -103,47 +111,50 @@ def test_embed_codeset_clause_with_condition_source_concept(self): # Should contain codeset join expression self.assertNotEqual(result, query) self.assertNotIn("@codesetClause", result) - + def test_embed_codeset_clause_without_codeset(self): """Test codeset clause embedding without codeset.""" query = "SELECT * FROM table @codesetClause WHERE condition" result = self.builder.embed_codeset_clause(query, self.criteria) expected = "SELECT * FROM table WHERE condition" self.assertEqual(result, expected) - + def test_embed_ordinal_expression_with_first_true(self): """Test ordinal expression embedding with first=True.""" criteria = ConditionOccurrence(first=True) query = "SELECT @ordinalExpression FROM table" where_clauses = [] result = self.builder.embed_ordinal_expression(query, criteria, where_clauses) - - self.assertIn("row_number() over (PARTITION BY co.person_id ORDER BY co.condition_start_date, co.condition_occurrence_id) as ordinal", result) + + self.assertIn( + "row_number() over (PARTITION BY co.person_id ORDER BY co.condition_start_date, co.condition_occurrence_id) as ordinal", + result, + ) self.assertIn("C.ordinal = 1", where_clauses) self.assertNotIn("@ordinalExpression", result) - + def test_embed_ordinal_expression_with_first_false(self): """Test ordinal expression embedding with first=False.""" criteria = ConditionOccurrence(first=False) query = "SELECT @ordinalExpression FROM table" where_clauses = [] result = self.builder.embed_ordinal_expression(query, criteria, where_clauses) - + self.assertNotIn("row_number()", result) self.assertNotIn("C.ordinal = 1", where_clauses) self.assertNotIn("@ordinalExpression", result) - + def test_embed_ordinal_expression_with_first_none(self): """Test ordinal expression embedding with first=None.""" criteria = ConditionOccurrence(first=None) query = "SELECT @ordinalExpression FROM table" where_clauses = [] result = self.builder.embed_ordinal_expression(query, criteria, where_clauses) - + self.assertNotIn("row_number()", result) self.assertNotIn("C.ordinal = 1", where_clauses) self.assertNotIn("@ordinalExpression", result) - + def test_resolve_select_clauses_basic(self): """Test basic select clauses resolution.""" result = self.builder.resolve_select_clauses(self.criteria) @@ -152,239 +163,310 @@ def test_resolve_select_clauses_basic(self): "co.condition_occurrence_id", "co.condition_concept_id", "co.visit_occurrence_id", - "co.condition_start_date as start_date, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)) as end_date" + "co.condition_start_date as start_date, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)) as end_date", ] self.assertEqual(result, expected) - + def test_resolve_select_clauses_with_condition_type(self): """Test select clauses with condition_type.""" criteria = ConditionOccurrence(condition_type=[Concept(concept_id=1)]) result = self.builder.resolve_select_clauses(criteria) self.assertIn("co.condition_type_concept_id", result) - + def test_resolve_select_clauses_with_condition_type_cs(self): """Test select clauses with condition_type_cs.""" - criteria = ConditionOccurrence(condition_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + condition_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_select_clauses(criteria) self.assertIn("co.condition_type_concept_id", result) - + def test_resolve_select_clauses_with_stop_reason(self): """Test select clauses with stop_reason.""" criteria = ConditionOccurrence(stop_reason=TextFilter(text="test")) result = self.builder.resolve_select_clauses(criteria) self.assertIn("co.stop_reason", result) - + def test_resolve_select_clauses_with_provider_specialty(self): """Test select clauses with provider_specialty.""" criteria = ConditionOccurrence(provider_specialty=[Concept(concept_id=1)]) result = self.builder.resolve_select_clauses(criteria) self.assertIn("co.provider_id", result) - + def test_resolve_select_clauses_with_provider_specialty_cs(self): """Test select clauses with provider_specialty_cs.""" - criteria = ConditionOccurrence(provider_specialty_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + provider_specialty_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_select_clauses(criteria) self.assertIn("co.provider_id", result) - + def test_resolve_select_clauses_with_condition_status(self): """Test select clauses with condition_status.""" criteria = ConditionOccurrence(condition_status=[Concept(concept_id=1)]) result = self.builder.resolve_select_clauses(criteria) self.assertIn("co.condition_status_concept_id", result) - + def test_resolve_select_clauses_with_condition_status_cs(self): """Test select clauses with condition_status_cs.""" - criteria = ConditionOccurrence(condition_status_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + condition_status_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_select_clauses(criteria) self.assertIn("co.condition_status_concept_id", result) - + def test_resolve_select_clauses_with_date_adjustment(self): """Test select clauses with date_adjustment.""" - criteria = ConditionOccurrence(date_adjustment=DateAdjustment( - start_offset=30, - end_offset=0, - start_with="start_date", - end_with="start_date" - )) + criteria = ConditionOccurrence( + date_adjustment=DateAdjustment( + start_offset=30, + end_offset=0, + start_with="start_date", + end_with="start_date", + ) + ) result = self.builder.resolve_select_clauses(criteria) # Should contain DATEADD expression self.assertTrue(any("DATEADD" in item for item in result)) - + def test_resolve_join_clauses_basic(self): """Test basic join clauses resolution.""" result = self.builder.resolve_join_clauses(self.criteria) self.assertEqual(result, []) - + def test_resolve_join_clauses_with_age(self): """Test join clauses with age criteria.""" criteria = ConditionOccurrence(age=NumericRange(op="gte", value=18, extent=65)) result = self.builder.resolve_join_clauses(criteria) - self.assertIn("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result) - + self.assertIn( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result + ) + def test_resolve_join_clauses_with_gender(self): """Test join clauses with gender criteria.""" criteria = ConditionOccurrence(gender=[Concept(concept_id=1)]) result = self.builder.resolve_join_clauses(criteria) - self.assertIn("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result) - + self.assertIn( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result + ) + def test_resolve_join_clauses_with_gender_cs(self): """Test join clauses with gender_cs criteria.""" - criteria = ConditionOccurrence(gender_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + gender_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_join_clauses(criteria) - self.assertIn("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result) - + self.assertIn( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result + ) + def test_resolve_join_clauses_with_visit_type(self): """Test join clauses with visit_type criteria.""" criteria = ConditionOccurrence(visit_type=[Concept(concept_id=1)]) result = self.builder.resolve_join_clauses(criteria) - self.assertIn("JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id", result) - + self.assertIn( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id", + result, + ) + def test_resolve_join_clauses_with_visit_type_cs(self): """Test join clauses with visit_type_cs criteria.""" - criteria = ConditionOccurrence(visit_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + visit_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_join_clauses(criteria) - self.assertIn("JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id", result) - + self.assertIn( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id", + result, + ) + def test_resolve_join_clauses_with_provider_specialty(self): """Test join clauses with provider_specialty criteria.""" criteria = ConditionOccurrence(provider_specialty=[Concept(concept_id=1)]) result = self.builder.resolve_join_clauses(criteria) - self.assertIn("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id", result) - + self.assertIn( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id", + result, + ) + def test_resolve_join_clauses_with_provider_specialty_cs(self): """Test join clauses with provider_specialty_cs criteria.""" - criteria = ConditionOccurrence(provider_specialty_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + provider_specialty_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_join_clauses(criteria) - self.assertIn("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id", result) - + self.assertIn( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id", + result, + ) + def test_resolve_join_clauses_with_multiple_conditions(self): """Test join clauses with multiple conditions.""" criteria = ConditionOccurrence( age=NumericRange(op="gte", value=18, extent=65), visit_type=[Concept(concept_id=1)], - provider_specialty=[Concept(concept_id=1)] + provider_specialty=[Concept(concept_id=1)], ) result = self.builder.resolve_join_clauses(criteria) self.assertEqual(len(result), 3) - self.assertIn("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result) - self.assertIn("JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id", result) - self.assertIn("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id", result) - + self.assertIn( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result + ) + self.assertIn( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id", + result, + ) + self.assertIn( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id", + result, + ) + def test_resolve_where_clauses_basic(self): """Test basic where clauses resolution.""" result = self.builder.resolve_where_clauses(self.criteria) self.assertEqual(result, []) - + def test_resolve_where_clauses_with_occurrence_start_date(self): """Test where clauses with occurrence_start_date.""" - criteria = ConditionOccurrence(occurrence_start_date=DateRange(op="gte", value="2020-01-01", extent="2020-12-31")) + criteria = ConditionOccurrence( + occurrence_start_date=DateRange( + op="gte", value="2020-01-01", extent="2020-12-31" + ) + ) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("C.start_date" in clause for clause in result)) - + def test_resolve_where_clauses_with_occurrence_end_date(self): """Test where clauses with occurrence_end_date.""" - criteria = ConditionOccurrence(occurrence_end_date=DateRange(op="gte", value="2020-01-01", extent="2020-12-31")) + criteria = ConditionOccurrence( + occurrence_end_date=DateRange( + op="gte", value="2020-01-01", extent="2020-12-31" + ) + ) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("C.end_date" in clause for clause in result)) - + def test_resolve_where_clauses_with_condition_type(self): """Test where clauses with condition_type.""" criteria = ConditionOccurrence(condition_type=[Concept(concept_id=1)]) result = self.builder.resolve_where_clauses(criteria) - self.assertTrue(any("C.condition_type_concept_id" in clause for clause in result)) - + self.assertTrue( + any("C.condition_type_concept_id" in clause for clause in result) + ) + def test_resolve_where_clauses_with_condition_type_exclude(self): """Test where clauses with condition_type_exclude=True.""" criteria = ConditionOccurrence( - condition_type=[Concept(concept_id=1)], - condition_type_exclude=True + condition_type=[Concept(concept_id=1)], condition_type_exclude=True ) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("not" in clause for clause in result)) - + def test_resolve_where_clauses_with_condition_type_cs(self): """Test where clauses with condition_type_cs.""" - criteria = ConditionOccurrence(condition_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + condition_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_where_clauses(criteria) - self.assertTrue(any("C.condition_type_concept_id" in clause for clause in result)) - + self.assertTrue( + any("C.condition_type_concept_id" in clause for clause in result) + ) + def test_resolve_where_clauses_with_stop_reason(self): """Test where clauses with stop_reason.""" criteria = ConditionOccurrence(stop_reason=TextFilter(text="test")) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("C.stop_reason" in clause for clause in result)) - + def test_resolve_where_clauses_with_age(self): """Test where clauses with age criteria.""" criteria = ConditionOccurrence(age=NumericRange(op="gte", value=18, extent=65)) result = self.builder.resolve_where_clauses(criteria) - self.assertTrue(any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in result)) - + self.assertTrue( + any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in result) + ) + def test_resolve_where_clauses_with_gender(self): """Test where clauses with gender criteria.""" criteria = ConditionOccurrence(gender=[Concept(concept_id=1)]) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("P.gender_concept_id" in clause for clause in result)) - + def test_resolve_where_clauses_with_gender_cs(self): """Test where clauses with gender_cs criteria.""" - criteria = ConditionOccurrence(gender_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + gender_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("P.gender_concept_id" in clause for clause in result)) - + def test_resolve_where_clauses_with_provider_specialty(self): """Test where clauses with provider_specialty criteria.""" criteria = ConditionOccurrence(provider_specialty=[Concept(concept_id=1)]) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("PR.specialty_concept_id" in clause for clause in result)) - + def test_resolve_where_clauses_with_provider_specialty_cs(self): """Test where clauses with provider_specialty_cs criteria.""" - criteria = ConditionOccurrence(provider_specialty_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + provider_specialty_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("PR.specialty_concept_id" in clause for clause in result)) - + def test_resolve_where_clauses_with_visit_type(self): """Test where clauses with visit_type criteria.""" criteria = ConditionOccurrence(visit_type=[Concept(concept_id=1)]) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("V.visit_concept_id" in clause for clause in result)) - + def test_resolve_where_clauses_with_visit_type_cs(self): """Test where clauses with visit_type_cs criteria.""" - criteria = ConditionOccurrence(visit_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + visit_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("V.visit_concept_id" in clause for clause in result)) - + def test_resolve_where_clauses_with_condition_status(self): """Test where clauses with condition_status criteria.""" criteria = ConditionOccurrence(condition_status=[Concept(concept_id=1)]) result = self.builder.resolve_where_clauses(criteria) - self.assertTrue(any("C.condition_status_concept_id" in clause for clause in result)) - + self.assertTrue( + any("C.condition_status_concept_id" in clause for clause in result) + ) + def test_resolve_where_clauses_with_condition_status_cs(self): """Test where clauses with condition_status_cs criteria.""" - criteria = ConditionOccurrence(condition_status_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) + criteria = ConditionOccurrence( + condition_status_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) + ) result = self.builder.resolve_where_clauses(criteria) - self.assertTrue(any("C.condition_status_concept_id" in clause for clause in result)) - + self.assertTrue( + any("C.condition_status_concept_id" in clause for clause in result) + ) + def test_resolve_where_clauses_with_multiple_conditions(self): """Test where clauses with multiple conditions.""" criteria = ConditionOccurrence( - occurrence_start_date=DateRange(op="gte", value="2020-01-01", extent="2020-12-31"), + occurrence_start_date=DateRange( + op="gte", value="2020-01-01", extent="2020-12-31" + ), age=NumericRange(op="gte", value=18, extent=65), - gender=[Concept(concept_id=1)] + gender=[Concept(concept_id=1)], ) result = self.builder.resolve_where_clauses(criteria) self.assertGreater(len(result), 0) self.assertTrue(any("C.start_date" in clause for clause in result)) - self.assertTrue(any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in result)) + self.assertTrue( + any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in result) + ) self.assertTrue(any("P.gender_concept_id" in clause for clause in result)) - + def test_get_criteria_sql_basic(self): """Test basic SQL generation.""" result = self.builder.get_criteria_sql(self.criteria) - + # Check that template placeholders are replaced self.assertNotIn("@selectClause", result) self.assertNotIn("@ordinalExpression", result) @@ -392,18 +474,18 @@ def test_get_criteria_sql_basic(self): self.assertNotIn("@joinClause", result) self.assertNotIn("@whereClause", result) self.assertNotIn("@additionalColumns", result) - + # Check that SQL structure is maintained self.assertIn("-- Begin Condition Occurrence Criteria", result) self.assertIn("-- End Condition Occurrence Criteria", result) self.assertIn("SELECT C.person_id", result) self.assertIn("FROM", result) - + def test_get_criteria_sql_with_codeset_id(self): """Test SQL generation with codeset_id.""" criteria = ConditionOccurrence(codeset_id=123) result = self.builder.get_criteria_sql(criteria) - + # Should not contain template placeholders self.assertNotIn("@codesetClause", result) self.assertNotIn("@selectClause", result) @@ -411,62 +493,64 @@ def test_get_criteria_sql_with_codeset_id(self): self.assertNotIn("@joinClause", result) self.assertNotIn("@whereClause", result) self.assertNotIn("@additionalColumns", result) - + def test_get_criteria_sql_with_first_true(self): """Test SQL generation with first=True.""" criteria = ConditionOccurrence(first=True) result = self.builder.get_criteria_sql(criteria) - + # Should contain ordinal expression self.assertIn("row_number()", result) self.assertIn("C.ordinal = 1", result) - + def test_get_criteria_sql_with_date_adjustment(self): """Test SQL generation with date_adjustment.""" - criteria = ConditionOccurrence(date_adjustment=DateAdjustment( - start_offset=30, - end_offset=0, - start_with="start_date", - end_with="start_date" - )) + criteria = ConditionOccurrence( + date_adjustment=DateAdjustment( + start_offset=30, + end_offset=0, + start_with="start_date", + end_with="start_date", + ) + ) result = self.builder.get_criteria_sql(criteria) - + # Should contain DATEADD expression self.assertIn("DATEADD", result) - + def test_get_criteria_sql_with_person_join(self): """Test SQL generation with person join.""" criteria = ConditionOccurrence(age=NumericRange(op="gte", value=18, extent=65)) result = self.builder.get_criteria_sql(criteria) - + # Should contain person join self.assertIn("JOIN @cdm_database_schema.PERSON P", result) - + def test_get_criteria_sql_with_options(self): """Test SQL generation with builder options.""" options = BuilderOptions() options.additional_columns = [CriteriaColumn.DOMAIN_CONCEPT] - + result = self.builder.get_criteria_sql_with_options(self.criteria, options) - + # Check that additional columns are included as NULL self.assertIn("C.condition_concept_id as domain_concept_id", result) - + def test_get_criteria_sql_with_options_none(self): """Test SQL generation with None options.""" result = self.builder.get_criteria_sql_with_options(self.criteria, None) - + # Should work without errors self.assertIsInstance(result, str) self.assertIn("-- Begin Condition Occurrence Criteria", result) - + def test_edge_case_empty_gender_list(self): """Test edge case with empty gender list.""" criteria = ConditionOccurrence(gender=[]) result = self.builder.resolve_where_clauses(criteria) # Should not add gender clause for empty list self.assertFalse(any("P.gender_concept_id" in clause for clause in result)) - + def test_edge_case_gender_with_none_concept_id(self): """Test edge case with gender containing None concept_id.""" # This tests Java interoperability - Java can send null concept_id values @@ -476,28 +560,36 @@ def test_edge_case_gender_with_none_concept_id(self): self.assertIsInstance(result, list) # Should not add gender clause since all concept_ids are None self.assertFalse(any("P.gender_concept_id" in clause for clause in result)) - + def test_edge_case_date_range_none_values(self): """Test edge case with date range containing None values.""" - criteria = ConditionOccurrence(occurrence_start_date=DateRange(op="gte", value=None, extent=None)) + criteria = ConditionOccurrence( + occurrence_start_date=DateRange(op="gte", value=None, extent=None) + ) result = self.builder.resolve_where_clauses(criteria) # Should handle None values gracefully self.assertIsInstance(result, list) - + def test_edge_case_numeric_range_none_values(self): """Test edge case with numeric range containing None values.""" - criteria = ConditionOccurrence(age=NumericRange(op="gte", value=None, extent=None)) + criteria = ConditionOccurrence( + age=NumericRange(op="gte", value=None, extent=None) + ) result = self.builder.resolve_where_clauses(criteria) # Should handle None values gracefully self.assertIsInstance(result, list) - + def test_comprehensive_integration_test(self): """Test comprehensive integration with multiple criteria.""" criteria = ConditionOccurrence( codeset_id=123, first=True, - occurrence_start_date=DateRange(op="gte", value="2020-01-01", extent="2020-12-31"), - occurrence_end_date=DateRange(op="gte", value="2020-01-01", extent="2020-12-31"), + occurrence_start_date=DateRange( + op="gte", value="2020-01-01", extent="2020-12-31" + ), + occurrence_end_date=DateRange( + op="gte", value="2020-01-01", extent="2020-12-31" + ), condition_type=[Concept(concept_id=1)], condition_type_exclude=False, stop_reason=TextFilter(text="test"), @@ -510,12 +602,12 @@ def test_comprehensive_integration_test(self): start_offset=30, end_offset=0, start_with="start_date", - end_with="start_date" - ) + end_with="start_date", + ), ) - + result = self.builder.get_criteria_sql(criteria) - + # Should generate complete SQL without template placeholders (except @cdm_database_schema which is expected) self.assertNotIn("@selectClause", result) self.assertNotIn("@ordinalExpression", result) @@ -527,13 +619,17 @@ def test_comprehensive_integration_test(self): self.assertIn("-- End Condition Occurrence Criteria", result) self.assertIn("SELECT C.person_id", result) self.assertIn("FROM", result) - + # Should contain various clauses self.assertIn("row_number()", result) # ordinal expression self.assertIn("JOIN @cdm_database_schema.PERSON P", result) # person join - self.assertIn("JOIN @cdm_database_schema.VISIT_OCCURRENCE V", result) # visit join - self.assertIn("LEFT JOIN @cdm_database_schema.PROVIDER PR", result) # provider join + self.assertIn( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V", result + ) # visit join + self.assertIn( + "LEFT JOIN @cdm_database_schema.PROVIDER PR", result + ) # provider join -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_criteria_classes.py b/tests/test_criteria_classes.py index e02c7023..6bdee417 100644 --- a/tests/test_criteria_classes.py +++ b/tests/test_criteria_classes.py @@ -45,10 +45,7 @@ class TestConditionOccurrence(unittest.TestCase): def test_condition_occurrence_initialization(self): """Test basic initialization of ConditionOccurrence.""" - condition = ConditionOccurrence( - first=True, - condition_type_exclude=False - ) + condition = ConditionOccurrence(first=True, condition_type_exclude=False) self.assertTrue(condition.first) self.assertFalse(condition.condition_type_exclude) self.assertIsNone(condition.gender) @@ -74,9 +71,9 @@ def test_condition_occurrence_with_all_fields(self): first=True, provider_specialty=[Concept(concept_id=7, concept_name="Cardiology")], age=NumericRange(op="gte", value=18, extent=65), - occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01") + occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), ) - + self.assertEqual(len(condition.gender), 1) self.assertEqual(condition.gender[0].concept_id, 8507) self.assertEqual(condition.stop_reason.text, "completed") @@ -85,19 +82,29 @@ def test_condition_occurrence_with_all_fields(self): def test_condition_occurrence_camel_case_aliases(self): """Test that camelCase aliases work correctly.""" - condition = ConditionOccurrence.model_validate({ - "occurrenceEndDate": {"op": "lt", "extent": "30", "value": "2023-01-01"}, - "conditionSourceConcept": 12345, - "genderCS": {"codesetId": 1, "isExclusion": False}, - "conditionTypeExclude": False, - "providerSpecialtyCS": {"codesetId": 3, "isExclusion": False}, - "visitTypeCS": {"codesetId": 4, "isExclusion": False}, - "conditionStatusCS": {"codesetId": 6, "isExclusion": False}, - "codesetId": 100, - "first": True, - "occurrenceStartDate": {"op": "gte", "extent": "0", "value": "2020-01-01"} - }) - + condition = ConditionOccurrence.model_validate( + { + "occurrenceEndDate": { + "op": "lt", + "extent": "30", + "value": "2023-01-01", + }, + "conditionSourceConcept": 12345, + "genderCS": {"codesetId": 1, "isExclusion": False}, + "conditionTypeExclude": False, + "providerSpecialtyCS": {"codesetId": 3, "isExclusion": False}, + "visitTypeCS": {"codesetId": 4, "isExclusion": False}, + "conditionStatusCS": {"codesetId": 6, "isExclusion": False}, + "codesetId": 100, + "first": True, + "occurrenceStartDate": { + "op": "gte", + "extent": "0", + "value": "2020-01-01", + }, + } + ) + self.assertIsNotNone(condition.occurrence_end_date) self.assertEqual(condition.condition_source_concept, 12345) self.assertEqual(condition.codeset_id, 100) @@ -109,10 +116,7 @@ class TestDrugExposure(unittest.TestCase): def test_drug_exposure_initialization(self): """Test basic initialization of DrugExposure.""" - drug = DrugExposure( - first=True, - drug_type_exclude=False - ) + drug = DrugExposure(first=True, drug_type_exclude=False) self.assertTrue(drug.first) self.assertFalse(drug.drug_type_exclude) self.assertIsNone(drug.gender) @@ -138,9 +142,9 @@ def test_drug_exposure_with_fields(self): first=True, provider_specialty=[Concept(concept_id=8, concept_name="Cardiology")], age=NumericRange(op="gte", value=18, extent=65), - occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01") + occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), ) - + self.assertEqual(len(drug.gender), 1) self.assertEqual(drug.stop_reason.text, "completed") self.assertEqual(drug.codeset_id, 100) @@ -148,19 +152,29 @@ def test_drug_exposure_with_fields(self): def test_drug_exposure_camel_case_aliases(self): """Test that camelCase aliases work correctly.""" - drug = DrugExposure.model_validate({ - "occurrenceEndDate": {"op": "lt", "extent": "30", "value": "2023-01-01"}, - "drugSourceConcept": 12345, - "genderCS": {"codesetId": 1, "isExclusion": False}, - "drugTypeExclude": False, - "providerSpecialtyCS": {"codesetId": 3, "isExclusion": False}, - "visitTypeCS": {"codesetId": 4, "isExclusion": False}, - "routeConceptCS": {"codesetId": 7, "isExclusion": False}, - "codesetId": 100, - "first": True, - "occurrenceStartDate": {"op": "gte", "extent": "0", "value": "2020-01-01"} - }) - + drug = DrugExposure.model_validate( + { + "occurrenceEndDate": { + "op": "lt", + "extent": "30", + "value": "2023-01-01", + }, + "drugSourceConcept": 12345, + "genderCS": {"codesetId": 1, "isExclusion": False}, + "drugTypeExclude": False, + "providerSpecialtyCS": {"codesetId": 3, "isExclusion": False}, + "visitTypeCS": {"codesetId": 4, "isExclusion": False}, + "routeConceptCS": {"codesetId": 7, "isExclusion": False}, + "codesetId": 100, + "first": True, + "occurrenceStartDate": { + "op": "gte", + "extent": "0", + "value": "2020-01-01", + }, + } + ) + self.assertIsNotNone(drug.occurrence_end_date) self.assertEqual(drug.drug_source_concept, 12345) self.assertEqual(drug.codeset_id, 100) @@ -172,10 +186,7 @@ class TestProcedureOccurrence(unittest.TestCase): def test_procedure_occurrence_initialization(self): """Test basic initialization of ProcedureOccurrence.""" - procedure = ProcedureOccurrence( - first=True, - procedure_type_exclude=False - ) + procedure = ProcedureOccurrence(first=True, procedure_type_exclude=False) self.assertTrue(procedure.first) self.assertFalse(procedure.procedure_type_exclude) self.assertIsNone(procedure.gender) @@ -200,9 +211,9 @@ def test_procedure_occurrence_with_fields(self): first=True, provider_specialty=[Concept(concept_id=8, concept_name="Surgery")], age=NumericRange(op="gte", value=18, extent=65), - occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01") + occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), ) - + self.assertEqual(len(procedure.gender), 1) self.assertEqual(procedure.procedure_source_concept, 12345) self.assertEqual(procedure.codeset_id, 100) @@ -214,9 +225,7 @@ class TestVisitOccurrence(unittest.TestCase): def test_visit_occurrence_initialization(self): """Test basic initialization of VisitOccurrence.""" - visit = VisitOccurrence( - visit_type_exclude=False - ) + visit = VisitOccurrence(visit_type_exclude=False) self.assertFalse(visit.visit_type_exclude) self.assertIsNone(visit.gender) @@ -232,9 +241,9 @@ def test_visit_occurrence_with_fields(self): provider_specialty_cs=ConceptSetSelection(codeset_id=3, is_exclusion=False), provider_specialty=[Concept(concept_id=4, concept_name="Cardiology")], age=NumericRange(op="gte", value=18, extent=65), - occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01") + occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), ) - + self.assertEqual(len(visit.gender), 1) self.assertEqual(len(visit.visit_type), 1) self.assertEqual(visit.visit_type[0].concept_id, 2) @@ -245,10 +254,7 @@ class TestObservation(unittest.TestCase): def test_observation_initialization(self): """Test basic initialization of Observation.""" - observation = Observation( - first=True, - observation_type_exclude=False - ) + observation = Observation(first=True, observation_type_exclude=False) self.assertTrue(observation.first) self.assertFalse(observation.observation_type_exclude) self.assertIsNone(observation.gender) @@ -272,9 +278,9 @@ def test_observation_with_fields(self): first=True, provider_specialty=[Concept(concept_id=6, concept_name="Lab")], age=NumericRange(op="gte", value=18, extent=65), - occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01") + occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), ) - + self.assertEqual(len(observation.gender), 1) self.assertEqual(observation.observation_source_concept, 12345) self.assertEqual(observation.value_as_string.text, "normal") @@ -286,10 +292,7 @@ class TestMeasurement(unittest.TestCase): def test_measurement_initialization(self): """Test basic initialization of Measurement.""" - measurement = Measurement( - first=True, - measurement_type_exclude=False - ) + measurement = Measurement(first=True, measurement_type_exclude=False) self.assertTrue(measurement.first) self.assertFalse(measurement.measurement_type_exclude) self.assertIsNone(measurement.gender) @@ -320,9 +323,9 @@ def test_measurement_with_fields(self): first=True, provider_specialty=[Concept(concept_id=8, concept_name="Lab")], age=NumericRange(op="gte", value=18, extent=65), - occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01") + occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), ) - + self.assertEqual(len(measurement.gender), 1) self.assertEqual(measurement.measurement_source_concept, 12345) self.assertEqual(measurement.value_as_number.value, 100) @@ -335,10 +338,7 @@ class TestDeviceExposure(unittest.TestCase): def test_device_exposure_initialization(self): """Test basic initialization of DeviceExposure.""" - device = DeviceExposure( - first=True, - device_type_exclude=False - ) + device = DeviceExposure(first=True, device_type_exclude=False) self.assertTrue(device.first) self.assertFalse(device.device_type_exclude) self.assertIsNone(device.gender) @@ -363,9 +363,9 @@ def test_device_exposure_with_fields(self): first=True, provider_specialty=[Concept(concept_id=6, concept_name="Cardiology")], age=NumericRange(op="gte", value=18, extent=65), - occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01") + occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), ) - + self.assertEqual(len(device.gender), 1) self.assertEqual(device.device_source_concept, 12345) self.assertEqual(device.unique_device_id.text, "DEVICE123") @@ -378,10 +378,7 @@ class TestSpecimen(unittest.TestCase): def test_specimen_initialization(self): """Test basic initialization of Specimen.""" - specimen = Specimen( - first=True, - specimen_type_exclude=False - ) + specimen = Specimen(first=True, specimen_type_exclude=False) self.assertTrue(specimen.first) self.assertFalse(specimen.specimen_type_exclude) self.assertIsNone(specimen.gender) @@ -407,9 +404,9 @@ def test_specimen_with_fields(self): codeset_id=100, first=True, age=NumericRange(op="gte", value=18, extent=65), - occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01") + occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), ) - + self.assertEqual(len(specimen.gender), 1) self.assertEqual(specimen.specimen_source_concept, 12345) self.assertEqual(len(specimen.specimen_type), 1) @@ -422,9 +419,7 @@ class TestDeath(unittest.TestCase): def test_death_initialization(self): """Test basic initialization of Death.""" - death = Death( - death_type_exclude=False - ) + death = Death(death_type_exclude=False) self.assertFalse(death.death_type_exclude) self.assertIsNone(death.gender) self.assertIsNone(death.codeset_id) @@ -440,12 +435,14 @@ def test_death_with_fields(self): death_type_cs=ConceptSetSelection(codeset_id=2, is_exclusion=False), death_type_exclude=False, cause_source_concept=67890, - cause_source_concept_cs=ConceptSetSelection(codeset_id=3, is_exclusion=False), + cause_source_concept_cs=ConceptSetSelection( + codeset_id=3, is_exclusion=False + ), codeset_id=100, age=NumericRange(op="gte", value=18, extent=65), - occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01") + occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), ) - + self.assertEqual(len(death.gender), 1) self.assertEqual(death.death_source_concept, 12345) self.assertEqual(death.cause_source_concept, 67890) @@ -457,27 +454,21 @@ class TestEraCriteria(unittest.TestCase): def test_condition_era_initialization(self): """Test basic initialization of ConditionEra.""" - era = ConditionEra( - first=True - ) + era = ConditionEra(first=True) self.assertTrue(era.first) self.assertIsNone(era.gender) self.assertIsNone(era.codeset_id) def test_drug_era_initialization(self): """Test basic initialization of DrugEra.""" - era = DrugEra( - first=True - ) + era = DrugEra(first=True) self.assertTrue(era.first) self.assertIsNone(era.gender) self.assertIsNone(era.codeset_id) def test_dose_era_initialization(self): """Test basic initialization of DoseEra.""" - era = DoseEra( - first=True - ) + era = DoseEra(first=True) self.assertTrue(era.first) self.assertIsNone(era.gender) self.assertIsNone(era.codeset_id) @@ -492,9 +483,9 @@ def test_era_with_fields(self): codeset_id=100, first=True, age=NumericRange(op="gte", value=18, extent=65), - occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01") + occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), ) - + self.assertEqual(len(condition_era.gender), 1) self.assertEqual(condition_era.era_length.value, 30) self.assertEqual(condition_era.codeset_id, 100) @@ -505,9 +496,7 @@ class TestOtherCriteria(unittest.TestCase): def test_visit_detail_initialization(self): """Test basic initialization of VisitDetail.""" - visit_detail = VisitDetail( - visit_detail_type_exclude=False - ) + visit_detail = VisitDetail(visit_detail_type_exclude=False) self.assertFalse(visit_detail.visit_detail_type_exclude) self.assertIsNone(visit_detail.gender) @@ -536,5 +525,5 @@ def test_geo_criteria_initialization(self): self.assertIsNone(geo.include) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_date_adjustment_parity.py b/tests/test_date_adjustment_parity.py index 8c7b2a15..3c5af553 100644 --- a/tests/test_date_adjustment_parity.py +++ b/tests/test_date_adjustment_parity.py @@ -1,4 +1,3 @@ - from circe.cohortdefinition import ( ConditionEra, ConditionOccurrence, @@ -18,7 +17,6 @@ class TestDateAdjustmentParity: - @classmethod def setup_class(cls): # ... setup db ... @@ -35,11 +33,11 @@ def test_drug_era_date_adjustment(self): ce = DrugEra() ce.codeset_id = 1 ce.date_adjustment = DateAdjustment(start_offset=5, end_offset=-5) - + sql = self.de_builder.get_criteria_sql(ce) assert "DATEADD(day,5, de.drug_era_start_date)" in sql assert "DATEADD(day,-5, de.drug_era_end_date)" in sql - + # Setup Data self.db.con.execute("DROP TABLE IF EXISTS drug_era") self.db.con.execute(""" @@ -55,21 +53,28 @@ def test_drug_era_date_adjustment(self): """) self.db.con.execute("DELETE FROM Codesets") # Insert record: 2020-01-10 to 2020-01-20 - self.db.con.execute("INSERT INTO drug_era (person_id, drug_era_id, drug_concept_id, drug_era_start_date, drug_era_end_date, drug_exposure_count, gap_days) VALUES (1, 100, 10, '2020-01-10'::DATE, '2020-01-20'::DATE, 1, 0)") - self.db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") - + self.db.con.execute( + "INSERT INTO drug_era (person_id, drug_era_id, drug_concept_id, drug_era_start_date, drug_era_end_date, drug_exposure_count, gap_days) VALUES (1, 100, 10, '2020-01-10'::DATE, '2020-01-20'::DATE, 1, 0)" + ) + self.db.con.execute( + "INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)" + ) + query = f"SELECT C.start_date, C.end_date FROM ({sql}) C" results = self.db.query(query) assert len(results) == 1 - + res_start = results[0][0] res_end = results[0][1] - + # Check logic: Start + 5 = 15th, End - 5 = 15th import datetime - if isinstance(res_start, datetime.datetime): res_start = res_start.date() - if isinstance(res_end, datetime.datetime): res_end = res_end.date() - + + if isinstance(res_start, datetime.datetime): + res_start = res_start.date() + if isinstance(res_end, datetime.datetime): + res_end = res_end.date() + assert res_start == datetime.date(2020, 1, 15) assert res_end == datetime.date(2020, 1, 15) @@ -77,13 +82,16 @@ def test_condition_occurrence_date_adjustment(self): co = ConditionOccurrence() co.codeset_id = 1 co.date_adjustment = DateAdjustment(start_offset=1, end_offset=1) - + sql = self.co_builder.get_criteria_sql(co) # Condition Occurrence uses co.condition_start_date / condition_end_date # Note: End date logic uses COALESCE for safety assert "DATEADD(day,1, co.condition_start_date)" in sql - assert "DATEADD(day,1, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)))" in sql - + assert ( + "DATEADD(day,1, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)))" + in sql + ) + # Setup Data self.db.con.execute("DROP TABLE IF EXISTS condition_occurrence") self.db.con.execute(""" @@ -105,20 +113,27 @@ def test_condition_occurrence_date_adjustment(self): """) self.db.con.execute("DELETE FROM Codesets") # Insert record - self.db.con.execute("INSERT INTO condition_occurrence (person_id, condition_occurrence_id, condition_concept_id, condition_start_date, condition_end_date, condition_type_concept_id) VALUES (1, 100, 10, '2020-02-01'::DATE, '2020-02-05'::DATE, 0)") - self.db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") - + self.db.con.execute( + "INSERT INTO condition_occurrence (person_id, condition_occurrence_id, condition_concept_id, condition_start_date, condition_end_date, condition_type_concept_id) VALUES (1, 100, 10, '2020-02-01'::DATE, '2020-02-05'::DATE, 0)" + ) + self.db.con.execute( + "INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)" + ) + query = f"SELECT C.start_date, C.end_date FROM ({sql}) C" results = self.db.query(query) assert len(results) == 1 - + res_start = results[0][0] res_end = results[0][1] - + import datetime - if isinstance(res_start, datetime.datetime): res_start = res_start.date() - if isinstance(res_end, datetime.datetime): res_end = res_end.date() - + + if isinstance(res_start, datetime.datetime): + res_start = res_start.date() + if isinstance(res_end, datetime.datetime): + res_end = res_end.date() + # 2020-02-01 + 1 = 2020-02-02 # 2020-02-05 + 1 = 2020-02-06 assert res_start == datetime.date(2020, 2, 2) @@ -128,17 +143,21 @@ def test_drug_exposure_date_adjustment(self): de = DrugExposure() de.codeset_id = 1 de.date_adjustment = DateAdjustment(start_offset=2, end_offset=2) - + sql = self.dexp_builder.get_criteria_sql(de) # Drug Exposure uses de.drug_exposure_start_date / drug_exposure_end_date - # Check if it uses COALESCE loop like ConditionOccurrence? - # Java DrugExposureSqlBuilder: + # Check if it uses COALESCE loop like ConditionOccurrence? + # Java DrugExposureSqlBuilder: # start_date = drug_exposure_start_date # end_date = COALESCE(drug_exposure_end_date, DATEADD(day, 0, drug_exposure_start_date)) (Wait, usually 0 or days_supply?) # Let's assume standard COALESCE pattern found in ConditionOccurrence assert "DATEADD(day,2, de.drug_exposure_start_date)" in sql - assert "DATEADD(day,2, COALESCE(de.drug_exposure_end_date, DATEADD(day,0,de.drug_exposure_start_date)))" in sql or "DATEADD(day,2, de.drug_exposure_end_date)" in sql - + assert ( + "DATEADD(day,2, COALESCE(de.drug_exposure_end_date, DATEADD(day,0,de.drug_exposure_start_date)))" + in sql + or "DATEADD(day,2, de.drug_exposure_end_date)" in sql + ) + # Setup Data self.db.con.execute("DROP TABLE IF EXISTS drug_exposure") self.db.con.execute(""" @@ -167,20 +186,27 @@ def test_drug_exposure_date_adjustment(self): """) self.db.con.execute("DELETE FROM Codesets") # Insert record - self.db.con.execute("INSERT INTO drug_exposure (person_id, drug_exposure_id, drug_concept_id, drug_exposure_start_date, drug_exposure_end_date, drug_type_concept_id) VALUES (1, 100, 10, '2020-03-01'::DATE, '2020-03-10'::DATE, 0)") - self.db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") - + self.db.con.execute( + "INSERT INTO drug_exposure (person_id, drug_exposure_id, drug_concept_id, drug_exposure_start_date, drug_exposure_end_date, drug_type_concept_id) VALUES (1, 100, 10, '2020-03-01'::DATE, '2020-03-10'::DATE, 0)" + ) + self.db.con.execute( + "INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)" + ) + query = f"SELECT C.start_date, C.end_date FROM ({sql}) C" results = self.db.query(query) assert len(results) == 1 - + res_start = results[0][0] res_end = results[0][1] - + import datetime - if isinstance(res_start, datetime.datetime): res_start = res_start.date() - if isinstance(res_end, datetime.datetime): res_end = res_end.date() - + + if isinstance(res_start, datetime.datetime): + res_start = res_start.date() + if isinstance(res_end, datetime.datetime): + res_end = res_end.date() + assert res_start == datetime.date(2020, 3, 3) assert res_end == datetime.date(2020, 3, 12) @@ -188,11 +214,11 @@ def test_dose_era_date_adjustment(self): de = DoseEra() de.codeset_id = 1 de.date_adjustment = DateAdjustment(start_offset=-1, end_offset=-1) - + sql = self.dose_builder.get_criteria_sql(de) assert "DATEADD(day,-1, de.dose_era_start_date)" in sql assert "DATEADD(day,-1, de.dose_era_end_date)" in sql - + # Setup Data self.db.con.execute("DROP TABLE IF EXISTS dose_era") self.db.con.execute(""" @@ -208,27 +234,34 @@ def test_dose_era_date_adjustment(self): """) self.db.con.execute("DELETE FROM Codesets") # Insert record - self.db.con.execute("INSERT INTO dose_era (person_id, dose_era_id, drug_concept_id, dose_era_start_date, dose_era_end_date) VALUES (1, 100, 10, '2020-04-01'::DATE, '2020-04-05'::DATE)") - self.db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") - + self.db.con.execute( + "INSERT INTO dose_era (person_id, dose_era_id, drug_concept_id, dose_era_start_date, dose_era_end_date) VALUES (1, 100, 10, '2020-04-01'::DATE, '2020-04-05'::DATE)" + ) + self.db.con.execute( + "INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)" + ) + query = f"SELECT C.start_date, C.end_date FROM ({sql}) C" results = self.db.query(query) assert len(results) == 1 - + res_start = results[0][0] res_end = results[0][1] - + import datetime - if isinstance(res_start, datetime.datetime): res_start = res_start.date() - if isinstance(res_end, datetime.datetime): res_end = res_end.date() - + + if isinstance(res_start, datetime.datetime): + res_start = res_start.date() + if isinstance(res_end, datetime.datetime): + res_end = res_end.date() + assert res_start == datetime.date(2020, 3, 31) assert res_end == datetime.date(2020, 4, 4) - + def test_condition_era_date_adjustment(self): """ Replicate Java: CriteriaQuery_5_0_0_Test.testConditionEraDateOffset - + Java Logic: ConditionEra era = new ConditionEra(); era.dateAdjustment = new DateAdjustment(); @@ -238,32 +271,32 @@ def test_condition_era_date_adjustment(self): # 1. Define Criteria ce = ConditionEra() # Ensure we have a codeset to make SQL valid/realistic (Java often uses 1) - ce.codeset_id = 1 - + ce.codeset_id = 1 + ce.date_adjustment = DateAdjustment(start_offset=2, end_offset=1) - + # 2. Generate SQL # Note: We need a dummy concept set expression for the builder to include codeset logic if needed, # but ConditionEra builder is usually standalone regarding codeset lookups if codeset_id is present? # Actually Builder usually needs a concept set mapping. - # For simplicity in this unit test, we might mock the result or just check the inner SQL + # For simplicity in this unit test, we might mock the result or just check the inner SQL # but `get_criteria_sql` usually returns the full inner selection. - + sql = self.ce_builder.get_criteria_sql(ce) - + # 3. Validation - String Check (Immediate feedback) - # We expect DATEADD/DATEFROMPARTS logic. + # We expect DATEADD/DATEFROMPARTS logic. # In T-SQL (OHDSI format): DATEADD(day, 2, start_date) assert "DATEADD(day,2, ce.condition_era_start_date)" in sql assert "DATEADD(day,1, ce.condition_era_end_date)" in sql - + # 4. Validation - DuckDB Execution (Functional Parity) # We need to wrap the generated criteria SQL in a runnable SELECT to verify it works # The criteria SQL usually starts with "SELECT ... FROM ...". - + # Add necessary context for it to run: # We need a dummy "condition_era" and "Codesets" table populated. - + # Setup Data self.db.con.execute("DROP TABLE IF EXISTS condition_era") self.db.con.execute(""" @@ -277,13 +310,17 @@ def test_condition_era_date_adjustment(self): ) """) self.db.con.execute("DELETE FROM Codesets") - + # Insert a matching record: start_date=2020-01-01, end_date=2020-01-10 - self.db.con.execute("INSERT INTO condition_era (person_id, condition_era_id, condition_concept_id, condition_era_start_date, condition_era_end_date, condition_occurrence_count) VALUES (1, 100, 10, '2020-01-01'::DATE, '2020-01-10'::DATE, 1)") - + self.db.con.execute( + "INSERT INTO condition_era (person_id, condition_era_id, condition_concept_id, condition_era_start_date, condition_era_end_date, condition_occurrence_count) VALUES (1, 100, 10, '2020-01-01'::DATE, '2020-01-10'::DATE, 1)" + ) + # Insert codeset mapping - self.db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") - + self.db.con.execute( + "INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)" + ) + # Construct full query # We replace @indexId with 0 or similar query = f""" @@ -296,37 +333,36 @@ def test_condition_era_date_adjustment(self): {sql} ) C """ - - # The builder emits SQL with @codesetId and @indexId placeholders? - # Actually `get_criteria_sql` usually renders fully if we pass options? + + # The builder emits SQL with @codesetId and @indexId placeholders? + # Actually `get_criteria_sql` usually renders fully if we pass options? # Or does it leave #Codesets? # It relies on #Codesets being created. - + # Run It # We expect the result dates to be adjusted: # Start: 2020-01-01 + 2 days = 2020-01-03 # End: 2020-01-10 + 1 day = 2020-01-11 - + # We need to clean up the SQL params manually as our helper does simple replacement # ConditionEra builder might not emit params if not using generic properties - + results = self.db.query(query) - + assert len(results) == 1 row = results[0] # DuckDB returns date objects (or strings depending on driver) # person_id, event_id, start_date, end_date res_start = row[2] res_end = row[3] - + import datetime - + # DuckDB might return datetime or date depending on driver/version if isinstance(res_start, datetime.datetime): res_start = res_start.date() if isinstance(res_end, datetime.datetime): res_end = res_end.date() - + assert res_start == datetime.date(2020, 1, 3) assert res_end == datetime.date(2020, 1, 11) - diff --git a/tests/test_device_exposure_sql.py b/tests/test_device_exposure_sql.py index 2809ecbe..a085ab30 100644 --- a/tests/test_device_exposure_sql.py +++ b/tests/test_device_exposure_sql.py @@ -8,52 +8,68 @@ class TestDeviceExposureSql(unittest.TestCase): - def test_basic_device_exposure(self): criteria = DeviceExposure( - codeset_id=1, - occurrence_start_date=DateRange(value="2023-01-01", op="gt") + codeset_id=1, occurrence_start_date=DateRange(value="2023-01-01", op="gt") ) builder = DeviceExposureSqlBuilder() options = BuilderOptions() - + # We need to minimally test the resolved clauses where_clauses = builder.resolve_where_clauses(criteria, options) join_clauses = builder.resolve_join_clauses(criteria, options) select_clauses = builder.resolve_select_clauses(criteria, options) - - self.assertTrue(any("C.start_date" in c for c in where_clauses), "Should have start date condition") - self.assertEqual(len(join_clauses), 0, "Should have no joins for basic criteria") - - def test_device_exposure_with_age(self): - criteria = DeviceExposure( - age=NumericRange(value=50, op="gt") + + self.assertTrue( + any("C.start_date" in c for c in where_clauses), + "Should have start date condition", + ) + self.assertEqual( + len(join_clauses), 0, "Should have no joins for basic criteria" ) + + def test_device_exposure_with_age(self): + criteria = DeviceExposure(age=NumericRange(value=50, op="gt")) builder = DeviceExposureSqlBuilder() options = BuilderOptions() - + where_clauses = builder.resolve_where_clauses(criteria, options) join_clauses = builder.resolve_join_clauses(criteria, options) - + # Check join to PERSON - self.assertTrue(any("JOIN @cdm_database_schema.PERSON P" in c for c in join_clauses), "Should join to PERSON when age is used") - + self.assertTrue( + any("JOIN @cdm_database_schema.PERSON P" in c for c in join_clauses), + "Should join to PERSON when age is used", + ) + # Check date diff logic for age - age_logic_present = any("YEAR(C.start_date) - P.year_of_birth" in c for c in where_clauses) + age_logic_present = any( + "YEAR(C.start_date) - P.year_of_birth" in c for c in where_clauses + ) self.assertTrue(age_logic_present, "Should use correct age calculation logic") - + def test_device_exposure_joins(self): criteria = DeviceExposure( visit_type=[Concept(concept_id=1, concept_name="Test")], - provider_specialty=[Concept(concept_id=2, concept_name="Test")] + provider_specialty=[Concept(concept_id=2, concept_name="Test")], ) builder = DeviceExposureSqlBuilder() options = BuilderOptions() - + join_clauses = builder.resolve_join_clauses(criteria, options) - - self.assertTrue(any("JOIN @cdm_database_schema.VISIT_OCCURRENCE V" in c for c in join_clauses), "Should join to VISIT_OCCURRENCE") - self.assertTrue(any("JOIN @cdm_database_schema.PROVIDER PR" in c for c in join_clauses), "Should join to PROVIDER") -if __name__ == '__main__': + self.assertTrue( + any( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V" in c + for c in join_clauses + ), + "Should join to VISIT_OCCURRENCE", + ) + self.assertTrue( + any("JOIN @cdm_database_schema.PROVIDER PR" in c for c in join_clauses), + "Should join to PROVIDER", + ) + + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 6bcdba25..bd853e99 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -13,6 +13,7 @@ except ModuleNotFoundError: # Python <3.11 fallback import tomli as tomllib + class TestDocumentation: """Test suite for documentation validation.""" @@ -45,9 +46,9 @@ def test_version_consistency(self): docs_version = release_match.group(1) # Assert all versions match - assert ( - pyproject_version == init_version == docs_version - ), f"Version mismatch: pyproject.toml={pyproject_version}, __init__.py={init_version}, docs/conf.py={docs_version}" + assert pyproject_version == init_version == docs_version, ( + f"Version mismatch: pyproject.toml={pyproject_version}, __init__.py={init_version}, docs/conf.py={docs_version}" + ) def test_repository_urls_consistent(self): """Verify repository URLs are consistent across documentation.""" @@ -76,15 +77,15 @@ def test_repository_urls_consistent(self): for pattern in incorrect_patterns: matches = re.findall(pattern, content, re.IGNORECASE) - assert ( - not matches - ), f"Found incorrect repository URL in {file_path.name}: {matches}" + assert not matches, ( + f"Found incorrect repository URL in {file_path.name}: {matches}" + ) # Verify correct URL is present if any github.com link exists if "github.com" in content: - assert ( - expected_repo in content - ), f"Expected repository URL '{expected_repo}' not found in {file_path.name}" + assert expected_repo in content, ( + f"Expected repository URL '{expected_repo}' not found in {file_path.name}" + ) def test_installation_instructions_present(self): """Verify installation instructions are present in key files.""" @@ -139,7 +140,9 @@ def test_pypi_marked_as_coming_soon(self): "future release", "[!note]", ] - ), f"PyPI installation in {file_path.name} not clearly marked as coming soon (line {i+1})" + ), ( + f"PyPI installation in {file_path.name} not clearly marked as coming soon (line {i + 1})" + ) def test_internal_links_valid(self): """Verify internal documentation links are valid.""" @@ -156,9 +159,9 @@ def test_internal_links_valid(self): # Check if file exists link_path = root / link_url - assert ( - link_path.exists() - ), f"Broken link in README.md: [{link_text}]({link_url}) - file not found" + assert link_path.exists(), ( + f"Broken link in README.md: [{link_text}]({link_url}) - file not found" + ) def test_changelog_has_current_version(self): """Verify CHANGELOG.md includes the current version.""" @@ -172,8 +175,7 @@ def test_changelog_has_current_version(self): # Check CHANGELOG changelog = (root / "CHANGELOG.md").read_text() assert ( - f"[{current_version}]" in changelog - or f"## {current_version}" in changelog + f"[{current_version}]" in changelog or f"## {current_version}" in changelog ), f"Current version {current_version} not found in CHANGELOG.md" def test_readme_shields_badges(self): @@ -238,6 +240,6 @@ def test_no_placeholder_text(self): f"Warning: Found {placeholder} in {file_path.name} - verify if intentional" ) else: - assert ( - placeholder not in content - ), f"Found placeholder text '{placeholder}' in {file_path.name}" + assert placeholder not in content, ( + f"Found placeholder text '{placeholder}' in {file_path.name}" + ) diff --git a/tests/test_drug_era_sql_builder.py b/tests/test_drug_era_sql_builder.py index 865d6618..57fd5c3e 100644 --- a/tests/test_drug_era_sql_builder.py +++ b/tests/test_drug_era_sql_builder.py @@ -5,7 +5,6 @@ ensuring 100% test coverage and functionality matching the Java implementation. """ - import pytest from circe.cohortdefinition.builders.drug_era import DrugEraSqlBuilder @@ -22,49 +21,65 @@ class TestDrugEraSqlBuilder: """Test cases for DrugEraSqlBuilder.""" - + def setup_method(self): """Set up test fixtures.""" self.builder = DrugEraSqlBuilder() - + def test_get_default_columns(self): """Test get_default_columns method.""" default_columns = self.builder.get_default_columns() - expected = {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_ID} + expected = { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_ID, + } assert default_columns == expected - + def test_get_table_column_for_criteria_column(self): """Test get_table_column_for_criteria_column method.""" # Test domain concept - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DOMAIN_CONCEPT + ) assert result == "C.drug_concept_id" - + # Test era occurrences - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.ERA_OCCURRENCES) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.ERA_OCCURRENCES + ) assert result == "C.drug_exposure_count" - + # Test gap days - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.GAP_DAYS) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.GAP_DAYS + ) assert result == "C.gap_days" - + # Test duration - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DURATION + ) assert result == "DATEDIFF(d,C.start_date, C.end_date)" - + # Test start date - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.START_DATE + ) assert result == "C.start_date" - + # Test end date - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.END_DATE + ) assert result == "C.end_date" - + # Test visit id - result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID) + result = self.builder.get_table_column_for_criteria_column( + CriteriaColumn.VISIT_ID + ) assert result == "NULL" - - def test_get_query_template(self): """Test get_query_template method.""" template = self.builder.get_query_template() @@ -75,247 +90,273 @@ def test_get_query_template(self): assert "@whereClause" in template assert "@additionalColumns" in template assert "DRUG_ERA" in template - + def test_embed_codeset_clause_with_codeset_id(self): """Test embed_codeset_clause with codeset_id.""" criteria = DrugEra(codeset_id=123) query = "SELECT * FROM table @codesetClause WHERE 1=1" - + result = self.builder.embed_codeset_clause(query, criteria) - + # Note: Reference uses lowercase 'where' and double space before #Codesets expected_clause = "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 123)" assert "@codesetClause" not in result assert expected_clause in result - + def test_embed_codeset_clause_without_codeset_id(self): """Test embed_codeset_clause without codeset_id.""" criteria = DrugEra(codeset_id=None) query = "SELECT * FROM table @codesetClause WHERE 1=1" - + result = self.builder.embed_codeset_clause(query, criteria) - + assert "@codesetClause" not in result assert "WHERE de.drug_concept_id" not in result - + def test_embed_ordinal_expression_with_first_true(self): """Test embed_ordinal_expression with first=True.""" criteria = DrugEra(first=True) query = "SELECT * @ordinalExpression FROM table" where_clauses = [] - + result = self.builder.embed_ordinal_expression(query, criteria, where_clauses) - + assert "@ordinalExpression" not in result assert "row_number() over" in result assert "C.ordinal = 1" in where_clauses - + def test_embed_ordinal_expression_with_first_false(self): """Test embed_ordinal_expression with first=False.""" criteria = DrugEra(first=False) query = "SELECT * @ordinalExpression FROM table" where_clauses = [] - + result = self.builder.embed_ordinal_expression(query, criteria, where_clauses) - + assert "@ordinalExpression" not in result assert "row_number() over" not in result assert len(where_clauses) == 0 - + def test_embed_ordinal_expression_with_first_none(self): """Test embed_ordinal_expression with first=None.""" criteria = DrugEra(first=None) query = "SELECT * @ordinalExpression FROM table" where_clauses = [] - + result = self.builder.embed_ordinal_expression(query, criteria, where_clauses) - + assert "@ordinalExpression" not in result assert "row_number() over" not in result assert len(where_clauses) == 0 - + def test_resolve_select_clauses_without_date_adjustment(self): """Test resolve_select_clauses without date adjustment.""" criteria = DrugEra() - + result = self.builder.resolve_select_clauses(criteria) - + assert "de.person_id" in result assert "de.drug_era_id" in result assert "de.drug_concept_id" in result assert "de.drug_exposure_count" in result assert "de.gap_days" in result - assert "de.drug_era_start_date as start_date, de.drug_era_end_date as end_date" in result - + assert ( + "de.drug_era_start_date as start_date, de.drug_era_end_date as end_date" + in result + ) + def test_resolve_select_clauses_with_date_adjustment(self): """Test resolve_select_clauses with date adjustment.""" date_adjustment = DateAdjustment( start_offset=30, end_offset=-30, start_with="start_date", - end_with="end_date" + end_with="end_date", ) criteria = DrugEra(date_adjustment=date_adjustment) - + result = self.builder.resolve_select_clauses(criteria) - + assert "de.person_id" in result assert any("DATEADD(day,30" in item for item in result) assert any("DATEADD(day,-30" in item for item in result) - + def test_resolve_join_clauses_without_person_joins(self): """Test resolve_join_clauses without person joins.""" criteria = DrugEra() - + result = self.builder.resolve_join_clauses(criteria) - + assert len(result) == 0 - + def test_resolve_join_clauses_with_age_at_start(self): """Test resolve_join_clauses with age_at_start.""" criteria = DrugEra(age_at_start=NumericRange(op="gte", value=18)) - + result = self.builder.resolve_join_clauses(criteria) - + assert len(result) == 1 - assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result[0] - + assert ( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + in result[0] + ) + def test_resolve_join_clauses_with_age_at_end(self): """Test resolve_join_clauses with age_at_end.""" criteria = DrugEra(age_at_end=NumericRange(op="lte", value=65)) - + result = self.builder.resolve_join_clauses(criteria) - + assert len(result) == 1 - assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result[0] - + assert ( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + in result[0] + ) + def test_resolve_join_clauses_with_gender(self): """Test resolve_join_clauses with gender.""" criteria = DrugEra(gender=[Concept(concept_id=8507)]) - + result = self.builder.resolve_join_clauses(criteria) - + assert len(result) == 1 - assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result[0] - + assert ( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + in result[0] + ) + def test_resolve_join_clauses_with_gender_cs(self): """Test resolve_join_clauses with gender_cs.""" - criteria = DrugEra(gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False)) - + criteria = DrugEra( + gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False) + ) + result = self.builder.resolve_join_clauses(criteria) - + assert len(result) == 1 - assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result[0] - + assert ( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + in result[0] + ) + def test_resolve_join_clauses_with_multiple_conditions(self): """Test resolve_join_clauses with multiple conditions.""" criteria = DrugEra( age_at_start=NumericRange(op="gte", value=18), gender=[Concept(concept_id=8507)], - gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False) + gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False), ) - + result = self.builder.resolve_join_clauses(criteria) - + assert len(result) == 1 # Should only join once - assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result[0] - + assert ( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" + in result[0] + ) + def test_resolve_where_clauses_empty(self): """Test resolve_where_clauses with no conditions.""" criteria = DrugEra() - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 0 - + def test_resolve_where_clauses_with_era_start_date(self): """Test resolve_where_clauses with era_start_date.""" criteria = DrugEra(era_start_date=DateRange(op="gte", value="2020-01-01")) - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 1 assert "C.start_date" in result[0] - + def test_resolve_where_clauses_with_era_end_date(self): """Test resolve_where_clauses with era_end_date.""" criteria = DrugEra(era_end_date=DateRange(op="lte", value="2023-12-31")) - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 1 assert "C.end_date" in result[0] - + def test_resolve_where_clauses_with_occurrence_count(self): """Test resolve_where_clauses with occurrence_count.""" criteria = DrugEra(occurrence_count=NumericRange(op="gte", value=2)) - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 1 assert "C.drug_exposure_count" in result[0] - + def test_resolve_where_clauses_with_era_length(self): """Test resolve_where_clauses with era_length.""" criteria = DrugEra(era_length=NumericRange(op="gte", value=30)) - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 1 assert "DATEDIFF(d,C.start_date, C.end_date)" in result[0] @pytest.mark.xfail( - reason="DrugEra.gapDays erroneously uses eraLength criteria in reference implementation. Python maintains parity.") + reason="DrugEra.gapDays erroneously uses eraLength criteria in reference implementation. Python maintains parity." + ) def test_resolve_where_clauses_with_gap_days(self): """Test resolve_where_clauses with gap_days. - + Note: Replicating Java bug where gap_days filter uses era_length value. """ - criteria = DrugEra(gap_days=NumericRange(op="lte", value=30), era_length=NumericRange(op="lte", value=60)) - + criteria = DrugEra( + gap_days=NumericRange(op="lte", value=30), + era_length=NumericRange(op="lte", value=60), + ) + result = self.builder.resolve_where_clauses(criteria) - - assert len(result) == 2 # gap_days and era_length + + assert len(result) == 2 # gap_days and era_length assert "C.gap_days" in result[1] - assert "30" in result[1] # Should use era_length value but uses era_length + assert "30" in result[1] # Should use era_length value but uses era_length def test_resolve_where_clauses_with_age_at_start(self): """Test resolve_where_clauses with age_at_start.""" criteria = DrugEra(age_at_start=NumericRange(op="gte", value=18)) - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 1 assert "YEAR(C.start_date) - P.year_of_birth" in result[0] - + def test_resolve_where_clauses_with_age_at_end(self): """Test resolve_where_clauses with age_at_end.""" criteria = DrugEra(age_at_end=NumericRange(op="lte", value=65)) - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 1 assert "YEAR(C.end_date) - P.year_of_birth" in result[0] - + def test_resolve_where_clauses_with_gender(self): """Test resolve_where_clauses with gender.""" criteria = DrugEra(gender=[Concept(concept_id=8507), Concept(concept_id=8532)]) - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 1 assert "P.gender_concept_id in (8507,8532)" in result[0] - + def test_resolve_where_clauses_with_gender_cs(self): """Test resolve_where_clauses with gender_cs.""" - criteria = DrugEra(gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False)) - + criteria = DrugEra( + gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False) + ) + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 1 assert "P.gender_concept_id" in result[0] assert "123" in result[0] - + def test_resolve_where_clauses_with_multiple_conditions(self): """Test resolve_where_clauses with multiple conditions.""" criteria = DrugEra( @@ -327,28 +368,34 @@ def test_resolve_where_clauses_with_multiple_conditions(self): age_at_start=NumericRange(op="gte", value=18), age_at_end=NumericRange(op="lte", value=65), gender=[Concept(concept_id=8507)], - gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False) + gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False), ) - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 9 assert any("C.start_date" in clause for clause in result) assert any("C.end_date" in clause for clause in result) assert any("C.drug_exposure_count" in clause for clause in result) - assert any("DATEDIFF(d,C.start_date, C.end_date)" in clause for clause in result) + assert any( + "DATEDIFF(d,C.start_date, C.end_date)" in clause for clause in result + ) assert any("C.gap_days" in clause for clause in result) - assert any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in result) + assert any( + "YEAR(C.start_date) - P.year_of_birth" in clause for clause in result + ) assert any("YEAR(C.end_date) - P.year_of_birth" in clause for clause in result) assert any("P.gender_concept_id in (8507)" in clause for clause in result) - assert any("P.gender_concept_id" in clause and "123" in clause for clause in result) - + assert any( + "P.gender_concept_id" in clause and "123" in clause for clause in result + ) + def test_get_criteria_sql_basic(self): """Test get_criteria_sql with basic criteria.""" criteria = DrugEra() - + result = self.builder.get_criteria_sql(criteria) - + # Note: Template uses lowercase 'select' to match Java output assert "select" in result assert "FROM" in result or "from" in result @@ -359,132 +406,137 @@ def test_get_criteria_sql_basic(self): assert "@joinClause" not in result assert "@whereClause" not in result assert "@additionalColumns" not in result - + def test_get_criteria_sql_with_codeset_id(self): """Test get_criteria_sql with codeset_id.""" criteria = DrugEra(codeset_id=123) - + result = self.builder.get_criteria_sql(criteria) - + # Note: Reference uses lowercase 'where' and double space before #Codesets - assert "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 123)" in result - + assert ( + "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 123)" + in result + ) + def test_get_criteria_sql_with_first_true(self): """Test get_criteria_sql with first=True.""" criteria = DrugEra(first=True) - + result = self.builder.get_criteria_sql(criteria) - + assert "row_number() over" in result assert "C.ordinal = 1" in result - + def test_get_criteria_sql_with_date_adjustment(self): """Test get_criteria_sql with date adjustment.""" date_adjustment = DateAdjustment( start_offset=30, end_offset=-30, start_with="start_date", - end_with="end_date" + end_with="end_date", ) criteria = DrugEra(date_adjustment=date_adjustment) - + result = self.builder.get_criteria_sql(criteria) - + assert "DATEADD(day,30" in result assert "DATEADD(day,-30" in result - + def test_get_criteria_sql_with_person_join(self): """Test get_criteria_sql with person join.""" criteria = DrugEra(age_at_start=NumericRange(op="gte", value=18)) - + result = self.builder.get_criteria_sql(criteria) - - assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result + + assert ( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result + ) assert "YEAR(C.start_date) - P.year_of_birth" in result - + def test_get_criteria_sql_with_gap_days(self): """Test get_criteria_sql with gap_days. - + Note: Replicating Java bug where gap_days filter uses era_length value. """ - criteria = DrugEra(gap_days=NumericRange(op="lte", value=30), era_length=NumericRange(op="lte", value=60)) - + criteria = DrugEra( + gap_days=NumericRange(op="lte", value=30), + era_length=NumericRange(op="lte", value=60), + ) + result = self.builder.get_criteria_sql(criteria) - + assert "C.gap_days" in result - assert "60" in result # Should use era_length value - + assert "60" in result # Should use era_length value + def test_get_criteria_sql_with_options(self): """Test get_criteria_sql_with_options.""" criteria = DrugEra() options = BuilderOptions() options.additional_columns = [CriteriaColumn.DURATION] - + result = self.builder.get_criteria_sql_with_options(criteria, options) - + assert "DATEDIFF(d,C.start_date, C.end_date)" in result - + def test_get_criteria_sql_with_options_none(self): """Test get_criteria_sql_with_options with None options.""" criteria = DrugEra() - + result = self.builder.get_criteria_sql_with_options(criteria, None) - + assert "select" in result.lower() assert "from" in result.lower() assert "drug_era" in result.lower() - + def test_edge_case_empty_gender_list(self): """Test edge case with empty gender list.""" criteria = DrugEra(gender=[]) - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 0 - + def test_edge_case_gender_with_none_concept_id(self): """Test edge case with gender containing None concept_id.""" criteria = DrugEra(gender=[Concept(concept_id=8507)]) - + result = self.builder.resolve_where_clauses(criteria) - + assert len(result) == 1 assert "P.gender_concept_id in (8507)" in result[0] - + def test_edge_case_date_range_none_values(self): """Test edge case with None date range values.""" criteria = DrugEra( era_start_date=DateRange(op="gte", value=None), - era_end_date=DateRange(op="lte", value=None) + era_end_date=DateRange(op="lte", value=None), ) - + result = self.builder.resolve_where_clauses(criteria) - + # Should handle None values gracefully assert isinstance(result, list) - + def test_edge_case_numeric_range_none_values(self): """Test edge case with None numeric range values.""" criteria = DrugEra( occurrence_count=NumericRange(op="gte", value=None), era_length=NumericRange(op="lte", value=None), - gap_days=NumericRange(op="lte", value=None) + gap_days=NumericRange(op="lte", value=None), ) - + result = self.builder.resolve_where_clauses(criteria) - + # Should handle None values gracefully assert isinstance(result, list) - + def test_comprehensive_integration_test(self): """Test comprehensive integration with all features.""" date_adjustment = DateAdjustment( - start_offset=7, - end_offset=-7, - start_with="start_date", - end_with="end_date" + start_offset=7, end_offset=-7, start_with="start_date", end_with="end_date" ) - + criteria = DrugEra( codeset_id=456, first=True, @@ -497,17 +549,22 @@ def test_comprehensive_integration_test(self): age_at_end=NumericRange(op="lte", value=80), gender=[Concept(concept_id=8507)], gender_cs=ConceptSetSelection(codeset_id=789, is_exclusion=False), - date_adjustment=date_adjustment + date_adjustment=date_adjustment, ) - + result = self.builder.get_criteria_sql(criteria) - + # Verify all components are present # Note: Reference uses lowercase 'where' and double space before #Codesets - assert "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 456)" in result + assert ( + "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 456)" + in result + ) assert "row_number() over" in result assert "C.ordinal = 1" in result - assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result + assert ( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result + ) assert "DATEADD(day,7" in result assert "DATEADD(day,-7" in result assert "C.start_date" in result diff --git a/tests/test_drug_exposure_builder.py b/tests/test_drug_exposure_builder.py index 2b7ef6e9..3eb0b1b2 100644 --- a/tests/test_drug_exposure_builder.py +++ b/tests/test_drug_exposure_builder.py @@ -14,29 +14,47 @@ class TestDrugExposureSqlBuilder(unittest.TestCase): - def setUp(self): self.builder = DrugExposureSqlBuilder() - + def test_get_default_columns(self): columns = self.builder.get_default_columns() self.assertIn(CriteriaColumn.START_DATE, columns) self.assertIn(CriteriaColumn.END_DATE, columns) self.assertIn(CriteriaColumn.VISIT_ID, columns) - + def test_get_table_column_for_criteria_column(self): - self.assertEqual(self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), "C.drug_concept_id") - self.assertEqual(self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), "(DATEDIFF(d,C.start_date, C.end_date))") - self.assertEqual(self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE), "C.start_date") - self.assertEqual(self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE), "C.end_date") - self.assertEqual(self.builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID), "C.visit_occurrence_id") - + self.assertEqual( + self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DOMAIN_CONCEPT + ), + "C.drug_concept_id", + ) + self.assertEqual( + self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), + "(DATEDIFF(d,C.start_date, C.end_date))", + ) + self.assertEqual( + self.builder.get_table_column_for_criteria_column( + CriteriaColumn.START_DATE + ), + "C.start_date", + ) + self.assertEqual( + self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE), + "C.end_date", + ) + self.assertEqual( + self.builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID), + "C.visit_occurrence_id", + ) + def test_resolve_select_clauses_basic(self): criteria = DrugExposure(codeset_id=1, first=False) select_cols = self.builder.resolve_select_clauses(criteria) self.assertIn("de.person_id", select_cols) self.assertIn("de.drug_exposure_id", select_cols) - + def test_resolve_select_clauses_with_attributes(self): """Test selection of additional columns based on attributes used.""" criteria = DrugExposure( @@ -45,48 +63,66 @@ def test_resolve_select_clauses_with_attributes(self): drug_type=[Concept(concept_id=1, concept_name="Type")], stop_reason=TextFilter(text="Reason", op="eq"), route_concept=[Concept(concept_id=2, concept_name="Route")], - provider_specialty=[Concept(concept_id=3, concept_name="Spec")] + provider_specialty=[Concept(concept_id=3, concept_name="Spec")], ) select_cols = self.builder.resolve_select_clauses(criteria) self.assertIn("de.drug_type_concept_id", select_cols) self.assertIn("de.stop_reason", select_cols) self.assertIn("de.route_concept_id", select_cols) self.assertIn("de.provider_id", select_cols) - + def test_resolve_select_clauses_date_adjustment(self): criteria = DrugExposure( codeset_id=1, first=False, - date_adjustment=DateAdjustment(start_with="start_date", end_with="start_date", start_offset=1, end_offset=1) + date_adjustment=DateAdjustment( + start_with="start_date", + end_with="start_date", + start_offset=1, + end_offset=1, + ), ) select_cols = self.builder.resolve_select_clauses(criteria) # Verify custom select logic replaces the default one - self.assertTrue(any("DATEADD(day,1, de.drug_exposure_start_date)" in col for col in select_cols)) - + self.assertTrue( + any( + "DATEADD(day,1, de.drug_exposure_start_date)" in col + for col in select_cols + ) + ) + def test_resolve_join_clauses(self): criteria = DrugExposure( codeset_id=1, first=False, age=NumericRange(value=20, op="gt"), visit_type=[Concept(concept_id=1, concept_name="Visit")], - provider_specialty=[Concept(concept_id=2, concept_name="Spec")] + provider_specialty=[Concept(concept_id=2, concept_name="Spec")], ) joins = self.builder.resolve_join_clauses(criteria) - self.assertTrue(any("JOIN @cdm_database_schema.PERSON P" in join for join in joins)) - self.assertTrue(any("JOIN @cdm_database_schema.VISIT_OCCURRENCE V" in join for join in joins)) - self.assertTrue(any("LEFT JOIN @cdm_database_schema.PROVIDER PR" in join for join in joins)) - + self.assertTrue( + any("JOIN @cdm_database_schema.PERSON P" in join for join in joins) + ) + self.assertTrue( + any( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V" in join for join in joins + ) + ) + self.assertTrue( + any("LEFT JOIN @cdm_database_schema.PROVIDER PR" in join for join in joins) + ) + def test_resolve_where_clauses_basic(self): criteria = DrugExposure( codeset_id=1, first=False, occurrence_start_date=DateRange(value="2020-01-01", op="gt"), - occurrence_end_date=DateRange(value="2021-01-01", op="lt") + occurrence_end_date=DateRange(value="2021-01-01", op="lt"), ) where_clauses = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("C.start_date" in clause for clause in where_clauses)) self.assertTrue(any("C.end_date" in clause for clause in where_clauses)) - + def test_resolve_where_clauses_attributes(self): criteria = DrugExposure( codeset_id=1, @@ -100,19 +136,37 @@ def test_resolve_where_clauses_attributes(self): gender=[Concept(concept_id=8507, concept_name="Male")], provider_specialty=[Concept(concept_id=3, concept_name="Spec")], visit_type=[Concept(concept_id=4, concept_name="Visit")], - route_concept=[Concept(concept_id=5, concept_name="Route")] + route_concept=[Concept(concept_id=5, concept_name="Route")], ) where_clauses = self.builder.resolve_where_clauses(criteria) - - self.assertTrue(any("C.drug_type_concept_id not in (1)" in clause for clause in where_clauses)) + + self.assertTrue( + any( + "C.drug_type_concept_id not in (1)" in clause + for clause in where_clauses + ) + ) self.assertTrue(any("C.refills > 1" in clause for clause in where_clauses)) self.assertTrue(any("C.quantity < 10" in clause for clause in where_clauses)) self.assertTrue(any("C.days_supply = 30" in clause for clause in where_clauses)) - self.assertTrue(any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in where_clauses)) - self.assertTrue(any("P.gender_concept_id in (8507)" in clause for clause in where_clauses)) - self.assertTrue(any("PR.specialty_concept_id in (3)" in clause for clause in where_clauses)) - self.assertTrue(any("V.visit_concept_id in (4)" in clause for clause in where_clauses)) - self.assertTrue(any("C.route_concept_id in (5)" in clause for clause in where_clauses)) + self.assertTrue( + any( + "YEAR(C.start_date) - P.year_of_birth" in clause + for clause in where_clauses + ) + ) + self.assertTrue( + any("P.gender_concept_id in (8507)" in clause for clause in where_clauses) + ) + self.assertTrue( + any("PR.specialty_concept_id in (3)" in clause for clause in where_clauses) + ) + self.assertTrue( + any("V.visit_concept_id in (4)" in clause for clause in where_clauses) + ) + self.assertTrue( + any("C.route_concept_id in (5)" in clause for clause in where_clauses) + ) def test_resolve_where_clauses_codesets(self): """Test attributes using codesets.""" @@ -123,12 +177,37 @@ def test_resolve_where_clauses_codesets(self): route_concept_cs=ConceptSetSelection(codeset_id=3, is_exclusion=False), gender_cs=ConceptSetSelection(codeset_id=4, is_exclusion=False), provider_specialty_cs=ConceptSetSelection(codeset_id=5, is_exclusion=False), - visit_type_cs=ConceptSetSelection(codeset_id=6, is_exclusion=False) + visit_type_cs=ConceptSetSelection(codeset_id=6, is_exclusion=False), ) where_clauses = self.builder.resolve_where_clauses(criteria) - - self.assertTrue(any("C.drug_type_concept_id" in clause and "codeset_id = 2" in clause for clause in where_clauses)) - self.assertTrue(any("C.route_concept_id" in clause and "codeset_id = 3" in clause for clause in where_clauses)) - self.assertTrue(any("P.gender_concept_id" in clause and "codeset_id = 4" in clause for clause in where_clauses)) - self.assertTrue(any("PR.specialty_concept_id" in clause and "codeset_id = 5" in clause for clause in where_clauses)) - self.assertTrue(any("V.visit_concept_id" in clause and "codeset_id = 6" in clause for clause in where_clauses)) + + self.assertTrue( + any( + "C.drug_type_concept_id" in clause and "codeset_id = 2" in clause + for clause in where_clauses + ) + ) + self.assertTrue( + any( + "C.route_concept_id" in clause and "codeset_id = 3" in clause + for clause in where_clauses + ) + ) + self.assertTrue( + any( + "P.gender_concept_id" in clause and "codeset_id = 4" in clause + for clause in where_clauses + ) + ) + self.assertTrue( + any( + "PR.specialty_concept_id" in clause and "codeset_id = 5" in clause + for clause in where_clauses + ) + ) + self.assertTrue( + any( + "V.visit_concept_id" in clause and "codeset_id = 6" in clause + for clause in where_clauses + ) + ) diff --git a/tests/test_hashing.py b/tests/test_hashing.py index 933aa6da..31491117 100644 --- a/tests/test_hashing.py +++ b/tests/test_hashing.py @@ -1,4 +1,3 @@ - import unittest from circe.cohortdefinition.cohort import CohortExpression @@ -26,8 +25,10 @@ def test_concept_name_agnosticism(self): c1 = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs1 = ConceptSet(id=1, name="Set 1") item1 = ConceptSetItem( - concept=Concept(concept_id=123, concept_name="Name A", standard_concept="S"), - isExcluded=False + concept=Concept( + concept_id=123, concept_name="Name A", standard_concept="S" + ), + isExcluded=False, ) cs1.expression = ConceptSetExpression(items=[item1]) c1.concept_sets = [cs1] @@ -36,14 +37,20 @@ def test_concept_name_agnosticism(self): c2 = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs2 = ConceptSet(id=1, name="Set 1") item2 = ConceptSetItem( - concept=Concept(concept_id=123, concept_name="Name B", standard_concept="S"), - isExcluded=False + concept=Concept( + concept_id=123, concept_name="Name B", standard_concept="S" + ), + isExcluded=False, ) cs2.expression = ConceptSetExpression(items=[item2]) c2.concept_sets = [cs2] # Should match despite name difference - self.assertEqual(c1.checksum(), c2.checksum(), "Checksum should ignore concept name differences") + self.assertEqual( + c1.checksum(), + c2.checksum(), + "Checksum should ignore concept name differences", + ) def test_metadata_agnosticism(self): """Test that checksums ignore other metadata fields.""" @@ -52,7 +59,7 @@ def test_metadata_agnosticism(self): cs1 = ConceptSet(id=1, name="Set 1") item1 = ConceptSetItem( concept=Concept(concept_id=123, standard_concept="S", vocabulary_id="None"), - isExcluded=False + isExcluded=False, ) cs1.expression = ConceptSetExpression(items=[item1]) c1.concept_sets = [cs1] @@ -61,13 +68,17 @@ def test_metadata_agnosticism(self): c2 = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs2 = ConceptSet(id=1, name="Set 1") item2 = ConceptSetItem( - concept=Concept(concept_id=123, standard_concept="C", vocabulary_id="RxNorm"), - isExcluded=False + concept=Concept( + concept_id=123, standard_concept="C", vocabulary_id="RxNorm" + ), + isExcluded=False, ) cs2.expression = ConceptSetExpression(items=[item2]) c2.concept_sets = [cs2] - self.assertEqual(c1.checksum(), c2.checksum(), "Checksum should ignore metadata differences") + self.assertEqual( + c1.checksum(), c2.checksum(), "Checksum should ignore metadata differences" + ) def test_crucial_flags_sensitivity(self): """Test that checksums CHANGE when functional flags change.""" @@ -85,7 +96,9 @@ def test_crucial_flags_sensitivity(self): cs2.expression = ConceptSetExpression(items=[item2]) c2.concept_sets = [cs2] - self.assertNotEqual(c1.checksum(), c2.checksum(), "Checksum must change if isExcluded changes") + self.assertNotEqual( + c1.checksum(), c2.checksum(), "Checksum must change if isExcluded changes" + ) def test_deduplication(self): """Test that duplicate concept items are handled as the same set.""" @@ -104,48 +117,69 @@ def test_deduplication(self): cs2.expression = ConceptSetExpression(items=[item2a, item2b]) c2.concept_sets = [cs2] - self.assertEqual(c1.checksum(), c2.checksum(), "Checksum should treat duplicate items as single item") + self.assertEqual( + c1.checksum(), + c2.checksum(), + "Checksum should treat duplicate items as single item", + ) def test_sensitivity_to_id(self): """Test sensitivity to Concept ID and Set Name.""" base = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs = ConceptSet(id=1, name="Set 1") - cs.expression = ConceptSetExpression(items=[ConceptSetItem(concept=Concept(concept_id=123))]) + cs.expression = ConceptSetExpression( + items=[ConceptSetItem(concept=Concept(concept_id=123))] + ) base.concept_sets = [cs] base_hash = base.checksum() # Change ID diff_id = base.model_copy(deep=True) diff_id.concept_sets[0].expression.items[0].concept.concept_id = 124 - self.assertNotEqual(base_hash, diff_id.checksum(), "Checksum must change if Concept ID changes") + self.assertNotEqual( + base_hash, diff_id.checksum(), "Checksum must change if Concept ID changes" + ) - # Change Set Name (Wait, user said concept names in concept sets don't matter... - # usually means render, but concept set name might matter if used in render? + # Change Set Name (Wait, user said concept names in concept sets don't matter... + # usually means render, but concept set name might matter if used in render? # Plan said: 'Changing ConceptSet.name (set name) MUST change the hash.' - adhering to plan) diff_name = base.model_copy(deep=True) diff_name.concept_sets[0].name = "Set 2" - self.assertNotEqual(base_hash, diff_name.checksum(), "Checksum must change if ConceptSet Name changes") + self.assertNotEqual( + base_hash, + diff_name.checksum(), + "Checksum must change if ConceptSet Name changes", + ) def test_defaults_handling(self): """Test that default values are handled consistently.""" # C1: Explicit False c1 = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs1 = ConceptSet(id=1, name="Set 1") - item1 = ConceptSetItem(concept=Concept(concept_id=123), isExcluded=False) # Explicit default + item1 = ConceptSetItem( + concept=Concept(concept_id=123), isExcluded=False + ) # Explicit default cs1.expression = ConceptSetExpression(items=[item1]) c1.concept_sets = [cs1] # C2: Implicit Default (None or missing handled by model default) c2 = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs2 = ConceptSet(id=1, name="Set 1") - item2 = ConceptSetItem(concept=Concept(concept_id=123)) # Implicit default isExcluded=False + item2 = ConceptSetItem( + concept=Concept(concept_id=123) + ) # Implicit default isExcluded=False cs2.expression = ConceptSetExpression(items=[item2]) c2.concept_sets = [cs2] - + # Verify defaults match logic self.assertEqual(item1.is_excluded, item2.is_excluded) - self.assertEqual(c1.checksum(), c2.checksum(), "Checksum should be same for explicit vs implicit defaults") + self.assertEqual( + c1.checksum(), + c2.checksum(), + "Checksum should be same for explicit vs implicit defaults", + ) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_java_interoperability.py b/tests/test_java_interoperability.py index 3b522f6b..bdd9a9cf 100644 --- a/tests/test_java_interoperability.py +++ b/tests/test_java_interoperability.py @@ -28,7 +28,7 @@ class TestJavaInteroperability(unittest.TestCase): """Test Java-Python JSON interoperability.""" - + def test_java_json_with_null_gender_concept_id(self): """Test handling JSON from Java with null concept_id values.""" # This is the kind of JSON that Java might generate (using ALL_CAPS for Concept fields) @@ -39,60 +39,51 @@ def test_java_json_with_null_gender_concept_id(self): { "CONCEPT_ID": None, # Java allows null "CONCEPT_NAME": "Unknown", - "CONCEPT_CODE": None + "CONCEPT_CODE": None, } ], - "Age": { - "Value": 18, - "Extent": 65 - } + "Age": {"Value": 18, "Extent": 65}, } - + # Python should be able to parse this JSON criteria = ConditionOccurrence.model_validate(java_json) - + # Should handle None concept_id gracefully self.assertIsNotNone(criteria.gender) self.assertEqual(len(criteria.gender), 1) self.assertIsNone(criteria.gender[0].concept_id) self.assertEqual(criteria.gender[0].concept_name, "Unknown") - + def test_java_json_with_null_gender_array(self): """Test handling JSON from Java with null gender array.""" java_json = { "codesetId": 123, "first": True, "gender": None, # Java allows null arrays - "age": { - "minValue": 18, - "maxValue": 65 - } + "age": {"minValue": 18, "maxValue": 65}, } - + # Python should be able to parse this JSON criteria = ConditionOccurrence.model_validate(java_json) - + # Should handle None gender gracefully self.assertIsNone(criteria.gender) - + def test_java_json_with_empty_gender_array(self): """Test handling JSON from Java with empty gender array.""" java_json = { "codesetId": 123, "first": True, "gender": [], # Java allows empty arrays - "age": { - "minValue": 18, - "maxValue": 65 - } + "age": {"minValue": 18, "maxValue": 65}, } - + # Python should be able to parse this JSON criteria = ConditionOccurrence.model_validate(java_json) - + # Should handle empty array gracefully self.assertEqual(criteria.gender, []) - + def test_python_to_java_json_roundtrip(self): """Test that Python can generate JSON that Java can consume.""" # Create Python criteria @@ -100,16 +91,16 @@ def test_python_to_java_json_roundtrip(self): codeset_id=123, first=True, gender=[Concept(concept_id=8507, concept_name="Male")], - age=NumericRange(value=18, extent=65) + age=NumericRange(value=18, extent=65), ) - + # Convert to JSON (note: polymorphic wrapper is added) json_data = criteria.model_dump(by_alias=True, exclude_none=True) - + # Check polymorphic wrapper self.assertIn("ConditionOccurrence", json_data) inner_data = json_data["ConditionOccurrence"] - + # Should contain the expected structure self.assertEqual(inner_data["CodesetId"], 123) self.assertTrue(inner_data["First"]) # PascalCase with alias @@ -117,11 +108,11 @@ def test_python_to_java_json_roundtrip(self): self.assertEqual(len(inner_data["gender"]), 1) self.assertEqual(inner_data["gender"][0]["CONCEPT_ID"], 8507) self.assertEqual(inner_data["gender"][0]["CONCEPT_NAME"], "Male") - + # Note: Polymorphic criteria can't be directly deserialized from wrapped format # They're meant to be deserialized as part of a CohortExpression structure # where the parent handles the polymorphic unwrapping - + def test_java_json_with_mixed_valid_invalid_concepts(self): """Test handling JSON with mix of valid and invalid concept IDs.""" java_json = { @@ -129,22 +120,22 @@ def test_java_json_with_mixed_valid_invalid_concepts(self): "gender": [ # Note: lowercase for simple fields { "CONCEPT_ID": 8507, # Valid concept ID (Male) - "CONCEPT_NAME": "Male" + "CONCEPT_NAME": "Male", }, { "CONCEPT_ID": None, # Invalid concept ID - "CONCEPT_NAME": "Unknown" + "CONCEPT_NAME": "Unknown", }, { "CONCEPT_ID": 8532, # Valid concept ID (Female) - "CONCEPT_NAME": "Female" - } - ] + "CONCEPT_NAME": "Female", + }, + ], } - + # Python should be able to parse this JSON criteria = ConditionOccurrence.model_validate(java_json) - + # Should handle mixed valid/invalid concept IDs self.assertIsNotNone(criteria.gender) self.assertEqual(len(criteria.gender), 3) @@ -155,7 +146,7 @@ def test_java_json_with_mixed_valid_invalid_concepts(self): class TestJavaExportCompatibility(unittest.TestCase): """Test JSON export compatibility with Java format.""" - + def test_field_names_use_pascal_case(self): """Test that exported JSON uses PascalCase field names like Java.""" # Create a cohort expression @@ -171,91 +162,87 @@ def test_field_names_use_pascal_case(self): concept=Concept(concept_id=123, concept_name="Test"), is_excluded=False, include_descendants=True, - include_mapped=False + include_mapped=False, ) ], is_excluded=False, include_mapped=False, - include_descendants=True - ) + include_descendants=True, + ), ) - ] + ], ) - + # Export to JSON json_data = cohort.model_dump(by_alias=True, exclude_none=True) - + # Check PascalCase field names (Java format) self.assertIn("ConceptSets", json_data) self.assertNotIn("conceptSets", json_data) self.assertNotIn("concept_sets", json_data) - + concept_set = json_data["ConceptSets"][0] self.assertIn("id", concept_set) # Java uses lowercase self.assertIn("name", concept_set) # Java uses lowercase self.assertIn("expression", concept_set) # Java uses lowercase - + expression = concept_set["expression"] self.assertIn("isExcluded", expression) # Java uses camelCase self.assertIn("includeMapped", expression) # Java uses camelCase self.assertIn("includeDescendants", expression) # Java uses camelCase self.assertIn("items", expression) # Java uses lowercase - + item = expression["items"][0] self.assertIn("concept", item) # Java uses lowercase self.assertIn("isExcluded", item) # Java uses camelCase self.assertIn("includeDescendants", item) # Java uses camelCase self.assertIn("includeMapped", item) # Java uses camelCase - + concept = item["concept"] self.assertIn("CONCEPT_ID", concept) # Java uses ALL_CAPS self.assertIn("CONCEPT_NAME", concept) # Java uses ALL_CAPS - + def test_criteria_polymorphic_wrapper(self): """Test that criteria objects are wrapped in type names.""" # Create a condition occurrence condition = ConditionOccurrence( - codeset_id=6, - first=False, - condition_type_exclude=False + codeset_id=6, first=False, condition_type_exclude=False ) - + # Export to JSON json_data = condition.model_dump(by_alias=True, exclude_none=True) - + # Check polymorphic wrapper self.assertIn("ConditionOccurrence", json_data) inner_data = json_data["ConditionOccurrence"] self.assertIn("CodesetId", inner_data) self.assertIn("ConditionTypeExclude", inner_data) - + def test_primary_criteria_uses_pascal_case(self): """Test PrimaryCriteria exports with PascalCase field names.""" - + primary = PrimaryCriteria( criteria_list=[ ConditionOccurrence( - codeset_id=1, - first=True, - condition_type_exclude=False + codeset_id=1, first=True, condition_type_exclude=False ) ], observation_window=ObservationFilter(prior_days=365, post_days=1), - primary_limit=ResultLimit(type="All") + primary_limit=ResultLimit(type="All"), ) - + json_data = primary.model_dump(by_alias=True, exclude_none=True) - + # Check PascalCase field names self.assertIn("CriteriaList", json_data) self.assertIn("ObservationWindow", json_data) self.assertIn("PrimaryCriteriaLimit", json_data) - + # Check ObservationWindow fields obs_window = json_data["ObservationWindow"] self.assertIn("PriorDays", obs_window) self.assertIn("PostDays", obs_window) - + def test_round_trip_with_java_format(self): """Test Python → JSON → Python round trip maintains data.""" # Create a cohort @@ -269,42 +256,42 @@ def test_round_trip_with_java_format(self): items=[], is_excluded=False, include_mapped=False, - include_descendants=True - ) + include_descendants=True, + ), ) - ] + ], ) - + # Export to JSON string (Java format) json_str = original.model_dump_json(by_alias=True, exclude_none=True) - + # Import back from JSON restored = CohortExpression.model_validate_json(json_str) - + # Check data integrity self.assertEqual(restored.title, "Test Cohort") self.assertEqual(len(restored.concept_sets), 1) self.assertEqual(restored.concept_sets[0].id, 1) self.assertEqual(restored.concept_sets[0].name, "Test") - + def test_compare_with_java_json_file(self): """Test that Python export matches Java JSON structure.""" # Load a Java JSON file test_dir = Path(__file__).parent java_json_path = test_dir / "cohorts" / "22159.json" - + if not java_json_path.exists(): self.skipTest(f"Java JSON file not found: {java_json_path}") - + with open(java_json_path) as f: java_data = json.load(f) - + # Parse with Python cohort = CohortExpression.model_validate(java_data) - + # Export back to JSON python_data = cohort.model_dump(by_alias=True, exclude_none=True) - + # Check key structure matches if "ConceptSets" in java_data: self.assertIn("ConceptSets", python_data) @@ -319,5 +306,5 @@ def test_compare_with_java_json_file(self): self.assertIn("ObservationWindow", python_pc) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_kitchen_sink_cohort.py b/tests/test_kitchen_sink_cohort.py index fc68ecdc..273c68c7 100644 --- a/tests/test_kitchen_sink_cohort.py +++ b/tests/test_kitchen_sink_cohort.py @@ -66,14 +66,14 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: concept_code="201826", domain_id="Condition", vocabulary_id="SNOMED", - concept_class_id="Clinical Finding" + concept_class_id="Clinical Finding", ), is_excluded=False, include_descendants=True, - include_mapped=False + include_mapped=False, ) ] - ) + ), ), ConceptSet( id=2, @@ -81,14 +81,16 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: expression=ConceptSetExpression( items=[ ConceptSetItem( - concept=Concept(concept_id=1112807, concept_name="Metformin"), + concept=Concept( + concept_id=1112807, concept_name="Metformin" + ), is_excluded=False, include_descendants=True, - include_mapped=True + include_mapped=True, ) ] - ) - ) + ), + ), ] # 2. Criteria Definitions (using non-default values where possible) @@ -99,15 +101,21 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: first=True, occurrence_start_date=DateRange(value="2010-01-01", op="gt"), occurrence_end_date=DateRange(value="2020-01-01", op="lt"), - condition_type=[Concept(concept_id=32020, concept_name="EHR encounter diagnosis")], + condition_type=[ + Concept(concept_id=32020, concept_name="EHR encounter diagnosis") + ], condition_type_exclude=True, stop_reason=TextFilter(text="recovered", op="contains"), condition_source_concept=123, age=NumericRange(value=18, op="gt"), gender=[Concept(concept_id=8507, concept_name="Male")], - provider_specialty=[Concept(concept_id=38004456, concept_name="Endocrinology")], + provider_specialty=[ + Concept(concept_id=38004456, concept_name="Endocrinology") + ], visit_type=[Concept(concept_id=9201, concept_name="Inpatient Visit")], - condition_status=[Concept(concept_id=4230359, concept_name="Final diagnosis")] + condition_status=[ + Concept(concept_id=4230359, concept_name="Final diagnosis") + ], ) # Drug Exposure @@ -116,7 +124,9 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: first=False, occurrence_start_date=DateRange(value="2010-01-01", op="gt"), occurrence_end_date=DateRange(value="2020-01-01", op="lt"), - drug_type=[Concept(concept_id=38000177, concept_name="Prescription written")], + drug_type=[ + Concept(concept_id=38000177, concept_name="Prescription written") + ], drug_type_exclude=False, stop_reason=TextFilter(text="adversereaction", op="endswith"), refills=NumericRange(value=1, op="gte"), @@ -128,39 +138,48 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: lot_number=TextFilter(text="LOT123", op="eq"), age=NumericRange(value=50, op="lt"), gender=[Concept(concept_id=8532, concept_name="Female")], - provider_specialty=[Concept(concept_id=38004456, concept_name="Endocrinology")], - visit_type=[Concept(concept_id=9202, concept_name="Outpatient Visit")] + provider_specialty=[ + Concept(concept_id=38004456, concept_name="Endocrinology") + ], + visit_type=[Concept(concept_id=9202, concept_name="Outpatient Visit")], ) - # Procedure Occurrence procedure = ProcedureOccurrence( codeset_id=1, first=True, occurrence_start_date=DateRange(value="2015-01-01", op="gt"), - procedure_type=[Concept(concept_id=38000275, concept_name="EHR order list entry")], + procedure_type=[ + Concept(concept_id=38000275, concept_name="EHR order list entry") + ], procedure_type_exclude=True, modifier=[Concept(concept_id=123, concept_name="Modifier")], quantity=NumericRange(value=1, op="eq"), procedure_source_concept=456, - age=NumericRange(value=20, op="gt") + age=NumericRange(value=20, op="gt"), ) # Visit Occurrence visit = VisitOccurrence( - codeset_id=0, # No codeset + codeset_id=0, # No codeset first=True, occurrence_start_date=DateRange(value="2010-01-01", op="gt"), occurrence_end_date=DateRange(value="2020-01-01", op="lt"), - visit_type=[Concept(concept_id=44818518, concept_name="Visit derived from EHR")], + visit_type=[ + Concept(concept_id=44818518, concept_name="Visit derived from EHR") + ], visit_type_exclude=False, visit_source_concept=789, visit_length=NumericRange(value=1, op="gt"), age=NumericRange(value=18, op="gt"), gender=[Concept(concept_id=8507, concept_name="Male")], - provider_specialty=[Concept(concept_id=38003845, concept_name="General Practice")], - place_of_service=[Concept(concept_id=8717, concept_name="Inpatient Hospital")], - place_of_service_location=12345 + provider_specialty=[ + Concept(concept_id=38003845, concept_name="General Practice") + ], + place_of_service=[ + Concept(concept_id=8717, concept_name="Inpatient Hospital") + ], + place_of_service_location=12345, ) # Measurement @@ -180,7 +199,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: range_high_ratio=NumericRange(value=1.5, op="lt"), abnormal=True, measurement_source_concept=111, - age=NumericRange(value=30, op="gt") + age=NumericRange(value=30, op="gt"), ) # Observation @@ -188,7 +207,11 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: codeset_id=1, first=False, occurrence_start_date=DateRange(value="2010-01-01", op="gt"), - observation_type=[Concept(concept_id=38000280, concept_name="Observation recorded from EHR")], + observation_type=[ + Concept( + concept_id=38000280, concept_name="Observation recorded from EHR" + ) + ], observation_type_exclude=False, value_as_number=NumericRange(value=10, op="gt"), value_as_string=TextFilter(text="positive", op="eq"), @@ -196,7 +219,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: qualifier=[Concept(concept_id=45882570, concept_name="Left")], unit=[Concept(concept_id=8510, concept_name="unit")], observation_source_concept=222, - age=NumericRange(value=40, op="gt") + age=NumericRange(value=40, op="gt"), ) # Device Exposure @@ -209,46 +232,50 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: unique_device_id=TextFilter(text="UDI123", op="eq"), quantity=NumericRange(value=1, op="eq"), device_source_concept=333, - age=NumericRange(value=50, op="gt") + age=NumericRange(value=50, op="gt"), ) - + # Specimen specimen = Specimen( - codeset_id=1, - first=True, - occurrence_start_date=DateRange(value="2010-01-01", op="gt"), - specimen_type=[Concept(concept_id=38000281, concept_name="Specimen from EHR")], - specimen_type_exclude=True, - unit=[Concept(concept_id=8576, concept_name="milligram")], - anatomic_site=[Concept(concept_id=4044352, concept_name="Arm")], - disease_status=[Concept(concept_id=4066212, concept_name="Healthy")], - specimen_source_concept=555 + codeset_id=1, + first=True, + occurrence_start_date=DateRange(value="2010-01-01", op="gt"), + specimen_type=[ + Concept(concept_id=38000281, concept_name="Specimen from EHR") + ], + specimen_type_exclude=True, + unit=[Concept(concept_id=8576, concept_name="milligram")], + anatomic_site=[Concept(concept_id=4044352, concept_name="Arm")], + disease_status=[Concept(concept_id=4066212, concept_name="Healthy")], + specimen_source_concept=555, ) emographic = DemographicCriteria( age=NumericRange(value=18, op="gt"), gender=[Concept(concept_id=8507, concept_name="Male")], race=[Concept(concept_id=8527, concept_name="White")], - ethnicity=[Concept(concept_id=38003564, concept_name="Not Hispanic or Latino")], + ethnicity=[ + Concept(concept_id=38003564, concept_name="Not Hispanic or Latino") + ], occurrence_start_date=DateRange(value="2010-01-01", op="gt"), - occurrence_end_date=DateRange(value="2020-01-01", op="lt") + occurrence_end_date=DateRange(value="2020-01-01", op="lt"), ) # Groups with nested criteria group1 = CriteriaGroup( - type="ALL", - criteria_list=[ - CorelatedCriteria( - criteria=ConditionOccurrence(codeset_id=1), - occurrence=Occurrence(type=2, count=1) - ), - CorelatedCriteria( - criteria=DrugExposure(codeset_id=2, first=True), - occurrence=Occurrence(type=2, count=1) - ) - ], - demographic_criteria_list=[emographic], - groups=[] + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=1), + occurrence=Occurrence(type=2, count=1), + ), + CorelatedCriteria( + criteria=DrugExposure(codeset_id=2, first=True), + occurrence=Occurrence(type=2, count=1), + ), + ], + demographic_criteria_list=[emographic], + groups=[], ) # 3. Primary Criteria @@ -259,76 +286,73 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: # criteria_list: List[Criteria] # observation_window: ObservationFilter # primary_limit: ResultLimit - None, 'none', None + None, + "none", + None, ) - + # Re-import PrimaryCriteria properly from circe.cohortdefinition.criteria import PrimaryCriteria # Complex Primary Criteria # Note: We need to use "Criteria" objects here, which wrap the domain criteria # and add window/adjustment info. - + # Wrapped Criteria 1: Condition with Window - crit1 = ConditionOccurrence( - codeset_id=1, - age=NumericRange(value=18, op="gt") - ) - + crit1 = ConditionOccurrence(codeset_id=1, age=NumericRange(value=18, op="gt")) + # Construction of Primary Criteria primary = PrimaryCriteria( criteria_list=[crit1], observation_window=ObservationFilter(prior_days=365, post_days=0), - primary_limit=ResultLimit(type="First") + primary_limit=ResultLimit(type="First"), ) # 4. Inclusion Rules - + # Rule 1: Must have Metformin rule1_crit = DrugExposure( - codeset_id=2, - first=True, - age=NumericRange(value=18, op="gt") + codeset_id=2, first=True, age=NumericRange(value=18, op="gt") ) # Corelated Criteria (Windowed) # We need to wrap the drug exposure in a CorelatedCriteria/WindowedCriteria structure usually # But InclusionRule takes a CriteriaGroup - + # Let's create a CorelatedCriteria wrapper for the drug exposure - # The internal structure is a bit complex. + # The internal structure is a bit complex. # CriteriaGroup -> criteria_list (which are CorelatedCriteria) - + corelated_crit = CorelatedCriteria( criteria=rule1_crit, start_window=Window( start=WindowBound(coeff=-1, days=30), end=WindowBound(coeff=1, days=30), use_index_end=False, - use_event_end=False + use_event_end=False, ), occurrence=Occurrence( - type=2, # AT_LEAST - count=1 - ) + type=2, # AT_LEAST + count=1, + ), ) - + rule1_group = CriteriaGroup( type="ALL", criteria_list=[corelated_crit], demographic_criteria_list=[], - groups=[group1], # Nest group1 here to use it - count=1 # Match at least 1 + groups=[group1], # Nest group1 here to use it + count=1, # Match at least 1 ) - + rule1 = InclusionRule( name="Metformin User", description="Patient must be on Metformin", - expression=rule1_group + expression=rule1_group, ) # 4b. Additional Criteria Types (New) - + # Visit Detail visit_detail = VisitDetail( codeset_id=1, @@ -339,21 +363,25 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: visit_detail_source_concept=123, visit_detail_length=NumericRange(value=1, op="gt"), age=NumericRange(value=18, op="gt"), - place_of_service_location=999 + place_of_service_location=999, ) - + # Observation Period obs_period = ObservationPeriod( first=True, period_start_date=DateRange(value="2010-01-01", op="gt"), period_end_date=DateRange(value="2020-01-01", op="lt"), - period_type=[Concept(concept_id=38000280, concept_name="Observation recorded from EHR")], + period_type=[ + Concept( + concept_id=38000280, concept_name="Observation recorded from EHR" + ) + ], period_length=NumericRange(value=365, op="gt"), age_at_start=NumericRange(value=18, op="gt"), age_at_end=NumericRange(value=90, op="lt"), - user_defined_period=Period(start_date="2010-01-01", end_date="2020-12-31") + user_defined_period=Period(start_date="2010-01-01", end_date="2020-12-31"), ) - + # Payer Plan Period payer_plan = PayerPlanPeriod( first=True, @@ -371,9 +399,9 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: plan_source_concept=200, sponsor_source_concept=300, stop_reason_source_concept=400, - user_defined_period=Period(start_date="2010-01-01", end_date="2015-01-01") + user_defined_period=Period(start_date="2010-01-01", end_date="2015-01-01"), ) - + # Condition Era condition_era = ConditionEra( codeset_id=1, @@ -384,9 +412,9 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: era_length=NumericRange(value=30, op="gt"), age_at_start=NumericRange(value=18, op="gt"), age_at_end=NumericRange(value=90, op="lt"), - gender=[Concept(concept_id=8532, concept_name="Female")] + gender=[Concept(concept_id=8532, concept_name="Female")], ) - + # Drug Era drug_era = DrugEra( codeset_id=2, @@ -398,9 +426,9 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: era_length=NumericRange(value=30, op="gt"), age_at_start=NumericRange(value=18, op="gt"), age_at_end=NumericRange(value=90, op="lt"), - gender=[Concept(concept_id=8507, concept_name="Male")] + gender=[Concept(concept_id=8507, concept_name="Male")], ) - + # Dose Era dose_era = DoseEra( codeset_id=2, @@ -412,16 +440,16 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: era_length=NumericRange(value=30, op="gt"), age_at_start=NumericRange(value=18, op="gt"), age_at_end=NumericRange(value=90, op="lt"), - gender=[Concept(concept_id=8507, concept_name="Male")] + gender=[Concept(concept_id=8507, concept_name="Male")], ) - + # 5. Censoring Criteria # Add new criteria to censoring list to verify they serialize correctly censoring = [ Death(first=True), visit_detail, obs_period, - payer_plan, + payer_plan, condition_era, drug_era, dose_era, @@ -433,10 +461,9 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: measurement, observation, device, - specimen + specimen, ] - # 6. Cohort Expression cohort = CohortExpression( title="Kitchen Sink Cohort", @@ -446,40 +473,33 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: inclusion_rules=[rule1], censoring_criteria=censoring, collapse_settings=CollapseSettings( - era_pad=0, - collapse_type=CollapseType.ERA - ), - censor_window=Period( - start_date="2010-01-01", - end_date="2025-01-01" + era_pad=0, collapse_type=CollapseType.ERA ), + censor_window=Period(start_date="2010-01-01", end_date="2025-01-01"), # End Strategies - Using CustomEraStrategy this time end_strategy=CustomEraStrategy( - drug_codeset_id=2, - gap_days=30, - offset=7, - days_supply_override=0 - ) + drug_codeset_id=2, gap_days=30, offset=7, days_supply_override=0 + ), ) - + return cohort def test_kitchen_sink_serialization(self): """Test that the kitchen sink cohort can be serialized and deserialized.""" cohort = self.create_kitchen_sink_cohort() - + # Serialize json_str = cohort.model_dump_json(indent=2) - + # Basic validation self.assertIn("Kitchen Sink Cohort", json_str) self.assertIn("Type 2 diabetes mellitus", json_str) self.assertIn("Metformin", json_str) self.assertIn("CustomEra", json_str) - + # Deserialize cohort_restored = CohortExpression.model_validate_json(json_str) - + # Check parity self.assertEqual(cohort.title, cohort_restored.title) self.assertEqual(len(cohort.concept_sets), 2) @@ -487,5 +507,6 @@ def test_kitchen_sink_serialization(self): self.assertIsInstance(cohort.end_strategy, CustomEraStrategy) self.assertEqual(cohort.end_strategy.offset, 7) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_markdown_render_coverage.py b/tests/test_markdown_render_coverage.py index 7c1b6958..2d5c26d2 100644 --- a/tests/test_markdown_render_coverage.py +++ b/tests/test_markdown_render_coverage.py @@ -24,7 +24,9 @@ def test_render_cohort_expression_string_input(self): def test_render_cohort_expression_with_concept_sets(self): # Line 90: cohort expression has concept sets cohort_json = '{"title": "With CS", "conceptSets": [{"id": 1, "name": "CS1", "expression": {"items": []}}], "primaryCriteria": {"observationWindow": {"priorDays": 0, "postDays": 0}, "primaryEvents": []}}' - output = self.renderer.render_cohort_expression(cohort_json, include_concept_sets=True) + output = self.renderer.render_cohort_expression( + cohort_json, include_concept_sets=True + ) self.assertIn("Concept Sets", output) self.assertIn("CS1", output) @@ -50,7 +52,7 @@ def test_render_concept_set_list_empty(self): # Line 125: empty concept sets output = self.renderer.render_concept_set_list([]) self.assertIn("No concept sets specified", output) - + output_none = self.renderer.render_concept_set_list(None) self.assertIn("No concept sets specified", output_none) @@ -65,15 +67,15 @@ def test_codeset_name_not_found(self): # Setup renderer with some concept sets cs = ConceptSet(id=1, name="Existing CodeSet", expression={"items": []}) renderer = MarkdownRender(concept_sets=[cs]) - + # Test ID that doesn't exist name = renderer._codeset_name(999, default_name="Default") self.assertEqual(name, "Default") - + # Line 175: ID found name_found = renderer._codeset_name(1, default_name="Default") self.assertEqual(name_found, "'Existing CodeSet'") - + # Line 170: ID is None name_none = renderer._codeset_name(None, default_name="Default") self.assertEqual(name_none, "Default") @@ -81,11 +83,11 @@ def test_codeset_name_not_found(self): def test_format_date_invalid(self): # Lines 195-197: Invalid date handling # Case 1: Wrong formatting but length 10 string -> triggers ValueError inside strptime -> returns "_invalid date_" - self.assertEqual(self.renderer._format_date("2020/01/01"), "_invalid date_") - + self.assertEqual(self.renderer._format_date("2020/01/01"), "_invalid date_") + # Case 2: String that is not length 10 -> returns input as is self.assertEqual(self.renderer._format_date("2020/01"), "2020/01") - + # Case 3: Non-string -> returns input as is (line 195) self.assertEqual(self.renderer._format_date(12345), 12345) @@ -99,12 +101,13 @@ def test_format_date_valid(self): def test_format_number_edge_cases(self): # Line 209: None input self.assertEqual(self.renderer._format_number(None), "") - + # Line 213: Float that is integer self.assertEqual(self.renderer._format_number(1000.0), "1,000") - + # Normal float self.assertEqual(self.renderer._format_number(1000.5), "1,000.5") -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_print_friendly_parity.py b/tests/test_print_friendly_parity.py index aa92aa50..43a70a8f 100644 --- a/tests/test_print_friendly_parity.py +++ b/tests/test_print_friendly_parity.py @@ -9,16 +9,18 @@ def get_resource_as_string(filename): # Depending on where pytest is run from, this path might need adjustment. # Assuming running from root of repo. - path = os.path.join(os.path.dirname(__file__), 'markdown_resources', filename) + path = os.path.join(os.path.dirname(__file__), "markdown_resources", filename) with open(path) as f: return f.read() + def normalize_whitespace(text): """Normalize whitespace by collapsing multiple spaces/newlines into a single space and stripping.""" if not text: return "" return " ".join(text.split()) + class TestPrintFriendlyParity(unittest.TestCase): def setUp(self): self.pf = MarkdownRender() @@ -27,7 +29,7 @@ def assertInNormalized(self, subst, markdown, *args, **kwargs): norm_subst = normalize_whitespace(subst) norm_markdown = normalize_whitespace(markdown) if not args and not kwargs: - msg = f"Normalized substring not found:\nExpected: {norm_subst}\nIn context: ...{norm_markdown[max(0, norm_markdown.find(norm_subst)-50):norm_markdown.find(norm_subst)+150]}..." + msg = f"Normalized substring not found:\nExpected: {norm_subst}\nIn context: ...{norm_markdown[max(0, norm_markdown.find(norm_subst) - 50) : norm_markdown.find(norm_subst) + 150]}..." self.assertIn(norm_subst, norm_markdown, msg) else: self.assertIn(norm_subst, norm_markdown, *args, **kwargs) @@ -36,11 +38,11 @@ def test_condition_era_test(self): json_str = get_resource_as_string("conditionEra.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. condition era of 'Concept Set 1' for the first time in the person's history, who are male < 30 years old at era start and <= 40 years old at era end; starting before January 1, 2010 and ending before December 31, 2014; era length is > 15 days; containing between 1 and 5 occurrences; having no condition eras of 'Concept Set 2', starting between 90 days before and 30 days after 'Concept Set 1' start date and ending between 7 days after and 90 days after 'Concept Set 1' start date.", "#### 1. Inclusion Rule 1", - "Entry events having at least 1 condition era of 'Concept Set 3' for the first time in the person's history, starting between 90 days before and 0 days before cohort entry start date." + "Entry events having at least 1 condition era of 'Concept Set 3' for the first time in the person's history, starting between 90 days before and 0 days before cohort entry start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -49,13 +51,13 @@ def test_condition_occurrence_test(self): json_str = get_resource_as_string("conditionOccurrence.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ - "1. condition occurrence of 'Concept Set 1' (including 'Concept Set 2' source concepts) for the first time in the person's history, who are male or female, >= 18 years old; starting before January 1, 2010 and ending after June 1, 2016; a condition type that is not: \"admission note\" or \"ancillary report\"; with a stop reason containing \"some stop reason\"; a provider specialty that is: \"rheumatology\"; a visit occurrence that is: \"emergency room visit\" or \"inpatient visit\"; with any of the following criteria:", + '1. condition occurrence of \'Concept Set 1\' (including \'Concept Set 2\' source concepts) for the first time in the person\'s history, who are male or female, >= 18 years old; starting before January 1, 2010 and ending after June 1, 2016; a condition type that is not: "admission note" or "ancillary report"; with a stop reason containing "some stop reason"; a provider specialty that is: "rheumatology"; a visit occurrence that is: "emergency room visit" or "inpatient visit"; with any of the following criteria:', "1. with the following event criteria: who are male >= 18 years old.", "2. having at least 1 condition occurrence of 'Concept Set 1', starting 1 days after 'Concept Set 1' start date; who are female < 30 years old.", "#### 1. Inclusion Rule 1", - "Entry events having at least 1 condition occurrence of 'Concept Set 3' for the first time in the person's history, starting between all days before and 1 days after cohort entry start date." + "Entry events having at least 1 condition occurrence of 'Concept Set 3' for the first time in the person's history, starting between all days before and 1 days after cohort entry start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -64,14 +66,14 @@ def test_death_test(self): json_str = get_resource_as_string("death.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. death of 'Concept Set 1' (including 'Concept Set 2' source concepts),", "who are female < 18 years old;", "starting on or after January 1, 2010", "having no death of 'Concept Set 3', starting anytime prior to 'Concept Set 1' start date.", "#### 1. Inclusion Rule 1", - "Entry events having at least 1 death of 'Concept Set 3', who are > 12 years old." + "Entry events having at least 1 death of 'Concept Set 3', who are > 12 years old.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -80,18 +82,18 @@ def test_device_exposure_test(self): json_str = get_resource_as_string("deviceExposure.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. device exposures of 'Concept Set 1' (including 'Concept Set 2' source concepts),", "starting before January 1, 2010 and ending after December 31, 2010;", - "a device type that is: \"admission note\" or \"ancillary report\";", + 'a device type that is: "admission note" or "ancillary report";', "quantity < 8;", - "a provider specialty that is: \"rheumatology\" or \"rheumatology\";", - "a visit occurrence that is: \"emergency room visit\" or \"inpatient visit\";", + 'a provider specialty that is: "rheumatology" or "rheumatology";', + 'a visit occurrence that is: "emergency room visit" or "inpatient visit";', "having at least 1 device exposure of 'Concept Set 2' for the first time in the person's history, starting between all days before and 1 days after 'Concept Set 1' start date; who are female or male, between 12 and 18 years old.", "Restrict entry events to having at least 1 device exposure of 'Concept Set 3' for the first time in the person's history, starting anytime prior to cohort entry start date.", "#### 1. Inclusion Rule 1", - "Entry events having at least 1 device exposure of 'Concept Set 3' for the first time in the person's history, starting between 30 days before and 30 days after cohort entry start date." + "Entry events having at least 1 device exposure of 'Concept Set 3' for the first time in the person's history, starting between 30 days before and 30 days after cohort entry start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -100,12 +102,12 @@ def test_dose_era_test(self): json_str = get_resource_as_string("doseEra.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. dose era of 'Concept Set 1' for the first time in the person's history,", "who are female or male, > 18 years old at era start and < 30 years old at era end;", "starting before January 1, 2010 and ending after January 1, 2011;", - "unit is: \"per gram\" or \"per deciliter\";", + 'unit is: "per gram" or "per deciliter";', "with era length > 10 days;", "with dose value between 15 and 45;", "with any of the following criteria:", @@ -117,7 +119,7 @@ def test_dose_era_test(self): "#### 1. Inclusion Rule 1", "Entry events with all of the following criteria:", "1. having at least 1 dose era of 'Concept Set 3' for the first time in the person's history, starting anytime on or before cohort entry start date.", - "2. having no dose eras of 'Concept Set 2', starting anytime prior to cohort entry start date; who are > 18 years old." + "2. having no dose eras of 'Concept Set 2', starting anytime prior to cohort entry start date; who are > 18 years old.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -126,7 +128,7 @@ def test_drug_era_test(self): json_str = get_resource_as_string("drugEra.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. drug era of 'Concept Set 1' for the first time in the person's history,", "who are female or male, >= 18 years old at era start and <= 64 years old at era end;", @@ -137,7 +139,7 @@ def test_drug_era_test(self): "1. having at least 1 drug era of 'Concept Set 2' for the first time in the person's history, starting anytime prior to 'Concept Set 1' start date.", "2. having at least 1 drug era of 'Concept Set 3', starting on or after January 1, 2010.", "#### 1. Inclusion Rule 1", - "Entry events having at least 1 drug era of 'Concept Set 3' for the first time in the person's history, starting between 0 days before and all days after cohort entry start date." + "Entry events having at least 1 drug era of 'Concept Set 3' for the first time in the person's history, starting between 0 days before and all days after cohort entry start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -146,25 +148,25 @@ def test_drug_exposure_test(self): json_str = get_resource_as_string("drugExposure.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. drug exposure of 'Concept Set 1' (including 'Concept Set 2' source concepts) for the first time in the person's history,", "who are female or male, > 18 years old;", "starting after January 1, 2010 and ending before January 1, 2016;", - "a drug type that is: \"admission note\" or \"ancillary report\";", + 'a drug type that is: "admission note" or "ancillary report";', "with refills = 2;", "with quantity >= 15;", "with days supply < 30 days;", "with effective drug dose < 15;", - "dose unit: \"per 24 hours\";", - "with route: \"nasal\" or \"oral\";", - "lot number containing \"12345\";", - "with a stop reason starting with \"some reason\";", - "a provider specialty that is: \"general practice\" or \"urology\";", - "a visit occurrence that is: \"emergency room visit\" or \"inpatient visit\";", + 'dose unit: "per 24 hours";', + 'with route: "nasal" or "oral";', + 'lot number containing "12345";', + 'with a stop reason starting with "some reason";', + 'a provider specialty that is: "general practice" or "urology";', + 'a visit occurrence that is: "emergency room visit" or "inpatient visit";', "with all of the following criteria:", "1. having at least 1 drug exposure of 'Concept Set 2', starting anytime prior to 'Concept Set 1' start date.", - "2. having at least 1 drug exposure of 'Concept Set 3', starting between 14 days before and 0 days before 'Concept Set 1' start date." + "2. having at least 1 drug exposure of 'Concept Set 3', starting between 14 days before and 0 days before 'Concept Set 1' start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -173,26 +175,26 @@ def test_measurement_test(self): json_str = get_resource_as_string("measurement.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. measurement of 'Concept Set 1' (including 'Concept Set 2' source concepts) for the first time in the person's history,", "who are female or male, > 18 years old;", "starting on or after January 1, 2016;", - "a measurement type that is: \"admission note\" or \"ancillary report\";", - "with operator: \"=\" or \"<=\";", + 'a measurement type that is: "admission note" or "ancillary report";', + 'with operator: "=" or "<=";', "numeric value between 5 and 10;", - "unit: \"per billion\";", - "with value as concept: \"good\" or \"significant change\";", + 'unit: "per billion";', + 'with value as concept: "good" or "significant change";', "low range > 10;", "high range > 20;", "low range-to-value ratio > 1.2", "high range-to-value ratio > 0.9;", "with an abormal result (measurement value falls outside the low and high range)", - "a provider specialty that is: \"gastroenterology\" or \"urology\";", - "a visit occurrence that is: \"emergency room visit\" or \"inpatient visit\";", + 'a provider specialty that is: "gastroenterology" or "urology";', + 'a visit occurrence that is: "emergency room visit" or "inpatient visit";', "with all of the following criteria:", "1. having at least 1 measurement of 'Concept Set 2' for the first time in the person's history, starting anytime on or before 'Concept Set 1' start date.", - "2. having at least 1 measurement of 'Concept Set 3', starting between 0 days before and all days after 'Concept Set 1' start date." + "2. having at least 1 measurement of 'Concept Set 3', starting between 0 days before and all days after 'Concept Set 1' start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -201,20 +203,20 @@ def test_observation_test(self): json_str = get_resource_as_string("observation.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. observation of 'Concept Set 1' for the first time in the person's history,", "who are female or male, > 18 years old;", "starting on or after October 1, 2015;", - "an observation type that is: \"condition procedure\" or \"discharge summary\";", + 'an observation type that is: "condition procedure" or "discharge summary";', "numeric value < 30;", - "unit: \"per hundred\";", - "with value as concept: \"positive\" or \"good\";", - "with value as string ending with \"obs value suffix\";", - "with qualifier: \"total charge\";", - "a provider specialty that is: \"health profession\" or \"psychologist\";", - "a visit occurrence that is: \"emergency room visit\" or \"inpatient visit\";", - "having no observation of 'Concept Set 2' for the first time in the person's history, starting anytime prior to 'Concept Set 1' start date." + 'unit: "per hundred";', + 'with value as concept: "positive" or "good";', + 'with value as string ending with "obs value suffix";', + 'with qualifier: "total charge";', + 'a provider specialty that is: "health profession" or "psychologist";', + 'a visit occurrence that is: "emergency room visit" or "inpatient visit";', + "having no observation of 'Concept Set 2' for the first time in the person's history, starting anytime prior to 'Concept Set 1' start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -223,15 +225,15 @@ def test_observation_period_test(self): json_str = get_resource_as_string("observationPeriod_1.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. observation period (first obsrvation period in person's history),", "who are > 18 years old at era start and < 32 years old at era end;", "starting before January 1, 2014 and ending after December 31, 2014;", "a user defiend start date of January 1, 2014 and end date of December 31, 2014;", - "period type is: \"observation recorded from ehr\" or \"problem list from ehr\";", + 'period type is: "observation recorded from ehr" or "problem list from ehr";', "with a length > 400 days;", - "having exactly 1 observation period, starting 1 days after observation period end date." + "having exactly 1 observation period, starting 1 days after observation period end date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -240,17 +242,17 @@ def test_procedure_occurrence_test(self): json_str = get_resource_as_string("procedureOccurrence.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. procedure occurrence of 'Concept Set 1' (including 'Concept Set 2' source concepts) for the first time in the person's history,", "who are female or male, > 18 years old;", "starting on or Before January 1, 2014;", - "a procedure type that is: \"admission note\" or \"ancillary report\";", - "with modifier: \"lateral meniscus structure\" or \"structure of base of lung\";", + 'a procedure type that is: "admission note" or "ancillary report";', + 'with modifier: "lateral meniscus structure" or "structure of base of lung";', "with quantity < 10;", - "a provider specialty that is: \"gastroenterology\" or \"urology\";", - "a visit occurrence that is: \"emergency room visit\" or \"inpatient visit\";", - "having at least 1 procedure occurrence of 'Concept Set 3', starting anytime prior to 'Concept Set 1' start date." + 'a provider specialty that is: "gastroenterology" or "urology";', + 'a visit occurrence that is: "emergency room visit" or "inpatient visit";', + "having at least 1 procedure occurrence of 'Concept Set 3', starting anytime prior to 'Concept Set 1' start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -259,18 +261,18 @@ def test_specimen_test(self): json_str = get_resource_as_string("specimen.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. specimen of 'Concept Set 1' for the first time in the person's history,", "who are female or male, > 18 years old;", "starting before January 1, 2010;", - "a specimen type that is: \"admission note\" or \"ancillary report\";", + 'a specimen type that is: "admission note" or "ancillary report";', "with quantity < 10;", - "with unit: \"per 24 hours\";", - "with anatomic site: \"lateral meniscus structure\" or \"structure of base of lung\"", - "with disease status: \"abnormal\";", - "with source ID starting with \"source Id Prefix\";", - "having at least 1 specimen of 'Concept Set 2', starting anytime prior to 'Concept Set 1' start date." + 'with unit: "per 24 hours";', + 'with anatomic site: "lateral meniscus structure" or "structure of base of lung"', + 'with disease status: "abnormal";', + 'with source ID starting with "source Id Prefix";', + "having at least 1 specimen of 'Concept Set 2', starting anytime prior to 'Concept Set 1' start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -279,15 +281,15 @@ def test_visit_test(self): json_str = get_resource_as_string("visit.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. visit occurrence of 'Concept Set 1' (including 'Concept Set 2' source concepts) for the first time in the person's history,", "who are female or male, between 18 and 64 years old;", "starting before January 1, 2010 and ending after January 7, 2010;", - "a visit type that is: \"admission note\" or \"ancillary report\";", - "a provider specialty that is: \"general practice\" or \"general surgery\";", + 'a visit type that is: "admission note" or "ancillary report";', + 'a provider specialty that is: "general practice" or "general surgery";', "with length > 12 days", - "having at least 1 visit occurrence of 'Concept Set 2', starting anytime on or before 'Concept Set 1' start date." + "having at least 1 visit occurrence of 'Concept Set 2', starting anytime on or before 'Concept Set 1' start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -296,7 +298,7 @@ def test_visit_detail_test(self): json_str = get_resource_as_string("visitDetail.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. visit detail of 'Concept Set 1' (including 'Concept Set 2' source concepts) for the first time in the person's history,", "who are gender in 'Concept Set 2' between 18 and 64 years old;", @@ -304,7 +306,7 @@ def test_visit_detail_test(self): "a visit detail type that is in 'Concept Set 2' concept set;", "a provider specialty that is in 'Concept Set 3' concept set;", "with length > 12 days", - "having at least 1 visit detail of 'Concept Set 3', starting anytime on or before 'Concept Set 1' start date." + "having at least 1 visit detail of 'Concept Set 3', starting anytime on or before 'Concept Set 1' start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -313,17 +315,20 @@ def test_date_offset_test(self): json_str = get_resource_as_string("dateOffset.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - - self.assertInNormalized("The cohort end date will be offset from index event's end date plus 7 days.", markdown) + + self.assertInNormalized( + "The cohort end date will be offset from index event's end date plus 7 days.", + markdown, + ) def test_custom_era_exit_test(self): json_str = get_resource_as_string("customEraExit.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "The cohort end date will be based on a continuous exposure to 'Concept Set 1':", - "allowing 14 days between exposures, adding 1 day after exposure ends, and forcing drug exposure days supply to: 7 days." + "allowing 14 days between exposures, adding 1 day after exposure ends, and forcing drug exposure days supply to: 7 days.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -332,7 +337,7 @@ def test_concept_set_simple_test(self): json_str = get_resource_as_string("conceptSet_simple.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_concept_set_list(expression.concept_sets) - + expected_substrings = [ "### Empty Concept Set", "There are no concept set items in this concept set.", @@ -340,7 +345,7 @@ def test_concept_set_simple_test(self): "|Concept ID|Concept Name|Code|Vocabulary|Excluded|Descendants|Mapped", "|140168|Psoriasis|9014002|SNOMED|NO|YES|NO|", "### Only Excluded", - "|140168|Psoriasis|9014002|SNOMED|YES|NO|NO|" + "|140168|Psoriasis|9014002|SNOMED|YES|NO|NO|", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -349,17 +354,17 @@ def test_any_condition_test(self): json_str = get_resource_as_string("anyCondition.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + self.assertInNormalized("1. condition occurrences of any condition.", markdown) def test_censor_criteria_test(self): json_str = get_resource_as_string("censorCriteria.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "The person exits the cohort when encountering any of the following events:", - "death of any form" + "death of any form", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -368,42 +373,56 @@ def test_no_censor_criteria_test(self): json_str = get_resource_as_string("noCensorCriteria.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - - self.assertNotIn("The person exits the cohort when encountering any of the following events:", markdown) + + self.assertNotIn( + "The person exits the cohort when encountering any of the following events:", + markdown, + ) def test_continuous_observation_none_test(self): json_str = get_resource_as_string("continuousObservation_none.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - - self.assertInNormalized("People enter the cohort when observing any of the following:", markdown) + + self.assertInNormalized( + "People enter the cohort when observing any of the following:", markdown + ) def test_continuous_observation_prior_test(self): json_str = get_resource_as_string("continuousObservation_prior.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - - self.assertInNormalized("People with continuous observation of 30 days before event enter the cohort when observing any of the following:", markdown) + + self.assertInNormalized( + "People with continuous observation of 30 days before event enter the cohort when observing any of the following:", + markdown, + ) def test_continuous_observation_post_test(self): json_str = get_resource_as_string("continuousObservation_post.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - - self.assertInNormalized("People with continuous observation of 30 days after event enter the cohort when observing any of the following:", markdown) + + self.assertInNormalized( + "People with continuous observation of 30 days after event enter the cohort when observing any of the following:", + markdown, + ) def test_continuous_observation_prior_post_test(self): json_str = get_resource_as_string("continuousObservation_priorpost.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - - self.assertInNormalized("People with continuous observation of 30 days before and 30 days after event enter the cohort when observing any of the following:", markdown) + + self.assertInNormalized( + "People with continuous observation of 30 days before and 30 days after event enter the cohort when observing any of the following:", + markdown, + ) def test_count_criteria_test(self): json_str = get_resource_as_string("countCriteria.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. condition occurrences of 'Empty Concept Set', starting on or after January 1, 2010.", "2. condition occurrences of 'Empty Concept Set', who are between 18 and 64 years old; having at least 1 condition occurrence of any condition, starting between 30 days before and 30 days after 'Empty Concept Set' start date.", @@ -428,7 +447,7 @@ def test_count_criteria_test(self): "2. having no condition occurrences of 'Empty Concept Set', starting between 0 days before and all days after cohort entry start date.", "3. with any of the following criteria:", "1. having at least 1 condition occurrence of 'Empty Concept Set', starting between 30 days before and 30 days after cohort entry start date.", - "2. having no condition occurrences of 'Empty Concept Set', starting anytime up to 31 days before cohort entry start date." + "2. having no condition occurrences of 'Empty Concept Set', starting anytime up to 31 days before cohort entry start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -437,14 +456,14 @@ def test_count_distinct_criteria_test(self): json_str = get_resource_as_string("countDistinctCriteria.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. condition occurrences of 'Empty Concept Set', starting on or after January 1, 2010.", "2. condition occurrences of 'Empty Concept Set', who are between 18 and 64 years old; having at least 1 distinct standard concepts from condition occurrence of any condition, starting between 30 days before and 30 days after 'Empty Concept Set' start date.", "3. condition occurrences of 'Empty Concept Set'; with all of the following criteria:", "1. having at least 1 distinct standard concepts from condition occurrence of 'Empty Concept Set', starting anytime on or before 'Empty Concept Set' start date; who are > 18 years old.", "2. having at least 1 distinct start dates from condition occurrence of 'Empty Concept Set', starting anytime on or before 'Empty Concept Set' start date; who are > 18 years old.", - "3. having at least 1 distinct visits from condition occurrence of any condition, starting between 0 days before and all days after 'Empty Concept Set' start date; who are < 64 years old." + "3. having at least 1 distinct visits from condition occurrence of any condition, starting between 0 days before and all days after 'Empty Concept Set' start date; who are < 64 years old.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -453,7 +472,7 @@ def test_date_adjust_test(self): json_str = get_resource_as_string("dateAdjust.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - + expected_substrings = [ "1. condition eras of 'Concept Set 1', starting 10 days after and ending 20 days after the event start date.", "2. condition occurrences of 'Concept Set 1', starting 30 days after and ending 40 days after the event end date.", @@ -466,7 +485,7 @@ def test_date_adjust_test(self): "10. procedure occurrences of 'Concept Set 1', starting 10 days after and ending 20 days after the event start date.", "11. specimens of 'Concept Set 1', starting 10 days after and ending 20 days after the event start date.", "12. visit occurrences of 'Concept Set 1', starting 10 days after and ending 20 days after the event start date.", - "13. visit details of 'Concept Set 1', starting 10 days after and ending 20 days after the event start date." + "13. visit details of 'Concept Set 1', starting 10 days after and ending 20 days after the event start date.", ] for subst in expected_substrings: self.assertInNormalized(subst, markdown) @@ -475,8 +494,12 @@ def test_empty_concept_list_test(self): json_str = get_resource_as_string("emptyConceptList.json") expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - - self.assertInNormalized("1. condition occurrences of 'Concept Set 1', a provider specialty that is: [none specified]; a visit occurrence that is: [none specified].", markdown) -if __name__ == '__main__': + self.assertInNormalized( + "1. condition occurrences of 'Concept Set 1', a provider specialty that is: [none specified]; a visit occurrence that is: [none specified].", + markdown, + ) + + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_query_builders.py b/tests/test_query_builders.py index cc4e53f4..5ce6eaee 100644 --- a/tests/test_query_builders.py +++ b/tests/test_query_builders.py @@ -50,11 +50,11 @@ def test_get_concept_ids(self): concepts = [ Concept(concept_id=12345, concept_name="Test Concept 1"), Concept(concept_id=67890, concept_name="Test Concept 2"), - Concept(concept_id=11111, concept_name="Test Concept 3") + Concept(concept_id=11111, concept_name="Test Concept 3"), ] - + concept_ids = self.builder.get_concept_ids(concepts) - + expected_ids = [12345, 67890, 11111] self.assertEqual(concept_ids, expected_ids) @@ -62,11 +62,11 @@ def test_get_concept_ids_with_none_values(self): """Test get_concept_ids with None concept_id values.""" concepts = [ Concept(concept_id=12345, concept_name="Test Concept 1"), - Concept(concept_id=11111, concept_name="Test Concept 3") + Concept(concept_id=11111, concept_name="Test Concept 3"), ] - + concept_ids = self.builder.get_concept_ids(concepts) - + expected_ids = [12345, 11111] # None values should be filtered out self.assertEqual(concept_ids, expected_ids) @@ -79,12 +79,12 @@ def test_build_concept_set_sub_query_with_concepts_only(self): """Test build_concept_set_sub_query with concepts only.""" concepts = [ Concept(concept_id=12345, concept_name="Test Concept 1"), - Concept(concept_id=67890, concept_name="Test Concept 2") + Concept(concept_id=67890, concept_name="Test Concept 2"), ] descendant_concepts = [] - + query = self.builder.build_concept_set_sub_query(concepts, descendant_concepts) - + # Note: Template uses lowercase to match Java output self.assertIn("select concept_id", query) self.assertIn("@vocabulary_database_schema.CONCEPT", query) @@ -96,11 +96,11 @@ def test_build_concept_set_sub_query_with_descendants_only(self): concepts = [] descendant_concepts = [ Concept(concept_id=12345, concept_name="Test Concept 1"), - Concept(concept_id=67890, concept_name="Test Concept 2") + Concept(concept_id=67890, concept_name="Test Concept 2"), ] - + query = self.builder.build_concept_set_sub_query(concepts, descendant_concepts) - + # Check for Java-compatible SQL with invalid_reason filtering # Note: Template uses lowercase to match Java output self.assertIn("select c.concept_id", query) @@ -114,9 +114,9 @@ def test_build_concept_set_sub_query_with_both(self): """Test build_concept_set_sub_query with both concepts and descendants.""" concepts = [Concept(concept_id=12345, concept_name="Test Concept 1")] descendant_concepts = [Concept(concept_id=67890, concept_name="Test Concept 2")] - + query = self.builder.build_concept_set_sub_query(concepts, descendant_concepts) - + self.assertIn("UNION", query) self.assertIn("12345", query) self.assertIn("67890", query) @@ -130,9 +130,11 @@ def test_build_concept_set_mapped_query(self): """Test build_concept_set_mapped_query method.""" mapped_concepts = [Concept(concept_id=12345, concept_name="Test Concept")] mapped_descendant_concepts = [] - - query = self.builder.build_concept_set_mapped_query(mapped_concepts, mapped_descendant_concepts) - + + query = self.builder.build_concept_set_mapped_query( + mapped_concepts, mapped_descendant_concepts + ) + self.assertIn("select distinct cr.concept_id_1 as concept_id", query) self.assertIn("@vocabulary_database_schema.concept_relationship", query) self.assertIn("Maps to", query) @@ -140,8 +142,11 @@ def test_build_concept_set_mapped_query(self): def test_build_concept_set_query_empty_concepts(self): """Test build_concept_set_query with empty concepts.""" query = self.builder.build_concept_set_query([], [], [], []) - - self.assertIn("select concept_id from @vocabulary_database_schema.CONCEPT where 0=1", query) + + self.assertIn( + "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1", + query, + ) def test_build_concept_set_query_with_mapped_concepts(self): """Test build_concept_set_query with mapped concepts.""" @@ -149,9 +154,11 @@ def test_build_concept_set_query_with_mapped_concepts(self): descendant_concepts = [] mapped_concepts = [Concept(concept_id=67890, concept_name="Mapped Concept")] mapped_descendant_concepts = [] - - query = self.builder.build_concept_set_query(concepts, descendant_concepts, mapped_concepts, mapped_descendant_concepts) - + + query = self.builder.build_concept_set_query( + concepts, descendant_concepts, mapped_concepts, mapped_descendant_concepts + ) + self.assertIn("UNION", query) self.assertIn("12345", query) self.assertIn("67890", query) @@ -164,22 +171,22 @@ def test_build_expression_query_included_concepts_only(self): concept=Concept(concept_id=12345, concept_name="Test Concept 1"), is_excluded=False, include_descendants=False, - include_mapped=False + include_mapped=False, ), ConceptSetItem( concept=Concept(concept_id=67890, concept_name="Test Concept 2"), is_excluded=False, include_descendants=True, - include_mapped=False - ) + include_mapped=False, + ), ], is_excluded=False, include_mapped=False, - include_descendants=False + include_descendants=False, ) - + query = self.builder.build_expression_query(expression) - + self.assertIn("select distinct I.concept_id", query) self.assertIn("12345", query) self.assertIn("67890", query) @@ -192,22 +199,22 @@ def test_build_expression_query_with_excluded_concepts(self): concept=Concept(concept_id=12345, concept_name="Included Concept"), is_excluded=False, include_descendants=False, - include_mapped=False + include_mapped=False, ), ConceptSetItem( concept=Concept(concept_id=67890, concept_name="Excluded Concept"), is_excluded=True, include_descendants=False, - include_mapped=False - ) + include_mapped=False, + ), ], is_excluded=False, include_mapped=False, - include_descendants=False + include_descendants=False, ) - + query = self.builder.build_expression_query(expression) - + # Now uses LEFT JOIN pattern instead of EXCEPT self.assertIn("LEFT JOIN", query) self.assertIn("WHERE E.concept_id is null", query) @@ -222,16 +229,16 @@ def test_build_expression_query_with_mapped_concepts(self): concept=Concept(concept_id=12345, concept_name="Test Concept"), is_excluded=False, include_descendants=False, - include_mapped=True + include_mapped=True, ) ], is_excluded=False, include_mapped=False, - include_descendants=False + include_descendants=False, ) - + query = self.builder.build_expression_query(expression) - + self.assertIn("UNION", query) self.assertIn("@vocabulary_database_schema.concept_relationship", query) @@ -243,28 +250,28 @@ def test_build_expression_query_complex_scenario(self): concept=Concept(concept_id=12345, concept_name="Included Concept"), is_excluded=False, include_descendants=True, - include_mapped=True + include_mapped=True, ), ConceptSetItem( concept=Concept(concept_id=67890, concept_name="Excluded Concept"), is_excluded=True, include_descendants=False, - include_mapped=False + include_mapped=False, ), ConceptSetItem( concept=Concept(concept_id=11111, concept_name="Another Included"), is_excluded=False, include_descendants=False, - include_mapped=False - ) + include_mapped=False, + ), ], is_excluded=False, include_mapped=False, - include_descendants=False + include_descendants=False, ) - + query = self.builder.build_expression_query(expression) - + self.assertIn("select distinct I.concept_id", query) # Now uses LEFT JOIN pattern instead of EXCEPT self.assertIn("LEFT JOIN", query) @@ -277,14 +284,11 @@ def test_build_expression_query_complex_scenario(self): def test_build_expression_query_empty_items(self): """Test build_expression_query with empty items.""" expression = ConceptSetExpression( - items=[], - is_excluded=False, - include_mapped=False, - include_descendants=False + items=[], is_excluded=False, include_mapped=False, include_descendants=False ) - + query = self.builder.build_expression_query(expression) - + self.assertIn("select distinct I.concept_id", query) self.assertNotIn("EXCEPT", query) @@ -303,18 +307,20 @@ def test_cohort_expression_query_builder_initialization(self): def test_build_expression_query_options_from_json(self): """Test BuildExpressionQueryOptions.from_json method.""" - json_str = json.dumps({ - "cohortIdFieldName": "test_cohort_id", - "cohortId": 123, - "cdmSchema": "cdm_schema", - "targetTable": "target_table", - "resultSchema": "result_schema", - "vocabularySchema": "vocabulary_schema", - "generateStats": True - }) - + json_str = json.dumps( + { + "cohortIdFieldName": "test_cohort_id", + "cohortId": 123, + "cdmSchema": "cdm_schema", + "targetTable": "target_table", + "resultSchema": "result_schema", + "vocabularySchema": "vocabulary_schema", + "generateStats": True, + } + ) + options = BuildExpressionQueryOptions.from_json(json_str) - + self.assertEqual(options.cohort_id_field_name, "test_cohort_id") self.assertEqual(options.cohort_id, 123) self.assertEqual(options.cdm_schema, "cdm_schema") @@ -344,40 +350,46 @@ def test_get_additional_columns(self): print("DEBUG: Inside test_get_additional_columns") columns = [CriteriaColumn.START_DATE, CriteriaColumn.END_DATE] result = self.builder._get_additional_columns(columns, "A.") - + self.assertIn("A.start_date", result) self.assertIn("A.end_date", result) def test_get_codeset_query_empty(self): """Test get_codeset_query with empty concept sets.""" query = self.builder.get_codeset_query([]) - + self.assertIn("CREATE TABLE #Codesets", query) self.assertNotIn("INSERT INTO #Codesets", query) def test_get_codeset_query_with_concept_sets(self): """Test get_codeset_query with concept sets.""" concept_sets = [ - type('ConceptSet', (), { - 'id': 12345, - 'expression': ConceptSetExpression( - items=[ - ConceptSetItem( - concept=Concept(concept_id=11111, concept_name="Test Concept"), - is_excluded=False, - include_descendants=False, - include_mapped=False - ) - ], - is_excluded=False, - include_mapped=False, - include_descendants=False - ) - })() + type( + "ConceptSet", + (), + { + "id": 12345, + "expression": ConceptSetExpression( + items=[ + ConceptSetItem( + concept=Concept( + concept_id=11111, concept_name="Test Concept" + ), + is_excluded=False, + include_descendants=False, + include_mapped=False, + ) + ], + is_excluded=False, + include_mapped=False, + include_descendants=False, + ), + }, + )() ] - + query = self.builder.get_codeset_query(concept_sets) - + self.assertIn("CREATE TABLE #Codesets", query) self.assertIn("INSERT INTO #Codesets", query) self.assertIn("12345", query) @@ -388,17 +400,15 @@ def test_get_primary_events_query(self): primary_criteria = PrimaryCriteria( criteria_list=[ ConditionOccurrence( - first=True, - condition_type_exclude=False, - codeset_id=12345 + first=True, condition_type_exclude=False, codeset_id=12345 ) ], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="ALL") + primary_limit=ResultLimit(type="ALL"), ) - + query = self.builder.get_primary_events_query(primary_criteria) - + self.assertIn("select E.person_id, E.start_date, E.end_date", query) # Note: Template now uses lowercase to match Java output self.assertIn("@cdm_database_schema.observation_period", query) @@ -407,7 +417,7 @@ def test_get_primary_events_query(self): def test_get_final_cohort_query_no_censor_window(self): """Test get_final_cohort_query without censor window.""" query = self.builder.get_final_cohort_query(None) - + self.assertIn("select @target_cohort_id as @cohort_id_field_name", query) self.assertIn("FROM #final_cohort CO", query) self.assertNotIn("WHERE", query) @@ -415,9 +425,9 @@ def test_get_final_cohort_query_no_censor_window(self): def test_get_final_cohort_query_with_censor_window(self): """Test get_final_cohort_query with censor window.""" censor_window = Period(start_date="2020-01-01", end_date="2023-01-01") - + query = self.builder.get_final_cohort_query(censor_window) - + self.assertIn("select @target_cohort_id as @cohort_id_field_name", query) self.assertIn("FROM #final_cohort CO", query) self.assertIn("WHERE", query) @@ -429,36 +439,36 @@ def test_get_inclusion_rule_table_sql_empty(self): primary_criteria=PrimaryCriteria( criteria_list=[], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="ALL") + primary_limit=ResultLimit(type="ALL"), ), - inclusion_rules=[] + inclusion_rules=[], ) - + query = self.builder.get_inclusion_rule_table_sql(expression) - + self.assertIn("CREATE TABLE #inclusion_rules", query) self.assertNotIn("UNION ALL", query) def test_get_inclusion_rule_table_sql_with_rules(self): """Test get_inclusion_rule_table_sql with inclusion rules.""" from circe.cohortdefinition.criteria import InclusionRule - + expression = CohortExpression( primary_criteria=PrimaryCriteria( criteria_list=[], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="ALL") + primary_limit=ResultLimit(type="ALL"), ), inclusion_rules=[ InclusionRule( name="Test Rule", - expression=CriteriaGroup(type="ALL", criteria_list=[]) + expression=CriteriaGroup(type="ALL", criteria_list=[]), ) - ] + ], ) - + query = self.builder.get_inclusion_rule_table_sql(expression) - + self.assertIn("into #inclusion_rules", query) # Single rule should NOT have UNION ALL (matches Java/R behavior) self.assertIn("SELECT CAST(0 as int) as rule_sequence", query) @@ -467,27 +477,27 @@ def test_get_inclusion_rule_table_sql_with_rules(self): def test_get_inclusion_rule_table_sql_with_multiple_rules(self): """Test get_inclusion_rule_table_sql with multiple inclusion rules.""" from circe.cohortdefinition.criteria import InclusionRule - + expression = CohortExpression( primary_criteria=PrimaryCriteria( criteria_list=[], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="ALL") + primary_limit=ResultLimit(type="ALL"), ), inclusion_rules=[ InclusionRule( name="Test Rule 1", - expression=CriteriaGroup(type="ALL", criteria_list=[]) + expression=CriteriaGroup(type="ALL", criteria_list=[]), ), InclusionRule( name="Test Rule 2", - expression=CriteriaGroup(type="ALL", criteria_list=[]) - ) - ] + expression=CriteriaGroup(type="ALL", criteria_list=[]), + ), + ], ) - + query = self.builder.get_inclusion_rule_table_sql(expression) - + self.assertIn("into #inclusion_rules", query) # Multiple rules SHOULD have UNION ALL self.assertIn("UNION ALL", query) @@ -497,7 +507,7 @@ def test_get_inclusion_rule_table_sql_with_multiple_rules(self): def test_get_inclusion_analysis_query(self): """Test get_inclusion_analysis_query method.""" query = self.builder.get_inclusion_analysis_query("#test_events", 1) - + self.assertIn("mode_id = 1", query) self.assertIn("#test_events", query) @@ -506,11 +516,11 @@ def test_get_demographic_criteria_query(self): criteria = DemographicCriteria( age=NumericRange(op="gte", value=18, extent=65), gender=[Concept(concept_id=8507, concept_name="Male")], - gender_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False) + gender_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), ) - + query = self.builder.get_demographic_criteria_query(criteria, "#test_events") - + self.assertIn("SELECT @indexId as index_id", query) self.assertIn("@cdm_database_schema.PERSON", query) self.assertIn("8507", query) @@ -520,20 +530,20 @@ def test_get_windowed_criteria_query(self): """Test get_windowed_criteria_query method.""" # This would need a proper WindowedCriteria object # For now, test the method exists - self.assertTrue(hasattr(self.builder, 'get_windowed_criteria_query')) + self.assertTrue(hasattr(self.builder, "get_windowed_criteria_query")) def test_get_corelated_criteria_query(self): """Test get_corelated_criteria_query method.""" # This would need a proper CorelatedCriteria object # For now, test the method exists - self.assertTrue(hasattr(self.builder, 'get_corelated_criteria_query')) + self.assertTrue(hasattr(self.builder, "get_corelated_criteria_query")) def test_get_criteria_group_query_empty(self): """Test get_criteria_group_query with empty group.""" group = CriteriaGroup(type="ALL", criteria_list=[]) - + query = self.builder.get_criteria_group_query(group, "#test_events") - + self.assertIn("-- Begin Criteria Group", query) self.assertIn("#test_events", query) @@ -544,26 +554,24 @@ def test_get_criteria_group_query_with_criteria(self): criteria_list=[ CorelatedCriteria( criteria=ConditionOccurrence( - first=True, - condition_type_exclude=False, - codeset_id=12345 + first=True, condition_type_exclude=False, codeset_id=12345 ), - occurrence=Occurrence(type=1, count=1, is_distinct=False) + occurrence=Occurrence(type=1, count=1, is_distinct=False), ) - ] + ], ) - + query = self.builder.get_criteria_group_query(group, "#test_events") - + self.assertIn("select @indexId as index_id", query) self.assertIn("#test_events", query) def test_get_strategy_sql_date_offset_strategy(self): """Test get_strategy_sql for DateOffsetStrategy.""" strategy = DateOffsetStrategy(offset=30, date_field="StartDate") - + query = self.builder.get_strategy_sql(strategy, "#test_events") - + self.assertIn("INTO #strategy_ends", query) self.assertIn("DATEADD(day,30,start_date)", query) self.assertIn("#test_events", query) @@ -571,14 +579,11 @@ def test_get_strategy_sql_date_offset_strategy(self): def test_get_strategy_sql_custom_era_strategy(self): """Test get_strategy_sql for CustomEraStrategy.""" strategy = CustomEraStrategy( - drug_codeset_id=12345, - gap_days=30, - offset=0, - days_supply_override=None + drug_codeset_id=12345, gap_days=30, offset=0, days_supply_override=None ) - + query = self.builder.get_strategy_sql(strategy, "#test_events") - + self.assertIn("INTO #strategy_ends", query) self.assertIn("12345", query) self.assertIn("30", query) @@ -587,21 +592,18 @@ def test_get_strategy_sql_custom_era_strategy(self): def test_get_strategy_sql_custom_era_strategy_no_codeset_id(self): """Test get_strategy_sql for CustomEraStrategy with no codeset ID.""" strategy = CustomEraStrategy( - drug_codeset_id=None, - gap_days=30, - offset=0, - days_supply_override=None + drug_codeset_id=None, gap_days=30, offset=0, days_supply_override=None ) - + with self.assertRaises(RuntimeError): self.builder.get_strategy_sql(strategy, "#test_events") def test_get_criteria_sql_delegation(self): """Test that get_criteria_sql methods delegate to appropriate builders.""" criteria = Death(first=True, death_type_exclude=False, codeset_id=12345) - + query = self.builder.get_criteria_sql(criteria) - + self.assertIn("SELECT", query) self.assertIn("FROM @cdm_database_schema.DEATH", query) self.assertIn("12345", query) @@ -612,29 +614,26 @@ def test_build_expression_query_basic(self): primary_criteria=PrimaryCriteria( criteria_list=[ ConditionOccurrence( - first=True, - condition_type_exclude=False, - codeset_id=12345 + first=True, condition_type_exclude=False, codeset_id=12345 ) ], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="ALL") + primary_limit=ResultLimit(type="ALL"), ), qualified_limit=ResultLimit(type="ALL"), expression_limit=ResultLimit(type="ALL"), inclusion_rules=[], collapse_settings=CollapseSettings( - collapse_type=CollapseType.COLLAPSE, - era_pad=30 - ) + collapse_type=CollapseType.COLLAPSE, era_pad=30 + ), ) - + options = BuildExpressionQueryOptions() options.cdm_schema = "cdm_schema" options.cohort_id = 123 - + query = self.builder.build_expression_query(expression, options) - + self.assertIn("cdm_schema", query) self.assertIn("123", query) self.assertIn("CREATE TABLE #Codesets", query) @@ -645,38 +644,33 @@ def test_build_expression_query_with_additional_criteria(self): primary_criteria=PrimaryCriteria( criteria_list=[ ConditionOccurrence( - first=True, - condition_type_exclude=False, - codeset_id=12345 + first=True, condition_type_exclude=False, codeset_id=12345 ) ], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="ALL") + primary_limit=ResultLimit(type="ALL"), ), additional_criteria=CriteriaGroup( type="ALL", criteria_list=[ CorelatedCriteria( criteria=Death( - first=True, - death_type_exclude=False, - codeset_id=67890 + first=True, death_type_exclude=False, codeset_id=67890 ), - occurrence=Occurrence(type=1, count=1, is_distinct=False) + occurrence=Occurrence(type=1, count=1, is_distinct=False), ) - ] + ], ), collapse_settings=CollapseSettings( - collapse_type=CollapseType.COLLAPSE, - era_pad=30 - ) + collapse_type=CollapseType.COLLAPSE, era_pad=30 + ), ) - + options = BuildExpressionQueryOptions() options.cdm_schema = "cdm_schema" - + query = self.builder.build_expression_query(expression, options) - + self.assertIn("JOIN", query) self.assertIn("12345", query) self.assertIn("67890", query) @@ -687,26 +681,23 @@ def test_build_expression_query_with_end_strategy(self): primary_criteria=PrimaryCriteria( criteria_list=[ ConditionOccurrence( - first=True, - condition_type_exclude=False, - codeset_id=12345 + first=True, condition_type_exclude=False, codeset_id=12345 ) ], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="ALL") + primary_limit=ResultLimit(type="ALL"), ), end_strategy=DateOffsetStrategy(offset=30, date_field="StartDate"), collapse_settings=CollapseSettings( - collapse_type=CollapseType.COLLAPSE, - era_pad=30 - ) + collapse_type=CollapseType.COLLAPSE, era_pad=30 + ), ) - + options = BuildExpressionQueryOptions() options.cdm_schema = "cdm_schema" - + query = self.builder.build_expression_query(expression, options) - + self.assertIn("INTO #strategy_ends", query) self.assertIn("DATEADD(day,30,start_date)", query) @@ -716,30 +707,27 @@ def test_build_expression_query_with_censor_window(self): primary_criteria=PrimaryCriteria( criteria_list=[ ConditionOccurrence( - first=True, - condition_type_exclude=False, - codeset_id=12345 + first=True, condition_type_exclude=False, codeset_id=12345 ) ], observation_window=ObservationFilter(prior_days=0, post_days=0), - primary_limit=ResultLimit(type="ALL") + primary_limit=ResultLimit(type="ALL"), ), censor_window=Period(start_date="2020-01-01", end_date="2023-01-01"), collapse_settings=CollapseSettings( - collapse_type=CollapseType.COLLAPSE, - era_pad=30 - ) + collapse_type=CollapseType.COLLAPSE, era_pad=30 + ), ) - + options = BuildExpressionQueryOptions() options.cdm_schema = "cdm_schema" - + query = self.builder.build_expression_query(expression, options) - + self.assertIn("CASE WHEN", query) self.assertIn("DATEFROMPARTS(2020, 1, 1)", query) self.assertIn("DATEFROMPARTS(2023, 1, 1)", query) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_range_checker_factory_coverage.py b/tests/test_range_checker_factory_coverage.py index 294abe48..11e3bfa5 100644 --- a/tests/test_range_checker_factory_coverage.py +++ b/tests/test_range_checker_factory_coverage.py @@ -1,4 +1,3 @@ - import unittest from unittest.mock import Mock, call, patch @@ -35,192 +34,464 @@ def setUp(self): def test_check_condition_era(self): c = ConditionEra(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.age_at_start, Constants.Criteria.CONDITION_ERA, Constants.Attributes.AGE_AT_ERA_START_ATTR), - call(c.age_at_end, Constants.Criteria.CONDITION_ERA, Constants.Attributes.AGE_AT_ERA_END_ATTR), - call(c.era_length, Constants.Criteria.CONDITION_ERA, Constants.Attributes.ERA_LENGTH_ATTR), - call(c.occurrence_count, Constants.Criteria.CONDITION_ERA, Constants.Attributes.OCCURRENCE_COUNT_ATTR), - call(c.era_start_date, Constants.Criteria.CONDITION_ERA, Constants.Attributes.ERA_START_DATE_ATTR), - call(c.era_end_date, Constants.Criteria.CONDITION_ERA, Constants.Attributes.ERA_END_DATE_ATTR), + call( + c.age_at_start, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.AGE_AT_ERA_START_ATTR, + ), + call( + c.age_at_end, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.AGE_AT_ERA_END_ATTR, + ), + call( + c.era_length, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.ERA_LENGTH_ATTR, + ), + call( + c.occurrence_count, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.OCCURRENCE_COUNT_ATTR, + ), + call( + c.era_start_date, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.ERA_START_DATE_ATTR, + ), + call( + c.era_end_date, + Constants.Criteria.CONDITION_ERA, + Constants.Attributes.ERA_END_DATE_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_condition_occurrence(self): c = ConditionOccurrence(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.occurrence_start_date, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.OCCURRENCE_START_DATE_ATTR), - call(c.occurrence_end_date, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.OCCURRENCE_END_DATE_ATTR), - call(c.age, Constants.Criteria.CONDITION_OCCURRENCE, Constants.Attributes.AGE_ATTR), + call( + c.occurrence_start_date, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ), + call( + c.occurrence_end_date, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.OCCURRENCE_END_DATE_ATTR, + ), + call( + c.age, + Constants.Criteria.CONDITION_OCCURRENCE, + Constants.Attributes.AGE_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_death(self): c = Death(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ call(c.age, Constants.Criteria.DEATH, Constants.Attributes.AGE_ATTR), - call(c.occurrence_start_date, Constants.Criteria.DEATH, Constants.Attributes.OCCURRENCE_START_DATE_ATTR), + call( + c.occurrence_start_date, + Constants.Criteria.DEATH, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_device_exposure(self): c = DeviceExposure(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.occurrence_start_date, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.OCCURRENCE_START_DATE_ATTR), - call(c.occurrence_end_date, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.OCCURRENCE_END_DATE_ATTR), - call(c.quantity, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.QUANTITY_ATTR), - call(c.age, Constants.Criteria.DEVICE_EXPOSURE, Constants.Attributes.AGE_ATTR), + call( + c.occurrence_start_date, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ), + call( + c.occurrence_end_date, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.OCCURRENCE_END_DATE_ATTR, + ), + call( + c.quantity, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.QUANTITY_ATTR, + ), + call( + c.age, + Constants.Criteria.DEVICE_EXPOSURE, + Constants.Attributes.AGE_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_dose_era(self): c = DoseEra(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.era_start_date, Constants.Criteria.DOSE_ERA, Constants.Attributes.ERA_START_DATE_ATTR), - call(c.era_end_date, Constants.Criteria.DOSE_ERA, Constants.Attributes.ERA_END_DATE_ATTR), - call(c.dose_value, Constants.Criteria.DOSE_ERA, Constants.Attributes.DOSE_VALUE_ATTR), - call(c.era_length, Constants.Criteria.DOSE_ERA, Constants.Attributes.ERA_LENGTH_ATTR), - call(c.age_at_start, Constants.Criteria.DOSE_ERA, Constants.Attributes.AGE_AT_START_ATTR), - call(c.age_at_end, Constants.Criteria.DOSE_ERA, Constants.Attributes.AGE_AT_END_ATTR), + call( + c.era_start_date, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.ERA_START_DATE_ATTR, + ), + call( + c.era_end_date, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.ERA_END_DATE_ATTR, + ), + call( + c.dose_value, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.DOSE_VALUE_ATTR, + ), + call( + c.era_length, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.ERA_LENGTH_ATTR, + ), + call( + c.age_at_start, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.AGE_AT_START_ATTR, + ), + call( + c.age_at_end, + Constants.Criteria.DOSE_ERA, + Constants.Attributes.AGE_AT_END_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_drug_era(self): c = DrugEra(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.era_start_date, Constants.Criteria.DRUG_ERA, Constants.Attributes.ERA_START_DATE_ATTR), - call(c.era_end_date, Constants.Criteria.DRUG_ERA, Constants.Attributes.ERA_END_DATE_ATTR), - call(c.occurrence_count, Constants.Criteria.DRUG_ERA, Constants.Attributes.OCCURRENCE_COUNT_ATTR), - call(c.gap_days, Constants.Criteria.DRUG_ERA, Constants.Attributes.GAP_DAYS_ATTR), - call(c.era_length, Constants.Criteria.DRUG_ERA, Constants.Attributes.ERA_LENGTH_ATTR), - call(c.age_at_start, Constants.Criteria.DRUG_ERA, Constants.Attributes.AGE_AT_START_ATTR), - call(c.age_at_end, Constants.Criteria.DRUG_ERA, Constants.Attributes.AGE_AT_END_ATTR), + call( + c.era_start_date, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.ERA_START_DATE_ATTR, + ), + call( + c.era_end_date, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.ERA_END_DATE_ATTR, + ), + call( + c.occurrence_count, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.OCCURRENCE_COUNT_ATTR, + ), + call( + c.gap_days, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.GAP_DAYS_ATTR, + ), + call( + c.era_length, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.ERA_LENGTH_ATTR, + ), + call( + c.age_at_start, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.AGE_AT_START_ATTR, + ), + call( + c.age_at_end, + Constants.Criteria.DRUG_ERA, + Constants.Attributes.AGE_AT_END_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_drug_exposure(self): c = DrugExposure(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.occurrence_start_date, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.OCCURRENCE_START_DATE_ATTR), - call(c.occurrence_end_date, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.OCCURRENCE_END_DATE_ATTR), - call(c.refills, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.REFILLS_ATTR), - call(c.quantity, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.QUANTITY_ATTR), - call(c.days_supply, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.DAYS_SUPPLY_ATTR), - call(c.effective_drug_dose, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.EFFECTIVE_DRUG_DOSE_ATTR), - call(c.age, Constants.Criteria.DRUG_EXPOSURE, Constants.Attributes.AGE_ATTR), + call( + c.occurrence_start_date, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ), + call( + c.occurrence_end_date, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.OCCURRENCE_END_DATE_ATTR, + ), + call( + c.refills, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.REFILLS_ATTR, + ), + call( + c.quantity, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.QUANTITY_ATTR, + ), + call( + c.days_supply, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.DAYS_SUPPLY_ATTR, + ), + call( + c.effective_drug_dose, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.EFFECTIVE_DRUG_DOSE_ATTR, + ), + call( + c.age, + Constants.Criteria.DRUG_EXPOSURE, + Constants.Attributes.AGE_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_measurement(self): c = Measurement(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.occurrence_start_date, Constants.Criteria.MEASUREMENT, Constants.Attributes.OCCURRENCE_START_DATE_ATTR), - call(c.value_as_number, Constants.Criteria.MEASUREMENT, Constants.Attributes.VALUE_AS_NUMBER_ATTR), - call(c.range_low, Constants.Criteria.MEASUREMENT, Constants.Attributes.RANGE_LOW_ATTR), - call(c.range_high, Constants.Criteria.MEASUREMENT, Constants.Attributes.RANGE_HIGH_ATTR), - call(c.range_low_ratio, Constants.Criteria.MEASUREMENT, Constants.Attributes.RANGE_LOW_RATIO_ATTR), - call(c.range_high_ratio, Constants.Criteria.MEASUREMENT, Constants.Attributes.RANGE_HIGH_RATIO_ATTR), - call(c.age, Constants.Criteria.MEASUREMENT, Constants.Attributes.AGE_ATTR), + call( + c.occurrence_start_date, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ), + call( + c.value_as_number, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.VALUE_AS_NUMBER_ATTR, + ), + call( + c.range_low, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.RANGE_LOW_ATTR, + ), + call( + c.range_high, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.RANGE_HIGH_ATTR, + ), + call( + c.range_low_ratio, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.RANGE_LOW_RATIO_ATTR, + ), + call( + c.range_high_ratio, + Constants.Criteria.MEASUREMENT, + Constants.Attributes.RANGE_HIGH_RATIO_ATTR, + ), + call( + c.age, Constants.Criteria.MEASUREMENT, Constants.Attributes.AGE_ATTR + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_observation(self): c = Observation(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.occurrence_start_date, Constants.Criteria.OBSERVATION, Constants.Attributes.OCCURRENCE_START_DATE_ATTR), - call(c.value_as_number, Constants.Criteria.OBSERVATION, Constants.Attributes.VALUE_AS_NUMBER_ATTR), - call(c.age, Constants.Criteria.OBSERVATION, Constants.Attributes.AGE_ATTR), + call( + c.occurrence_start_date, + Constants.Criteria.OBSERVATION, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ), + call( + c.value_as_number, + Constants.Criteria.OBSERVATION, + Constants.Attributes.VALUE_AS_NUMBER_ATTR, + ), + call( + c.age, Constants.Criteria.OBSERVATION, Constants.Attributes.AGE_ATTR + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_observation_period(self): c = ObservationPeriod() - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.period_start_date, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.PERIOD_START_DATE_ATTR), - call(c.period_end_date, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.PERIOD_END_DATE_ATTR), - call(c.period_length, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.PERIOD_LENGTH_ATTR), - call(c.age_at_start, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.AGE_AT_START_ATTR), - call(c.age_at_end, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.AGE_AT_END_ATTR), - call(c.user_defined_period, Constants.Criteria.OBSERVATION_PERIOD, Constants.Attributes.USER_DEFINED_PERIOD_ATTR), + call( + c.period_start_date, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.PERIOD_START_DATE_ATTR, + ), + call( + c.period_end_date, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.PERIOD_END_DATE_ATTR, + ), + call( + c.period_length, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.PERIOD_LENGTH_ATTR, + ), + call( + c.age_at_start, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.AGE_AT_START_ATTR, + ), + call( + c.age_at_end, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.AGE_AT_END_ATTR, + ), + call( + c.user_defined_period, + Constants.Criteria.OBSERVATION_PERIOD, + Constants.Attributes.USER_DEFINED_PERIOD_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_procedure_occurrence(self): c = ProcedureOccurrence(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.occurrence_start_date, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.OCCURRENCE_START_DATE_ATTR), - call(c.quantity, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.QUANTITY_ATTR), - call(c.age, Constants.Criteria.PROCEDURE_OCCURRENCE, Constants.Attributes.AGE_ATTR), + call( + c.occurrence_start_date, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ), + call( + c.quantity, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.QUANTITY_ATTR, + ), + call( + c.age, + Constants.Criteria.PROCEDURE_OCCURRENCE, + Constants.Attributes.AGE_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_specimen(self): c = Specimen(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.occurrence_start_date, Constants.Criteria.SPECIMEN, Constants.Attributes.OCCURRENCE_START_DATE_ATTR), - call(c.quantity, Constants.Criteria.SPECIMEN, Constants.Attributes.QUANTITY_ATTR), + call( + c.occurrence_start_date, + Constants.Criteria.SPECIMEN, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ), + call( + c.quantity, + Constants.Criteria.SPECIMEN, + Constants.Attributes.QUANTITY_ATTR, + ), call(c.age, Constants.Criteria.SPECIMEN, Constants.Attributes.AGE_ATTR), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_visit_occurrence(self): c = VisitOccurrence(codeset_id=0) - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.occurrence_start_date, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.OCCURRENCE_START_DATE_ATTR), - call(c.occurrence_end_date, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.OCCURRENCE_END_DATE_ATTR), - call(c.visit_length, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.VISIT_LENGTH_ATTR), - call(c.age, Constants.Criteria.VISIT_OCCURRENCE, Constants.Attributes.AGE_ATTR), + call( + c.occurrence_start_date, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ), + call( + c.occurrence_end_date, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.OCCURRENCE_END_DATE_ATTR, + ), + call( + c.visit_length, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.VISIT_LENGTH_ATTR, + ), + call( + c.age, + Constants.Criteria.VISIT_OCCURRENCE, + Constants.Attributes.AGE_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_visit_detail(self): c = VisitDetail() - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.visit_detail_start_date, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.VISIT_DETAIL_START_DATE_ATTR), - call(c.visit_detail_end_date, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.VISIT_DETAIL_END_DATE_ATTR), - call(c.visit_detail_length, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.VISIT_DETAIL_LENGTH_ATTR), - call(c.age, Constants.Criteria.VISIT_DETAIL, Constants.Attributes.AGE_ATTR), + call( + c.visit_detail_start_date, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.VISIT_DETAIL_START_DATE_ATTR, + ), + call( + c.visit_detail_end_date, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.VISIT_DETAIL_END_DATE_ATTR, + ), + call( + c.visit_detail_length, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.VISIT_DETAIL_LENGTH_ATTR, + ), + call( + c.age, + Constants.Criteria.VISIT_DETAIL, + Constants.Attributes.AGE_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_payer_plan_period(self): c = PayerPlanPeriod() - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.period_start_date, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.PERIOD_START_DATE_ATTR), - call(c.period_end_date, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.PERIOD_END_DATE_ATTR), - call(c.period_length, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.PERIOD_LENGTH_ATTR), - call(c.age_at_start, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.AGE_AT_START_ATTR), - call(c.age_at_end, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.AGE_AT_END_ATTR), - call(c.user_defined_period, Constants.Criteria.PAYER_PLAN_PERIOD, Constants.Attributes.USER_DEFINED_PERIOD_ATTR), + call( + c.period_start_date, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.PERIOD_START_DATE_ATTR, + ), + call( + c.period_end_date, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.PERIOD_END_DATE_ATTR, + ), + call( + c.period_length, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.PERIOD_LENGTH_ATTR, + ), + call( + c.age_at_start, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.AGE_AT_START_ATTR, + ), + call( + c.age_at_end, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.AGE_AT_END_ATTR, + ), + call( + c.user_defined_period, + Constants.Criteria.PAYER_PLAN_PERIOD, + Constants.Attributes.USER_DEFINED_PERIOD_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) @@ -228,35 +499,53 @@ def test_check_location_region(self): c = LocationRegion() # Workaround: LocationRegion class def is missing these fields, but Factory checks them. # Bypass Pydantic validation to add them. - object.__setattr__(c, 'start_date', DateRange(value="2020-01-01")) - object.__setattr__(c, 'end_date', DateRange(value="2020-01-02")) - - with patch.object(self.factory, '_check_range') as mock_check: + object.__setattr__(c, "start_date", DateRange(value="2020-01-01")) + object.__setattr__(c, "end_date", DateRange(value="2020-01-02")) + + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.end_date, Constants.Criteria.LOCATION_REGION, Constants.Attributes.LOCATION_REGION_START_DATE_ATTR), - call(c.start_date, Constants.Criteria.LOCATION_REGION, Constants.Attributes.LOCATION_REGION_END_DATE_ATTR), + call( + c.end_date, + Constants.Criteria.LOCATION_REGION, + Constants.Attributes.LOCATION_REGION_START_DATE_ATTR, + ), + call( + c.start_date, + Constants.Criteria.LOCATION_REGION, + Constants.Attributes.LOCATION_REGION_END_DATE_ATTR, + ), ] mock_check.assert_has_calls(calls, any_order=True) def test_check_demographic_criteria(self): c = DemographicCriteria() - with patch.object(self.factory, '_check_range') as mock_check: + with patch.object(self.factory, "_check_range") as mock_check: self.factory.check(c) calls = [ - call(c.occurrence_end_date, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.OCCURRENCE_END_DATE_ATTR), - call(c.occurrence_start_date, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.OCCURRENCE_START_DATE_ATTR), - call(c.age, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.AGE_ATTR), + call( + c.occurrence_end_date, + Constants.Criteria.DEMOGRAPHIC, + Constants.Attributes.OCCURRENCE_END_DATE_ATTR, + ), + call( + c.occurrence_start_date, + Constants.Criteria.DEMOGRAPHIC, + Constants.Attributes.OCCURRENCE_START_DATE_ATTR, + ), + call( + c.age, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.AGE_ATTR + ), ] mock_check.assert_has_calls(calls, any_order=True) - + def test_check_default(self): # Unhandled criteria should just return (noop) # Must inherit from Criteria to bypass BaseCheckerFactory check and reach _get_check_criteria from pydantic import BaseModel from circe.cohortdefinition.criteria import Criteria - + # Pydantic requires forward references to be resolved. # Since 'Criteria' definition refers to 'CriteriaGroup', we need to mock it # or at least ensure it's available for the new subclass to be built. @@ -266,14 +555,16 @@ class CriteriaGroup(BaseModel): class UnknownCriteria(Criteria): pass - - c = UnknownCriteria() - - with patch.object(self.factory, '_get_check_criteria', wraps=self.factory._get_check_criteria) as mock_get: + + c = UnknownCriteria() + + with patch.object( + self.factory, "_get_check_criteria", wraps=self.factory._get_check_criteria + ) as mock_get: self.factory.check(c) # Verify that we actually reached the factory method mock_get.assert_called_with(c) - + # No error, no mocked calls (because _check_range not reachable if no match) # To be safe, verify no reporter calls are made self.reporter.assert_not_called() @@ -285,7 +576,10 @@ def test_check_range_date_invalid(self): dr = DateRange(value="invalid-date", op="eq") self.factory._check_range(dr, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_DATE_IS_INVALID, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_DATE_IS_INVALID, + "Test Group", + "TestCriteria", + "TestAttr", ) def test_check_range_date_bt_empty_start(self): @@ -293,7 +587,10 @@ def test_check_range_date_bt_empty_start(self): dr = DateRange(op="bt", value=None, extent="2020-01-01") self.factory._check_range(dr, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_EMPTY_START_VALUE, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_EMPTY_START_VALUE, + "Test Group", + "TestCriteria", + "TestAttr", ) def test_check_range_date_bt_empty_end(self): @@ -301,15 +598,21 @@ def test_check_range_date_bt_empty_end(self): dr = DateRange(op="bt", value="2020-01-01", extent=None) self.factory._check_range(dr, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_EMPTY_END_VALUE, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_EMPTY_END_VALUE, + "Test Group", + "TestCriteria", + "TestAttr", ) - + def test_check_range_date_bt_invalid_end(self): # 'bt' op with invalid extent dr = DateRange(op="bt", value="2020-01-01", extent="bad-date") self.factory._check_range(dr, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_DATE_IS_INVALID, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_DATE_IS_INVALID, + "Test Group", + "TestCriteria", + "TestAttr", ) def test_check_range_date_bt_start_gt_end(self): @@ -317,7 +620,10 @@ def test_check_range_date_bt_start_gt_end(self): dr = DateRange(op="bt", value="2020-02-01", extent="2020-01-01") self.factory._check_range(dr, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_START_GREATER_THAN_END, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_START_GREATER_THAN_END, + "Test Group", + "TestCriteria", + "TestAttr", ) def test_check_range_date_other_op_empty(self): @@ -325,7 +631,10 @@ def test_check_range_date_other_op_empty(self): dr = DateRange(op="gt", value=None) self.factory._check_range(dr, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_EMPTY_START_VALUE, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_EMPTY_START_VALUE, + "Test Group", + "TestCriteria", + "TestAttr", ) # --- Test Logic of _check_range with NumericRange --- @@ -335,7 +644,10 @@ def test_check_range_numeric_bt_empty_start(self): nr = NumericRange(op="bt", value=None, extent=10) self.factory._check_range(nr, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_EMPTY_START_VALUE, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_EMPTY_START_VALUE, + "Test Group", + "TestCriteria", + "TestAttr", ) def test_check_range_numeric_bt_empty_end(self): @@ -343,23 +655,32 @@ def test_check_range_numeric_bt_empty_end(self): nr = NumericRange(op="bt", value=10, extent=None) self.factory._check_range(nr, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_EMPTY_END_VALUE, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_EMPTY_END_VALUE, + "Test Group", + "TestCriteria", + "TestAttr", ) def test_check_range_numeric_bt_start_gt_end(self): nr = NumericRange(op="bt", value=20, extent=10) self.factory._check_range(nr, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_START_GREATER_THAN_END, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_START_GREATER_THAN_END, + "Test Group", + "TestCriteria", + "TestAttr", ) - + def test_check_range_numeric_other_op_empty(self): nr = NumericRange(op="gt", value=None) self.factory._check_range(nr, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_EMPTY_START_VALUE, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_EMPTY_START_VALUE, + "Test Group", + "TestCriteria", + "TestAttr", ) - + def test_check_range_none(self): self.factory._check_range(None, "TestCriteria", "TestAttr") self.reporter.assert_not_called() @@ -374,30 +695,40 @@ def test_check_range_period_invalid_start(self): p = Period(start_date="bad-date") self.factory.check_range(p, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_DATE_IS_INVALID, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_DATE_IS_INVALID, + "Test Group", + "TestCriteria", + "TestAttr", ) def test_check_range_period_invalid_end(self): p = Period(start_date="2020-01-01", end_date="bad-date") self.factory.check_range(p, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_DATE_IS_INVALID, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_DATE_IS_INVALID, + "Test Group", + "TestCriteria", + "TestAttr", ) def test_check_range_period_start_gt_end(self): p = Period(start_date="2020-02-01", end_date="2020-01-01") self.factory.check_range(p, "TestCriteria", "TestAttr") self.reporter.assert_called_with( - self.factory.WARNING_START_GREATER_THAN_END, "Test Group", "TestCriteria", "TestAttr" + self.factory.WARNING_START_GREATER_THAN_END, + "Test Group", + "TestCriteria", + "TestAttr", ) # --- Test check(expression) for censor window --- - + def test_check_cohort_expression_censor_window(self): - ce = CohortExpression( - censor_window=Period(start_date="bad-date") - ) + ce = CohortExpression(censor_window=Period(start_date="bad-date")) self.factory.check(ce) self.reporter.assert_called_with( - self.factory.WARNING_DATE_IS_INVALID, "Test Group", self.factory.ROOT_OBJECT, Constants.Attributes.CENSOR_WINDOW_ATTR + self.factory.WARNING_DATE_IS_INVALID, + "Test Group", + self.factory.ROOT_OBJECT, + Constants.Attributes.CENSOR_WINDOW_ATTR, ) diff --git a/tests/test_real_example_cohorts.py b/tests/test_real_example_cohorts.py index a9c884f9..cdbc4d46 100644 --- a/tests/test_real_example_cohorts.py +++ b/tests/test_real_example_cohorts.py @@ -1,7 +1,7 @@ """ Tests for real example cohorts - comparing Python output with R/Java reference implementation. -These cohorts were added to test cases that work with the Java implementation +These cohorts were added to test cases that work with the Java implementation but may not work correctly with the current Python implementation. Reference outputs were generated using R CirceR package and are stored in @@ -26,8 +26,8 @@ # Test cohort files - these are the cohorts added in the recent commit # Directories -COHORTS_DIR = Path(__file__).parent / 'cohorts' -REFERENCE_DIR = COHORTS_DIR / 'reference_outputs' +COHORTS_DIR = Path(__file__).parent / "cohorts" +REFERENCE_DIR = COHORTS_DIR / "reference_outputs" # Dynamic discovery of cohort files import random @@ -37,21 +37,22 @@ def get_target_cohort_files(config): """Discover cohort files based on configuration.""" if not COHORTS_DIR.exists(): return [] - - all_files = sorted([f.name for f in COHORTS_DIR.glob('*.json')]) - + + all_files = sorted([f.name for f in COHORTS_DIR.glob("*.json")]) + cohort_filter = config.getoption("--cohort-filter") sample_cohorts = config.getoption("--sample-cohorts") - + if cohort_filter: - targets = [f.strip() for f in cohort_filter.split(',')] + targets = [f.strip() for f in cohort_filter.split(",")] return targets - + if sample_cohorts: return random.sample(all_files, min(len(all_files), 10)) - + return all_files + def pytest_generate_tests(metafunc): """Dynamic parameterization for cohort tests.""" if "cohort_name" in metafunc.fixturenames: @@ -64,18 +65,16 @@ def pytest_generate_tests(metafunc): def get_reference_sql(cohort_name: str) -> Optional[str]: """Get pre-generated reference SQL from R/Java implementation.""" - ref_file = REFERENCE_DIR / cohort_name.replace('.json', '.sql') + ref_file = REFERENCE_DIR / cohort_name.replace(".json", ".sql") if ref_file.exists(): return ref_file.read_text() return None - - def generate_python_outputs(cohort_file: Path) -> Tuple[Optional[str], Optional[str]]: """ Run Python reference implementation to generate SQL. - + Returns: Tuple of (sql, error_message) """ @@ -104,162 +103,188 @@ def generate_python_outputs(cohort_file: Path) -> Tuple[Optional[str], Optional[ def normalize_sql(sql: str) -> str: """ Normalize SQL for comparison - removes ALL formatting differences. - + This aggressive normalization focuses on functional differences only: - Case insensitive - Multi-line and single-line comments removed - Template markers removed - All whitespace (spaces, tabs, newlines) collapsed to single spaces - Consistent spacing around punctuation and operators - + This means only the actual SQL tokens matter, not formatting. """ import re - + # Convert to lowercase for case-insensitive comparison sql = sql.lower() - + # Remove multi-line comments /* ... */ - sql = re.sub(r'/\*.*?\*/', ' ', sql, flags=re.DOTALL) - + sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL) + # Remove single-line comments -- ... # Be careful to handle comments at the end of the string - sql = re.sub(r'--.*$', '', sql, flags=re.MULTILINE) - + sql = re.sub(r"--.*$", "", sql, flags=re.MULTILINE) + # Remove template markers like {0 != 0}?{ and } that appear in reference SQL # and also handle nested or complex template structures - sql = re.sub(r'\{[^}]*\}\?\{', '', sql) - sql = re.sub(r'\}', ' ', sql) - - # Remove orphaned template content like "-- comment... where(condition)" + sql = re.sub(r"\{[^}]*\}\?\{", "", sql) + sql = re.sub(r"\}", " ", sql) + + # Remove orphaned template content like "-- comment... where(condition)" # that appears in reference when conditional blocks aren't fully processed # Be robust to nested parentheses in "where(mg.inclusion_rule_mask = power(cast(2 as bigint),0)-1)" # We match the specific pattern for the inclusion rule mask filter - sql = re.sub(r'--\s*the matching group.*?inclusion_rule_mask\s+=\s+power\(.*?\)\s*-\s*1\)\)\s*results', ') results', sql, flags=re.IGNORECASE | re.DOTALL) + sql = re.sub( + r"--\s*the matching group.*?inclusion_rule_mask\s+=\s+power\(.*?\)\s*-\s*1\)\)\s*results", + ") results", + sql, + flags=re.IGNORECASE | re.DOTALL, + ) # Also handle the variant without the comment or with different spacing - sql = re.sub(r'where\s*\(mg\.inclusion_rule_mask\s*=\s*power\(cast\(2\s+as\s+bigint\),\s*0\)\s*-\s*1\)', '', sql, flags=re.IGNORECASE) - + sql = re.sub( + r"where\s*\(mg\.inclusion_rule_mask\s*=\s*power\(cast\(2\s+as\s+bigint\),\s*0\)\s*-\s*1\)", + "", + sql, + flags=re.IGNORECASE, + ) + # Normalize Observation criteria SELECT columns to ignore "value_as_string, o.value_as_concept_id, o.unit_concept_id" # if they are extra in Python output. We want to focus on functional equivalence. # Pattern: select o.person_id, o.observation_id, ..., o.observation_date as start_date # We will just remove the extra ones if they appear in a comma-separated list - sql = re.sub(r',o\.value_as_string', '', sql) - sql = re.sub(r',o\.value_as_concept_id', '', sql) - sql = re.sub(r',o\.unit_concept_id', '', sql) + sql = re.sub(r",o\.value_as_string", "", sql) + sql = re.sub(r",o\.value_as_concept_id", "", sql) + sql = re.sub(r",o\.unit_concept_id", "", sql) # Be careful with unit_concept_id as it might be in reference too if used in filter # But for 1329.json it was extra. # Actually, if we just normalize the entire SELECT list to a minimal set? - + # Replace all whitespace sequences (including newlines) with a single space - sql = re.sub(r'\s+', ' ', sql) - + sql = re.sub(r"\s+", " ", sql) + # Consistency for SQL tokens: remove spaces around functional separators # This helps ignore differences like "(x)" vs "( x )" or "a=b" vs "a = b" - sql = re.sub(r'\s*([(),=<>!]+)\s*', r'\1', sql) - + sql = re.sub(r"\s*([(),=<>!]+)\s*", r"\1", sql) + # Re-normalize observation selects after space removal sql = sql.replace(",o.value_as_string", "") sql = sql.replace(",o.value_as_concept_id", "") sql = sql.replace(",o.unit_concept_id", "") - - # Final cleanup of multiple spaces - sql = re.sub(r'\s+', ' ', sql) - - return sql.strip() - - + # Final cleanup of multiple spaces + sql = re.sub(r"\s+", " ", sql) + return sql.strip() def compare_outputs(python_output: str, reference_output: str, label: str) -> dict: """ Compare Python output with reference output. - + Returns a dict with comparison results and analysis. """ py_normalized = normalize_sql(python_output) ref_normalized = normalize_sql(reference_output) - + is_identical = py_normalized == ref_normalized - + # Since normalization creates single-line strings, split them into chunks for readable diff if is_identical: diff = [] else: # Break normalized output into chunks (every 100 chars) for diff display def chunk_string(s, size=100): - return [s[i:i+size] for i in range(0, len(s), size)] - + return [s[i : i + size] for i in range(0, len(s), size)] + py_chunks = chunk_string(py_normalized) ref_chunks = chunk_string(ref_normalized) - - diff = list(unified_diff( - ref_chunks, - py_chunks, - fromfile='Reference (R/Java)', - tofile='Python', - lineterm='', - n=2 - )) - + + diff = list( + unified_diff( + ref_chunks, + py_chunks, + fromfile="Reference (R/Java)", + tofile="Python", + lineterm="", + n=2, + ) + ) + return { - 'is_identical': is_identical, - 'python_length': len(py_normalized), - 'reference_length': len(ref_normalized), - 'python_lines': len(python_output.splitlines()), # Original line count for reference - 'reference_lines': len(reference_output.splitlines()), # Original line count - 'diff_lines': len([line for line in diff if line.startswith('+') or line.startswith('-')]), - 'diff': diff[:50], # Limit to first 50 chunks for readability + "is_identical": is_identical, + "python_length": len(py_normalized), + "reference_length": len(ref_normalized), + "python_lines": len( + python_output.splitlines() + ), # Original line count for reference + "reference_lines": len(reference_output.splitlines()), # Original line count + "diff_lines": len( + [line for line in diff if line.startswith("+") or line.startswith("-")] + ), + "diff": diff[:50], # Limit to first 50 chunks for readability } def analyze_sql_differences(py_sql: str, ref_sql: str) -> list: """ Analyze SQL differences and identify potential issues. - + Returns a list of issues found. """ issues = [] - + # Check for missing key structures key_structures = [ - ('#Codesets', 'Codeset table'), - ('#qualified_events', 'Qualified events table'), - ('#included_events', 'Included events table'), - ('#cohort_rows', 'Cohort rows table'), - ('#final_cohort', 'Final cohort table'), - ('#inclusion_events', 'Inclusion events table'), + ("#Codesets", "Codeset table"), + ("#qualified_events", "Qualified events table"), + ("#included_events", "Included events table"), + ("#cohort_rows", "Cohort rows table"), + ("#final_cohort", "Final cohort table"), + ("#inclusion_events", "Inclusion events table"), ] - + for pattern, name in key_structures: in_py = pattern.lower() in py_sql.lower() in_ref = pattern.lower() in ref_sql.lower() if in_ref and not in_py: issues.append(f"Missing {name} ({pattern}) in Python output") - + # Check for specific criteria handling - if 'drug_era' in ref_sql.lower() and 'drug_era' not in py_sql.lower(): - issues.append("Missing DRUG_ERA handling - DrugEra criteria may not be implemented") - - if 'measurement' in ref_sql.lower() and 'measurement' not in py_sql.lower(): - issues.append("Missing MEASUREMENT handling - Measurement criteria may not be implemented") - - if 'procedure_occurrence' in ref_sql.lower() and 'procedure_occurrence' not in py_sql.lower(): - issues.append("Missing PROCEDURE_OCCURRENCE handling - ProcedureOccurrence criteria may not be implemented") - + if "drug_era" in ref_sql.lower() and "drug_era" not in py_sql.lower(): + issues.append( + "Missing DRUG_ERA handling - DrugEra criteria may not be implemented" + ) + + if "measurement" in ref_sql.lower() and "measurement" not in py_sql.lower(): + issues.append( + "Missing MEASUREMENT handling - Measurement criteria may not be implemented" + ) + + if ( + "procedure_occurrence" in ref_sql.lower() + and "procedure_occurrence" not in py_sql.lower() + ): + issues.append( + "Missing PROCEDURE_OCCURRENCE handling - ProcedureOccurrence criteria may not be implemented" + ) + # Check for value_as_number handling - if 'value_as_number' in ref_sql.lower() and 'value_as_number' not in py_sql.lower(): - issues.append("Missing value_as_number handling - numeric range criteria may not be implemented") - - # Check for source concept handling - if 'source_concept_id' in ref_sql.lower() or 'source_value' in ref_sql.lower(): - if 'source_concept_id' not in py_sql.lower() and 'source_value' not in py_sql.lower(): - issues.append("Missing source concept handling - ConditionSourceConcept may not be implemented") - - return issues + if "value_as_number" in ref_sql.lower() and "value_as_number" not in py_sql.lower(): + issues.append( + "Missing value_as_number handling - numeric range criteria may not be implemented" + ) + # Check for source concept handling + if "source_concept_id" in ref_sql.lower() or "source_value" in ref_sql.lower(): + if ( + "source_concept_id" not in py_sql.lower() + and "source_value" not in py_sql.lower() + ): + issues.append( + "Missing source concept handling - ConditionSourceConcept may not be implemented" + ) + return issues # ============================================================================= @@ -270,49 +295,48 @@ def analyze_sql_differences(py_sql: str, ref_sql: str) -> list: def test_sql_generation_produces_output(cohort_name): """ Test that Python generates SQL without crashing. - + This is a basic sanity check - if this fails, there's a serious issue like a missing field or deserialization error. """ cohort_file = COHORTS_DIR / cohort_name if not cohort_file.exists(): pytest.skip(f"Cohort file not found: {cohort_file}") - + sql, error = generate_python_outputs(cohort_file) - + if error: pytest.fail(f"Generation error for {cohort_name}: {error}") - - assert sql is not None, f"No SQL generated for {cohort_name}" + assert sql is not None, f"No SQL generated for {cohort_name}" def test_sql_generation_has_key_structures(cohort_name): """ Test that generated SQL has key structural elements. - - The Python implementation should produce SQL with the same + + The Python implementation should produce SQL with the same structural elements as the R/Java implementation. """ cohort_file = COHORTS_DIR / cohort_name if not cohort_file.exists(): pytest.skip(f"Cohort file not found: {cohort_file}") - + sql, error = generate_python_outputs(cohort_file) if error or sql is None: pytest.skip(f"SQL generation failed: {error}") - + ref_sql = get_reference_sql(cohort_name) if ref_sql is None: pytest.skip(f"No reference SQL for {cohort_name}") - + # Check for required structures present in reference issues = analyze_sql_differences(sql, ref_sql) - + if issues: pytest.fail( - f"SQL structure issues for {cohort_name}:\n" + - "\n".join(f" - {issue}" for issue in issues) + f"SQL structure issues for {cohort_name}:\n" + + "\n".join(f" - {issue}" for issue in issues) ) @@ -322,20 +346,20 @@ def generate_token_diff(ref_norm, gen_norm): This makes specific missing columns or keywords obvious. """ # Split by space to get a list of tokens (since your normalizer handles punctuation) - ref_tokens = ref_norm.split(' ') - gen_tokens = gen_norm.split(' ') + ref_tokens = ref_norm.split(" ") + gen_tokens = gen_norm.split(" ") diff = difflib.unified_diff( ref_tokens, gen_tokens, - fromfile='Reference (Normalized)', - tofile='Generated (Normalized)', - lineterm='' + fromfile="Reference (Normalized)", + tofile="Generated (Normalized)", + lineterm="", ) # Filter out lines that are just context (start with space) # to focus strictly on what changed. - changes = [line for line in diff if line.startswith(('-', '+'))] + changes = [line for line in diff if line.startswith(("-", "+"))] return "\n".join(changes[:30]) # Show first 30 changes @@ -383,8 +407,6 @@ def test_sql_matches_reference(cohort_name): pytest.fail(textwrap.dedent(failure_msg)) - - # ============================================================================= # Markdown Generation Tests (Moved from test_markdown_parity.py) # ============================================================================= @@ -392,13 +414,14 @@ def test_sql_matches_reference(cohort_name): # Cache for generated markdown to avoid redundant work _MARKDOWN_CACHE: Dict[str, Tuple[Optional[str], Optional[str]]] = {} + def get_generated_markdown(cohort_name: str) -> Tuple[Optional[str], Optional[str]]: """ Get generated markdown for a cohort, using cache if available. """ if cohort_name in _MARKDOWN_CACHE: return _MARKDOWN_CACHE[cohort_name] - + cohort_file = COHORTS_DIR / cohort_name markdown = None error = None @@ -420,135 +443,143 @@ def get_generated_markdown(cohort_name: str) -> Tuple[Optional[str], Optional[st _MARKDOWN_CACHE[cohort_name] = (markdown, error) return markdown, error + def get_reference_markdown(cohort_name: str) -> Optional[str]: """Get pre-generated reference Markdown from R/Java implementation.""" - ref_file = REFERENCE_DIR / cohort_name.replace('.json', '.md') + ref_file = REFERENCE_DIR / cohort_name.replace(".json", ".md") if ref_file.exists(): return ref_file.read_text() return None + def normalize_markdown(text: str) -> str: """ Normalize markdown for comparison - removes ALL formatting differences. """ # Convert to lowercase for case-insensitive comparison text = text.lower() - lines = text.split('\n') + lines = text.split("\n") normalized = [] skip_section = False - + for line in lines: line = line.strip() - + # Skip title and description sections (Python adds these, R doesn't) - if line.startswith('# ') and not line.startswith('###'): + if line.startswith("# ") and not line.startswith("###"): skip_section = True continue - if line.startswith('## ') and not line.startswith('###'): + if line.startswith("## ") and not line.startswith("###"): skip_section = True continue - if skip_section and line.startswith('###'): + if skip_section and line.startswith("###"): skip_section = False if skip_section: continue - + # Skip empty lines if not line: continue - + # Normalize whitespace - collapse multiple spaces to single space - line = ' '.join(line.split()) + line = " ".join(line.split()) normalized.append(line) - + # Join all lines with single space to ignore line break differences - result = ' '.join(normalized) - + result = " ".join(normalized) + # Normalize some common markdown patterns # Normalize bullet points - spaces around * or - - result = re.sub(r'\s*\*\s*', '* ', result) - result = re.sub(r'\s*-\s*', '- ', result) + result = re.sub(r"\s*\*\s*", "* ", result) + result = re.sub(r"\s*-\s*", "- ", result) # Normalize heading markers - result = re.sub(r'\s*###\s*', '### ', result) - result = re.sub(r'\s*##\s*', '## ', result) - result = re.sub(r'\s*#\s*', '# ', result) - + result = re.sub(r"\s*###\s*", "### ", result) + result = re.sub(r"\s*##\s*", "## ", result) + result = re.sub(r"\s*#\s*", "# ", result) + return result.strip() + def compare_markdown_outputs(python_output: str, reference_output: str) -> dict: """ Compare Python markdown with reference output. """ py_normalized = normalize_markdown(python_output) ref_normalized = normalize_markdown(reference_output) - + is_identical = py_normalized == ref_normalized - + # Since normalization creates single-line strings, split them into chunks for readable diff if is_identical: diff = [] else: # Break normalized output into chunks (every 100 chars) for diff display def chunk_string(s, size=100): - return [s[i:i+size] for i in range(0, len(s), size)] - + return [s[i : i + size] for i in range(0, len(s), size)] + py_chunks = chunk_string(py_normalized) ref_chunks = chunk_string(ref_normalized) - - diff = list(unified_diff( - ref_chunks, - py_chunks, - fromfile='Reference (R/Java)', - tofile='Python', - lineterm='', - n=2 - )) - + + diff = list( + unified_diff( + ref_chunks, + py_chunks, + fromfile="Reference (R/Java)", + tofile="Python", + lineterm="", + n=2, + ) + ) + return { - 'is_identical': is_identical, - 'python_lines': len(python_output.splitlines()), - 'reference_lines': len(reference_output.splitlines()), - 'diff': diff[:50], + "is_identical": is_identical, + "python_lines": len(python_output.splitlines()), + "reference_lines": len(reference_output.splitlines()), + "diff": diff[:50], } + def analyze_markdown_differences(py_md: str, ref_md: str) -> list: """ Analyze Markdown differences and identify potential issues. """ issues = [] - + # Check for "Unknown criteria type" errors - if 'unknown criteria type' in py_md.lower(): - matches = re.findall(r'unknown criteria type[:\s]+(\w+)', py_md.lower()) + if "unknown criteria type" in py_md.lower(): + matches = re.findall(r"unknown criteria type[:\s]+(\w+)", py_md.lower()) for match in matches: issues.append(f"Unknown criteria type: {match} - deserialization issue") - + # Check for missing sections sections = [ - ('### Cohort Entry Events', 'Cohort Entry Events section'), - ('### Inclusion Criteria', 'Inclusion Criteria section'), - ('### Cohort Exit', 'Cohort Exit section'), - ('### Cohort Eras', 'Cohort Eras section'), + ("### Cohort Entry Events", "Cohort Entry Events section"), + ("### Inclusion Criteria", "Inclusion Criteria section"), + ("### Cohort Exit", "Cohort Exit section"), + ("### Cohort Eras", "Cohort Eras section"), ] - + py_normalized = normalize_markdown(py_md) - + for pattern, name in sections: if pattern not in py_normalized: pass - + return issues + def test_markdown_generation_produces_output(cohort_name): """ Test that Python generates Markdown without crashing. """ markdown, error = get_generated_markdown(cohort_name) - + if error: pytest.fail(f"Markdown generation error for {cohort_name}: {error}") - + assert markdown is not None, f"No Markdown generated for {cohort_name}" + def test_markdown_has_no_unknown_types(cohort_name): """ Test that Markdown doesn't contain "Unknown criteria type" errors. @@ -556,19 +587,22 @@ def test_markdown_has_no_unknown_types(cohort_name): markdown, error = get_generated_markdown(cohort_name) if error or markdown is None: pytest.skip(f"Markdown generation failed: {error}") - + # Check for unknown type errors - unknown_pattern = re.compile(r'unknown criteria type', re.IGNORECASE) + unknown_pattern = re.compile(r"unknown criteria type", re.IGNORECASE) matches = unknown_pattern.findall(markdown) - + if matches: - lines_with_unknown = [line for line in markdown.split('\n') if 'unknown' in line.lower()] + lines_with_unknown = [ + line for line in markdown.split("\n") if "unknown" in line.lower() + ] pytest.fail( f"Markdown contains 'Unknown criteria type' for {cohort_name}\n\n" - f"Lines with unknown types:\n" + - "\n".join(f" {line}" for line in lines_with_unknown) + f"Lines with unknown types:\n" + + "\n".join(f" {line}" for line in lines_with_unknown) ) + def test_markdown_matches_reference(cohort_name): """ Test that Python Markdown matches the reference R/Java Markdown. @@ -576,24 +610,28 @@ def test_markdown_matches_reference(cohort_name): markdown, error = get_generated_markdown(cohort_name) if error or markdown is None: pytest.fail(f"Markdown generation failed: {error}") - + ref_md = get_reference_markdown(cohort_name) if ref_md is None: pytest.skip(f"No reference Markdown for {cohort_name}") - + comparison = compare_markdown_outputs(markdown, ref_md) - - if not comparison['is_identical']: + + if not comparison["is_identical"]: issues = analyze_markdown_differences(markdown, ref_md) - diff_preview = '\n'.join(comparison['diff'][:30]) - + diff_preview = "\n".join(comparison["diff"][:30]) + pytest.fail( f"Markdown does not match reference for {cohort_name}\n\n" f"Summary:\n" f" Python lines: {comparison['python_lines']}, Reference lines: {comparison['reference_lines']}\n" - f"Issues found:\n" + - ("\n".join(f" - {issue}" for issue in issues) if issues else " (no specific issues identified)") + - f"\n\nFirst 30 lines of diff:\n{diff_preview}" + f"Issues found:\n" + + ( + "\n".join(f" - {issue}" for issue in issues) + if issues + else " (no specific issues identified)" + ) + + f"\n\nFirst 30 lines of diff:\n{diff_preview}" ) @@ -601,90 +639,95 @@ def test_markdown_matches_reference(cohort_name): # Summary Test # ============================================================================= + def test_real_cohorts_summary(request): """ Summary test that reports overall status of all real example cohorts. - + This test always runs and provides a summary of what works and what doesn't. """ cohort_files = get_target_cohort_files(request.config) - + # Save results to JSON for the Debug App import json - + # Re-implementing the loop logic to capture statuses correctly results = { - 'total': len(cohort_files), - 'sql_success': 0, # Generation success - 'sql_matches': 0, # Content match - 'md_success': 0, - 'md_matches': 0, - 'failures': [], + "total": len(cohort_files), + "sql_success": 0, # Generation success + "sql_matches": 0, # Content match + "md_success": 0, + "md_matches": 0, + "failures": [], } - + app_results = {} for cohort_name in cohort_files: cohort_file = COHORTS_DIR / cohort_name if not cohort_file.exists(): continue - + app_results[cohort_name] = { "sql_generated": False, "sql_match": False, "md_generated": False, - "md_match": False + "md_match": False, } - + sql, error = generate_python_outputs(cohort_file) - + # Check SQL if sql: - results['sql_success'] += 1 + results["sql_success"] += 1 app_results[cohort_name]["sql_generated"] = True ref_sql = get_reference_sql(cohort_name) if ref_sql: comparison = compare_outputs(sql, ref_sql, "SQL") - if comparison['is_identical']: - results['sql_matches'] += 1 + if comparison["is_identical"]: + results["sql_matches"] += 1 app_results[cohort_name]["sql_match"] = True else: issues = analyze_sql_differences(sql, ref_sql) - results['failures'].append({ - 'cohort': cohort_name, - 'type': 'SQL', - 'issues': issues, - }) - + results["failures"].append( + { + "cohort": cohort_name, + "type": "SQL", + "issues": issues, + } + ) + # Markdown check md, md_error = get_generated_markdown(cohort_name) - + if md: - results['md_success'] += 1 + results["md_success"] += 1 app_results[cohort_name]["md_generated"] = True - + ref_md = get_reference_markdown(cohort_name) if ref_md: md_comparison = compare_markdown_outputs(md, ref_md) - if md_comparison['is_identical']: - results['md_matches'] += 1 + if md_comparison["is_identical"]: + results["md_matches"] += 1 app_results[cohort_name]["md_match"] = True else: - md_issues = analyze_markdown_differences(md, ref_md) - results['failures'].append({ - 'cohort': cohort_name, - 'type': 'Markdown', - 'issues': md_issues, - }) + md_issues = analyze_markdown_differences(md, ref_md) + results["failures"].append( + { + "cohort": cohort_name, + "type": "Markdown", + "issues": md_issues, + } + ) pass - + # Write to file - output_path = Path(__file__).parent.parent / 'debug_app' / 'test_results.json' + output_path = Path(__file__).parent.parent / "debug_app" / "test_results.json" try: if not output_path.parent.exists(): output_path.parent.mkdir(parents=True) - - with open(output_path, 'w') as f: + + with open(output_path, "w") as f: json.dump(app_results, f, indent=2) print(f"\nSaved test results to {output_path}") except Exception as e: @@ -700,17 +743,16 @@ def test_real_cohorts_summary(request): print(f"Markdown generation success: {results['md_success']}/{results['total']}") print(f"Markdown matches reference: {results['md_matches']}/{results['total']}") print() - - if results['failures']: + + if results["failures"]: print("FAILURES:") - for failure in results['failures']: + for failure in results["failures"]: print(f" {failure['cohort']} ({failure['type']}):") - for issue in failure['issues'][:3]: + for issue in failure["issues"][:3]: print(f" - {issue}") print() - + print("=" * 70) - + # This test always passes - it's just for reporting assert True - diff --git a/tests/test_schema_compatibility.py b/tests/test_schema_compatibility.py index 433b97c8..0c9f9187 100644 --- a/tests/test_schema_compatibility.py +++ b/tests/test_schema_compatibility.py @@ -4,6 +4,7 @@ Ensures the Python Pydantic models match the *full nested structure*, types, and required fields declared in the Java JSON Schema, serving as a 1:1 replacement for the Java version. """ + import json from deepdiff import DeepDiff # pip install deepdiff @@ -13,10 +14,11 @@ # Path to Java schema JSON JAVA_SCHEMA_PATH = "java_cohort_expression_schema.json" + def normalize_schema(schema): """ Normalize Pydantic V2 schema to match Java schema structure for equivalence check. - + Transformations: 1. Convert "anyOf": [{"type": "T"}, {"type": "null"}] -> "type": ["T", "null"] 2. Remove "title", "description", "default", "examples" @@ -27,17 +29,17 @@ def normalize_schema(schema): for key in ["title", "description", "default", "examples", "properties"]: if key in schema and key != "properties": del schema[key] - + # Handle properties recursively if "properties" in schema: for prop, val in schema["properties"].items(): schema["properties"][prop] = normalize_schema(val) - + # Handle $defs recursively if "$defs" in schema: for def_name, def_val in schema["$defs"].items(): schema["$defs"][def_name] = normalize_schema(def_val) - + # Handle array items recursively if "items" in schema: schema["items"] = normalize_schema(schema["items"]) @@ -49,7 +51,7 @@ def normalize_schema(schema): types = set() is_nullable = False valid_types = True - + for opt in options: if "type" in opt and opt["type"] == "null": is_nullable = True @@ -57,17 +59,18 @@ def normalize_schema(schema): types.add(opt["type"]) else: valid_types = False - + if valid_types and is_nullable and len(types) == 1: # Convert to type array: ["string", "null"] schema["type"] = [list(types)[0], "null"] del schema["anyOf"] - + return schema elif isinstance(schema, list): return [normalize_schema(item) for item in schema] return schema + def test_compare_python_java_schema(): # Load Java schema with open(JAVA_SCHEMA_PATH) as f: @@ -75,7 +78,7 @@ def test_compare_python_java_schema(): # Generate Python schema from Pydantic python_schema = get_json_schema() - + # Normalize both schemas norm_java = normalize_schema(java_schema) norm_python = normalize_schema(python_schema) @@ -84,12 +87,11 @@ def test_compare_python_java_schema(): # We ignore: # - version (hardcoded) # - specific definition keys that we know differ (e.g. CriteriaColumn is missing in Python) - exclude_regex = [ - r"root\['version'\]", - r"root\['\$defs'\]\['CriteriaColumn'\]" - ] - - diff = DeepDiff(norm_java, norm_python, ignore_order=True, exclude_regex_paths=exclude_regex) + exclude_regex = [r"root\['version'\]", r"root\['\$defs'\]\['CriteriaColumn'\]"] + + diff = DeepDiff( + norm_java, norm_python, ignore_order=True, exclude_regex_paths=exclude_regex + ) if diff: print("\n❌ Schema differences found after normalization:") diff --git a/tests/test_simple_sql_builders.py b/tests/test_simple_sql_builders.py index 8875d1e3..829ecb8a 100644 --- a/tests/test_simple_sql_builders.py +++ b/tests/test_simple_sql_builders.py @@ -26,74 +26,95 @@ class TestBasicSqlBuilderFunctionality: """Test basic functionality of all SQL builders.""" - + def test_dose_era_sql_builder_basic(self): """Test basic DoseEraSqlBuilder functionality.""" builder = DoseEraSqlBuilder() criteria = DoseEra(first=False) - + # Test basic methods assert isinstance(builder.get_query_template(), str) assert isinstance(builder.get_default_columns(), set) assert "DOSE_ERA" in builder.get_query_template() - + # Test column mapping - assert builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) == "C.drug_concept_id" - assert builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) == "DATEDIFF(d, C.start_date, C.end_date)" - + assert ( + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + == "C.drug_concept_id" + ) + assert ( + builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) + == "DATEDIFF(d, C.start_date, C.end_date)" + ) + def test_observation_period_sql_builder_basic(self): """Test basic ObservationPeriodSqlBuilder functionality.""" builder = ObservationPeriodSqlBuilder() criteria = ObservationPeriod() - + # Test basic methods assert isinstance(builder.get_query_template(), str) assert isinstance(builder.get_default_columns(), set) assert "OBSERVATION_PERIOD" in builder.get_query_template() - + # Test column mapping - assert builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) == "C.period_type_concept_id" - + assert ( + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + == "C.period_type_concept_id" + ) + def test_payer_plan_period_sql_builder_basic(self): """Test basic PayerPlanPeriodSqlBuilder functionality.""" builder = PayerPlanPeriodSqlBuilder() criteria = PayerPlanPeriod() - + # Test basic methods assert isinstance(builder.get_query_template(), str) assert isinstance(builder.get_default_columns(), set) assert "PAYER_PLAN_PERIOD" in builder.get_query_template() - + # Test column mapping - assert builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) == "C.payer_concept_id" - + assert ( + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + == "C.payer_concept_id" + ) + def test_visit_detail_sql_builder_basic(self): """Test basic VisitDetailSqlBuilder functionality.""" builder = VisitDetailSqlBuilder() criteria = VisitDetail(visit_detail_type_exclude=False) - + # Test basic methods assert isinstance(builder.get_query_template(), str) assert isinstance(builder.get_default_columns(), set) assert "VISIT_DETAIL" in builder.get_query_template() - + # Test column mapping - assert builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) == "C.visit_detail_concept_id" - assert builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_DETAIL_ID) == "C.visit_detail_id" - + assert ( + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + == "C.visit_detail_concept_id" + ) + assert ( + builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_DETAIL_ID) + == "C.visit_detail_id" + ) + def test_location_region_sql_builder_basic(self): """Test basic LocationRegionSqlBuilder functionality.""" builder = LocationRegionSqlBuilder() criteria = LocationRegion() - + # Test basic methods assert isinstance(builder.get_query_template(), str) assert isinstance(builder.get_default_columns(), set) assert "LOCATION" in builder.get_query_template() - + # Test column mapping - assert builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) == "C.region_concept_id" - + assert ( + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) + == "C.region_concept_id" + ) + def test_all_builders_have_required_methods(self): """Test that all builders implement required methods.""" builders = [ @@ -101,40 +122,40 @@ def test_all_builders_have_required_methods(self): ObservationPeriodSqlBuilder(), PayerPlanPeriodSqlBuilder(), VisitDetailSqlBuilder(), - LocationRegionSqlBuilder() + LocationRegionSqlBuilder(), ] - + for builder in builders: # Test required abstract methods - assert hasattr(builder, 'get_query_template') - assert hasattr(builder, 'get_default_columns') - assert hasattr(builder, 'get_table_column_for_criteria_column') - assert hasattr(builder, 'embed_codeset_clause') - assert hasattr(builder, 'embed_ordinal_expression') - assert hasattr(builder, 'resolve_select_clauses') - assert hasattr(builder, 'resolve_join_clauses') - assert hasattr(builder, 'resolve_where_clauses') - + assert hasattr(builder, "get_query_template") + assert hasattr(builder, "get_default_columns") + assert hasattr(builder, "get_table_column_for_criteria_column") + assert hasattr(builder, "embed_codeset_clause") + assert hasattr(builder, "embed_ordinal_expression") + assert hasattr(builder, "resolve_select_clauses") + assert hasattr(builder, "resolve_join_clauses") + assert hasattr(builder, "resolve_where_clauses") + # Test that methods are callable assert callable(builder.get_query_template) assert callable(builder.get_default_columns) assert callable(builder.get_table_column_for_criteria_column) - + def test_builder_inheritance(self): """Test that all builders inherit from CriteriaSqlBuilder.""" from circe.cohortdefinition.builders.base import CriteriaSqlBuilder - + builders = [ DoseEraSqlBuilder(), ObservationPeriodSqlBuilder(), PayerPlanPeriodSqlBuilder(), VisitDetailSqlBuilder(), - LocationRegionSqlBuilder() + LocationRegionSqlBuilder(), ] - + for builder in builders: assert isinstance(builder, CriteriaSqlBuilder) - + def test_criteria_column_enum(self): """Test that CriteriaColumn enum has all required values.""" required_columns = { @@ -146,18 +167,20 @@ def test_criteria_column_enum(self): CriteriaColumn.DURATION, CriteriaColumn.UNIT, CriteriaColumn.VALUE_AS_NUMBER, - } - + for column in required_columns: assert column in CriteriaColumn - + def test_builder_options(self): """Test BuilderOptions functionality.""" options = BuilderOptions() assert isinstance(options.additional_columns, list) - - options.additional_columns = [CriteriaColumn.START_DATE, CriteriaColumn.END_DATE] + + options.additional_columns = [ + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + ] assert len(options.additional_columns) == 2 assert CriteriaColumn.START_DATE in options.additional_columns assert CriteriaColumn.END_DATE in options.additional_columns diff --git a/tests/test_sql_builders.py b/tests/test_sql_builders.py index 34c796cb..70313ed5 100644 --- a/tests/test_sql_builders.py +++ b/tests/test_sql_builders.py @@ -63,7 +63,7 @@ def test_get_query_template(self): """Test get_query_template method.""" builder = DeathSqlBuilder() template = builder.get_query_template() - + self.assertIn("@selectClause", template) self.assertIn("@joinClause", template) self.assertIn("@whereClause", template) @@ -74,82 +74,72 @@ def test_get_default_columns(self): """Test get_default_columns method.""" builder = DeathSqlBuilder() columns = builder.get_default_columns() - + expected_columns = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } self.assertEqual(columns, expected_columns) def test_get_table_column_for_criteria_column(self): """Test get_table_column_for_criteria_column method.""" builder = DeathSqlBuilder() - + # Test each column type self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE), - "C.start_date" + "C.start_date", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE), - "C.end_date" + "C.end_date", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), - "coalesce(C.cause_concept_id,0)" + "coalesce(C.cause_concept_id,0)", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), - "CAST(1 as int)" + "CAST(1 as int)", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID), - "NULL" + "NULL", ) def test_get_criteria_sql_basic(self): """Test get_criteria_sql with basic criteria.""" builder = DeathSqlBuilder() - criteria = Death( - first=True, - death_type_exclude=False - ) - + criteria = Death(first=True, death_type_exclude=False) + sql = builder.get_criteria_sql(criteria) - + self.assertIn("SELECT", sql) self.assertIn("FROM @cdm_database_schema.DEATH d", sql) self.assertIn(") C", sql) - def test_get_criteria_sql_with_options(self): """Test get_criteria_sql with builder options.""" builder = DeathSqlBuilder() - criteria = Death( - first=True, - death_type_exclude=False - ) + criteria = Death(first=True, death_type_exclude=False) options = BuilderOptions() options.additional_columns = [CriteriaColumn.VISIT_ID] - + sql = builder.get_criteria_sql_with_options(criteria, options) - + self.assertIn("SELECT", sql) self.assertIn("FROM @cdm_database_schema.DEATH d", sql) self.assertIn(") C", sql) - def test_embed_codeset_clause(self): """Test embed_codeset_clause method.""" builder = DeathSqlBuilder() - criteria = Death( - codeset_id=12345, - first=True, - death_type_exclude=False + criteria = Death(codeset_id=12345, first=True, death_type_exclude=False) + + clause = builder.embed_codeset_clause( + "SELECT * FROM table @codesetClause", criteria ) - - clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) # Updated alias check self.assertIn("d.cause_concept_id", clause) self.assertIn("12345", clause) @@ -157,17 +147,14 @@ def test_embed_codeset_clause(self): def test_embed_codeset_clause_no_codeset(self): """Test embed_codeset_clause with no codeset ID.""" builder = DeathSqlBuilder() - criteria = Death( - first=True, - death_type_exclude=False + criteria = Death(first=True, death_type_exclude=False) + + clause = builder.embed_codeset_clause( + "SELECT * FROM table @codesetClause", criteria ) - - clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertEqual(clause, "SELECT * FROM table ") - - class TestObservationSqlBuilder(unittest.TestCase): """Test ObservationSqlBuilder class.""" @@ -180,7 +167,7 @@ def test_get_query_template(self): """Test get_query_template method.""" builder = ObservationSqlBuilder() template = builder.get_query_template() - + self.assertIn("@selectClause", template) self.assertIn("@joinClause", template) self.assertIn("@whereClause", template) @@ -191,52 +178,48 @@ def test_get_default_columns(self): """Test get_default_columns method.""" builder = ObservationSqlBuilder() columns = builder.get_default_columns() - + expected_columns = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.DOMAIN_CONCEPT, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } self.assertEqual(columns, expected_columns) def test_get_table_column_for_criteria_column(self): """Test get_table_column_for_criteria_column method.""" builder = ObservationSqlBuilder() - + # Test each column type self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE), - "C.start_date" + "C.start_date", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE), - "C.end_date" + "C.end_date", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), - "C.observation_concept_id" + "C.observation_concept_id", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), - "NULL" + "NULL", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID), - "C.visit_occurrence_id" + "C.visit_occurrence_id", ) - def test_get_criteria_sql_basic(self): """Test get_criteria_sql with basic criteria.""" builder = ObservationSqlBuilder() - criteria = Observation( - first=True, - observation_type_exclude=False - ) - + criteria = Observation(first=True, observation_type_exclude=False) + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("C.ordinal = 1", sql) @@ -245,15 +228,12 @@ def test_get_criteria_sql_basic(self): def test_get_criteria_sql_with_options(self): """Test get_criteria_sql with builder options.""" builder = ObservationSqlBuilder() - criteria = Observation( - first=True, - observation_type_exclude=False - ) + criteria = Observation(first=True, observation_type_exclude=False) options = BuilderOptions() options.additional_columns = [CriteriaColumn.DURATION] - + sql = builder.get_criteria_sql_with_options(criteria, options) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("WHERE", sql) @@ -267,11 +247,11 @@ def test_get_criteria_sql_with_date_ranges(self): first=True, observation_type_exclude=False, occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), - occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01") + occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01"), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("WHERE", sql) @@ -284,11 +264,11 @@ def test_get_criteria_sql_with_age_condition(self): criteria = Observation( first=True, observation_type_exclude=False, - age=NumericRange(op="gte", value=18, extent=65) + age=NumericRange(op="gte", value=18, extent=65), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("WHERE", sql) @@ -301,11 +281,11 @@ def test_get_criteria_sql_with_value_as_string(self): criteria = Observation( first=True, observation_type_exclude=False, - value_as_string=TextFilter(text="normal", op="eq") + value_as_string=TextFilter(text="normal", op="eq"), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("WHERE", sql) @@ -317,11 +297,13 @@ def test_get_criteria_sql_with_provider_specialty(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False) + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("WHERE", sql) @@ -336,11 +318,13 @@ def test_get_criteria_sql_with_provider_specialty_exclusion(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=True) + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=True + ), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("WHERE", sql) @@ -353,13 +337,11 @@ def test_get_criteria_sql_with_codeset_id(self): """Test get_criteria_sql with codeset ID.""" builder = ObservationSqlBuilder() criteria = Observation( - first=True, - observation_type_exclude=False, - codeset_id=12345 + first=True, observation_type_exclude=False, codeset_id=12345 ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("WHERE", sql) @@ -376,40 +358,43 @@ def test_get_criteria_sql_complex_scenario(self): occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01"), age=NumericRange(op="gte", value=18, extent=65), value_as_string=TextFilter(text="normal", op="eq"), - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), - codeset_id=67890 + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), + codeset_id=67890, ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("WHERE", sql) - self.assertIn("JOIN @cdm_database_schema.PERSON P", sql) # Age requires PERSON join + self.assertIn( + "JOIN @cdm_database_schema.PERSON P", sql + ) # Age requires PERSON join self.assertIn("AND", sql) # Should have multiple conditions joined with AND def test_embed_codeset_clause(self): """Test embed_codeset_clause method.""" builder = ObservationSqlBuilder() criteria = Observation( - codeset_id=12345, - first=True, - observation_type_exclude=False + codeset_id=12345, first=True, observation_type_exclude=False + ) + + clause = builder.embed_codeset_clause( + "SELECT * FROM table @codesetClause", criteria ) - - clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertIn("o.observation_concept_id", clause) self.assertIn("12345", clause) def test_embed_codeset_clause_no_codeset(self): """Test embed_codeset_clause with no codeset ID.""" builder = ObservationSqlBuilder() - criteria = Observation( - first=True, - observation_type_exclude=False + criteria = Observation(first=True, observation_type_exclude=False) + + clause = builder.embed_codeset_clause( + "SELECT * FROM table @codesetClause", criteria ) - - clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertEqual(clause, "SELECT * FROM table ") def test_resolve_select_clauses_basic(self): @@ -417,9 +402,9 @@ def test_resolve_select_clauses_basic(self): builder = ObservationSqlBuilder() criteria = Observation(first=True, observation_type_exclude=False) options = BuilderOptions() - + select_clause = builder.resolve_select_clauses(criteria, options) - + self.assertIn("o.observation_date as start_date", select_clause) self.assertIn("DATEADD(day,1,o.observation_date) as end_date", select_clause) self.assertIn("o.person_id", select_clause) @@ -431,9 +416,9 @@ def test_resolve_select_clauses_with_additional_columns(self): criteria = Observation(first=True, observation_type_exclude=False) options = BuilderOptions() options.additional_columns = [CriteriaColumn.DOMAIN_CONCEPT] - + select_clause = builder.resolve_select_clauses(criteria, options) - + # resolve_select_clauses now only returns inner query columns # Additional columns are handled by get_additional_columns separately self.assertIn("o.observation_date as start_date", select_clause) @@ -444,9 +429,9 @@ def test_resolve_join_clauses_no_joins(self): builder = ObservationSqlBuilder() criteria = Observation(first=True, observation_type_exclude=False) options = BuilderOptions() - + join_clause = builder.resolve_join_clauses(criteria, options) - + self.assertEqual(join_clause, []) def test_resolve_join_clauses_with_provider_specialty(self): @@ -455,14 +440,23 @@ def test_resolve_join_clauses_with_provider_specialty(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False) + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), ) options = BuilderOptions() - + join_clause = builder.resolve_join_clauses(criteria, options) - - self.assertTrue(any("JOIN @cdm_database_schema.PROVIDER PR" in clause for clause in join_clause)) - self.assertTrue(any("C.provider_id = PR.provider_id" in clause for clause in join_clause)) + + self.assertTrue( + any( + "JOIN @cdm_database_schema.PROVIDER PR" in clause + for clause in join_clause + ) + ) + self.assertTrue( + any("C.provider_id = PR.provider_id" in clause for clause in join_clause) + ) def test_resolve_join_clauses_with_provider_specialty_no_codeset_id(self): """Test resolve_join_clauses with provider specialty but no codeset_id.""" @@ -470,12 +464,14 @@ def test_resolve_join_clauses_with_provider_specialty_no_codeset_id(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=None, is_exclusion=False) + provider_specialty_cs=ConceptSetSelection( + codeset_id=None, is_exclusion=False + ), ) options = BuilderOptions() - + join_clause = builder.resolve_join_clauses(criteria, options) - + self.assertEqual(join_clause, []) def test_resolve_where_clauses_basic(self): @@ -483,9 +479,9 @@ def test_resolve_where_clauses_basic(self): builder = ObservationSqlBuilder() criteria = Observation(first=True, observation_type_exclude=False) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + self.assertEqual(where_clause, []) def test_resolve_where_clauses_with_date_ranges(self): @@ -495,13 +491,18 @@ def test_resolve_where_clauses_with_date_ranges(self): first=True, observation_type_exclude=False, occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), - occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01") + occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01"), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - - self.assertTrue(any("C.start_date" in clause or "C.end_date" in clause for clause in where_clause)) + + self.assertTrue( + any( + "C.start_date" in clause or "C.end_date" in clause + for clause in where_clause + ) + ) # Should have multiple conditions self.assertGreater(len(where_clause), 1) @@ -511,13 +512,18 @@ def test_resolve_where_clauses_with_age_condition(self): criteria = Observation( first=True, observation_type_exclude=False, - age=NumericRange(op="gte", value=18, extent=65) + age=NumericRange(op="gte", value=18, extent=65), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - - self.assertTrue(any("C.start_date" in clause and "P.year_of_birth" in clause for clause in where_clause)) + + self.assertTrue( + any( + "C.start_date" in clause and "P.year_of_birth" in clause + for clause in where_clause + ) + ) def test_resolve_where_clauses_with_value_as_string(self): """Test resolve_where_clauses with value as string condition.""" @@ -525,12 +531,12 @@ def test_resolve_where_clauses_with_value_as_string(self): criteria = Observation( first=True, observation_type_exclude=False, - value_as_string=TextFilter(text="normal", op="eq") + value_as_string=TextFilter(text="normal", op="eq"), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + self.assertTrue(any("C.value_as_string" in clause for clause in where_clause)) def test_resolve_where_clauses_with_provider_specialty(self): @@ -539,14 +545,18 @@ def test_resolve_where_clauses_with_provider_specialty(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False) + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + # ObservationSqlBuilder uses PR alias for PROVIDER (to avoid conflict with PERSON alias P) - self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) + self.assertTrue( + any("PR.specialty_concept_id" in clause for clause in where_clause) + ) self.assertTrue(any("12345" in clause for clause in where_clause)) def test_resolve_where_clauses_with_provider_specialty_exclusion(self): @@ -555,28 +565,30 @@ def test_resolve_where_clauses_with_provider_specialty_exclusion(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=True) + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=True + ), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + # ObservationSqlBuilder uses PR alias for PROVIDER (to avoid conflict with PERSON alias P) - self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) + self.assertTrue( + any("PR.specialty_concept_id" in clause for clause in where_clause) + ) self.assertTrue(any("not" in clause for clause in where_clause)) def test_resolve_where_clauses_with_codeset_id(self): """Test resolve_where_clauses with codeset ID.""" builder = ObservationSqlBuilder() criteria = Observation( - first=True, - observation_type_exclude=False, - codeset_id=12345 + first=True, observation_type_exclude=False, codeset_id=12345 ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + # Codeset filtering is now handled via JOIN in the inner query, not in WHERE clause # So where_clause should be empty for just codeset_id self.assertEqual(where_clause, []) @@ -591,21 +603,30 @@ def test_resolve_where_clauses_complex_scenario(self): occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01"), age=NumericRange(op="gte", value=18, extent=65), value_as_string=TextFilter(text="normal", op="eq"), - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), - codeset_id=67890 + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), + codeset_id=67890, ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + # Check for date conditions (uses C.start_date and C.end_date) - self.assertTrue(any("C.start_date" in clause or "C.end_date" in clause for clause in where_clause)) + self.assertTrue( + any( + "C.start_date" in clause or "C.end_date" in clause + for clause in where_clause + ) + ) # Check for age condition (uses C.start_date and P.year_of_birth) self.assertTrue(any("P.year_of_birth" in clause for clause in where_clause)) # Check for value_as_string self.assertTrue(any("C.value_as_string" in clause for clause in where_clause)) # ObservationSqlBuilder uses PR alias for PROVIDER (to avoid conflict with PERSON alias P) - self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) + self.assertTrue( + any("PR.specialty_concept_id" in clause for clause in where_clause) + ) # Note: codeset_id is now handled via JOIN, not WHERE clause # Should have multiple conditions (where_clause is a list of strings) self.assertGreater(len(where_clause), 3) @@ -615,9 +636,9 @@ def test_resolve_ordinal_expression_with_first(self): builder = ObservationSqlBuilder() criteria = Observation(first=True, observation_type_exclude=False) options = BuilderOptions() - + ordinal_expression = builder.resolve_ordinal_expression(criteria, options) - + # Now uses row_number() over with partition by person_id self.assertIn("row_number() over", ordinal_expression.lower()) self.assertIn("o.person_id", ordinal_expression) @@ -628,15 +649,15 @@ def test_resolve_ordinal_expression_without_first(self): builder = ObservationSqlBuilder() criteria = Observation(first=False, observation_type_exclude=False) options = BuilderOptions() - + ordinal_expression = builder.resolve_ordinal_expression(criteria, options) - + self.assertEqual(ordinal_expression, "") def test_sql_generation_edge_cases(self): """Test SQL generation with edge cases.""" builder = ObservationSqlBuilder() - + # Test with None values criteria = Observation( first=True, @@ -646,15 +667,17 @@ def test_sql_generation_edge_cases(self): age=None, value_as_string=None, provider_specialty_cs=None, - codeset_id=None + codeset_id=None, ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("C.ordinal = 1", sql) # WHERE clause for first=True - self.assertNotIn("JOIN @cdm_database_schema.PERSON", sql) # No age condition, no PERSON join + self.assertNotIn( + "JOIN @cdm_database_schema.PERSON", sql + ) # No age condition, no PERSON join def test_sql_generation_with_empty_concept_lists(self): """Test SQL generation with empty concept lists.""" @@ -664,11 +687,11 @@ def test_sql_generation_with_empty_concept_lists(self): observation_type_exclude=False, gender=[], observation_type=[], - provider_specialty=[] + provider_specialty=[], ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("C.ordinal = 1", sql) # WHERE clause for first=True @@ -680,19 +703,21 @@ def test_sql_template_placeholder_replacement(self): first=True, observation_type_exclude=False, occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), - codeset_id=67890 + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), + codeset_id=67890, ) - + sql = builder.get_criteria_sql(criteria) - + # All placeholders should be replaced self.assertNotIn("@selectClause", sql) self.assertNotIn("@joinClause", sql) self.assertNotIn("@whereClause", sql) self.assertNotIn("@ordinalExpression", sql) self.assertNotIn("@codesetClause", sql) - + # Should have actual content with new nested structure self.assertIn("o.observation_date as start_date", sql) self.assertIn("JOIN @cdm_database_schema.PROVIDER PR", sql) # Uses PR alias @@ -712,7 +737,7 @@ def test_get_query_template(self): """Test get_query_template method.""" builder = MeasurementSqlBuilder() template = builder.get_query_template() - + self.assertIn("@selectClause", template) self.assertIn("@joinClause", template) self.assertIn("@whereClause", template) @@ -723,52 +748,48 @@ def test_get_default_columns(self): """Test get_default_columns method.""" builder = MeasurementSqlBuilder() columns = builder.get_default_columns() - + expected_columns = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.DOMAIN_CONCEPT, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } self.assertEqual(columns, expected_columns) def test_get_table_column_for_criteria_column(self): """Test get_table_column_for_criteria_column method.""" builder = MeasurementSqlBuilder() - + # Test each column type self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE), - "C.start_date" + "C.start_date", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE), - "C.end_date" + "C.end_date", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), - "C.measurement_concept_id" + "C.measurement_concept_id", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), - "NULL" + "NULL", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID), - "C.visit_occurrence_id" + "C.visit_occurrence_id", ) - def test_get_criteria_sql_basic(self): """Test get_criteria_sql with basic criteria.""" builder = MeasurementSqlBuilder() - criteria = Measurement( - first=True, - measurement_type_exclude=False - ) - + criteria = Measurement(first=True, measurement_type_exclude=False) + sql = builder.get_criteria_sql(criteria) - + # Check for nested structure with lowercase keywords self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) @@ -777,15 +798,12 @@ def test_get_criteria_sql_basic(self): def test_get_criteria_sql_with_options(self): """Test get_criteria_sql with builder options.""" builder = MeasurementSqlBuilder() - criteria = Measurement( - first=True, - measurement_type_exclude=False - ) + criteria = Measurement(first=True, measurement_type_exclude=False) options = BuilderOptions() options.additional_columns = [CriteriaColumn.VALUE_AS_NUMBER] - + sql = builder.get_criteria_sql_with_options(criteria, options) - + # Check for nested structure self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) @@ -799,11 +817,11 @@ def test_get_criteria_sql_with_date_ranges(self): first=True, measurement_type_exclude=False, occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), - occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01") + occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01"), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) self.assertIn("WHERE", sql) @@ -816,11 +834,11 @@ def test_get_criteria_sql_with_age_condition(self): criteria = Measurement( first=True, measurement_type_exclude=False, - age=NumericRange(op="gte", value=18, extent=65) + age=NumericRange(op="gte", value=18, extent=65), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) self.assertIn("WHERE", sql) @@ -833,11 +851,11 @@ def test_get_criteria_sql_with_value_as_number(self): criteria = Measurement( first=True, measurement_type_exclude=False, - value_as_number=NumericRange(op="gte", value=100, extent=200) + value_as_number=NumericRange(op="gte", value=100, extent=200), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) self.assertIn("WHERE", sql) self.assertIn("C.value_as_number", sql) @@ -848,11 +866,11 @@ def test_get_criteria_sql_with_range_low(self): criteria = Measurement( first=True, measurement_type_exclude=False, - range_low=NumericRange(op="gte", value=50, extent=100) + range_low=NumericRange(op="gte", value=50, extent=100), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) self.assertIn("WHERE", sql) @@ -864,11 +882,11 @@ def test_get_criteria_sql_with_range_high(self): criteria = Measurement( first=True, measurement_type_exclude=False, - range_high=NumericRange(op="lt", value=200, extent=300) + range_high=NumericRange(op="lt", value=200, extent=300), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) self.assertIn("WHERE", sql) @@ -880,11 +898,13 @@ def test_get_criteria_sql_with_provider_specialty(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False) + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) self.assertIn("WHERE", sql) @@ -899,11 +919,13 @@ def test_get_criteria_sql_with_provider_specialty_exclusion(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=True) + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=True + ), ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) # Codeset filtering is via JOIN in inner query @@ -915,13 +937,11 @@ def test_get_criteria_sql_with_codeset_id(self): """Test get_criteria_sql with codeset ID.""" builder = MeasurementSqlBuilder() criteria = Measurement( - first=True, - measurement_type_exclude=False, - codeset_id=12345 + first=True, measurement_type_exclude=False, codeset_id=12345 ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) # Codeset filtering is via JOIN in inner query @@ -942,12 +962,14 @@ def test_get_criteria_sql_complex_scenario(self): value_as_string=TextFilter(text="normal", op="eq"), range_low=NumericRange(op="gte", value=50, extent=100), range_high=NumericRange(op="lt", value=200, extent=300), - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), - codeset_id=67890 + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), + codeset_id=67890, ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) self.assertIn("WHERE", sql) @@ -959,24 +981,25 @@ def test_embed_codeset_clause(self): """Test embed_codeset_clause method.""" builder = MeasurementSqlBuilder() criteria = Measurement( - codeset_id=12345, - first=True, - measurement_type_exclude=False + codeset_id=12345, first=True, measurement_type_exclude=False + ) + + clause = builder.embed_codeset_clause( + "SELECT * FROM table @codesetClause", criteria ) - - clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) - self.assertIn("m.measurement_concept_id", clause) # Use m. prefix in inner query + self.assertIn( + "m.measurement_concept_id", clause + ) # Use m. prefix in inner query self.assertIn("12345", clause) def test_embed_codeset_clause_no_codeset(self): """Test embed_codeset_clause with no codeset ID.""" builder = MeasurementSqlBuilder() - criteria = Measurement( - first=True, - measurement_type_exclude=False + criteria = Measurement(first=True, measurement_type_exclude=False) + + clause = builder.embed_codeset_clause( + "SELECT * FROM table @codesetClause", criteria ) - - clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertEqual(clause, "SELECT * FROM table ") def test_resolve_select_clauses_basic(self): @@ -984,11 +1007,13 @@ def test_resolve_select_clauses_basic(self): builder = MeasurementSqlBuilder() criteria = Measurement(first=True, measurement_type_exclude=False) options = BuilderOptions() - + select_clause = builder.resolve_select_clauses(criteria, options) - + # Inner query uses m. prefix - self.assertTrue(any("m.measurement_date as start_date" in col for col in select_clause)) + self.assertTrue( + any("m.measurement_date as start_date" in col for col in select_clause) + ) self.assertIn("m.person_id", select_clause) self.assertIn("m.measurement_id", select_clause) self.assertIn("m.measurement_concept_id", select_clause) @@ -1000,12 +1025,14 @@ def test_resolve_select_clauses_with_additional_columns(self): criteria = Measurement(first=True, measurement_type_exclude=False) options = BuilderOptions() options.additional_columns = [CriteriaColumn.VALUE_AS_NUMBER] - + select_clause = builder.resolve_select_clauses(criteria, options) - + # resolve_select_clauses returns inner query columns (m. prefix) # Additional columns are handled elsewhere so check for standard columns - self.assertTrue(any("m.measurement_date as start_date" in col for col in select_clause)) + self.assertTrue( + any("m.measurement_date as start_date" in col for col in select_clause) + ) self.assertIn("m.measurement_concept_id", select_clause) def test_resolve_join_clauses_no_joins(self): @@ -1013,9 +1040,9 @@ def test_resolve_join_clauses_no_joins(self): builder = MeasurementSqlBuilder() criteria = Measurement(first=True, measurement_type_exclude=False) options = BuilderOptions() - + join_clause = builder.resolve_join_clauses(criteria, options) - + self.assertEqual(join_clause, []) def test_resolve_join_clauses_with_provider_specialty(self): @@ -1024,15 +1051,24 @@ def test_resolve_join_clauses_with_provider_specialty(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False) + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), ) options = BuilderOptions() - + join_clause = builder.resolve_join_clauses(criteria, options) - + # Provider now uses PR alias to avoid conflict with PERSON P - self.assertTrue(any("JOIN @cdm_database_schema.PROVIDER PR" in clause for clause in join_clause)) - self.assertTrue(any("C.provider_id = PR.provider_id" in clause for clause in join_clause)) + self.assertTrue( + any( + "JOIN @cdm_database_schema.PROVIDER PR" in clause + for clause in join_clause + ) + ) + self.assertTrue( + any("C.provider_id = PR.provider_id" in clause for clause in join_clause) + ) def test_resolve_join_clauses_with_provider_specialty_no_codeset_id(self): """Test resolve_join_clauses with provider specialty but no codeset_id.""" @@ -1040,12 +1076,14 @@ def test_resolve_join_clauses_with_provider_specialty_no_codeset_id(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=None, is_exclusion=False) + provider_specialty_cs=ConceptSetSelection( + codeset_id=None, is_exclusion=False + ), ) options = BuilderOptions() - + join_clause = builder.resolve_join_clauses(criteria, options) - + self.assertEqual(join_clause, []) def test_resolve_where_clauses_basic(self): @@ -1053,9 +1091,9 @@ def test_resolve_where_clauses_basic(self): builder = MeasurementSqlBuilder() criteria = Measurement(first=True, measurement_type_exclude=False) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + self.assertEqual(where_clause, []) def test_resolve_where_clauses_with_date_ranges(self): @@ -1065,14 +1103,19 @@ def test_resolve_where_clauses_with_date_ranges(self): first=True, measurement_type_exclude=False, occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), - occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01") + occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01"), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + # Now uses C.start_date and C.end_date (from outer query) - self.assertTrue(any("C.start_date" in clause or "C.end_date" in clause for clause in where_clause)) + self.assertTrue( + any( + "C.start_date" in clause or "C.end_date" in clause + for clause in where_clause + ) + ) # Should have multiple clauses for date ranges self.assertGreater(len(where_clause), 0) @@ -1082,12 +1125,12 @@ def test_resolve_where_clauses_with_age_condition(self): criteria = Measurement( first=True, measurement_type_exclude=False, - age=NumericRange(op="gte", value=18, extent=65) + age=NumericRange(op="gte", value=18, extent=65), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + # Age condition uses YEAR(C.start_date) - P.year_of_birth self.assertTrue(any("YEAR(C.start_date)" in clause for clause in where_clause)) @@ -1097,14 +1140,13 @@ def test_resolve_where_clauses_with_value_as_number(self): criteria = Measurement( first=True, measurement_type_exclude=False, - value_as_number=NumericRange(op="gte", value=100, extent=200) + value_as_number=NumericRange(op="gte", value=100, extent=200), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - - self.assertTrue(any("C.value_as_number" in clause for clause in where_clause)) + self.assertTrue(any("C.value_as_number" in clause for clause in where_clause)) def test_resolve_where_clauses_with_range_low(self): """Test resolve_where_clauses with range low condition.""" @@ -1112,12 +1154,12 @@ def test_resolve_where_clauses_with_range_low(self): criteria = Measurement( first=True, measurement_type_exclude=False, - range_low=NumericRange(op="gte", value=50, extent=100) + range_low=NumericRange(op="gte", value=50, extent=100), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + self.assertTrue(any("C.range_low" in clause for clause in where_clause)) def test_resolve_where_clauses_with_range_high(self): @@ -1126,12 +1168,12 @@ def test_resolve_where_clauses_with_range_high(self): criteria = Measurement( first=True, measurement_type_exclude=False, - range_high=NumericRange(op="lt", value=200, extent=300) + range_high=NumericRange(op="lt", value=200, extent=300), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + self.assertTrue(any("C.range_high" in clause for clause in where_clause)) def test_resolve_where_clauses_with_provider_specialty(self): @@ -1140,14 +1182,18 @@ def test_resolve_where_clauses_with_provider_specialty(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False) + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + # Provider now uses PR alias to avoid conflict with PERSON (P) - self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) + self.assertTrue( + any("PR.specialty_concept_id" in clause for clause in where_clause) + ) self.assertTrue(any("12345" in clause for clause in where_clause)) def test_resolve_where_clauses_with_provider_specialty_exclusion(self): @@ -1156,28 +1202,30 @@ def test_resolve_where_clauses_with_provider_specialty_exclusion(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=True) + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=True + ), ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + # Provider now uses PR alias - self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) + self.assertTrue( + any("PR.specialty_concept_id" in clause for clause in where_clause) + ) self.assertTrue(any("not" in clause for clause in where_clause)) def test_resolve_where_clauses_with_codeset_id(self): """Test resolve_where_clauses with codeset ID.""" builder = MeasurementSqlBuilder() criteria = Measurement( - first=True, - measurement_type_exclude=False, - codeset_id=12345 + first=True, measurement_type_exclude=False, codeset_id=12345 ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + # Codeset filtering is now handled via JOIN in inner query, not WHERE clause self.assertEqual(where_clause, []) @@ -1194,22 +1242,31 @@ def test_resolve_where_clauses_complex_scenario(self): value_as_string=TextFilter(text="normal", op="eq"), range_low=NumericRange(op="gte", value=50, extent=100), range_high=NumericRange(op="lt", value=200, extent=300), - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), - codeset_id=67890 + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), + codeset_id=67890, ) options = BuilderOptions() - + where_clause = builder.resolve_where_clauses(criteria, options) - + # Date conditions use C.start_date/C.end_date in outer query - self.assertTrue(any("C.start_date" in clause or "C.end_date" in clause for clause in where_clause)) + self.assertTrue( + any( + "C.start_date" in clause or "C.end_date" in clause + for clause in where_clause + ) + ) # Age conditions use YEAR(C.start_date) self.assertTrue(any("YEAR(C.start_date)" in clause for clause in where_clause)) self.assertTrue(any("C.value_as_number" in clause for clause in where_clause)) self.assertTrue(any("C.range_low" in clause for clause in where_clause)) self.assertTrue(any("C.range_high" in clause for clause in where_clause)) # Provider now uses PR alias to avoid conflict with PERSON P - self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) + self.assertTrue( + any("PR.specialty_concept_id" in clause for clause in where_clause) + ) # codeset_id is now handled via JOIN in inner query, not WHERE clause # Should have multiple conditions self.assertGreater(len(where_clause), 5) @@ -1219,9 +1276,9 @@ def test_resolve_ordinal_expression_with_first(self): builder = MeasurementSqlBuilder() criteria = Measurement(first=True, measurement_type_exclude=False) options = BuilderOptions() - + ordinal_expression = builder.resolve_ordinal_expression(criteria, options) - + # Now uses standard ORDER BY for ORDINAL expression in Measurement self.assertIn("ORDER BY m.measurement_date", ordinal_expression) self.assertIn("m.measurement_id", ordinal_expression) @@ -1231,15 +1288,15 @@ def test_resolve_ordinal_expression_without_first(self): builder = MeasurementSqlBuilder() criteria = Measurement(first=False, measurement_type_exclude=False) options = BuilderOptions() - + ordinal_expression = builder.resolve_ordinal_expression(criteria, options) - + self.assertEqual(ordinal_expression, "") def test_sql_generation_edge_cases(self): """Test SQL generation with edge cases.""" builder = MeasurementSqlBuilder() - + # Test with None values criteria = Measurement( first=True, @@ -1252,11 +1309,11 @@ def test_sql_generation_edge_cases(self): range_low=None, range_high=None, provider_specialty_cs=None, - codeset_id=None + codeset_id=None, ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) # With first=True, generates ROW_NUMBER() OVER @@ -1275,11 +1332,11 @@ def test_sql_generation_with_empty_concept_lists(self): measurement_type=[], operator=[], unit=[], - provider_specialty=[] + provider_specialty=[], ) - + sql = builder.get_criteria_sql(criteria) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) # With first=True, generates ROW_NUMBER() OVER @@ -1292,19 +1349,21 @@ def test_sql_template_placeholder_replacement(self): first=True, measurement_type_exclude=False, occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), - provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), - codeset_id=67890 + provider_specialty_cs=ConceptSetSelection( + codeset_id=12345, is_exclusion=False + ), + codeset_id=67890, ) - + sql = builder.get_criteria_sql(criteria) - + # All placeholders should be replaced (now uses @codesetClause, @additionalColumns) self.assertNotIn("@selectClause", sql) self.assertNotIn("@joinClause", sql) self.assertNotIn("@whereClause", sql) self.assertNotIn("@ordinalExpression", sql) self.assertNotIn("@codesetClause", sql) - + # Should have actual content with nested structure self.assertIn("m.measurement_date as start_date", sql) # Inner query self.assertIn("JOIN @cdm_database_schema.PROVIDER PR", sql) # PR alias @@ -1324,7 +1383,7 @@ def test_get_query_template(self): """Test get_query_template method.""" builder = DeviceExposureSqlBuilder() template = builder.get_query_template() - + self.assertIn("@selectClause", template) self.assertIn("@joinClause", template) self.assertIn("@whereClause", template) @@ -1335,51 +1394,47 @@ def test_get_default_columns(self): """Test get_default_columns method.""" builder = DeviceExposureSqlBuilder() columns = builder.get_default_columns() - + expected_columns = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } self.assertEqual(columns, expected_columns) def test_get_table_column_for_criteria_column(self): """Test get_table_column_for_criteria_column method.""" builder = DeviceExposureSqlBuilder() - + # Test each column type self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE), - "C.start_date" + "C.start_date", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE), - "C.end_date" + "C.end_date", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), - "C.device_concept_id" + "C.device_concept_id", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), - "DATEDIFF(day, C.start_date, C.end_date)" + "DATEDIFF(day, C.start_date, C.end_date)", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID), - "C.visit_occurrence_id" + "C.visit_occurrence_id", ) - def test_get_criteria_sql_basic(self): """Test get_criteria_sql with basic criteria.""" builder = DeviceExposureSqlBuilder() - criteria = DeviceExposure( - first=True, - device_type_exclude=False - ) - + criteria = DeviceExposure(first=True, device_type_exclude=False) + sql = builder.get_criteria_sql(criteria) - + self.assertIn("SELECT", sql) self.assertIn("FROM @cdm_database_schema.DEVICE_EXPOSURE de", sql) self.assertIn(") C", sql) @@ -1388,12 +1443,12 @@ def test_embed_codeset_clause(self): """Test embed_codeset_clause method.""" builder = DeviceExposureSqlBuilder() criteria = DeviceExposure( - codeset_id=12345, - first=True, - device_type_exclude=False + codeset_id=12345, first=True, device_type_exclude=False + ) + + clause = builder.embed_codeset_clause( + "SELECT * FROM table @codesetClause", criteria ) - - clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertIn("de.device_concept_id", clause) self.assertIn("12345", clause) @@ -1410,7 +1465,7 @@ def test_get_query_template(self): """Test get_query_template method.""" builder = SpecimenSqlBuilder() template = builder.get_query_template() - + self.assertIn("@selectClause", template) self.assertIn("@joinClause", template) self.assertIn("@whereClause", template) @@ -1421,51 +1476,47 @@ def test_get_default_columns(self): """Test get_default_columns method.""" builder = SpecimenSqlBuilder() columns = builder.get_default_columns() - + expected_columns = { CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID + CriteriaColumn.VISIT_ID, } self.assertEqual(columns, expected_columns) def test_get_table_column_for_criteria_column(self): """Test get_table_column_for_criteria_column method.""" builder = SpecimenSqlBuilder() - + # Test each column type self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE), - "C.specimen_date" + "C.specimen_date", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE), - "C.specimen_date" + "C.specimen_date", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), - "C.specimen_concept_id" + "C.specimen_concept_id", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), - "CAST(1 as int)" + "CAST(1 as int)", ) self.assertEqual( builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID), - "C.visit_occurrence_id" + "C.visit_occurrence_id", ) - def test_get_criteria_sql_basic(self): """Test get_criteria_sql with basic criteria.""" builder = SpecimenSqlBuilder() - criteria = Specimen( - first=True, - specimen_type_exclude=False - ) - + criteria = Specimen(first=True, specimen_type_exclude=False) + sql = builder.get_criteria_sql(criteria) - + self.assertIn("SELECT", sql) self.assertIn("FROM @cdm_database_schema.SPECIMEN s", sql) self.assertIn(") C", sql) @@ -1475,13 +1526,11 @@ def test_get_criteria_sql_basic(self): def test_embed_codeset_clause(self): """Test embed_codeset_clause method.""" builder = SpecimenSqlBuilder() - criteria = Specimen( - codeset_id=12345, - first=True, - specimen_type_exclude=False + criteria = Specimen(codeset_id=12345, first=True, specimen_type_exclude=False) + + clause = builder.embed_codeset_clause( + "SELECT * FROM table @codesetClause", criteria ) - - clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertIn("s.specimen_concept_id", clause) self.assertIn("12345", clause) @@ -1499,7 +1548,7 @@ def test_all_new_builders_importable(self): SpecimenSqlBuilder, VisitOccurrenceSqlBuilder, ) - + # Test that all builders are importable self.assertTrue(DeathSqlBuilder is not None) self.assertTrue(VisitOccurrenceSqlBuilder is not None) @@ -1512,23 +1561,23 @@ def test_builder_options_with_new_builders(self): """Test BuilderOptions with new builders.""" options = BuilderOptions() options.additional_columns = [CriteriaColumn.VISIT_ID, CriteriaColumn.DURATION] - + builders = [ DeathSqlBuilder(), VisitOccurrenceSqlBuilder(), ObservationSqlBuilder(), MeasurementSqlBuilder(), DeviceExposureSqlBuilder(), - SpecimenSqlBuilder() + SpecimenSqlBuilder(), ] - + for builder in builders: # Test that all builders can handle the options self.assertIsNotNone(builder) # Test that they have the required methods - self.assertTrue(hasattr(builder, 'get_criteria_sql')) - self.assertTrue(hasattr(builder, 'get_default_columns')) - self.assertTrue(hasattr(builder, 'get_query_template')) + self.assertTrue(hasattr(builder, "get_criteria_sql")) + self.assertTrue(hasattr(builder, "get_default_columns")) + self.assertTrue(hasattr(builder, "get_query_template")) def test_sql_template_structure_consistency(self): """Test that all new builders have consistent SQL template structure.""" @@ -1538,12 +1587,12 @@ def test_sql_template_structure_consistency(self): ObservationSqlBuilder(), MeasurementSqlBuilder(), DeviceExposureSqlBuilder(), - SpecimenSqlBuilder() + SpecimenSqlBuilder(), ] - + for builder in builders: template = builder.get_query_template() - + # All templates should have these placeholders self.assertIn("@selectClause", template) self.assertIn("@joinClause", template) @@ -1552,12 +1601,12 @@ def test_sql_template_structure_consistency(self): # DeathSqlBuilder does not use ordinal expression in Java parity if not isinstance(builder, DeathSqlBuilder): self.assertIn("@ordinalExpression", template) - + # All templates should have basic SQL structure (case-insensitive) self.assertIn("select", template.lower()) self.assertIn("from", template.lower()) self.assertIn("where", template.lower()) - + # All templates should reference the CDM database schema self.assertIn("@cdm_database_schema", template) @@ -1569,16 +1618,16 @@ def test_criteria_column_consistency_across_new_builders(self): ObservationSqlBuilder(), MeasurementSqlBuilder(), DeviceExposureSqlBuilder(), - SpecimenSqlBuilder() + SpecimenSqlBuilder(), ] - + for builder in builders: columns = builder.get_default_columns() - + # All builders should have at least START_DATE and END_DATE self.assertIn(CriteriaColumn.START_DATE, columns) self.assertIn(CriteriaColumn.END_DATE, columns) - + # Test that column mapping works for all builders for column in columns: table_column = builder.get_table_column_for_criteria_column(column) @@ -1588,12 +1637,12 @@ def test_criteria_column_consistency_across_new_builders(self): class TestDoseEraSqlBuilder(unittest.TestCase): """Test DoseEraSqlBuilder class.""" - + def test_get_query_template(self): """Test query template generation.""" builder = DoseEraSqlBuilder() template = builder.get_query_template() - + self.assertIn("@selectClause", template) self.assertIn("@codesetClause", template) self.assertIn("@joinClause", template) @@ -1601,79 +1650,97 @@ def test_get_query_template(self): self.assertIn("@ordinalExpression", template) self.assertIn("@additionalColumns", template) self.assertIn("DOSE_ERA", template) - + def test_get_default_columns(self): """Test default columns.""" builder = DoseEraSqlBuilder() default_cols = builder.get_default_columns() - - expected_cols = {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_ID} + + expected_cols = { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_ID, + } self.assertEqual(default_cols, expected_cols) - + def test_get_table_column_for_criteria_column(self): """Test criteria column mapping.""" builder = DoseEraSqlBuilder() - - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), "C.drug_concept_id") - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), "DATEDIFF(d, C.start_date, C.end_date)") - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.UNIT), "C.unit_concept_id") - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.VALUE_AS_NUMBER), "C.dose_value") - + + self.assertEqual( + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), + "C.drug_concept_id", + ) + self.assertEqual( + builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), + "DATEDIFF(d, C.start_date, C.end_date)", + ) + self.assertEqual( + builder.get_table_column_for_criteria_column(CriteriaColumn.UNIT), + "C.unit_concept_id", + ) + self.assertEqual( + builder.get_table_column_for_criteria_column( + CriteriaColumn.VALUE_AS_NUMBER + ), + "C.dose_value", + ) + def test_embed_codeset_clause(self): """Test codeset clause embedding.""" builder = DoseEraSqlBuilder() criteria = DoseEra(first=False, codeset_id=123) - + query = "SELECT * FROM table @codesetClause" result = builder.embed_codeset_clause(query, criteria) - + self.assertNotIn("@codesetClause", result) self.assertIn("codeset_id = 123", result) - + def test_embed_codeset_clause_no_codeset(self): """Test codeset clause embedding with no codeset.""" builder = DoseEraSqlBuilder() criteria = DoseEra(first=False) - + query = "SELECT * FROM table @codesetClause" result = builder.embed_codeset_clause(query, criteria) - + self.assertNotIn("@codesetClause", result) self.assertEqual(result, "SELECT * FROM table ") - + def test_embed_ordinal_expression_first(self): """Test ordinal expression with first=True.""" builder = DoseEraSqlBuilder() criteria = DoseEra(first=True) where_clauses = [] - + query = "SELECT * FROM table @ordinalExpression" result = builder.embed_ordinal_expression(query, criteria, where_clauses) - + self.assertNotIn("@ordinalExpression", result) self.assertIn("row_number()", result) self.assertIn("C.ordinal = 1", where_clauses) - + def test_embed_ordinal_expression_not_first(self): """Test ordinal expression with first=False.""" builder = DoseEraSqlBuilder() criteria = DoseEra(first=False) where_clauses = [] - + query = "SELECT * FROM table @ordinalExpression" result = builder.embed_ordinal_expression(query, criteria, where_clauses) - + self.assertNotIn("@ordinalExpression", result) self.assertNotIn("row_number()", result) self.assertEqual(len(where_clauses), 0) - + def test_resolve_select_clauses(self): """Test select clauses resolution.""" builder = DoseEraSqlBuilder() criteria = DoseEra(first=False) - + select_clauses = builder.resolve_select_clauses(criteria) - + self.assertIn("de.person_id", select_clauses) self.assertIn("de.dose_era_id", select_clauses) self.assertIn("de.drug_concept_id", select_clauses) @@ -1681,46 +1748,46 @@ def test_resolve_select_clauses(self): self.assertIn("de.dose_value", select_clauses) self.assertIn("de.dose_era_start_date as start_date", " ".join(select_clauses)) self.assertIn("de.dose_era_end_date as end_date", " ".join(select_clauses)) - + def test_resolve_join_clauses(self): """Test join clauses resolution.""" builder = DoseEraSqlBuilder() criteria = DoseEra(first=False) - + join_clauses = builder.resolve_join_clauses(criteria) - + self.assertEqual(len(join_clauses), 0) - + def test_resolve_join_clauses_with_person(self): """Test join clauses resolution with person join.""" builder = DoseEraSqlBuilder() criteria = DoseEra(first=False, age_at_start=NumericRange(op="gte", value=18)) - + join_clauses = builder.resolve_join_clauses(criteria) - + self.assertEqual(len(join_clauses), 1) self.assertIn("PERSON P", join_clauses[0]) - + def test_resolve_where_clauses(self): """Test where clauses resolution.""" builder = DoseEraSqlBuilder() criteria = DoseEra(first=False) - + where_clauses = builder.resolve_where_clauses(criteria) - + self.assertEqual(len(where_clauses), 0) - + def test_resolve_where_clauses_with_filters(self): """Test where clauses resolution with filters.""" builder = DoseEraSqlBuilder() criteria = DoseEra( first=False, era_start_date=DateRange(op="gte", value="2020-01-01"), - dose_value=NumericRange(op="gt", value=100) + dose_value=NumericRange(op="gt", value=100), ) - + where_clauses = builder.resolve_where_clauses(criteria) - + self.assertGreaterEqual(len(where_clauses), 2) self.assertTrue(any("C.start_date" in clause for clause in where_clauses)) self.assertTrue(any("C.dose_value" in clause for clause in where_clauses)) @@ -1728,120 +1795,134 @@ def test_resolve_where_clauses_with_filters(self): class TestObservationPeriodSqlBuilder(unittest.TestCase): """Test ObservationPeriodSqlBuilder class.""" - + def test_get_query_template(self): """Test query template generation.""" builder = ObservationPeriodSqlBuilder() template = builder.get_query_template() - + self.assertIn("@selectClause", template) # Note: ObservationPeriod doesn't use @codesetClause since it doesn't filter by concepts self.assertIn("@joinClause", template) self.assertIn("@whereClause", template) self.assertIn("@additionalColumns", template) self.assertIn("OBSERVATION_PERIOD", template) - + def test_get_default_columns(self): """Test default columns.""" builder = ObservationPeriodSqlBuilder() default_cols = builder.get_default_columns() - - expected_cols = {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_ID} + + expected_cols = { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_ID, + } self.assertEqual(default_cols, expected_cols) - + def test_get_table_column_for_criteria_column(self): """Test criteria column mapping.""" builder = ObservationPeriodSqlBuilder() - - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), "C.period_type_concept_id") - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), "DATEDIFF(d, @startDateExpression, @endDateExpression)") - + + self.assertEqual( + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), + "C.period_type_concept_id", + ) + self.assertEqual( + builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), + "DATEDIFF(d, @startDateExpression, @endDateExpression)", + ) + def test_embed_codeset_clause(self): """Test codeset clause embedding.""" builder = ObservationPeriodSqlBuilder() criteria = ObservationPeriod() - + query = "SELECT * FROM table @codesetClause" result = builder.embed_codeset_clause(query, criteria) - + self.assertNotIn("@codesetClause", result) self.assertEqual(result, "SELECT * FROM table ") - + def test_embed_ordinal_expression(self): """Test ordinal expression embedding.""" builder = ObservationPeriodSqlBuilder() criteria = ObservationPeriod() where_clauses = [] - + query = "SELECT * FROM table @ordinalExpression" result = builder.embed_ordinal_expression(query, criteria, where_clauses) - + self.assertNotIn("@ordinalExpression", result) self.assertEqual(len(where_clauses), 0) - + def test_resolve_select_clauses(self): """Test select clauses resolution.""" builder = ObservationPeriodSqlBuilder() criteria = ObservationPeriod() - + select_clauses = builder.resolve_select_clauses(criteria) - + self.assertIn("op.person_id", select_clauses) self.assertIn("op.observation_period_id", select_clauses) self.assertIn("op.period_type_concept_id", select_clauses) - self.assertIn("op.observation_period_start_date as start_date", " ".join(select_clauses)) - self.assertIn("op.observation_period_end_date as end_date", " ".join(select_clauses)) - + self.assertIn( + "op.observation_period_start_date as start_date", " ".join(select_clauses) + ) + self.assertIn( + "op.observation_period_end_date as end_date", " ".join(select_clauses) + ) + def test_resolve_join_clauses(self): """Test join clauses resolution.""" builder = ObservationPeriodSqlBuilder() criteria = ObservationPeriod() - + join_clauses = builder.resolve_join_clauses(criteria) - + self.assertEqual(len(join_clauses), 0) - + def test_resolve_join_clauses_with_person(self): """Test join clauses resolution with person join.""" builder = ObservationPeriodSqlBuilder() criteria = ObservationPeriod(age_at_start=NumericRange(op="gte", value=18)) - + join_clauses = builder.resolve_join_clauses(criteria) - + self.assertEqual(len(join_clauses), 1) self.assertIn("PERSON P", join_clauses[0]) - + def test_resolve_where_clauses(self): """Test where clauses resolution.""" builder = ObservationPeriodSqlBuilder() criteria = ObservationPeriod() - + where_clauses = builder.resolve_where_clauses(criteria) - + self.assertEqual(len(where_clauses), 0) - + def test_resolve_where_clauses_with_filters(self): """Test where clauses resolution with filters.""" builder = ObservationPeriodSqlBuilder() criteria = ObservationPeriod( period_start_date=DateRange(op="gte", value="2020-01-01"), - age_at_start=NumericRange(op="gt", value=30) + age_at_start=NumericRange(op="gt", value=30), ) - + where_clauses = builder.resolve_where_clauses(criteria) - + self.assertGreaterEqual(len(where_clauses), 1) self.assertTrue(any("C.start_date" in clause for clause in where_clauses)) class TestPayerPlanPeriodSqlBuilder(unittest.TestCase): """Test PayerPlanPeriodSqlBuilder class.""" - + def test_get_query_template(self): """Test query template generation.""" builder = PayerPlanPeriodSqlBuilder() template = builder.get_query_template() - + self.assertIn("@selectClause", template) self.assertIn("@codesetClause", template) self.assertIn("@joinClause", template) @@ -1849,123 +1930,136 @@ def test_get_query_template(self): self.assertIn("@ordinalExpression", template) self.assertIn("@additionalColumns", template) self.assertIn("PAYER_PLAN_PERIOD", template) - + def test_get_default_columns(self): """Test default columns.""" builder = PayerPlanPeriodSqlBuilder() default_cols = builder.get_default_columns() - - expected_cols = {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_ID} + + expected_cols = { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_ID, + } self.assertEqual(default_cols, expected_cols) - + def test_get_table_column_for_criteria_column(self): """Test criteria column mapping.""" builder = PayerPlanPeriodSqlBuilder() - - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), "C.payer_concept_id") - + + self.assertEqual( + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), + "C.payer_concept_id", + ) + def test_embed_codeset_clause(self): """Test codeset clause embedding.""" builder = PayerPlanPeriodSqlBuilder() criteria = PayerPlanPeriod() - + query = "SELECT * FROM table @codesetClause" result = builder.embed_codeset_clause(query, criteria) - + self.assertNotIn("@codesetClause", result) self.assertEqual(result, "SELECT * FROM table ") - + def test_embed_ordinal_expression(self): """Test ordinal expression embedding.""" builder = PayerPlanPeriodSqlBuilder() criteria = PayerPlanPeriod() where_clauses = [] - + query = "SELECT * FROM table @ordinalExpression" result = builder.embed_ordinal_expression(query, criteria, where_clauses) - + self.assertNotIn("@ordinalExpression", result) self.assertEqual(len(where_clauses), 0) - + def test_resolve_select_clauses(self): """Test select clauses resolution.""" builder = PayerPlanPeriodSqlBuilder() criteria = PayerPlanPeriod() - + select_clauses = builder.resolve_select_clauses(criteria) - + self.assertIn("ppp.person_id", select_clauses) self.assertIn("ppp.payer_plan_period_id", select_clauses) - self.assertIn("ppp.payer_plan_period_start_date as start_date", " ".join(select_clauses)) - self.assertIn("ppp.payer_plan_period_end_date as end_date", " ".join(select_clauses)) - + self.assertIn( + "ppp.payer_plan_period_start_date as start_date", " ".join(select_clauses) + ) + self.assertIn( + "ppp.payer_plan_period_end_date as end_date", " ".join(select_clauses) + ) + def test_resolve_select_clauses_with_concepts(self): """Test select clauses resolution with concept fields.""" builder = PayerPlanPeriodSqlBuilder() criteria = PayerPlanPeriod( payer_source_concept=123, plan_source_concept=456, - sponsor_source_concept=789 + sponsor_source_concept=789, ) - + select_clauses = builder.resolve_select_clauses(criteria) - + self.assertIn("ppp.payer_source_concept_id", select_clauses) self.assertIn("ppp.plan_source_concept_id", select_clauses) self.assertIn("ppp.sponsor_source_concept_id", select_clauses) - + def test_resolve_join_clauses(self): """Test join clauses resolution.""" builder = PayerPlanPeriodSqlBuilder() criteria = PayerPlanPeriod() - + join_clauses = builder.resolve_join_clauses(criteria) - + self.assertEqual(len(join_clauses), 0) - + def test_resolve_join_clauses_with_person(self): """Test join clauses resolution with person join.""" builder = PayerPlanPeriodSqlBuilder() criteria = PayerPlanPeriod(age_at_start=NumericRange(op="gte", value=18)) - + join_clauses = builder.resolve_join_clauses(criteria) - + self.assertEqual(len(join_clauses), 1) self.assertIn("PERSON P", join_clauses[0]) - + def test_resolve_where_clauses(self): """Test where clauses resolution.""" builder = PayerPlanPeriodSqlBuilder() criteria = PayerPlanPeriod() - + where_clauses = builder.resolve_where_clauses(criteria) - + self.assertEqual(len(where_clauses), 1) self.assertEqual(where_clauses[0], "1=1") - + def test_resolve_where_clauses_with_filters(self): """Test where clauses resolution with filters.""" builder = PayerPlanPeriodSqlBuilder() criteria = PayerPlanPeriod( period_start_date=DateRange(op="gte", value="2020-01-01"), - payer_source_concept=123 + payer_source_concept=123, ) - + where_clauses = builder.resolve_where_clauses(criteria) - + self.assertGreaterEqual(len(where_clauses), 2) self.assertTrue(any("C.start_date" in clause for clause in where_clauses)) - self.assertTrue(any("payer_source_concept_id" in clause for clause in where_clauses)) + self.assertTrue( + any("payer_source_concept_id" in clause for clause in where_clauses) + ) class TestVisitDetailSqlBuilder(unittest.TestCase): """Test VisitDetailSqlBuilder class.""" - + def test_get_query_template(self): """Test query template generation.""" builder = VisitDetailSqlBuilder() template = builder.get_query_template() - + self.assertIn("@selectClause", template) self.assertIn("@codesetClause", template) self.assertIn("@joinClause", template) @@ -1973,113 +2067,132 @@ def test_get_query_template(self): self.assertIn("@ordinalExpression", template) self.assertIn("@additionalColumns", template) self.assertIn("VISIT_DETAIL", template) - + def test_get_default_columns(self): """Test default columns.""" builder = VisitDetailSqlBuilder() default_cols = builder.get_default_columns() - - expected_cols = {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_DETAIL_ID} + + expected_cols = { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_DETAIL_ID, + } self.assertEqual(default_cols, expected_cols) - + def test_get_table_column_for_criteria_column(self): """Test criteria column mapping.""" builder = VisitDetailSqlBuilder() - - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), "C.visit_detail_concept_id") - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), "DATEDIFF(d, C.start_date, C.end_date)") - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_DETAIL_ID), "C.visit_detail_id") - + + self.assertEqual( + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), + "C.visit_detail_concept_id", + ) + self.assertEqual( + builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), + "DATEDIFF(d, C.start_date, C.end_date)", + ) + self.assertEqual( + builder.get_table_column_for_criteria_column( + CriteriaColumn.VISIT_DETAIL_ID + ), + "C.visit_detail_id", + ) + def test_embed_codeset_clause(self): """Test codeset clause embedding.""" builder = VisitDetailSqlBuilder() criteria = VisitDetail(visit_detail_type_exclude=False, codeset_id=123) - + query = "SELECT * FROM table @codesetClause" result = builder.embed_codeset_clause(query, criteria) - + self.assertNotIn("@codesetClause", result) self.assertIn("Codesets", result) - + def test_embed_ordinal_expression_first(self): """Test ordinal expression with first=True.""" builder = VisitDetailSqlBuilder() criteria = VisitDetail(visit_detail_type_exclude=False, first=True) where_clauses = [] - + query = "SELECT * FROM table @ordinalExpression" result = builder.embed_ordinal_expression(query, criteria, where_clauses) - + self.assertNotIn("@ordinalExpression", result) self.assertIn("row_number()", result) self.assertIn("C.ordinal = 1", where_clauses) - + def test_resolve_select_clauses(self): """Test select clauses resolution.""" builder = VisitDetailSqlBuilder() criteria = VisitDetail(visit_detail_type_exclude=False) - + select_clauses = builder.resolve_select_clauses(criteria) - + self.assertIn("vd.person_id", select_clauses) self.assertIn("vd.visit_detail_id", select_clauses) self.assertIn("vd.visit_detail_concept_id", select_clauses) self.assertIn("vd.visit_occurrence_id", select_clauses) - self.assertIn("vd.visit_detail_start_date as start_date", " ".join(select_clauses)) + self.assertIn( + "vd.visit_detail_start_date as start_date", " ".join(select_clauses) + ) self.assertIn("vd.visit_detail_end_date as end_date", " ".join(select_clauses)) - + def test_resolve_join_clauses(self): """Test join clauses resolution.""" builder = VisitDetailSqlBuilder() criteria = VisitDetail(visit_detail_type_exclude=False) - + join_clauses = builder.resolve_join_clauses(criteria) - + self.assertEqual(len(join_clauses), 0) - + def test_resolve_join_clauses_with_person(self): """Test join clauses resolution with person join.""" builder = VisitDetailSqlBuilder() - criteria = VisitDetail(visit_detail_type_exclude=False, age=NumericRange(op="gte", value=18)) - + criteria = VisitDetail( + visit_detail_type_exclude=False, age=NumericRange(op="gte", value=18) + ) + join_clauses = builder.resolve_join_clauses(criteria) - + self.assertEqual(len(join_clauses), 1) self.assertIn("PERSON P", join_clauses[0]) - + def test_resolve_join_clauses_with_care_site(self): """Test join clauses resolution with care site join.""" builder = VisitDetailSqlBuilder() criteria = VisitDetail( - visit_detail_type_exclude=False, - place_of_service_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False) + visit_detail_type_exclude=False, + place_of_service_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False), ) - + join_clauses = builder.resolve_join_clauses(criteria) - + self.assertEqual(len(join_clauses), 1) self.assertIn("CARE_SITE CS", join_clauses[0]) - + def test_resolve_where_clauses(self): """Test where clauses resolution.""" builder = VisitDetailSqlBuilder() criteria = VisitDetail(visit_detail_type_exclude=False) - + where_clauses = builder.resolve_where_clauses(criteria) - + self.assertEqual(len(where_clauses), 0) - + def test_resolve_where_clauses_with_filters(self): """Test where clauses resolution with filters.""" builder = VisitDetailSqlBuilder() criteria = VisitDetail( visit_detail_type_exclude=False, visit_detail_start_date=DateRange(op="gte", value="2020-01-01"), - age=NumericRange(op="gt", value=1) + age=NumericRange(op="gt", value=1), ) - + where_clauses = builder.resolve_where_clauses(criteria) - + self.assertGreaterEqual(len(where_clauses), 2) self.assertTrue(any("C.start_date" in clause for clause in where_clauses)) self.assertTrue(any("P.year_of_birth" in clause for clause in where_clauses)) @@ -2087,12 +2200,12 @@ def test_resolve_where_clauses_with_filters(self): class TestLocationRegionSqlBuilder(unittest.TestCase): """Test LocationRegionSqlBuilder class.""" - + def test_get_query_template(self): """Test query template generation.""" builder = LocationRegionSqlBuilder() template = builder.get_query_template() - + self.assertIn("@selectClause", template) self.assertIn("@codesetClause", template) self.assertIn("@joinClause", template) @@ -2100,66 +2213,73 @@ def test_get_query_template(self): self.assertIn("@ordinalExpression", template) self.assertIn("@additionalColumns", template) self.assertIn("LOCATION", template) - + def test_get_default_columns(self): """Test default columns.""" builder = LocationRegionSqlBuilder() default_cols = builder.get_default_columns() - - expected_cols = {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_ID} + + expected_cols = { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_ID, + } self.assertEqual(default_cols, expected_cols) - + def test_get_table_column_for_criteria_column(self): """Test criteria column mapping.""" builder = LocationRegionSqlBuilder() - - self.assertEqual(builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), "C.region_concept_id") - + + self.assertEqual( + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), + "C.region_concept_id", + ) + def test_embed_codeset_clause(self): """Test codeset clause embedding.""" builder = LocationRegionSqlBuilder() criteria = LocationRegion() - + query = "SELECT * FROM table @codesetClause" result = builder.embed_codeset_clause(query, criteria) - + self.assertNotIn("@codesetClause", result) self.assertEqual(result, "SELECT * FROM table ") - + def test_embed_ordinal_expression(self): """Test ordinal expression embedding.""" builder = LocationRegionSqlBuilder() criteria = LocationRegion() where_clauses = [] - + query = "SELECT * FROM table @ordinalExpression" result = builder.embed_ordinal_expression(query, criteria, where_clauses) - + self.assertNotIn("@ordinalExpression", result) self.assertEqual(len(where_clauses), 0) - + def test_resolve_join_clauses(self): """Test join clauses resolution.""" builder = LocationRegionSqlBuilder() criteria = LocationRegion() - + join_clauses = builder.resolve_join_clauses(criteria) - + self.assertEqual(len(join_clauses), 0) - + def test_resolve_where_clauses(self): """Test where clauses resolution.""" builder = LocationRegionSqlBuilder() criteria = LocationRegion() - + where_clauses = builder.resolve_where_clauses(criteria) - + self.assertEqual(len(where_clauses), 0) class TestBuilderIntegration(unittest.TestCase): """Integration tests for all builders.""" - + def test_all_builders_have_required_methods(self): """Test that all builders implement required methods.""" builders = [ @@ -2167,38 +2287,38 @@ def test_all_builders_have_required_methods(self): ObservationPeriodSqlBuilder(), PayerPlanPeriodSqlBuilder(), VisitDetailSqlBuilder(), - LocationRegionSqlBuilder() + LocationRegionSqlBuilder(), ] - + for builder in builders: # Test required abstract methods - self.assertTrue(hasattr(builder, 'get_query_template')) - self.assertTrue(hasattr(builder, 'get_default_columns')) - self.assertTrue(hasattr(builder, 'get_table_column_for_criteria_column')) - self.assertTrue(hasattr(builder, 'embed_codeset_clause')) - self.assertTrue(hasattr(builder, 'embed_ordinal_expression')) - self.assertTrue(hasattr(builder, 'resolve_select_clauses')) - self.assertTrue(hasattr(builder, 'resolve_join_clauses')) - self.assertTrue(hasattr(builder, 'resolve_where_clauses')) - + self.assertTrue(hasattr(builder, "get_query_template")) + self.assertTrue(hasattr(builder, "get_default_columns")) + self.assertTrue(hasattr(builder, "get_table_column_for_criteria_column")) + self.assertTrue(hasattr(builder, "embed_codeset_clause")) + self.assertTrue(hasattr(builder, "embed_ordinal_expression")) + self.assertTrue(hasattr(builder, "resolve_select_clauses")) + self.assertTrue(hasattr(builder, "resolve_join_clauses")) + self.assertTrue(hasattr(builder, "resolve_where_clauses")) + # Test that methods are callable self.assertTrue(callable(builder.get_query_template)) self.assertTrue(callable(builder.get_default_columns)) self.assertTrue(callable(builder.get_table_column_for_criteria_column)) - + def test_builder_options_integration(self): """Test builders work with BuilderOptions.""" options = BuilderOptions() options.additional_columns = [CriteriaColumn.START_DATE] - + builders = [ DoseEraSqlBuilder(), ObservationPeriodSqlBuilder(), PayerPlanPeriodSqlBuilder(), VisitDetailSqlBuilder(), - LocationRegionSqlBuilder() + LocationRegionSqlBuilder(), ] - + for builder in builders: # Test that builders can handle options self.assertIsInstance(builder.get_default_columns(), set) @@ -2215,11 +2335,17 @@ def test_builder_options_integration(self): mock_criteria = LocationRegion() else: mock_criteria = Mock() - - self.assertIsInstance(builder.resolve_select_clauses(mock_criteria, options), list) - self.assertIsInstance(builder.resolve_join_clauses(mock_criteria, options), list) - self.assertIsInstance(builder.resolve_where_clauses(mock_criteria, options), list) + + self.assertIsInstance( + builder.resolve_select_clauses(mock_criteria, options), list + ) + self.assertIsInstance( + builder.resolve_join_clauses(mock_criteria, options), list + ) + self.assertIsInstance( + builder.resolve_where_clauses(mock_criteria, options), list + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_sql_rendering_parity.py b/tests/test_sql_rendering_parity.py index 357f3135..783aac99 100644 --- a/tests/test_sql_rendering_parity.py +++ b/tests/test_sql_rendering_parity.py @@ -47,263 +47,568 @@ class TestDrugExposureBuilder(unittest.TestCase): - def setUp(self): self.builder = DrugExposureSqlBuilder() - + def test_includes_dose_unit_logic(self): # Create DrugExposure with dose_unit criteria de = DrugExposure( - doseUnit=[Concept(conceptId=123, conceptName="mg", domainId="Unit", vocabularyId="UCUM", standardConcept="S", conceptCode="mg")], + doseUnit=[ + Concept( + conceptId=123, + conceptName="mg", + domainId="Unit", + vocabularyId="UCUM", + standardConcept="S", + conceptCode="mg", + ) + ], doseUnitCS=None, - first=False # Using default + first=False, # Using default ) - + sql = self.builder.get_criteria_sql(de) - + # Check Select Clause - self.assertIn("de.dose_unit_concept_id", sql, "SQL should select dose_unit_concept_id when doseUnit criteria is present") - + self.assertIn( + "de.dose_unit_concept_id", + sql, + "SQL should select dose_unit_concept_id when doseUnit criteria is present", + ) + # Check Where Clause - self.assertIn("C.dose_unit_concept_id in (123)", sql, "SQL should filter by dose_unit_concept_id in WHERE clause") + self.assertIn( + "C.dose_unit_concept_id in (123)", + sql, + "SQL should filter by dose_unit_concept_id in WHERE clause", + ) def test_includes_lot_number_logic(self): # Create DrugExposure with lot_number criteria - de = DrugExposure( - lotNumber=TextFilter(text="LOT123", op="eq"), - first=False - ) - + de = DrugExposure(lotNumber=TextFilter(text="LOT123", op="eq"), first=False) + sql = self.builder.get_criteria_sql(de) - + # Check Select Clause - self.assertIn("de.lot_number", sql, "SQL should select lot_number when lotNumber criteria is present") - + self.assertIn( + "de.lot_number", + sql, + "SQL should select lot_number when lotNumber criteria is present", + ) + # Check Where Clause -- TextFilter usually renders as LIKE or = depending on op # BuilderUtils.build_text_filter_clause("C.lot_number", criteria.lot_number) # Assuming op="eq" -> = 'LOT123' self.assertIn("C.lot_number", sql, "SQL should filter by C.lot_number") self.assertIn("'LOT123'", sql, "SQL should contain the lot number value") + class TestConditionOccurrenceBuilder(unittest.TestCase): - def setUp(self): self.builder = ConditionOccurrenceSqlBuilder() - + def test_includes_condition_status_logic(self): # Create ConditionOccurrence with conditionStatus co = ConditionOccurrence( - conditionStatus=[Concept(conceptId=456, conceptName="Active", domainId="Condition", vocabularyId="SNOMED", standardConcept="S", conceptCode="Active")], - first=False + conditionStatus=[ + Concept( + conceptId=456, + conceptName="Active", + domainId="Condition", + vocabularyId="SNOMED", + standardConcept="S", + conceptCode="Active", + ) + ], + first=False, ) - + sql = self.builder.get_criteria_sql(co) - + # Check Select Clause - self.assertIn("co.condition_status_concept_id", sql, "SQL should select condition_status_concept_id when conditionStatus criteria is present") - + self.assertIn( + "co.condition_status_concept_id", + sql, + "SQL should select condition_status_concept_id when conditionStatus criteria is present", + ) + # Check Where Clause - self.assertIn("C.condition_status_concept_id in (456)", sql, "SQL should filter by condition_status_concept_id") + self.assertIn( + "C.condition_status_concept_id in (456)", + sql, + "SQL should filter by condition_status_concept_id", + ) class TestProcedureOccurrenceBuilder(unittest.TestCase): - def setUp(self): self.builder = ProcedureOccurrenceSqlBuilder() - + def test_includes_full_logic(self): # Create ProcedureOccurrence with various criteria to test select, join, and where clauses po = ProcedureOccurrence( - procedure_type=[Concept(conceptId=10, conceptName="Type A", domainId="Type", vocabularyId="Type", standardConcept="S", conceptCode="A")], - provider_specialty=[Concept(conceptId=20, conceptName="Surgeon", domainId="Provider", vocabularyId="Specialty", standardConcept="S", conceptCode="Surg")], - visit_type=[Concept(conceptId=30, conceptName="Inpatient", domainId="Visit", vocabularyId="Visit", standardConcept="S", conceptCode="IP")], - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], + procedure_type=[ + Concept( + conceptId=10, + conceptName="Type A", + domainId="Type", + vocabularyId="Type", + standardConcept="S", + conceptCode="A", + ) + ], + provider_specialty=[ + Concept( + conceptId=20, + conceptName="Surgeon", + domainId="Provider", + vocabularyId="Specialty", + standardConcept="S", + conceptCode="Surg", + ) + ], + visit_type=[ + Concept( + conceptId=30, + conceptName="Inpatient", + domainId="Visit", + vocabularyId="Visit", + standardConcept="S", + conceptCode="IP", + ) + ], + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], quantity=NumericRange(op="gt", value=5), - first=False + first=False, ) - + sql = self.builder.get_criteria_sql(po) - + # 1. Check Select Clauses - self.assertIn("po.procedure_type_concept_id", sql, "SQL should select procedure_type_concept_id") + self.assertIn( + "po.procedure_type_concept_id", + sql, + "SQL should select procedure_type_concept_id", + ) self.assertIn("po.provider_id", sql, "SQL should select provider_id") - + # 2. Check Join Clauses # Gender -> Join Person - self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON for gender check") + self.assertIn( + "JOIN @cdm_database_schema.PERSON P", + sql, + "Should join PERSON for gender check", + ) # VisitType -> Join VisitOccurrence - self.assertIn("JOIN @cdm_database_schema.VISIT_OCCURRENCE V", sql, "Should join VISIT_OCCURRENCE for visit type check") + self.assertIn( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V", + sql, + "Should join VISIT_OCCURRENCE for visit type check", + ) # ProviderSpecialty -> Join Provider - self.assertIn("LEFT JOIN @cdm_database_schema.PROVIDER PR", sql, "Should join PROVIDER for specialty check") - + self.assertIn( + "LEFT JOIN @cdm_database_schema.PROVIDER PR", + sql, + "Should join PROVIDER for specialty check", + ) + # 3. Check Where Clauses - self.assertIn("C.procedure_type_concept_id in (10)", sql, "Should filter procedure_type_concept_id") - self.assertIn("PR.specialty_concept_id in (20)", sql, "Should filter provider specialty") + self.assertIn( + "C.procedure_type_concept_id in (10)", + sql, + "Should filter procedure_type_concept_id", + ) + self.assertIn( + "PR.specialty_concept_id in (20)", sql, "Should filter provider specialty" + ) self.assertIn("V.visit_concept_id in (30)", sql, "Should filter visit type") self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") self.assertIn("C.quantity > 5", sql, "Should filter quantity") self.assertIn("C.quantity > 5", sql, "Should filter quantity") + class TestMeasurementBuilder(unittest.TestCase): - def setUp(self): self.builder = MeasurementSqlBuilder() - + def test_includes_full_logic(self): # Create Measurement with various criteria meas = Measurement( - measurement_type=[Concept(conceptId=10, conceptName="Type A", domainId="Type", vocabularyId="Type", standardConcept="S", conceptCode="A")], - operator=[Concept(conceptId=20, conceptName="Op", domainId="Op", vocabularyId="Op", standardConcept="S", conceptCode="Op")], + measurement_type=[ + Concept( + conceptId=10, + conceptName="Type A", + domainId="Type", + vocabularyId="Type", + standardConcept="S", + conceptCode="A", + ) + ], + operator=[ + Concept( + conceptId=20, + conceptName="Op", + domainId="Op", + vocabularyId="Op", + standardConcept="S", + conceptCode="Op", + ) + ], value_as_number=NumericRange(op="gt", value=150.5), - unit=[Concept(conceptId=30, conceptName="mg/dL", domainId="Unit", vocabularyId="Unit", standardConcept="S", conceptCode="mg/dL")], + unit=[ + Concept( + conceptId=30, + conceptName="mg/dL", + domainId="Unit", + vocabularyId="Unit", + standardConcept="S", + conceptCode="mg/dL", + ) + ], abnormal=True, age=NumericRange(op="gt", value=18), - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], - visit_type=[Concept(conceptId=40, conceptName="IP", domainId="Visit", vocabularyId="Visit", standardConcept="S", conceptCode="IP")], - first=False + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], + visit_type=[ + Concept( + conceptId=40, + conceptName="IP", + domainId="Visit", + vocabularyId="Visit", + standardConcept="S", + conceptCode="IP", + ) + ], + first=False, ) - + sql = self.builder.get_criteria_sql(meas) - + # 1. Check Select Clauses - self.assertIn("m.measurement_type_concept_id", sql, "Should select measurement_type_concept_id") + self.assertIn( + "m.measurement_type_concept_id", + sql, + "Should select measurement_type_concept_id", + ) self.assertIn("m.operator_concept_id", sql, "Should select operator_concept_id") self.assertIn("m.unit_concept_id", sql, "Should select unit_concept_id") - + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - self.assertIn("JOIN @cdm_database_schema.VISIT_OCCURRENCE V", sql, "Should join VISIT_OCCURRENCE") - + self.assertIn( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V", + sql, + "Should join VISIT_OCCURRENCE", + ) + # 3. Check Where Clauses - self.assertIn("C.measurement_type_concept_id in (10)", sql, "Should filter measurement type") + self.assertIn( + "C.measurement_type_concept_id in (10)", + sql, + "Should filter measurement type", + ) self.assertIn("C.operator_concept_id in (20)", sql, "Should filter operator") - self.assertIn("C.value_as_number > 150.5000", sql, "Should filter value_as_number") + self.assertIn( + "C.value_as_number > 150.5000", sql, "Should filter value_as_number" + ) self.assertIn("C.unit_concept_id in (30)", sql, "Should filter unit") - self.assertIn("(C.value_as_number < C.range_low or C.value_as_number > C.range_high or C.value_as_concept_id in (4155142, 4155143))", sql, "Should filter abnormal") - self.assertIn("YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age") + self.assertIn( + "(C.value_as_number < C.range_low or C.value_as_number > C.range_high or C.value_as_concept_id in (4155142, 4155143))", + sql, + "Should filter abnormal", + ) + self.assertIn( + "YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age" + ) self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") self.assertIn("V.visit_concept_id in (40)", sql, "Should filter visit type") + class TestObservationBuilder(unittest.TestCase): - def setUp(self): self.builder = ObservationSqlBuilder() - + def test_includes_full_logic(self): # Create Observation with various criteria obs = Observation( - observation_type=[Concept(conceptId=10, conceptName="Type A", domainId="Type", vocabularyId="Type", standardConcept="S", conceptCode="A")], + observation_type=[ + Concept( + conceptId=10, + conceptName="Type A", + domainId="Type", + vocabularyId="Type", + standardConcept="S", + conceptCode="A", + ) + ], value_as_string=TextFilter(text="Positive", op="eq"), value_as_number=NumericRange(op="gt", value=100), - unit=[Concept(conceptId=30, conceptName="mg", domainId="Unit", vocabularyId="Unit", standardConcept="S", conceptCode="mg")], - qualifier=[Concept(conceptId=50, conceptName="Severe", domainId="Qualifier", vocabularyId="Qualifier", standardConcept="S", conceptCode="Sev")], + unit=[ + Concept( + conceptId=30, + conceptName="mg", + domainId="Unit", + vocabularyId="Unit", + standardConcept="S", + conceptCode="mg", + ) + ], + qualifier=[ + Concept( + conceptId=50, + conceptName="Severe", + domainId="Qualifier", + vocabularyId="Qualifier", + standardConcept="S", + conceptCode="Sev", + ) + ], age=NumericRange(op="gt", value=18), - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], - visit_type=[Concept(conceptId=40, conceptName="IP", domainId="Visit", vocabularyId="Visit", standardConcept="S", conceptCode="IP")], - first=False + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], + visit_type=[ + Concept( + conceptId=40, + conceptName="IP", + domainId="Visit", + vocabularyId="Visit", + standardConcept="S", + conceptCode="IP", + ) + ], + first=False, ) - + sql = self.builder.get_criteria_sql(obs) - + # 1. Check Select Clauses - self.assertIn("o.observation_type_concept_id", sql, "Should select observation_type_concept_id") + self.assertIn( + "o.observation_type_concept_id", + sql, + "Should select observation_type_concept_id", + ) self.assertIn("o.value_as_string", sql, "Should select value_as_string") - self.assertIn("o.qualifier_concept_id", sql, "Should select qualifier_concept_id") + self.assertIn( + "o.qualifier_concept_id", sql, "Should select qualifier_concept_id" + ) self.assertIn("o.unit_concept_id", sql, "Should select unit_concept_id") - + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") # Discrepancy check: Java uses 'V', Python uses 'VO' currently. We strictly test for 'V' to enforce parity. - self.assertIn("JOIN @cdm_database_schema.VISIT_OCCURRENCE V ", sql, "Should join VISIT_OCCURRENCE with alias V") - + self.assertIn( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V ", + sql, + "Should join VISIT_OCCURRENCE with alias V", + ) + # 3. Check Where Clauses - self.assertIn("C.observation_type_concept_id in (10)", sql, "Should filter observation type") - self.assertIn("C.value_as_string = 'Positive'", sql, "Should filter value_as_string") + self.assertIn( + "C.observation_type_concept_id in (10)", + sql, + "Should filter observation type", + ) + self.assertIn( + "C.value_as_string = 'Positive'", sql, "Should filter value_as_string" + ) self.assertIn("C.value_as_number > 100", sql, "Should filter value_as_number") self.assertIn("C.unit_concept_id in (30)", sql, "Should filter unit") self.assertIn("C.qualifier_concept_id in (50)", sql, "Should filter qualifier") - self.assertIn("YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age") + self.assertIn( + "YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age" + ) self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") - self.assertIn("V.visit_concept_id in (40)", sql, "Should filter visit type with alias V") + self.assertIn( + "V.visit_concept_id in (40)", sql, "Should filter visit type with alias V" + ) + class TestDeviceExposureBuilder(unittest.TestCase): - def setUp(self): self.builder = DeviceExposureSqlBuilder() - + def test_includes_full_logic(self): # Create DeviceExposure with various criteria de = DeviceExposure( - device_type=[Concept(conceptId=10, conceptName="Type A", domainId="Type", vocabularyId="Type", standardConcept="S", conceptCode="A")], + device_type=[ + Concept( + conceptId=10, + conceptName="Type A", + domainId="Type", + vocabularyId="Type", + standardConcept="S", + conceptCode="A", + ) + ], unique_device_id=TextFilter(text="UDI123", op="eq"), quantity=NumericRange(op="gt", value=5), age=NumericRange(op="gt", value=18), - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], - visit_type=[Concept(conceptId=40, conceptName="IP", domainId="Visit", vocabularyId="Visit", standardConcept="S", conceptCode="IP")], - first=False + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], + visit_type=[ + Concept( + conceptId=40, + conceptName="IP", + domainId="Visit", + vocabularyId="Visit", + standardConcept="S", + conceptCode="IP", + ) + ], + first=False, ) - + sql = self.builder.get_criteria_sql(de) - + # 1. Check Select Clauses - self.assertIn("de.device_type_concept_id", sql, "Should select device_type_concept_id") + self.assertIn( + "de.device_type_concept_id", sql, "Should select device_type_concept_id" + ) self.assertIn("de.unique_device_id", sql, "Should select unique_device_id") self.assertIn("de.quantity", sql, "Should select quantity") - + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - self.assertIn("JOIN @cdm_database_schema.VISIT_OCCURRENCE V", sql, "Should join VISIT_OCCURRENCE") - + self.assertIn( + "JOIN @cdm_database_schema.VISIT_OCCURRENCE V", + sql, + "Should join VISIT_OCCURRENCE", + ) + # 3. Check Where Clauses # Note: Testing for case-insensitive match for keywords or exact match if builder is specific - self.assertTrue("C.device_type_concept_id IN (10)" in sql or "C.device_type_concept_id in (10)" in sql, "Should filter device type") - self.assertIn("C.unique_device_id = 'UDI123'", sql, "Should filter unique_device_id") + self.assertTrue( + "C.device_type_concept_id IN (10)" in sql + or "C.device_type_concept_id in (10)" in sql, + "Should filter device type", + ) + self.assertIn( + "C.unique_device_id = 'UDI123'", sql, "Should filter unique_device_id" + ) self.assertIn("C.quantity > 5", sql, "Should filter quantity") - self.assertIn("YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age") + self.assertIn( + "YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age" + ) # Check gender filter - builder output might be IN or in - self.assertTrue("P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender") - self.assertTrue("P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender") - self.assertTrue("V.visit_concept_id IN (40)" in sql or "V.visit_concept_id in (40)" in sql, "Should filter visit type") + self.assertTrue( + "P.gender_concept_id IN (8507)" in sql + or "P.gender_concept_id in (8507)" in sql, + "Should filter gender", + ) + self.assertTrue( + "P.gender_concept_id IN (8507)" in sql + or "P.gender_concept_id in (8507)" in sql, + "Should filter gender", + ) + self.assertTrue( + "V.visit_concept_id IN (40)" in sql or "V.visit_concept_id in (40)" in sql, + "Should filter visit type", + ) + class TestDeathBuilder(unittest.TestCase): - def setUp(self): self.builder = DeathSqlBuilder() - + def test_includes_full_logic(self): # Create Death with various criteria death = Death( - death_type=[Concept(conceptId=10, conceptName="Type A", domainId="Type", vocabularyId="Type", standardConcept="S", conceptCode="A")], + death_type=[ + Concept( + conceptId=10, + conceptName="Type A", + domainId="Type", + vocabularyId="Type", + standardConcept="S", + conceptCode="A", + ) + ], age=NumericRange(op="gt", value=60), - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], occurrence_start_date=DateRange(op="gt", value="2020-01-01"), - first=False + first=False, ) # Re-check criteria.py for Death structure. - + sql = self.builder.get_criteria_sql(death) - + # 1. Check Select Clauses self.assertIn("d.person_id", sql, "Should select person_id with alias d") self.assertIn("d.cause_concept_id", sql, "Should select cause_concept_id") self.assertIn("d.death_date", sql, "Should select death_date") - + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - + # 3. Check Where Clauses - self.assertTrue("C.death_type_concept_id IN (10)" in sql or "C.death_type_concept_id in (10)" in sql, "Should filter death type") - self.assertIn("YEAR(C.start_date) - P.year_of_birth > 60", sql, "Should filter age") - self.assertTrue("P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender") - self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter occurrence_start_date") + self.assertTrue( + "C.death_type_concept_id IN (10)" in sql + or "C.death_type_concept_id in (10)" in sql, + "Should filter death type", + ) + self.assertIn( + "YEAR(C.start_date) - P.year_of_birth > 60", sql, "Should filter age" + ) + self.assertTrue( + "P.gender_concept_id IN (8507)" in sql + or "P.gender_concept_id in (8507)" in sql, + "Should filter gender", + ) + self.assertIn( + "C.start_date > DATEFROMPARTS(2020, 1, 1)", + sql, + "Should filter occurrence_start_date", + ) + class TestConditionEraBuilder(unittest.TestCase): - def setUp(self): self.builder = ConditionEraSqlBuilder() - + def test_includes_full_logic(self): # Create ConditionEra with various criteria ce = ConditionEra( @@ -314,46 +619,87 @@ def test_includes_full_logic(self): era_length=NumericRange(op="gt", value=10), age_at_start=NumericRange(op="gt", value=40), age_at_end=NumericRange(op="lt", value=80), - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], - first=True + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], + first=True, ) - + sql = self.builder.get_criteria_sql(ce) - + # 1. Check Select Clauses self.assertIn("ce.person_id", sql, "Should select person_id") self.assertIn("ce.condition_era_id", sql, "Should select condition_era_id") - + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - + # 3. Check Where Clauses # Codeset filter inside subquery (double filtering might apply) # Python implementation currently puts it in subquery via embed_codeset_clause - self.assertIn("where ce.condition_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)", sql, "Should filter codeset inside subquery with Java-style formatting") - - self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter era_start_date") - self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date") - self.assertIn("C.condition_occurrence_count > 2", sql, "Should filter occurrence_count") - + self.assertIn( + "where ce.condition_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)", + sql, + "Should filter codeset inside subquery with Java-style formatting", + ) + + self.assertIn( + "C.start_date > DATEFROMPARTS(2020, 1, 1)", + sql, + "Should filter era_start_date", + ) + self.assertIn( + "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date" + ) + self.assertIn( + "C.condition_occurrence_count > 2", sql, "Should filter occurrence_count" + ) + # Note: DATEDIFF vs datediff. Python builder uses DATEDIFF(d,C.start_date, C.end_date) - self.assertIn("DATEDIFF(d,C.start_date, C.end_date) > 10", sql, "Should filter era_length") - - self.assertIn("YEAR(C.start_date) - P.year_of_birth > 40", sql, "Should filter age_at_start") - self.assertIn("YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end") - - self.assertTrue("P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender") - + self.assertIn( + "DATEDIFF(d,C.start_date, C.end_date) > 10", sql, "Should filter era_length" + ) + + self.assertIn( + "YEAR(C.start_date) - P.year_of_birth > 40", + sql, + "Should filter age_at_start", + ) + self.assertIn( + "YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end" + ) + + self.assertTrue( + "P.gender_concept_id IN (8507)" in sql + or "P.gender_concept_id in (8507)" in sql, + "Should filter gender", + ) + # 4. Check Ordinal - self.assertIn("row_number() over (PARTITION BY ce.person_id ORDER BY ce.condition_era_start_date, ce.condition_era_id) as ordinal", sql, "Should have ordinal window func") - self.assertIn("row_number() over (PARTITION BY ce.person_id ORDER BY ce.condition_era_start_date, ce.condition_era_id) as ordinal", sql, "Should have ordinal window func") + self.assertIn( + "row_number() over (PARTITION BY ce.person_id ORDER BY ce.condition_era_start_date, ce.condition_era_id) as ordinal", + sql, + "Should have ordinal window func", + ) + self.assertIn( + "row_number() over (PARTITION BY ce.person_id ORDER BY ce.condition_era_start_date, ce.condition_era_id) as ordinal", + sql, + "Should have ordinal window func", + ) self.assertIn("C.ordinal = 1", sql, "Should filter first ordinal") + class TestDrugEraBuilder(unittest.TestCase): - def setUp(self): self.builder = DrugEraSqlBuilder() - + def test_includes_full_logic(self): # Create DrugEra with various criteria de = DrugEra( @@ -364,142 +710,304 @@ def test_includes_full_logic(self): era_length=NumericRange(op="gt", value=10), age_at_start=NumericRange(op="gt", value=40), age_at_end=NumericRange(op="lt", value=80), - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], - first=True + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], + first=True, ) - + sql = self.builder.get_criteria_sql(de) - + # 1. Check Select Clauses self.assertIn("de.person_id", sql, "Should select person_id") self.assertIn("de.drug_era_id", sql, "Should select drug_era_id") - + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - + # 3. Check Where Clauses # Codeset filter inside subquery - self.assertIn("where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)", sql, "Should filter codeset inside subquery with Java-style formatting") - - self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter era_start_date") - self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date") - self.assertIn("C.drug_exposure_count > 2", sql, "Should filter occurrence_count") - - self.assertIn("DATEDIFF(d,C.start_date, C.end_date) > 10", sql, "Should filter era_length") - - self.assertIn("YEAR(C.start_date) - P.year_of_birth > 40", sql, "Should filter age_at_start") - self.assertIn("YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end") - - self.assertTrue("P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender") - + self.assertIn( + "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)", + sql, + "Should filter codeset inside subquery with Java-style formatting", + ) + + self.assertIn( + "C.start_date > DATEFROMPARTS(2020, 1, 1)", + sql, + "Should filter era_start_date", + ) + self.assertIn( + "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date" + ) + self.assertIn( + "C.drug_exposure_count > 2", sql, "Should filter occurrence_count" + ) + + self.assertIn( + "DATEDIFF(d,C.start_date, C.end_date) > 10", sql, "Should filter era_length" + ) + + self.assertIn( + "YEAR(C.start_date) - P.year_of_birth > 40", + sql, + "Should filter age_at_start", + ) + self.assertIn( + "YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end" + ) + + self.assertTrue( + "P.gender_concept_id IN (8507)" in sql + or "P.gender_concept_id in (8507)" in sql, + "Should filter gender", + ) + # 4. Check Ordinal - self.assertIn("row_number() over (PARTITION BY de.person_id ORDER BY de.drug_era_start_date, de.drug_era_id) as ordinal", sql, "Should have ordinal window func") + self.assertIn( + "row_number() over (PARTITION BY de.person_id ORDER BY de.drug_era_start_date, de.drug_era_id) as ordinal", + sql, + "Should have ordinal window func", + ) self.assertIn("C.ordinal = 1", sql, "Should filter first ordinal") + class TestDoseEraBuilder(unittest.TestCase): - def setUp(self): self.builder = DoseEraSqlBuilder() - + def test_includes_full_logic(self): # Create DoseEra with various criteria de = DoseEra( codeset_id=1, era_start_date=DateRange(op="gt", value="2020-01-01"), era_end_date=DateRange(op="lt", value="2021-01-01"), - unit=[Concept(conceptId=8507, conceptName="mg", domainId="Unit", vocabularyId="Unit", standardConcept="S", conceptCode="mg")], + unit=[ + Concept( + conceptId=8507, + conceptName="mg", + domainId="Unit", + vocabularyId="Unit", + standardConcept="S", + conceptCode="mg", + ) + ], dose_value=NumericRange(op="gt", value=10), era_length=NumericRange(op="gt", value=5), age_at_start=NumericRange(op="gt", value=40), age_at_end=NumericRange(op="lt", value=80), - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], - first=True + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], + first=True, ) - + sql = self.builder.get_criteria_sql(de) - + # 1. Check Select Clauses self.assertIn("de.person_id", sql, "Should select person_id") self.assertIn("de.dose_era_id", sql, "Should select dose_era_id") self.assertIn("de.dose_value", sql, "Should select dose_value") - + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - + # 3. Check Where Clauses # Codeset filter inside subquery - self.assertIn("where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)", sql, "Should filter codeset inside subquery with Java-style formatting") - - self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter era_start_date") - self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date") + self.assertIn( + "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)", + sql, + "Should filter codeset inside subquery with Java-style formatting", + ) + + self.assertIn( + "C.start_date > DATEFROMPARTS(2020, 1, 1)", + sql, + "Should filter era_start_date", + ) + self.assertIn( + "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date" + ) self.assertIn("C.dose_value > 10.0000", sql, "Should filter dose_value") - self.assertTrue("C.unit_concept_id IN (8507)" in sql or "C.unit_concept_id in (8507)" in sql, "Should filter unit") - - self.assertIn("DATEDIFF(d,C.start_date, C.end_date) > 5", sql, "Should filter era_length") - - self.assertIn("YEAR(C.start_date) - P.year_of_birth > 40", sql, "Should filter age_at_start") - self.assertIn("YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end") - - self.assertTrue("P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender") - + self.assertTrue( + "C.unit_concept_id IN (8507)" in sql + or "C.unit_concept_id in (8507)" in sql, + "Should filter unit", + ) + + self.assertIn( + "DATEDIFF(d,C.start_date, C.end_date) > 5", sql, "Should filter era_length" + ) + + self.assertIn( + "YEAR(C.start_date) - P.year_of_birth > 40", + sql, + "Should filter age_at_start", + ) + self.assertIn( + "YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end" + ) + + self.assertTrue( + "P.gender_concept_id IN (8507)" in sql + or "P.gender_concept_id in (8507)" in sql, + "Should filter gender", + ) + # 4. Check Ordinal - self.assertIn("row_number() over (PARTITION BY de.person_id ORDER BY de.dose_era_start_date, de.dose_era_id) as ordinal", sql, "Should have ordinal window func") + self.assertIn( + "row_number() over (PARTITION BY de.person_id ORDER BY de.dose_era_start_date, de.dose_era_id) as ordinal", + sql, + "Should have ordinal window func", + ) self.assertIn("C.ordinal = 1", sql, "Should filter first ordinal") + class TestSpecimenBuilder(unittest.TestCase): - def setUp(self): self.builder = SpecimenSqlBuilder() - + def test_includes_full_logic(self): # Create Specimen with various criteria spec = Specimen( codeset_id=1, occurrence_start_date=DateRange(op="gt", value="2020-01-01"), - specimen_type=[Concept(conceptId=10, conceptName="Blood", domainId="Specimen", vocabularyId="Specimen", standardConcept="S", conceptCode="Blood")], + specimen_type=[ + Concept( + conceptId=10, + conceptName="Blood", + domainId="Specimen", + vocabularyId="Specimen", + standardConcept="S", + conceptCode="Blood", + ) + ], quantity=NumericRange(op="gt", value=5), - unit=[Concept(conceptId=8587, conceptName="ml", domainId="Unit", vocabularyId="Unit", standardConcept="S", conceptCode="ml")], - anatomic_site=[Concept(conceptId=123, conceptName="Arm", domainId="Specimen", vocabularyId="Specimen", standardConcept="S", conceptCode="Arm")], - disease_status=[Concept(conceptId=456, conceptName="Sick", domainId="Specimen", vocabularyId="Specimen", standardConcept="S", conceptCode="Sick")], + unit=[ + Concept( + conceptId=8587, + conceptName="ml", + domainId="Unit", + vocabularyId="Unit", + standardConcept="S", + conceptCode="ml", + ) + ], + anatomic_site=[ + Concept( + conceptId=123, + conceptName="Arm", + domainId="Specimen", + vocabularyId="Specimen", + standardConcept="S", + conceptCode="Arm", + ) + ], + disease_status=[ + Concept( + conceptId=456, + conceptName="Sick", + domainId="Specimen", + vocabularyId="Specimen", + standardConcept="S", + conceptCode="Sick", + ) + ], source_id=TextFilter(op="startsWith", text="123"), age=NumericRange(op="gt", value=40), - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], - first=True + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], + first=True, ) - + sql = self.builder.get_criteria_sql(spec) - + # 1. Check Select Clauses # Java selects: s.person_id, s.specimen_id, s.specimen_concept_id, s.specimen_date, s.visit_occurrence_id # Python likely misses some or uses different alias self.assertIn("s.person_id", sql, "Should select person_id with alias s") - + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - + # codeset join logic - self.assertIn("JOIN #Codesets cs on (s.specimen_concept_id = cs.concept_id and cs.codeset_id = 1)", sql, "Should filter codeset via JOIN") - - self.assertIn("C.specimen_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter occurrence_start_date") + self.assertIn( + "JOIN #Codesets cs on (s.specimen_concept_id = cs.concept_id and cs.codeset_id = 1)", + sql, + "Should filter codeset via JOIN", + ) + + self.assertIn( + "C.specimen_date > DATEFROMPARTS(2020, 1, 1)", + sql, + "Should filter occurrence_start_date", + ) self.assertIn("C.quantity > 5", sql, "Should filter quantity") - self.assertTrue("C.unit_concept_id IN (8587)" in sql or "C.unit_concept_id in (8587)" in sql, "Should filter unit") - self.assertTrue("C.anatomic_site_concept_id IN (123)" in sql or "C.anatomic_site_concept_id in (123)" in sql, "Should filter anatomic_site") - self.assertTrue("C.disease_status_concept_id IN (456)" in sql or "C.disease_status_concept_id in (456)" in sql, "Should filter disease_status") - - self.assertIn("C.specimen_source_id LIKE '123%'", sql, "Should filter source_id") - - self.assertIn("YEAR(C.specimen_date) - P.year_of_birth > 40", sql, "Should filter age") - self.assertTrue("P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender") - + self.assertTrue( + "C.unit_concept_id IN (8587)" in sql + or "C.unit_concept_id in (8587)" in sql, + "Should filter unit", + ) + self.assertTrue( + "C.anatomic_site_concept_id IN (123)" in sql + or "C.anatomic_site_concept_id in (123)" in sql, + "Should filter anatomic_site", + ) + self.assertTrue( + "C.disease_status_concept_id IN (456)" in sql + or "C.disease_status_concept_id in (456)" in sql, + "Should filter disease_status", + ) + + self.assertIn( + "C.specimen_source_id LIKE '123%'", sql, "Should filter source_id" + ) + + self.assertIn( + "YEAR(C.specimen_date) - P.year_of_birth > 40", sql, "Should filter age" + ) + self.assertTrue( + "P.gender_concept_id IN (8507)" in sql + or "P.gender_concept_id in (8507)" in sql, + "Should filter gender", + ) + # 4. Check Ordinal - self.assertIn("row_number() over (PARTITION BY s.person_id ORDER BY s.specimen_date, s.specimen_id) as ordinal", sql, "Should have ordinal window func") + self.assertIn( + "row_number() over (PARTITION BY s.person_id ORDER BY s.specimen_date, s.specimen_id) as ordinal", + sql, + "Should have ordinal window func", + ) self.assertIn("C.ordinal = 1", sql, "Should filter first ordinal") + class TestVisitDetailBuilder(unittest.TestCase): - def setUp(self): self.builder = VisitDetailSqlBuilder() - + def test_includes_full_logic(self): # Create VisitDetail with various criteria vd = VisitDetail( @@ -512,54 +1020,108 @@ def test_includes_full_logic(self): place_of_service_cs=ConceptSetSelection(codesetId=4), place_of_service_location=5, age=NumericRange(op="gt", value=40), - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], - first=True + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], + first=True, ) - + sql = self.builder.get_criteria_sql(vd) - + # 1. Check Select Clauses self.assertIn("vd.person_id", sql, "Should select person_id") self.assertIn("vd.visit_detail_id", sql, "Should select visit_detail_id") self.assertIn("vd.provider_id", sql, "Should select provider_id") self.assertIn("vd.care_site_id", sql, "Should select care_site_id") - + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - self.assertIn("JOIN @cdm_database_schema.CARE_SITE CS", sql, "Should join CARE_SITE") - self.assertIn("LEFT JOIN @cdm_database_schema.PROVIDER PR", sql, "Should join PROVIDER") - self.assertIn("JOIN @cdm_database_schema.LOCATION_HISTORY LH", sql, "Should join LOCATION_HISTORY") - self.assertIn("JOIN @cdm_database_schema.LOCATION LOC", sql, "Should join LOCATION") - + self.assertIn( + "JOIN @cdm_database_schema.CARE_SITE CS", sql, "Should join CARE_SITE" + ) + self.assertIn( + "LEFT JOIN @cdm_database_schema.PROVIDER PR", sql, "Should join PROVIDER" + ) + self.assertIn( + "JOIN @cdm_database_schema.LOCATION_HISTORY LH", + sql, + "Should join LOCATION_HISTORY", + ) + self.assertIn( + "JOIN @cdm_database_schema.LOCATION LOC", sql, "Should join LOCATION" + ) + # 3. Check Where Clauses # Codeset join logic - self.assertIn("JOIN #Codesets cs on (vd.visit_detail_concept_id = cs.concept_id and cs.codeset_id = 1)", sql) - - self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date") - self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date") - - self.assertTrue("C.visit_detail_type_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 2)" in sql or "C.visit_detail_type_concept_id in (select concept_id from #Codesets where codeset_id = 2)" in sql, "Should filter visit_detail_type_concept_id") - - self.assertIn("DATEDIFF(d,C.start_date, C.end_date) > 1", sql, "Should filter visit_length") - - self.assertIn("YEAR(C.end_date) - P.year_of_birth > 40", sql, "Should filter age") + self.assertIn( + "JOIN #Codesets cs on (vd.visit_detail_concept_id = cs.concept_id and cs.codeset_id = 1)", + sql, + ) + + self.assertIn( + "C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date" + ) + self.assertIn( + "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date" + ) + + self.assertTrue( + "C.visit_detail_type_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 2)" + in sql + or "C.visit_detail_type_concept_id in (select concept_id from #Codesets where codeset_id = 2)" + in sql, + "Should filter visit_detail_type_concept_id", + ) + + self.assertIn( + "DATEDIFF(d,C.start_date, C.end_date) > 1", + sql, + "Should filter visit_length", + ) + + self.assertIn( + "YEAR(C.end_date) - P.year_of_birth > 40", sql, "Should filter age" + ) self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") - - self.assertTrue("PR.specialty_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 3)" in sql or "PR.specialty_concept_id in (select concept_id from #Codesets where codeset_id = 3)" in sql, "Should filter provider") - self.assertTrue("CS.place_of_service_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 4)" in sql or "CS.place_of_service_concept_id in (select concept_id from #Codesets where codeset_id = 4)" in sql, "Should filter place of service") - + + self.assertTrue( + "PR.specialty_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 3)" + in sql + or "PR.specialty_concept_id in (select concept_id from #Codesets where codeset_id = 3)" + in sql, + "Should filter provider", + ) + self.assertTrue( + "CS.place_of_service_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 4)" + in sql + or "CS.place_of_service_concept_id in (select concept_id from #Codesets where codeset_id = 4)" + in sql, + "Should filter place of service", + ) + # Location filtering via join # Just check join existence for now - + # 4. Check Ordinal - self.assertIn("row_number() over (PARTITION BY vd.person_id ORDER BY vd.visit_detail_start_date, vd.visit_detail_id) as ordinal", sql, "Should have ordinal window func") + self.assertIn( + "row_number() over (PARTITION BY vd.person_id ORDER BY vd.visit_detail_start_date, vd.visit_detail_id) as ordinal", + sql, + "Should have ordinal window func", + ) self.assertIn("C.ordinal = 1", sql, "Should filter first ordinal") + class TestPayerPlanPeriodBuilder(unittest.TestCase): - def setUp(self): self.builder = PayerPlanPeriodSqlBuilder() - + def test_includes_full_logic(self): # Create PayerPlanPeriod with various criteria ppp = PayerPlanPeriod( @@ -576,120 +1138,221 @@ def test_includes_full_logic(self): period_length=NumericRange(op="gt", value=10), age_at_start=NumericRange(op="gt", value=40), age_at_end=NumericRange(op="lt", value=80), - gender=[Concept(conceptId=8507, conceptName="Male", domainId="Gender", vocabularyId="Gender", standardConcept="S", conceptCode="M")], - first=True + gender=[ + Concept( + conceptId=8507, + conceptName="Male", + domainId="Gender", + vocabularyId="Gender", + standardConcept="S", + conceptCode="M", + ) + ], + first=True, ) - + sql = self.builder.get_criteria_sql(ppp) - + # 1. Check Select Clauses self.assertIn("ppp.person_id", sql, "Should select person_id") - self.assertIn("ppp.payer_plan_period_id", sql, "Should select payer_plan_period_id") + self.assertIn( + "ppp.payer_plan_period_id", sql, "Should select payer_plan_period_id" + ) self.assertIn("ppp.payer_concept_id", sql, "Should select payer_concept_id") self.assertIn("ppp.plan_concept_id", sql, "Should select plan_concept_id") self.assertIn("ppp.sponsor_concept_id", sql, "Should select sponsor_concept_id") - self.assertIn("ppp.stop_reason_concept_id", sql, "Should select stop_reason_concept_id") - + self.assertIn( + "ppp.stop_reason_concept_id", sql, "Should select stop_reason_concept_id" + ) + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - + # 3. Check Where Clauses - self.assertIn("C.payer_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)", sql, "Should filter payer_concept") - self.assertIn("C.plan_concept_id in (SELECT concept_id from #Codesets where codeset_id = 2)", sql, "Should filter plan_concept") - self.assertIn("C.sponsor_concept_id in (SELECT concept_id from #Codesets where codeset_id = 3)", sql, "Should filter sponsor_concept") - self.assertIn("C.stop_reason_concept_id in (SELECT concept_id from #Codesets where codeset_id = 4)", sql, "Should filter stop_reason_concept") - - self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date") - self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date") - - self.assertIn("DATEDIFF(d,C.start_date, C.end_date) > 10", sql, "Should filter period_length") - - self.assertIn("YEAR(C.start_date) - P.year_of_birth > 40", sql, "Should filter age_at_start") - self.assertIn("YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end") - + self.assertIn( + "C.payer_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)", + sql, + "Should filter payer_concept", + ) + self.assertIn( + "C.plan_concept_id in (SELECT concept_id from #Codesets where codeset_id = 2)", + sql, + "Should filter plan_concept", + ) + self.assertIn( + "C.sponsor_concept_id in (SELECT concept_id from #Codesets where codeset_id = 3)", + sql, + "Should filter sponsor_concept", + ) + self.assertIn( + "C.stop_reason_concept_id in (SELECT concept_id from #Codesets where codeset_id = 4)", + sql, + "Should filter stop_reason_concept", + ) + + self.assertIn( + "C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date" + ) + self.assertIn( + "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date" + ) + + self.assertIn( + "DATEDIFF(d,C.start_date, C.end_date) > 10", + sql, + "Should filter period_length", + ) + + self.assertIn( + "YEAR(C.start_date) - P.year_of_birth > 40", + sql, + "Should filter age_at_start", + ) + self.assertIn( + "YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end" + ) + self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") - + # 4. Check Ordinal self.assertIn("C.ordinal = 1", sql, "Should filter first ordinal") + class TestObservationPeriodBuilder(unittest.TestCase): - def setUp(self): self.builder = ObservationPeriodSqlBuilder() - + def test_includes_full_logic(self): # Create ObservationPeriod with various criteria op = ObservationPeriod( period_start_date=DateRange(op="gt", value="2020-01-01"), period_end_date=DateRange(op="lt", value="2021-01-01"), - period_type=[Concept(conceptId=1, conceptName="Type1", domainId="Type", vocabularyId="Type", standardConcept="S", conceptCode="1")], + period_type=[ + Concept( + conceptId=1, + conceptName="Type1", + domainId="Type", + vocabularyId="Type", + standardConcept="S", + conceptCode="1", + ) + ], period_type_cs=ConceptSetSelection(codesetId=2), period_length=NumericRange(op="gt", value=365), age_at_start=NumericRange(op="gt", value=18), age_at_end=NumericRange(op="lt", value=100), user_defined_period=Period(start_date="2020-01-01", end_date="2021-01-01"), - first=True + first=True, ) - + sql = self.builder.get_criteria_sql(op) - + # 1. Check Select Clauses self.assertIn("op.person_id", sql, "Should select person_id") - self.assertIn("op.observation_period_id", sql, "Should select observation_period_id") - self.assertIn("op.period_type_concept_id", sql, "Should select period_type_concept_id") - + self.assertIn( + "op.observation_period_id", sql, "Should select observation_period_id" + ) + self.assertIn( + "op.period_type_concept_id", sql, "Should select period_type_concept_id" + ) + # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - + # 3. Check Where Clauses - self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date") - self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date") - - self.assertIn("C.period_type_concept_id in (1)", sql, "Should filter period_type") - self.assertTrue("C.period_type_concept_id in (select concept_id from #Codesets where codeset_id = 2)" in sql or "C.period_type_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 2)" in sql, "Should filter period_type_cs") - - self.assertIn("DATEDIFF(d,C.start_date, C.end_date) > 365", sql, "Should filter period_length") - - self.assertIn("YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age_at_start") - self.assertIn("YEAR(C.end_date) - P.year_of_birth < 100", sql, "Should filter age_at_end") - + self.assertIn( + "C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date" + ) + self.assertIn( + "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date" + ) + + self.assertIn( + "C.period_type_concept_id in (1)", sql, "Should filter period_type" + ) + self.assertTrue( + "C.period_type_concept_id in (select concept_id from #Codesets where codeset_id = 2)" + in sql + or "C.period_type_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 2)" + in sql, + "Should filter period_type_cs", + ) + + self.assertIn( + "DATEDIFF(d,C.start_date, C.end_date) > 365", + sql, + "Should filter period_length", + ) + + self.assertIn( + "YEAR(C.start_date) - P.year_of_birth > 18", + sql, + "Should filter age_at_start", + ) + self.assertIn( + "YEAR(C.end_date) - P.year_of_birth < 100", sql, "Should filter age_at_end" + ) + # User defined period bounds - self.assertIn("C.start_date <= DATEFROMPARTS(2020, 1, 1) and C.end_date >= DATEFROMPARTS(2020, 1, 1)", sql, "Should filter user defined start") - self.assertIn("C.start_date <= DATEFROMPARTS(2021, 1, 1) and C.end_date >= DATEFROMPARTS(2021, 1, 1)", sql, "Should filter user defined end") - + self.assertIn( + "C.start_date <= DATEFROMPARTS(2020, 1, 1) and C.end_date >= DATEFROMPARTS(2020, 1, 1)", + sql, + "Should filter user defined start", + ) + self.assertIn( + "C.start_date <= DATEFROMPARTS(2021, 1, 1) and C.end_date >= DATEFROMPARTS(2021, 1, 1)", + sql, + "Should filter user defined end", + ) + # 4. Check Ordinal self.assertIn("C.ordinal = 1", sql, "Should filter first ordinal") + class TestLocationRegionBuilder(unittest.TestCase): - def setUp(self): self.builder = LocationRegionSqlBuilder() - + def test_includes_full_logic(self): # Create LocationRegion with codeset - lr = LocationRegion( - codeset_id=1 - ) - + lr = LocationRegion(codeset_id=1) + sql = self.builder.get_criteria_sql(lr) - + # 1. Check Select Clauses self.assertIn("C.person_id", sql, "Should select person_id") self.assertIn("C.location_id", sql, "Should select location_id") self.assertIn("C.region_concept_id", sql, "Should select region_concept_id") - + # 2. Check Codeset Clause # The python builder now uses AND l.region_concept_id ... - self.assertTrue("AND l.region_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)" in sql or "AND l.region_concept_id in (select concept_id from #Codesets where codeset_id = 1)" in sql, "Should have codeset logic") - + self.assertTrue( + "AND l.region_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)" + in sql + or "AND l.region_concept_id in (select concept_id from #Codesets where codeset_id = 1)" + in sql, + "Should have codeset logic", + ) + # 3. Check Template Structure (that implies Person is present) - self.assertIn("FROM @cdm_database_schema.LOCATION_HISTORY lh", sql, "Should select from LOCATION_HISTORY") - self.assertIn("JOIN @cdm_database_schema.LOCATION l on lh.location_id = l.location_id", sql, "Should join LOCATION") - self.assertIn("WHERE lh.domain_id = 'PERSON'", sql, "Should filter PERSON domain") - + self.assertIn( + "FROM @cdm_database_schema.LOCATION_HISTORY lh", + sql, + "Should select from LOCATION_HISTORY", + ) + self.assertIn( + "JOIN @cdm_database_schema.LOCATION l on lh.location_id = l.location_id", + sql, + "Should join LOCATION", + ) + self.assertIn( + "WHERE lh.domain_id = 'PERSON'", sql, "Should filter PERSON domain" + ) + # Verify that start_date and end_date are selected self.assertIn("C.start_date", sql, "Should select C.start_date") self.assertIn("C.end_date", sql, "Should select C.end_date") -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_supporting_classes.py b/tests/test_supporting_classes.py index 9c96b929..bb4f4a16 100644 --- a/tests/test_supporting_classes.py +++ b/tests/test_supporting_classes.py @@ -33,28 +33,19 @@ def test_text_filter_initialization(self): def test_text_filter_with_fields(self): """Test TextFilter with fields populated.""" - text_filter = TextFilter( - text="completed", - op="eq" - ) + text_filter = TextFilter(text="completed", op="eq") self.assertEqual(text_filter.text, "completed") self.assertEqual(text_filter.op, "eq") def test_text_filter_empty_string(self): """Test TextFilter with empty string.""" - text_filter = TextFilter( - text="", - op="ne" - ) + text_filter = TextFilter(text="", op="ne") self.assertEqual(text_filter.text, "") self.assertEqual(text_filter.op, "ne") def test_text_filter_unicode_text(self): """Test TextFilter with unicode text.""" - text_filter = TextFilter( - text="café", - op="like" - ) + text_filter = TextFilter(text="café", op="like") self.assertEqual(text_filter.text, "café") self.assertEqual(text_filter.op, "like") @@ -70,28 +61,19 @@ def test_window_bound_initialization(self): def test_window_bound_with_days(self): """Test WindowBound with days populated.""" - window_bound = WindowBound( - coeff=1, - days=30 - ) + window_bound = WindowBound(coeff=1, days=30) self.assertEqual(window_bound.coeff, 1) self.assertEqual(window_bound.days, 30) def test_window_bound_negative_coeff(self): """Test WindowBound with negative coefficient.""" - window_bound = WindowBound( - coeff=-1, - days=7 - ) + window_bound = WindowBound(coeff=-1, days=7) self.assertEqual(window_bound.coeff, -1) self.assertEqual(window_bound.days, 7) def test_window_bound_zero_coeff(self): """Test WindowBound with zero coefficient.""" - window_bound = WindowBound( - coeff=0, - days=0 - ) + window_bound = WindowBound(coeff=0, days=0) self.assertEqual(window_bound.coeff, 0) self.assertEqual(window_bound.days, 0) @@ -101,9 +83,7 @@ class TestWindow(unittest.TestCase): def test_window_initialization(self): """Test basic initialization of Window.""" - window = Window( - use_event_end=True - ) + window = Window(use_event_end=True) self.assertTrue(window.use_event_end) self.assertFalse(window.use_index_end) self.assertIsNone(window.start) @@ -113,14 +93,11 @@ def test_window_with_all_fields(self): """Test Window with all fields populated.""" start_bound = WindowBound(coeff=1, days=30) end_bound = WindowBound(coeff=-1, days=7) - + window = Window( - use_event_end=True, - use_index_end=False, - start=start_bound, - end=end_bound + use_event_end=True, use_index_end=False, start=start_bound, end=end_bound ) - + self.assertTrue(window.use_event_end) self.assertFalse(window.use_index_end) self.assertEqual(window.start.coeff, 1) @@ -130,12 +107,14 @@ def test_window_with_all_fields(self): def test_window_camel_case_aliases(self): """Test that camelCase aliases work correctly.""" - window = Window.model_validate({ - "useEventEnd": True, - "start": {"coeff": -1, "days": 0}, - "end": {"coeff": 1, "days": 30} - }) - + window = Window.model_validate( + { + "useEventEnd": True, + "start": {"coeff": -1, "days": 0}, + "end": {"coeff": 1, "days": 30}, + } + ) + self.assertTrue(window.use_event_end) self.assertEqual(window.start.coeff, -1) self.assertEqual(window.start.days, 0) @@ -145,9 +124,7 @@ def test_window_camel_case_aliases(self): def test_window_use_event_end_false(self): """Test Window with use_event_end=False.""" window = Window( - use_event_end=False, - start=WindowBound(coeff=-1), - end=WindowBound(coeff=1) + use_event_end=False, start=WindowBound(coeff=-1), end=WindowBound(coeff=1) ) self.assertFalse(window.use_event_end) self.assertEqual(window.start.coeff, -1) @@ -169,20 +146,20 @@ def test_windowed_criteria_with_windows(self): start_window = Window( use_event_end=True, start=WindowBound(coeff=-1, days=0), - end=WindowBound(coeff=1, days=30) + end=WindowBound(coeff=1, days=30), ) end_window = Window( use_event_end=False, start=WindowBound(coeff=-1, days=7), - end=WindowBound(coeff=1, days=14) + end=WindowBound(coeff=1, days=14), ) - + windowed_criteria = WindowedCriteria( criteria=ConditionOccurrence(), start_window=start_window, - end_window=end_window + end_window=end_window, ) - + self.assertTrue(isinstance(windowed_criteria.criteria, ConditionOccurrence)) self.assertEqual(windowed_criteria.start_window.end.coeff, 1) self.assertEqual(windowed_criteria.start_window.end.days, 30) @@ -191,20 +168,22 @@ def test_windowed_criteria_with_windows(self): def test_windowed_criteria_camel_case_aliases(self): """Test that camelCase aliases work correctly.""" - windowed_criteria = WindowedCriteria.model_validate({ - "Criteria": {"ConditionOccurrence": {}}, - "StartWindow": { - "useEventEnd": True, - "start": {"coeff": -1, "days": 0}, - "end": {"coeff": 1, "days": 30} - }, - "EndWindow": { - "useEventEnd": False, - "start": {"coeff": -1, "days": 7}, - "end": {"coeff": 1, "days": 14} + windowed_criteria = WindowedCriteria.model_validate( + { + "Criteria": {"ConditionOccurrence": {}}, + "StartWindow": { + "useEventEnd": True, + "start": {"coeff": -1, "days": 0}, + "end": {"coeff": 1, "days": 30}, + }, + "EndWindow": { + "useEventEnd": False, + "start": {"coeff": -1, "days": 7}, + "end": {"coeff": 1, "days": 14}, + }, } - }) - + ) + self.assertTrue(isinstance(windowed_criteria.criteria, ConditionOccurrence)) self.assertIsNotNone(windowed_criteria.start_window) self.assertIsNotNone(windowed_criteria.end_window) @@ -219,50 +198,37 @@ class TestDateOffsetStrategy(unittest.TestCase): def test_date_offset_strategy_initialization(self): """Test basic initialization of DateOffsetStrategy.""" - strategy = DateOffsetStrategy( - offset=30, - date_field="start_date" - ) + strategy = DateOffsetStrategy(offset=30, date_field="start_date") self.assertEqual(strategy.offset, 30) self.assertEqual(strategy.date_field, "start_date") def test_date_offset_strategy_negative_offset(self): """Test DateOffsetStrategy with negative offset.""" - strategy = DateOffsetStrategy( - offset=-7, - date_field="end_date" - ) + strategy = DateOffsetStrategy(offset=-7, date_field="end_date") self.assertEqual(strategy.offset, -7) self.assertEqual(strategy.date_field, "end_date") def test_date_offset_strategy_zero_offset(self): """Test DateOffsetStrategy with zero offset.""" - strategy = DateOffsetStrategy( - offset=0, - date_field="event_date" - ) + strategy = DateOffsetStrategy(offset=0, date_field="event_date") self.assertEqual(strategy.offset, 0) self.assertEqual(strategy.date_field, "event_date") def test_date_offset_strategy_camel_case_aliases(self): """Test that camelCase aliases work correctly.""" - strategy = DateOffsetStrategy.model_validate({ - "offset": 30, - "dateField": "start_date" - }) - + strategy = DateOffsetStrategy.model_validate( + {"offset": 30, "dateField": "start_date"} + ) + self.assertEqual(strategy.offset, 30) self.assertEqual(strategy.date_field, "start_date") def test_date_offset_strategy_different_date_fields(self): """Test DateOffsetStrategy with different date fields.""" fields = ["start_date", "end_date", "event_date", "observation_date"] - + for field in fields: - strategy = DateOffsetStrategy( - offset=15, - date_field=field - ) + strategy = DateOffsetStrategy(offset=15, date_field=field) self.assertEqual(strategy.offset, 15) self.assertEqual(strategy.date_field, field) @@ -272,21 +238,14 @@ class TestCustomEraStrategy(unittest.TestCase): def test_custom_era_strategy_initialization(self): """Test basic initialization of CustomEraStrategy.""" - strategy = CustomEraStrategy( - gap_days=30, - offset=0 - ) + strategy = CustomEraStrategy(gap_days=30, offset=0) self.assertIsNone(strategy.drug_codeset_id) self.assertEqual(strategy.gap_days, 30) self.assertEqual(strategy.offset, 0) def test_custom_era_strategy_with_drug_codeset(self): """Test CustomEraStrategy with drug codeset ID.""" - strategy = CustomEraStrategy( - drug_codeset_id=12345, - gap_days=30, - offset=0 - ) + strategy = CustomEraStrategy(drug_codeset_id=12345, gap_days=30, offset=0) self.assertEqual(strategy.drug_codeset_id, 12345) self.assertEqual(strategy.gap_days, 30) self.assertEqual(strategy.offset, 0) @@ -294,35 +253,27 @@ def test_custom_era_strategy_with_drug_codeset(self): def test_custom_era_strategy_different_gap_days(self): """Test CustomEraStrategy with different gap days.""" gap_days_values = [0, 7, 14, 30, 60, 90] - + for gap_days in gap_days_values: - strategy = CustomEraStrategy( - gap_days=gap_days, - offset=0 - ) + strategy = CustomEraStrategy(gap_days=gap_days, offset=0) self.assertEqual(strategy.gap_days, gap_days) self.assertEqual(strategy.offset, 0) def test_custom_era_strategy_different_offsets(self): """Test CustomEraStrategy with different offsets.""" offset_values = [-30, -7, 0, 7, 30] - + for offset in offset_values: - strategy = CustomEraStrategy( - gap_days=30, - offset=offset - ) + strategy = CustomEraStrategy(gap_days=30, offset=offset) self.assertEqual(strategy.gap_days, 30) self.assertEqual(strategy.offset, offset) def test_custom_era_strategy_camel_case_aliases(self): """Test that camelCase aliases work correctly.""" - strategy = CustomEraStrategy.model_validate({ - "drugCodesetId": 12345, - "gapDays": 30, - "offset": 0 - }) - + strategy = CustomEraStrategy.model_validate( + {"drugCodesetId": 12345, "gapDays": 30, "offset": 0} + ) + self.assertEqual(strategy.drug_codeset_id, 12345) self.assertEqual(strategy.gap_days, 30) self.assertEqual(strategy.offset, 0) @@ -330,21 +281,13 @@ def test_custom_era_strategy_camel_case_aliases(self): def test_custom_era_strategy_edge_cases(self): """Test CustomEraStrategy with edge case values.""" # Test with maximum values - strategy = CustomEraStrategy( - drug_codeset_id=999999, - gap_days=365, - offset=365 - ) + strategy = CustomEraStrategy(drug_codeset_id=999999, gap_days=365, offset=365) self.assertEqual(strategy.drug_codeset_id, 999999) self.assertEqual(strategy.gap_days, 365) self.assertEqual(strategy.offset, 365) - + # Test with minimum values - strategy = CustomEraStrategy( - drug_codeset_id=1, - gap_days=0, - offset=-365 - ) + strategy = CustomEraStrategy(drug_codeset_id=1, gap_days=0, offset=-365) self.assertEqual(strategy.drug_codeset_id, 1) self.assertEqual(strategy.gap_days, 0) self.assertEqual(strategy.offset, -365) @@ -357,15 +300,11 @@ def test_window_with_window_bound_integration(self): """Test Window integration with WindowBound.""" start_bound = WindowBound(coeff=1, days=30) end_bound = WindowBound(coeff=-1, days=7) - + window = Window( - use_event_end=True, - start=start_bound, - coeff=1, - days=30, - end=end_bound + use_event_end=True, start=start_bound, coeff=1, days=30, end=end_bound ) - + # Test that the bounds are properly integrated self.assertEqual(window.start.coeff, 1) self.assertEqual(window.start.days, 30) @@ -377,20 +316,20 @@ def test_windowed_criteria_with_window_integration(self): start_window = Window( use_event_end=True, start=WindowBound(coeff=-1, days=0), - end=WindowBound(coeff=1, days=30) + end=WindowBound(coeff=1, days=30), ) end_window = Window( use_event_end=False, start=WindowBound(coeff=-1, days=7), - end=WindowBound(coeff=1, days=14) + end=WindowBound(coeff=1, days=14), ) - + windowed_criteria = WindowedCriteria( criteria=ConditionOccurrence(), start_window=start_window, - end_window=end_window + end_window=end_window, ) - + # Test that the windows are properly integrated self.assertEqual(windowed_criteria.start_window.end.coeff, 1) self.assertEqual(windowed_criteria.start_window.end.days, 30) @@ -400,15 +339,13 @@ def test_windowed_criteria_with_window_integration(self): def test_text_filter_with_criteria_integration(self): """Test TextFilter integration with criteria classes.""" from circe.cohortdefinition.criteria import ConditionOccurrence - + text_filter = TextFilter(text="completed", op="eq") - + condition = ConditionOccurrence( - stop_reason=text_filter, - first=True, - condition_type_exclude=False + stop_reason=text_filter, first=True, condition_type_exclude=False ) - + # Test that the text filter is properly integrated self.assertEqual(condition.stop_reason.text, "completed") self.assertEqual(condition.stop_reason.op, "eq") @@ -423,7 +360,7 @@ def test_all_supporting_classes_importable(self): WindowBound, ) from circe.cohortdefinition.criteria import WindowedCriteria - + # Test that all classes are importable self.assertTrue(TextFilter is not None) self.assertTrue(WindowBound is not None) @@ -433,5 +370,5 @@ def test_all_supporting_classes_importable(self): self.assertTrue(CustomEraStrategy is not None) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_utils_db.py b/tests/test_utils_db.py index 993f1951..93e4e855 100644 --- a/tests/test_utils_db.py +++ b/tests/test_utils_db.py @@ -1,4 +1,3 @@ - from typing import Any, List import duckdb @@ -8,19 +7,19 @@ class DuckDBTestHelper: """Helper class for running OHDSI SQL in DuckDB tests.""" - + def __init__(self): self.con = duckdb.connect(":memory:") self._setup_schema() - + def _setup_schema(self): """Setup basic OMOP CDM schema structure.""" # Create schema for CDM self.con.execute("CREATE SCHEMA IF NOT EXISTS main") - + # Create basic tables needed for tests (empty for now) # Note: We use specific types broadly compatible with CDM 5.3+ - + # CDM Tables used by criteria tables = [ "person", @@ -39,42 +38,44 @@ def _setup_schema(self): "cost", "payer_plan_period", "drug_era", - "dose_era", + "dose_era", "condition_era", "location", "care_site", - "provider" + "provider", ] - + for table in tables: - # Create dummy tables with a few key columns to avoid "table not found" errors - # The exact schema isn't strictly needed for parsing, but helps if we insert data later - self.con.execute(f"CREATE TABLE IF NOT EXISTS {table} (person_id INTEGER)") - + # Create dummy tables with a few key columns to avoid "table not found" errors + # The exact schema isn't strictly needed for parsing, but helps if we insert data later + self.con.execute(f"CREATE TABLE IF NOT EXISTS {table} (person_id INTEGER)") + # Create temp tables usually expected by OHDSI SQL - self.con.execute("CREATE TABLE IF NOT EXISTS Codesets (codeset_id INTEGER, concept_id INTEGER)") - + self.con.execute( + "CREATE TABLE IF NOT EXISTS Codesets (codeset_id INTEGER, concept_id INTEGER)" + ) + def translate_sql(self, sql: str) -> str: """Translate OHDSI SQL (T-SQL) to DuckDB SQL.""" # Simple translation pipeline try: # Parse as T-SQL expression = sqlglot.parse(sql, read="tsql") - + # Additional transformations if needed for DuckDB specific quirks # (e.g. date math, string formatting) - + # Generate as DuckDB # Note: We might need to handle specific OHDSI dialects like @cdm_database_schema - + # Replace basic parameters manually if not handled by sqlglot # OHDSI SQL uses @parameter logic often - + return sqlglot.transpile(sql, read="tsql", write="duckdb")[0] except Exception as e: print(f"FAILED SQL:\n{sql}") raise RuntimeError(f"Translation failed: {e}") - + def execute_query(self, sql: str): """Execute translated query.""" # Remove OHDSI params for local testing BEFORE translation @@ -85,15 +86,16 @@ def execute_query(self, sql: str): sql_clean = sql_clean.replace("@vocabulary_database_schema", "main") sql_clean = sql_clean.replace("#Codesets", "Codesets") sql_clean = sql_clean.replace("JOIN Codesets", "INNER JOIN Codesets") - + translated = self.translate_sql(sql_clean) - + return self.con.execute(translated) - + def query(self, sql: str) -> List[Any]: """Execute and return results.""" return self.execute_query(sql).fetchall() + @pytest.fixture(scope="module") def duckdb_helper(): return DuckDBTestHelper() diff --git a/tests/test_visit_occurrence_parity.py b/tests/test_visit_occurrence_parity.py index 37618c4b..41d00149 100644 --- a/tests/test_visit_occurrence_parity.py +++ b/tests/test_visit_occurrence_parity.py @@ -18,30 +18,55 @@ def test_get_query_template(self): def test_get_default_columns(self): columns = self.builder.get_default_columns() - self.assertEqual(columns, {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_ID}) + self.assertEqual( + columns, + { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_ID, + }, + ) def test_get_table_column_for_criteria_column(self): - self.assertEqual(self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), "C.visit_concept_id") - self.assertEqual(self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), "DATEDIFF(d, C.start_date, C.end_date)") + self.assertEqual( + self.builder.get_table_column_for_criteria_column( + CriteriaColumn.DOMAIN_CONCEPT + ), + "C.visit_concept_id", + ) + self.assertEqual( + self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION), + "DATEDIFF(d, C.start_date, C.end_date)", + ) with self.assertRaises(ValueError): - self.builder.get_table_column_for_criteria_column(CriteriaColumn.VALUE_AS_NUMBER) + self.builder.get_table_column_for_criteria_column( + CriteriaColumn.VALUE_AS_NUMBER + ) def test_get_criteria_sql_basic(self): criteria = VisitOccurrence() sql = self.builder.get_criteria_sql(criteria) - self.assertIn("C.person_id, C.visit_occurrence_id as event_id, C.start_date, C.end_date", sql) + self.assertIn( + "C.person_id, C.visit_occurrence_id as event_id, C.start_date, C.end_date", + sql, + ) self.assertIn("vo.person_id,vo.visit_occurrence_id,vo.visit_concept_id", sql) - self.assertIn("vo.visit_start_date as start_date, vo.visit_end_date as end_date", sql) + self.assertIn( + "vo.visit_start_date as start_date, vo.visit_end_date as end_date", sql + ) def test_get_criteria_sql_with_codeset(self): criteria = VisitOccurrence(codeset_id=123) sql = self.builder.get_criteria_sql(criteria) - self.assertIn("JOIN #Codesets cs on (vo.visit_concept_id = cs.concept_id and cs.codeset_id = 123)", sql) + self.assertIn( + "JOIN #Codesets cs on (vo.visit_concept_id = cs.concept_id and cs.codeset_id = 123)", + sql, + ) def test_get_criteria_sql_with_date_ranges(self): criteria = VisitOccurrence( occurrence_start_date=DateRange(op="gt", value="2020-01-01"), - occurrence_end_date=DateRange(op="lt", value="2021-01-01") + occurrence_end_date=DateRange(op="lt", value="2021-01-01"), ) sql = self.builder.get_criteria_sql(criteria) self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql) @@ -51,8 +76,11 @@ def test_get_criteria_sql_with_visit_type(self): # Using codeset for visit type criteria = VisitOccurrence(visit_type_cs=ConceptSetSelection(codeset_id=456)) sql = self.builder.get_criteria_sql(criteria) - self.assertIn("vo.visit_type_concept_id", sql) # Added to select - self.assertIn("C.visit_type_concept_id in (select concept_id from #Codesets where codeset_id = 456)", sql) + self.assertIn("vo.visit_type_concept_id", sql) # Added to select + self.assertIn( + "C.visit_type_concept_id in (select concept_id from #Codesets where codeset_id = 456)", + sql, + ) def test_get_criteria_sql_with_visit_length(self): criteria = VisitOccurrence(visit_length=NumericRange(op="gt", value=5)) @@ -62,35 +90,66 @@ def test_get_criteria_sql_with_visit_length(self): def test_get_criteria_sql_with_age(self): criteria = VisitOccurrence(age=NumericRange(op="gte", value=18)) sql = self.builder.get_criteria_sql(criteria) - self.assertIn("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", sql) + self.assertIn( + "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", sql + ) self.assertIn("YEAR(C.start_date) - P.year_of_birth >= 18", sql) def test_get_criteria_sql_with_provider_specialty(self): - criteria = VisitOccurrence(provider_specialty_cs=ConceptSetSelection(codeset_id=789)) + criteria = VisitOccurrence( + provider_specialty_cs=ConceptSetSelection(codeset_id=789) + ) sql = self.builder.get_criteria_sql(criteria) - self.assertIn("vo.provider_id", sql) # Added to select - self.assertIn("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id", sql) - self.assertIn("PR.specialty_concept_id in (select concept_id from #Codesets where codeset_id = 789)", sql) + self.assertIn("vo.provider_id", sql) # Added to select + self.assertIn( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id", + sql, + ) + self.assertIn( + "PR.specialty_concept_id in (select concept_id from #Codesets where codeset_id = 789)", + sql, + ) def test_get_criteria_sql_with_place_of_service(self): - criteria = VisitOccurrence(place_of_service_cs=ConceptSetSelection(codeset_id=101)) + criteria = VisitOccurrence( + place_of_service_cs=ConceptSetSelection(codeset_id=101) + ) sql = self.builder.get_criteria_sql(criteria) - self.assertIn("vo.care_site_id", sql) # Added to select - self.assertIn("JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id", sql) - self.assertIn("CS.place_of_service_concept_id in (select concept_id from #Codesets where codeset_id = 101)", sql) + self.assertIn("vo.care_site_id", sql) # Added to select + self.assertIn( + "JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id", + sql, + ) + self.assertIn( + "CS.place_of_service_concept_id in (select concept_id from #Codesets where codeset_id = 101)", + sql, + ) def test_get_criteria_sql_with_place_of_service_location(self): criteria = VisitOccurrence(place_of_service_location=202) sql = self.builder.get_criteria_sql(criteria) - self.assertIn("JOIN @cdm_database_schema.LOCATION_HISTORY LH on LH.entity_id = C.care_site_id AND LH.domain_id = 'CARE_SITE'", sql) - self.assertIn("JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id", sql) - self.assertIn("JOIN #Codesets cs on (LOC.region_concept_id = cs.concept_id and cs.codeset_id = 202)", sql) + self.assertIn( + "JOIN @cdm_database_schema.LOCATION_HISTORY LH on LH.entity_id = C.care_site_id AND LH.domain_id = 'CARE_SITE'", + sql, + ) + self.assertIn( + "JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id", + sql, + ) + self.assertIn( + "JOIN #Codesets cs on (LOC.region_concept_id = cs.concept_id and cs.codeset_id = 202)", + sql, + ) def test_get_criteria_sql_with_first(self): criteria = VisitOccurrence(first=True) sql = self.builder.get_criteria_sql(criteria) - self.assertIn(", row_number() over (PARTITION BY vo.person_id ORDER BY vo.visit_start_date, vo.visit_occurrence_id) as ordinal", sql) + self.assertIn( + ", row_number() over (PARTITION BY vo.person_id ORDER BY vo.visit_start_date, vo.visit_occurrence_id) as ordinal", + sql, + ) self.assertIn("C.ordinal = 1", sql) + if __name__ == "__main__": unittest.main() From 230d3e09e0921ff90cd973153c7e9fe01fdcd507 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 11:26:37 -0700 Subject: [PATCH 11/62] more ruff reformatting and checks --- circe/__init__.py | 8 +- circe/api.py | 4 +- circe/chat.py | 2 +- circe/check/check.py | 9 +- circe/check/checker.py | 9 +- circe/check/checkers/base_check.py | 10 +- circe/check/checkers/comparisons.py | 14 +- .../check/checkers/concept_checker_factory.py | 4 +- .../checkers/criteria_checker_factory.py | 6 +- .../checkers/criteria_contradictions_check.py | 8 +- circe/check/checkers/domain_type_check.py | 3 +- circe/check/checkers/drug_domain_check.py | 4 +- .../checkers/duplicates_criteria_check.py | 16 +-- circe/check/checkers/incomplete_rule_check.py | 3 +- circe/check/checkers/range_checker_factory.py | 4 +- circe/check/checkers/time_pattern_check.py | 4 +- circe/check/checkers/unused_concepts_check.py | 26 ++-- circe/cohortdefinition/builders/base.py | 20 +-- .../builders/condition_era.py | 12 +- .../builders/condition_occurrence.py | 12 +- circe/cohortdefinition/builders/death.py | 12 +- .../builders/device_exposure.py | 11 +- circe/cohortdefinition/builders/dose_era.py | 12 +- circe/cohortdefinition/builders/drug_era.py | 12 +- .../builders/drug_exposure.py | 12 +- .../builders/location_region.py | 12 +- .../cohortdefinition/builders/measurement.py | 14 +- .../cohortdefinition/builders/observation.py | 14 +- .../builders/observation_period.py | 14 +- .../builders/payer_plan_period.py | 14 +- .../builders/procedure_occurrence.py | 12 +- circe/cohortdefinition/builders/specimen.py | 10 +- circe/cohortdefinition/builders/utils.py | 8 +- .../cohortdefinition/builders/visit_detail.py | 20 +-- .../builders/visit_occurrence.py | 14 +- circe/cohortdefinition/code_generator.py | 7 +- circe/cohortdefinition/cohort.py | 29 ++-- .../cohort_expression_query_builder.py | 14 +- .../concept_set_expression_query_builder.py | 15 +- circe/cohortdefinition/criteria.py | 134 +++++++++--------- .../printfriendly/markdown_render.py | 6 +- circe/execution/build_context.py | 35 ++--- circe/execution/builders/common.py | 45 +++--- circe/execution/builders/groups.py | 23 ++- circe/execution/builders/registry.py | 3 +- circe/execution/ibis.py | 28 ++-- circe/execution/ibis_compat.py | 2 +- circe/execution/options.py | 18 +-- circe/helper/cohort_modifiers.py | 32 ++--- circe/io.py | 3 +- circe/vocabulary/concept.py | 4 +- .../concept_set_expression_query_builder.py | 15 +- debug_app/app.py | 4 +- debug_app/sandbox.py | 4 +- examples/complex_cohort.py | 2 +- scripts/generate_skill_backup.py | 29 ++-- tests/test_builders.py | 5 +- tests/test_cli.py | 2 +- tests/test_code_generator.py | 2 +- ...ohort_expression_query_builder_extended.py | 2 +- tests/test_device_exposure_sql.py | 2 +- tests/test_kitchen_sink_cohort.py | 2 +- tests/test_real_example_cohorts.py | 10 +- tests/test_simple_sql_builders.py | 10 +- tests/test_utils_db.py | 6 +- 65 files changed, 411 insertions(+), 451 deletions(-) diff --git a/circe/__init__.py b/circe/__init__.py index 3f9c09f8..924f4025 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -109,7 +109,7 @@ def safe_model_rebuild(package): 'ValueError: call stack is not deep enough' during instantiation. """ try: - for loader, module_name, is_pkg in pkgutil.walk_packages( + for _loader, module_name, _is_pkg in pkgutil.walk_packages( package.__path__, package.__name__ + "." ): try: @@ -117,7 +117,7 @@ def safe_model_rebuild(package): except ImportError: continue - for name, obj in inspect.getmembers(mod): + for _name, obj in inspect.getmembers(mod): if inspect.isclass(obj) and issubclass(obj, BaseModel): try: # Rebuild Pydantic v2 models @@ -140,7 +140,7 @@ def get_json_schema() -> dict: in the same shape as the Java version. """ # Map name → Pydantic model - models: Dict[str, type] = { + models: dict[str, type] = { "CohortExpression": CohortExpression, "ConceptSet": ConceptSet, "ConceptSetExpression": ConceptSetExpression, @@ -189,7 +189,7 @@ def get_json_schema() -> dict: } # Build root-level $defs with each schema - defs: Dict[str, dict] = {} + defs: dict[str, dict] = {} for name, model in models.items(): # Use by_alias=True so JSON keys match Java casing if you set aliases in models schema = model.model_json_schema(by_alias=True) diff --git a/circe/api.py b/circe/api.py index b4deac57..2e1f4313 100644 --- a/circe/api.py +++ b/circe/api.py @@ -7,7 +7,7 @@ - cohort_print_friendly(): Generate Markdown from cohort expression """ -from typing import List, Optional +from typing import Optional from .cohortdefinition import ( BuildExpressionQueryOptions, @@ -103,7 +103,7 @@ def build_cohort_query( def cohort_print_friendly( expression: CohortExpression, - concept_sets: Optional[List[ConceptSet]] = None, + concept_sets: Optional[list[ConceptSet]] = None, title: Optional[str] = None, include_concept_sets: bool = False, ) -> str: diff --git a/circe/chat.py b/circe/chat.py index f5b51ed6..159a6cc8 100644 --- a/circe/chat.py +++ b/circe/chat.py @@ -222,7 +222,7 @@ def _process_response_content(content: str, output_base: Optional[str]): cohort_obj = local_scope.get("cohort") if not cohort_obj: # Try to find any variable that is a tuple (builder) or CohortExpression - for k, v in local_scope.items(): + for _k, v in local_scope.items(): if hasattr(v, "to_json"): # CohortExpression has to_json? Check API. cohort_obj = v break diff --git a/circe/check/check.py b/circe/check/check.py index f3440f2a..9421f880 100644 --- a/circe/check/check.py +++ b/circe/check/check.py @@ -9,8 +9,9 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +import contextlib from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING from .warning import Warning @@ -18,10 +19,8 @@ from ..cohortdefinition.cohort import CohortExpression else: # Import at runtime to avoid circular dependencies - try: + with contextlib.suppress(ImportError): from ..cohortdefinition.cohort import CohortExpression - except ImportError: - pass class Check(ABC): @@ -34,7 +33,7 @@ class Check(ABC): """ @abstractmethod - def check(self, expression: "CohortExpression") -> List[Warning]: + def check(self, expression: "CohortExpression") -> list[Warning]: """Check a cohort expression and return any warnings. Args: diff --git a/circe/check/checker.py b/circe/check/checker.py index d34f72bd..37de87e5 100644 --- a/circe/check/checker.py +++ b/circe/check/checker.py @@ -9,7 +9,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List from .check import Check from .warning import Warning @@ -33,7 +32,7 @@ class Checker(Check): cohort expression and collects all warnings. """ - def _get_checks(self) -> List[Check]: + def _get_checks(self) -> list[Check]: """Get the list of all checks to run. Returns: @@ -67,7 +66,7 @@ def _get_checks(self) -> List[Check]: from .checkers.time_window_check import TimeWindowCheck from .checkers.unused_concepts_check import UnusedConceptsCheck - checks: List[Check] = [ + checks: list[Check] = [ UnusedConceptsCheck(), ExitCriteriaCheck(), ExitCriteriaDaysOffsetCheck(), @@ -96,7 +95,7 @@ def _get_checks(self) -> List[Check]: return checks - def check(self, expression: "CohortExpression") -> List[Warning]: + def check(self, expression: "CohortExpression") -> list[Warning]: """Run all validation checks against a cohort expression. Args: @@ -105,7 +104,7 @@ def check(self, expression: "CohortExpression") -> List[Warning]: Returns: A list of all warnings found by all checks. """ - result: List[Warning] = [] + result: list[Warning] = [] for check in self._get_checks(): result.extend(check.check(expression)) return result diff --git a/circe/check/checkers/base_check.py b/circe/check/checkers/base_check.py index 29ec5413..dffb0b07 100644 --- a/circe/check/checkers/base_check.py +++ b/circe/check/checkers/base_check.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List +from typing import Any from ..check import Check from ..warning import Warning @@ -39,7 +39,7 @@ class BaseCheck(Check): ADDITIONAL_RULE = "additional rule" INITIAL_EVENT = "initial event" - def check(self, expression: "CohortExpression") -> List[Warning]: + def check(self, expression: "CohortExpression") -> list[Warning]: """Check a cohort expression and return warnings. This is the main entry point that sets up the warning reporter @@ -51,7 +51,7 @@ def check(self, expression: "CohortExpression") -> List[Warning]: Returns: A list of warnings found during validation """ - warnings: List[Warning] = [] + warnings: list[Warning] = [] self._check(expression, self._define_reporter(warnings)) return warnings @@ -72,7 +72,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.CRITICAL - def _define_reporter(self, warnings: List[Warning]) -> WarningReporter: + def _define_reporter(self, warnings: list[Warning]) -> WarningReporter: """Define the warning reporter for this check. Args: @@ -84,7 +84,7 @@ def _define_reporter(self, warnings: List[Warning]) -> WarningReporter: return self._get_reporter(self._define_severity(), warnings) def _get_reporter( - self, severity: WarningSeverity, warnings: List[Warning] + self, severity: WarningSeverity, warnings: list[Warning] ) -> WarningReporter: """Get a warning reporter for the given severity level. diff --git a/circe/check/checkers/comparisons.py b/circe/check/checkers/comparisons.py index 83b61c3d..10d645c1 100644 --- a/circe/check/checkers/comparisons.py +++ b/circe/check/checkers/comparisons.py @@ -259,19 +259,7 @@ def compare_criteria(c1: "Criteria", c2: "Criteria") -> bool: ) if ( - isinstance(c1, ConditionEra) - or isinstance(c1, ConditionOccurrence) - or isinstance(c1, Death) - or isinstance(c1, DeviceExposure) - or isinstance(c1, DoseEra) - or isinstance(c1, DrugEra) - or isinstance(c1, DrugExposure) - or isinstance(c1, Measurement) - or isinstance(c1, Observation) - or isinstance(c1, ProcedureOccurrence) - or isinstance(c1, Specimen) - or isinstance(c1, VisitOccurrence) - or isinstance(c1, VisitDetail) + isinstance(c1, (ConditionEra, ConditionOccurrence, Death, DeviceExposure, DoseEra, DrugEra, DrugExposure, Measurement, Observation, ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail)) ): return c1.codeset_id == c2.codeset_id diff --git a/circe/check/checkers/concept_checker_factory.py b/circe/check/checkers/concept_checker_factory.py index 0c069369..8fbf6476 100644 --- a/circe/check/checkers/concept_checker_factory.py +++ b/circe/check/checkers/concept_checker_factory.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Callable, List, Optional +from typing import Callable, Optional from ..constants import Constants from ..operations.operations import Operations @@ -447,7 +447,7 @@ def check(c: "DemographicCriteria") -> None: return check def _check_concept( - self, concepts: Optional[List["Concept"]], criteria_name: str, attribute: str + self, concepts: Optional[list["Concept"]], criteria_name: str, attribute: str ) -> None: """Check if a concept array is empty. diff --git a/circe/check/checkers/criteria_checker_factory.py b/circe/check/checkers/criteria_checker_factory.py index cbeb6ab4..941fad36 100644 --- a/circe/check/checkers/criteria_checker_factory.py +++ b/circe/check/checkers/criteria_checker_factory.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Callable, List, Optional +from typing import Callable, Optional # Import at runtime to avoid circular dependencies try: @@ -219,7 +219,7 @@ def default_check(c: "Criteria") -> bool: def _get_concept_set_selection_suppliers( self, criteria: "VisitDetail" - ) -> List[Callable[[], Optional["ConceptSetSelection"]]]: + ) -> list[Callable[[], Optional["ConceptSetSelection"]]]: """Get suppliers for ConceptSetSelection fields in VisitDetail. Args: @@ -228,7 +228,7 @@ def _get_concept_set_selection_suppliers( Returns: A list of functions that return ConceptSetSelection objects """ - suppliers: List[Callable[[], Optional[ConceptSetSelection]]] = [] + suppliers: list[Callable[[], Optional[ConceptSetSelection]]] = [] suppliers.append(lambda: criteria.place_of_service_cs) suppliers.append(lambda: criteria.gender_cs) suppliers.append(lambda: criteria.provider_specialty_cs) diff --git a/circe/check/checkers/criteria_contradictions_check.py b/circe/check/checkers/criteria_contradictions_check.py index 6da30bdc..b53f2ea0 100644 --- a/circe/check/checkers/criteria_contradictions_check.py +++ b/circe/check/checkers/criteria_contradictions_check.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Tuple +from typing import Optional from ..utils.criteria_name_helper import CriteriaNameHelper from ..warning_severity import WarningSeverity @@ -66,7 +66,7 @@ class CriteriaContradictionsCheck(BaseCorelatedCriteriaCheck): def __init__(self): """Initialize the criteria contradictions check.""" super().__init__() - self._criteria_list: List[CriteriaInfo] = [] + self._criteria_list: list[CriteriaInfo] = [] def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. @@ -131,7 +131,7 @@ def _check_contradiction( # Check if ranges overlap return not self._ranges_overlap(range1, range2) - def _get_occurrence_range(self, occurrence: "Occurrence") -> Tuple[int, int]: + def _get_occurrence_range(self, occurrence: "Occurrence") -> tuple[int, int]: """Get the range of valid occurrence counts. Args: @@ -150,7 +150,7 @@ def _get_occurrence_range(self, occurrence: "Occurrence") -> Tuple[int, int]: else: return (float("-inf"), float("inf")) - def _ranges_overlap(self, range1: Tuple[int, int], range2: Tuple[int, int]) -> bool: + def _ranges_overlap(self, range1: tuple[int, int], range2: tuple[int, int]) -> bool: """Check if two ranges overlap. Args: diff --git a/circe/check/checkers/domain_type_check.py b/circe/check/checkers/domain_type_check.py index 949b9282..4dbe042b 100644 --- a/circe/check/checkers/domain_type_check.py +++ b/circe/check/checkers/domain_type_check.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List from ..operations.operations import Operations from ..utils.criteria_name_helper import CriteriaNameHelper @@ -53,7 +52,7 @@ class DomainTypeCheck(BaseCriteriaCheck): def __init__(self): """Initialize the domain type check.""" super().__init__() - self._warn_names: List[str] = [] + self._warn_names: list[str] = [] def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. diff --git a/circe/check/checkers/drug_domain_check.py b/circe/check/checkers/drug_domain_check.py index 2ebd196c..84e57a25 100644 --- a/circe/check/checkers/drug_domain_check.py +++ b/circe/check/checkers/drug_domain_check.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional +from typing import Optional from ..operations.operations import Operations from ..warning_severity import WarningSeverity @@ -60,7 +60,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N ): return - concept_sets: List[ConceptSet] = [] + concept_sets: list[ConceptSet] = [] # Map criteria to codeset IDs codeset_ids = [ diff --git a/circe/check/checkers/duplicates_criteria_check.py b/circe/check/checkers/duplicates_criteria_check.py index 58c59222..e1c6eeac 100644 --- a/circe/check/checkers/duplicates_criteria_check.py +++ b/circe/check/checkers/duplicates_criteria_check.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Tuple from ..utils.criteria_name_helper import CriteriaNameHelper from ..warning_severity import WarningSeverity @@ -38,7 +37,7 @@ class DuplicatesCriteriaCheck(BaseCriteriaCheck): def __init__(self): """Initialize the duplicates criteria check.""" super().__init__() - self._criteria_list: List[Tuple[str, Criteria]] = [] + self._criteria_list: list[tuple[str, Criteria]] = [] def _after_check( self, reporter: WarningReporter, expression: "CohortExpression" @@ -109,13 +108,7 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: and c1.condition_source_concept == c2.condition_source_concept ) elif ( - isinstance(c1, Death) - or isinstance(c1, DeviceExposure) - or isinstance(c1, DoseEra) - or isinstance(c1, DrugEra) - or isinstance(c1, DrugExposure) - or isinstance(c1, Measurement) - or isinstance(c1, Observation) + isinstance(c1, (Death, DeviceExposure, DoseEra, DrugEra, DrugExposure, Measurement, Observation)) ): return c1.codeset_id == c2.codeset_id elif isinstance(c1, ObservationPeriod): @@ -126,10 +119,7 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: and self._compare_objects(c1.period_length, c2.period_length) ) elif ( - isinstance(c1, ProcedureOccurrence) - or isinstance(c1, Specimen) - or isinstance(c1, VisitOccurrence) - or isinstance(c1, VisitDetail) + isinstance(c1, (ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail)) ): return c1.codeset_id == c2.codeset_id elif isinstance(c1, PayerPlanPeriod): diff --git a/circe/check/checkers/incomplete_rule_check.py b/circe/check/checkers/incomplete_rule_check.py index b4821817..2f304419 100644 --- a/circe/check/checkers/incomplete_rule_check.py +++ b/circe/check/checkers/incomplete_rule_check.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List from ..warning import Warning from ..warning_severity import WarningSeverity @@ -35,7 +34,7 @@ class IncompleteRuleCheck(BaseCheck): """ def _get_reporter( - self, severity: WarningSeverity, warnings: List[Warning] + self, severity: WarningSeverity, warnings: list[Warning] ) -> WarningReporter: """Get a warning reporter that creates IncompleteRuleWarning instances. diff --git a/circe/check/checkers/range_checker_factory.py b/circe/check/checkers/range_checker_factory.py index 3d9fb05f..470591df 100644 --- a/circe/check/checkers/range_checker_factory.py +++ b/circe/check/checkers/range_checker_factory.py @@ -728,7 +728,5 @@ def check(self, expression_or_criteria) -> None: Constants.Attributes.CENSOR_WINDOW_ATTR, ) # Handle DemographicCriteria (delegate to base class) - elif isinstance(expression_or_criteria, DemographicCriteria) or isinstance( - expression_or_criteria, Criteria - ): + elif isinstance(expression_or_criteria, (DemographicCriteria, Criteria)): super().check(expression_or_criteria) diff --git a/circe/check/checkers/time_pattern_check.py b/circe/check/checkers/time_pattern_check.py index 64512fb6..54c62b0c 100644 --- a/circe/check/checkers/time_pattern_check.py +++ b/circe/check/checkers/time_pattern_check.py @@ -9,7 +9,7 @@ """ from collections import Counter -from typing import List, Optional +from typing import Optional from ..utils.criteria_name_helper import CriteriaNameHelper from ..warning_severity import WarningSeverity @@ -73,7 +73,7 @@ class TimePatternCheck(BaseCorelatedCriteriaCheck): def __init__(self): """Initialize the time pattern check.""" super().__init__() - self._time_window_info_list: List[TimeWindowInfo] = [] + self._time_window_info_list: list[TimeWindowInfo] = [] def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. diff --git a/circe/check/checkers/unused_concepts_check.py b/circe/check/checkers/unused_concepts_check.py index 5ad37576..0ec13faf 100644 --- a/circe/check/checkers/unused_concepts_check.py +++ b/circe/check/checkers/unused_concepts_check.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional +from typing import Optional from ..warning_severity import WarningSeverity from ..warnings.concept_set_warning import ConceptSetWarning @@ -51,7 +51,7 @@ def _define_severity(self) -> WarningSeverity: return WarningSeverity.WARNING def _get_reporter( - self, severity: WarningSeverity, warnings: List + self, severity: WarningSeverity, warnings: list ) -> WarningReporter: """Get a warning reporter that creates ConceptSetWarning instances. @@ -85,7 +85,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N def _get_additional_criteria( self, expression: "CohortExpression" - ) -> List["Criteria"]: + ) -> list["Criteria"]: """Get all criteria from additional criteria. Args: @@ -94,7 +94,7 @@ def _get_additional_criteria( Returns: A list of all criteria from additional criteria """ - additional_criteria: List[Criteria] = [] + additional_criteria: list[Criteria] = [] if expression.additional_criteria: additional_criteria.extend( self._to_criteria_list(expression.additional_criteria.criteria_list) @@ -110,7 +110,7 @@ def _get_additional_criteria( def _is_used( self, expression: "CohortExpression", - additional_criteria: List["Criteria"], + additional_criteria: list["Criteria"], concept_set: "ConceptSet", ) -> bool: """Check if a concept set is used. @@ -200,7 +200,7 @@ def _is_concept_set_used(self, concept_set: "ConceptSet", target) -> bool: return False def _is_concept_set_used_in_list( - self, concept_set: "ConceptSet", criteria_list: List["Criteria"] + self, concept_set: "ConceptSet", criteria_list: list["Criteria"] ) -> bool: """Check if a concept set is used in a criteria list. @@ -235,7 +235,7 @@ def _is_concept_set_used_in_list( return False - def _correlated_criteria_to_list(self, correlated_criteria) -> List["Criteria"]: + def _correlated_criteria_to_list(self, correlated_criteria) -> list["Criteria"]: """Convert correlated criteria to a list of criteria. Args: @@ -244,7 +244,7 @@ def _correlated_criteria_to_list(self, correlated_criteria) -> List["Criteria"]: Returns: A list of Criteria """ - criteria_list: List[Criteria] = [] + criteria_list: list[Criteria] = [] if ( hasattr(correlated_criteria, "criteria_list") and correlated_criteria.criteria_list @@ -269,8 +269,8 @@ def _correlated_criteria_to_list(self, correlated_criteria) -> List["Criteria"]: return criteria_list def _to_criteria_list( - self, criteria_list: Optional[List["CorelatedCriteria"]] - ) -> List["Criteria"]: + self, criteria_list: Optional[list["CorelatedCriteria"]] + ) -> list["Criteria"]: """Convert a list of CorelatedCriteria to a list of Criteria. Args: @@ -286,8 +286,8 @@ def _to_criteria_list( ] def _to_criteria_list_from_groups( - self, groups: Optional[List["CriteriaGroup"]] - ) -> List["Criteria"]: + self, groups: Optional[list["CriteriaGroup"]] + ) -> list["Criteria"]: """Convert groups to a list of criteria. Args: @@ -296,7 +296,7 @@ def _to_criteria_list_from_groups( Returns: A list of Criteria """ - criteria: List[Criteria] = [] + criteria: list[Criteria] = [] if groups: for group in groups: if group.criteria_list: diff --git a/circe/cohortdefinition/builders/base.py b/circe/cohortdefinition/builders/base.py index 172a0bef..401d7129 100644 --- a/circe/cohortdefinition/builders/base.py +++ b/circe/cohortdefinition/builders/base.py @@ -10,7 +10,7 @@ """ from abc import ABC, abstractmethod -from typing import Generic, List, Optional, Set, TypeVar +from typing import Generic, Optional, TypeVar from ..criteria import Criteria from .utils import BuilderOptions, CriteriaColumn @@ -92,7 +92,7 @@ def get_query_template(self) -> str: pass @abstractmethod - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for this builder. Java equivalent: CriteriaSqlBuilder.getDefaultColumns() @@ -109,7 +109,7 @@ def embed_codeset_clause(self, query: str, criteria: T) -> str: def resolve_select_clauses( self, criteria: T, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for criteria. Java equivalent: CriteriaSqlBuilder.resolveSelectClauses() @@ -119,7 +119,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: T, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for criteria. Java equivalent: CriteriaSqlBuilder.resolveJoinClauses() @@ -129,7 +129,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: T, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for criteria. Java equivalent: CriteriaSqlBuilder.resolveWhereClauses() @@ -138,7 +138,7 @@ def resolve_where_clauses( return [] def embed_ordinal_expression( - self, query: str, criteria: T, where_clauses: List[str] + self, query: str, criteria: T, where_clauses: list[str] ) -> str: """Embed ordinal expression in query. @@ -147,7 +147,7 @@ def embed_ordinal_expression( # This would need to be implemented based on the Java logic return query.replace("@ordinalExpression", "") - def embed_select_clauses(self, query: str, select_clauses: List[str]) -> str: + def embed_select_clauses(self, query: str, select_clauses: list[str]) -> str: """Embed select clauses in query. Java equivalent: CriteriaSqlBuilder.embedSelectClauses() @@ -156,7 +156,7 @@ def embed_select_clauses(self, query: str, select_clauses: List[str]) -> str: select_clause = ",".join(select_clauses) if select_clauses else "" return query.replace("@selectClause", select_clause) - def embed_join_clauses(self, query: str, join_clauses: List[str]) -> str: + def embed_join_clauses(self, query: str, join_clauses: list[str]) -> str: """Embed join clauses in query. Java equivalent: CriteriaSqlBuilder.embedJoinClauses() @@ -164,7 +164,7 @@ def embed_join_clauses(self, query: str, join_clauses: List[str]) -> str: join_clause = " ".join(join_clauses) if join_clauses else "" return query.replace("@joinClause", join_clause) - def embed_where_clauses(self, query: str, where_clauses: List[str]) -> str: + def embed_where_clauses(self, query: str, where_clauses: list[str]) -> str: """Embed where clauses in query. Java equivalent: CriteriaSqlBuilder.embedWhereClauses() @@ -174,7 +174,7 @@ def embed_where_clauses(self, query: str, where_clauses: List[str]) -> str: where_clause = "WHERE " + " AND ".join(where_clauses) return query.replace("@whereClause", where_clause) - def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: + def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: """Get additional columns string. Java equivalent: CriteriaSqlBuilder.getAdditionalColumns() diff --git a/circe/cohortdefinition/builders/condition_era.py b/circe/cohortdefinition/builders/condition_era.py index 842c0810..ac6913ba 100644 --- a/circe/cohortdefinition/builders/condition_era.py +++ b/circe/cohortdefinition/builders/condition_era.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import ConditionEra from .base import CriteriaSqlBuilder @@ -48,7 +48,7 @@ def get_query_template(self) -> str: -- End Condition Era Criteria """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for condition era criteria.""" return self.DEFAULT_COLUMNS @@ -77,7 +77,7 @@ def embed_codeset_clause(self, query: str, criteria: ConditionEra) -> str: return query.replace("@codesetClause", codeset_clause) def embed_ordinal_expression( - self, query: str, criteria: ConditionEra, where_clauses: List[str] + self, query: str, criteria: ConditionEra, where_clauses: list[str] ) -> str: """Embed ordinal expression in query.""" # first @@ -93,7 +93,7 @@ def embed_ordinal_expression( def resolve_select_clauses( self, criteria: ConditionEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for condition era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -123,7 +123,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: ConditionEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for condition era criteria.""" join_clauses = [] @@ -142,7 +142,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: ConditionEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for condition era criteria.""" where_clauses = [] diff --git a/circe/cohortdefinition/builders/condition_occurrence.py b/circe/cohortdefinition/builders/condition_occurrence.py index 65982b0f..658f6820 100644 --- a/circe/cohortdefinition/builders/condition_occurrence.py +++ b/circe/cohortdefinition/builders/condition_occurrence.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import ConditionOccurrence from .base import CriteriaSqlBuilder @@ -53,7 +53,7 @@ def get_query_template(self) -> str: -- End Condition Occurrence Criteria """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for condition occurrence criteria.""" return self.DEFAULT_COLUMNS @@ -86,7 +86,7 @@ def embed_codeset_clause(self, query: str, criteria: ConditionOccurrence) -> str ) def embed_ordinal_expression( - self, query: str, criteria: ConditionOccurrence, where_clauses: List[str] + self, query: str, criteria: ConditionOccurrence, where_clauses: list[str] ) -> str: """Embed ordinal expression in query.""" # first @@ -102,7 +102,7 @@ def embed_ordinal_expression( def resolve_select_clauses( self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for condition occurrence criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -155,7 +155,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for condition occurrence criteria.""" join_clauses = [] @@ -190,7 +190,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for condition occurrence criteria.""" where_clauses = [] diff --git a/circe/cohortdefinition/builders/death.py b/circe/cohortdefinition/builders/death.py index 8e4d38a9..94b8e7b3 100644 --- a/circe/cohortdefinition/builders/death.py +++ b/circe/cohortdefinition/builders/death.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import Death from .base import CriteriaSqlBuilder @@ -42,7 +42,7 @@ def get_query_template(self) -> str: -- End Death Criteria """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for death criteria.""" return { CriteriaColumn.START_DATE, @@ -75,7 +75,7 @@ def embed_codeset_clause(self, query: str, criteria: Death) -> str: ) def embed_ordinal_expression( - self, query: str, criteria: Death, where_clauses: List[str] + self, query: str, criteria: Death, where_clauses: list[str] ) -> str: """Embed ordinal expression in query. @@ -87,7 +87,7 @@ def embed_ordinal_expression( def resolve_select_clauses( self, criteria: Death, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for death criteria.""" select_cols = ["d.person_id", "d.cause_concept_id"] @@ -116,7 +116,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: Death, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for death criteria.""" joins = [] @@ -134,7 +134,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: Death, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for death criteria.""" where_clauses = super().resolve_where_clauses(criteria) diff --git a/circe/cohortdefinition/builders/device_exposure.py b/circe/cohortdefinition/builders/device_exposure.py index cd129644..12a6c5dd 100644 --- a/circe/cohortdefinition/builders/device_exposure.py +++ b/circe/cohortdefinition/builders/device_exposure.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Set from ..criteria import DeviceExposure from .base import CriteriaSqlBuilder @@ -36,7 +35,7 @@ def get_query_template(self) -> str: @whereClause -- End Device Exposure Criteria""" - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for device exposure criteria.""" return { CriteriaColumn.START_DATE, @@ -60,7 +59,7 @@ def get_table_column_for_criteria_column( def resolve_select_clauses( self, criteria: DeviceExposure, options: BuilderOptions - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for device exposure criteria.""" select_cols = [ "de.person_id", @@ -112,7 +111,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: DeviceExposure, options: BuilderOptions - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for device exposure criteria.""" joins = [] @@ -158,7 +157,7 @@ def embed_codeset_clause(self, query: str, criteria: DeviceExposure) -> str: def resolve_where_clauses( self, criteria: DeviceExposure, options: BuilderOptions - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for device exposure criteria.""" conditions = [] @@ -280,7 +279,7 @@ def resolve_ordinal_expression( def get_ordinal_expression_where_clause( self, criteria: DeviceExposure, options: BuilderOptions - ) -> List[str]: + ) -> list[str]: if criteria.first: return ["C.ordinal = 1"] return [] diff --git a/circe/cohortdefinition/builders/dose_era.py b/circe/cohortdefinition/builders/dose_era.py index f246711b..21b235f7 100644 --- a/circe/cohortdefinition/builders/dose_era.py +++ b/circe/cohortdefinition/builders/dose_era.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import DoseEra from .base import CriteriaSqlBuilder @@ -53,7 +53,7 @@ def get_query_template(self) -> str: -- End Dose Era Criteria """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for dose era criteria.""" return self.DEFAULT_COLUMNS @@ -84,7 +84,7 @@ def embed_codeset_clause(self, query: str, criteria: DoseEra) -> str: return query.replace("@codesetClause", codeset_clause) def embed_ordinal_expression( - self, query: str, criteria: DoseEra, where_clauses: List[str] + self, query: str, criteria: DoseEra, where_clauses: list[str] ) -> str: """Embed ordinal expression in query.""" # first @@ -100,7 +100,7 @@ def embed_ordinal_expression( def resolve_select_clauses( self, criteria: DoseEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for dose era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -130,7 +130,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: DoseEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for dose era criteria.""" join_clauses = [] @@ -149,7 +149,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: DoseEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for dose era criteria.""" where_clauses = [] diff --git a/circe/cohortdefinition/builders/drug_era.py b/circe/cohortdefinition/builders/drug_era.py index 95b16e85..e528229b 100644 --- a/circe/cohortdefinition/builders/drug_era.py +++ b/circe/cohortdefinition/builders/drug_era.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import DrugEra from .base import CriteriaSqlBuilder @@ -57,7 +57,7 @@ def get_query_template(self) -> str: -- End Drug Era Criteria """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for drug era criteria.""" return self.DEFAULT_COLUMNS @@ -88,7 +88,7 @@ def embed_codeset_clause(self, query: str, criteria: DrugEra) -> str: return query.replace("@codesetClause", codeset_clause) def embed_ordinal_expression( - self, query: str, criteria: DrugEra, where_clauses: List[str] + self, query: str, criteria: DrugEra, where_clauses: list[str] ) -> str: """Embed ordinal expression in query.""" # first @@ -104,7 +104,7 @@ def embed_ordinal_expression( def resolve_select_clauses( self, criteria: DrugEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for drug era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -136,7 +136,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: DrugEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for drug era criteria.""" join_clauses = [] @@ -155,7 +155,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: DrugEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for drug era criteria.""" where_clauses = [] diff --git a/circe/cohortdefinition/builders/drug_exposure.py b/circe/cohortdefinition/builders/drug_exposure.py index 44492f60..b44aab86 100644 --- a/circe/cohortdefinition/builders/drug_exposure.py +++ b/circe/cohortdefinition/builders/drug_exposure.py @@ -9,7 +9,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import DrugExposure from .base import CriteriaSqlBuilder @@ -55,7 +55,7 @@ class DrugExposureSqlBuilder(CriteriaSqlBuilder[DrugExposure]): "refills", ] - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for this builder. Java equivalent: DrugExposureSqlBuilder.getDefaultColumns() @@ -103,7 +103,7 @@ def embed_codeset_clause(self, query: str, criteria: DrugExposure) -> str: ) def embed_ordinal_expression( - self, query: str, criteria: DrugExposure, where_clauses: List[str] + self, query: str, criteria: DrugExposure, where_clauses: list[str] ) -> str: """Embed ordinal expression in query. @@ -123,7 +123,7 @@ def embed_ordinal_expression( def resolve_select_clauses( self, criteria: DrugExposure, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveSelectClauses() @@ -197,7 +197,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: DrugExposure, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveJoinClauses() @@ -234,7 +234,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: DrugExposure, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveWhereClauses() diff --git a/circe/cohortdefinition/builders/location_region.py b/circe/cohortdefinition/builders/location_region.py index 5d744a8e..aaea2f85 100644 --- a/circe/cohortdefinition/builders/location_region.py +++ b/circe/cohortdefinition/builders/location_region.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import LocationRegion from .base import CriteriaSqlBuilder @@ -51,7 +51,7 @@ def get_query_template(self) -> str: @additionalColumns """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for location region criteria.""" return self.DEFAULT_COLUMNS @@ -76,14 +76,14 @@ def embed_codeset_clause(self, query: str, criteria: LocationRegion) -> str: return query.replace("@codesetClause", codeset_clause) def embed_ordinal_expression( - self, query: str, criteria: LocationRegion, where_clauses: List[str] + self, query: str, criteria: LocationRegion, where_clauses: list[str] ) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") def resolve_select_clauses( self, criteria: LocationRegion, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for location region criteria.""" # Default select columns that are always returned select_cols = ["C.person_id", "C.location_id", "C.region_concept_id"] @@ -102,12 +102,12 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: LocationRegion, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for location region criteria.""" return [] def resolve_where_clauses( self, criteria: LocationRegion, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for location region criteria.""" return [] diff --git a/circe/cohortdefinition/builders/measurement.py b/circe/cohortdefinition/builders/measurement.py index 9c2068d6..675ccab1 100644 --- a/circe/cohortdefinition/builders/measurement.py +++ b/circe/cohortdefinition/builders/measurement.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import Measurement from .base import CriteriaSqlBuilder @@ -37,7 +37,7 @@ def get_query_template(self) -> str: -- End Measurement Criteria """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for measurement criteria.""" return { CriteriaColumn.START_DATE, @@ -60,7 +60,7 @@ def get_table_column_for_criteria_column( return column_mapping.get(criteria_column, "NULL") def embed_ordinal_expression( - self, query: str, criteria: Measurement, where_clauses: List[str] + self, query: str, criteria: Measurement, where_clauses: list[str] ) -> str: """Embed ordinal expression in query. @@ -92,7 +92,7 @@ def embed_codeset_clause(self, query: str, criteria: Measurement) -> str: def resolve_select_clauses( self, criteria: Measurement, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveSelectClauses() @@ -160,7 +160,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: Measurement, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveJoinClauses() @@ -206,7 +206,7 @@ def resolve_ordinal_expression( def resolve_where_clauses( self, criteria: Measurement, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveWhereClauses() @@ -402,7 +402,7 @@ def resolve_where_clauses( return where_clauses - def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: + def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: """Get additional columns string with proper aliases. Java equivalent: MeasurementSqlBuilder.getAdditionalColumns() diff --git a/circe/cohortdefinition/builders/observation.py b/circe/cohortdefinition/builders/observation.py index d9321095..3267f482 100644 --- a/circe/cohortdefinition/builders/observation.py +++ b/circe/cohortdefinition/builders/observation.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import Observation from .base import CriteriaSqlBuilder @@ -37,7 +37,7 @@ def get_query_template(self) -> str: -- End Observation Criteria """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for observation criteria.""" return { CriteriaColumn.START_DATE, @@ -73,7 +73,7 @@ def embed_codeset_clause(self, query: str, criteria: Observation) -> str: def resolve_select_clauses( self, criteria: Observation, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for observation criteria. Java equivalent: ObservationSqlBuilder.resolveSelectClauses() @@ -116,7 +116,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: Observation, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for observation criteria. Java equivalent: ObservationSqlBuilder.resolveJoinClauses() @@ -154,7 +154,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: Observation, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for observation criteria.""" where_clauses = super().resolve_where_clauses(criteria) @@ -325,7 +325,7 @@ def resolve_where_clauses( return where_clauses - def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: + def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: """Get additional columns string with proper aliases. Java equivalent: ObservationSqlBuilder.getAdditionalColumns() @@ -338,7 +338,7 @@ def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: ) def embed_ordinal_expression( - self, query: str, criteria: Observation, where_clauses: List[str] + self, query: str, criteria: Observation, where_clauses: list[str] ) -> str: """Embed ordinal expression in query.""" # first diff --git a/circe/cohortdefinition/builders/observation_period.py b/circe/cohortdefinition/builders/observation_period.py index 56c6ba1f..05b3e1f8 100644 --- a/circe/cohortdefinition/builders/observation_period.py +++ b/circe/cohortdefinition/builders/observation_period.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import ObservationPeriod from .base import CriteriaSqlBuilder @@ -54,7 +54,7 @@ def get_query_template(self) -> str: -- End Observation Period Criteria """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for observation period criteria.""" return self.DEFAULT_COLUMNS @@ -102,14 +102,14 @@ def embed_codeset_clause(self, query: str, criteria: ObservationPeriod) -> str: return query.replace("@codesetClause", "") def embed_ordinal_expression( - self, query: str, criteria: ObservationPeriod, where_clauses: List[str] + self, query: str, criteria: ObservationPeriod, where_clauses: list[str] ) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") def resolve_select_clauses( self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for observation period criteria. Note: The outer SELECT in the template handles event_id, start_date, end_date, visit_occurrence_id, sort_date. @@ -143,7 +143,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for observation period criteria.""" join_clauses = [] @@ -157,7 +157,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for observation period criteria.""" where_clauses = [] @@ -250,7 +250,7 @@ def resolve_where_clauses( return where_clauses - def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: + def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: """Get additional columns string with proper aliases. Java equivalent: ObservationPeriodSqlBuilder.getAdditionalColumns() diff --git a/circe/cohortdefinition/builders/payer_plan_period.py b/circe/cohortdefinition/builders/payer_plan_period.py index beb7fddf..73b1c870 100644 --- a/circe/cohortdefinition/builders/payer_plan_period.py +++ b/circe/cohortdefinition/builders/payer_plan_period.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import PayerPlanPeriod from .base import CriteriaSqlBuilder @@ -59,7 +59,7 @@ def get_query_template(self) -> str: @additionalColumns """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for payer plan period criteria.""" return self.DEFAULT_COLUMNS @@ -105,14 +105,14 @@ def embed_codeset_clause(self, query: str, criteria: PayerPlanPeriod) -> str: return query.replace("@codesetClause", "") def embed_ordinal_expression( - self, query: str, criteria: PayerPlanPeriod, where_clauses: List[str] + self, query: str, criteria: PayerPlanPeriod, where_clauses: list[str] ) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") def resolve_select_clauses( self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for payer plan period criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -179,7 +179,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for payer plan period criteria.""" join_clauses = [] @@ -197,7 +197,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for payer plan period criteria.""" where_clauses = [] @@ -337,7 +337,7 @@ def resolve_where_clauses( return where_clauses if where_clauses else ["1=1"] - def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: + def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: """Get additional columns string with proper aliases. Java equivalent: PayerPlanPeriodSqlBuilder.getAdditionalColumns() diff --git a/circe/cohortdefinition/builders/procedure_occurrence.py b/circe/cohortdefinition/builders/procedure_occurrence.py index ff3b6c95..f6a62b70 100644 --- a/circe/cohortdefinition/builders/procedure_occurrence.py +++ b/circe/cohortdefinition/builders/procedure_occurrence.py @@ -9,7 +9,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import Criteria from .base import CriteriaSqlBuilder @@ -56,7 +56,7 @@ class ProcedureOccurrenceSqlBuilder(CriteriaSqlBuilder[Criteria]): "po.quantity", ] - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for this builder. Java equivalent: ProcedureOccurrenceSqlBuilder.getDefaultColumns() @@ -91,7 +91,7 @@ def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: return f"C.{column.value}" def embed_ordinal_expression( - self, query: str, criteria: Criteria, where_clauses: List[str] + self, query: str, criteria: Criteria, where_clauses: list[str] ) -> str: """Embed ordinal expression in query. @@ -130,7 +130,7 @@ def embed_codeset_clause(self, query: str, criteria: Criteria) -> str: def resolve_select_clauses( self, criteria: Criteria, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for criteria. Java equivalent: ProcedureOccurrenceSqlBuilder.resolveSelectClauses() @@ -193,7 +193,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: Criteria, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for criteria. Java equivalent: ProcedureOccurrenceSqlBuilder.resolveJoinClauses() @@ -243,7 +243,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: Criteria, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for criteria. Java equivalent: ProcedureOccurrenceSqlBuilder.resolveWhereClauses() diff --git a/circe/cohortdefinition/builders/specimen.py b/circe/cohortdefinition/builders/specimen.py index cc49e8a8..af4073c5 100644 --- a/circe/cohortdefinition/builders/specimen.py +++ b/circe/cohortdefinition/builders/specimen.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import Specimen from .base import CriteriaSqlBuilder @@ -37,7 +37,7 @@ def get_query_template(self) -> str: -- End Specimen Criteria """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for specimen criteria.""" return { CriteriaColumn.START_DATE, @@ -73,7 +73,7 @@ def embed_codeset_clause(self, query: str, criteria: Specimen) -> str: ) def embed_ordinal_expression( - self, query: str, criteria: Specimen, where_clauses: List[str] + self, query: str, criteria: Specimen, where_clauses: list[str] ) -> str: """Embed ordinal expression in query.""" if criteria.first: @@ -88,7 +88,7 @@ def embed_ordinal_expression( def resolve_join_clauses( self, criteria: Specimen, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for specimen criteria.""" joins = [] @@ -106,7 +106,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: Specimen, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for specimen criteria.""" where_clauses = [] diff --git a/circe/cohortdefinition/builders/utils.py b/circe/cohortdefinition/builders/utils.py index 73491a6f..b8dded9a 100644 --- a/circe/cohortdefinition/builders/utils.py +++ b/circe/cohortdefinition/builders/utils.py @@ -9,7 +9,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, Optional +from typing import Any, Optional from ...vocabulary.concept import Concept from ..core import DateAdjustment, DateRange, NumericRange @@ -23,7 +23,7 @@ class BuilderOptions: """ def __init__(self): - self.additional_columns: List[CriteriaColumn] = [] + self.additional_columns: list[CriteriaColumn] = [] class BuilderUtils: @@ -115,7 +115,7 @@ def get_codeset_in_expression( ) @staticmethod - def get_concept_ids_from_concepts(concepts: List[Concept]) -> List[int]: + def get_concept_ids_from_concepts(concepts: list[Concept]) -> list[int]: """Get concept IDs from concept list. Java equivalent: BuilderUtils.getConceptIdsFromConcepts() @@ -248,7 +248,7 @@ def build_text_filter_clause( @staticmethod def split_in_clause( - column_name: str, values: List[int], max_length: int = 1000 + column_name: str, values: list[int], max_length: int = 1000 ) -> str: """Split IN clause for large value lists. diff --git a/circe/cohortdefinition/builders/visit_detail.py b/circe/cohortdefinition/builders/visit_detail.py index f378cccb..8d0c2fa7 100644 --- a/circe/cohortdefinition/builders/visit_detail.py +++ b/circe/cohortdefinition/builders/visit_detail.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import VisitDetail from .base import CriteriaSqlBuilder @@ -61,7 +61,7 @@ def get_query_template(self) -> str: @additionalColumns """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for visit detail criteria.""" return self.DEFAULT_COLUMNS @@ -89,7 +89,7 @@ def embed_codeset_clause(self, query: str, criteria: VisitDetail) -> str: return query.replace("@codesetClause", codeset_clause) def embed_ordinal_expression( - self, query: str, criteria: VisitDetail, where_clauses: List[str] + self, query: str, criteria: VisitDetail, where_clauses: list[str] ) -> str: """Embed ordinal expression in query.""" # first @@ -105,7 +105,7 @@ def embed_ordinal_expression( def resolve_select_clauses( self, criteria: VisitDetail, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for visit detail criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -152,7 +152,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: VisitDetail, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for visit detail criteria.""" join_clauses = [] @@ -187,7 +187,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: VisitDetail, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for visit detail criteria.""" where_clauses = [] @@ -261,7 +261,7 @@ def resolve_where_clauses( return where_clauses - def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: + def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: """Get additional columns string with proper aliases. Java equivalent: VisitDetailSqlBuilder.getAdditionalColumns() @@ -274,7 +274,7 @@ def get_additional_columns(self, columns: List[CriteriaColumn]) -> str: ) def add_filtering_by_care_site_location_region( - self, join_clauses: List[str], codeset_id: int + self, join_clauses: list[str], codeset_id: int ): """Add filtering by care site location region.""" join_clauses.append( @@ -287,7 +287,7 @@ def add_filtering_by_care_site_location_region( def add_where_clause( self, - where_clauses: List[str], + where_clauses: list[str], concept_set_selection, concept_column: str, exclude: Optional[bool] = None, @@ -303,7 +303,7 @@ def add_where_clause( where_clauses.append(codeset_clause) def add_filtering( - self, join_clauses: List[str], codeset_id: int, standard_concept_column: str + self, join_clauses: list[str], codeset_id: int, standard_concept_column: str ): """Add filtering join clause.""" join_clauses.append( diff --git a/circe/cohortdefinition/builders/visit_occurrence.py b/circe/cohortdefinition/builders/visit_occurrence.py index 824e4b6a..9ef55f39 100644 --- a/circe/cohortdefinition/builders/visit_occurrence.py +++ b/circe/cohortdefinition/builders/visit_occurrence.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional, Set +from typing import Optional from ..criteria import VisitOccurrence from .base import CriteriaSqlBuilder @@ -38,7 +38,7 @@ def get_query_template(self) -> str: -- End Visit Occurrence Criteria """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for visit occurrence criteria.""" return { CriteriaColumn.START_DATE, @@ -77,7 +77,7 @@ def embed_codeset_clause(self, query: str, criteria: VisitOccurrence) -> str: def resolve_select_clauses( self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve select clauses for visit occurrence criteria.""" # Default select columns that are always returned select_cols = ["vo.person_id", "vo.visit_occurrence_id", "vo.visit_concept_id"] @@ -130,7 +130,7 @@ def resolve_select_clauses( def resolve_join_clauses( self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve join clauses for visit occurrence criteria.""" join_clauses = [] @@ -173,7 +173,7 @@ def resolve_join_clauses( def resolve_where_clauses( self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None - ) -> List[str]: + ) -> list[str]: """Resolve where clauses for visit occurrence criteria.""" where_clauses = super().resolve_where_clauses(criteria, options) @@ -289,7 +289,7 @@ def resolve_where_clauses( return where_clauses def embed_ordinal_expression( - self, query: str, criteria: VisitOccurrence, where_clauses: List[str] + self, query: str, criteria: VisitOccurrence, where_clauses: list[str] ) -> str: """Embed ordinal expression for visit occurrence criteria.""" if criteria.first is not None and criteria.first: @@ -300,7 +300,7 @@ def embed_ordinal_expression( return query.replace("@ordinalExpression", "") def _add_filtering_by_care_site_location_region( - self, join_clauses: List[str], codeset_id: int + self, join_clauses: list[str], codeset_id: int ): """Add joins for filtering by care site location region.""" join_clauses.append( diff --git a/circe/cohortdefinition/code_generator.py b/circe/cohortdefinition/code_generator.py index 14c32881..c20a75e4 100644 --- a/circe/cohortdefinition/code_generator.py +++ b/circe/cohortdefinition/code_generator.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, Set +from typing import Any def to_python_code(obj: Any) -> str: @@ -7,7 +7,7 @@ def to_python_code(obj: Any) -> str: Converts a CohortExpression (or any circe model) into a human-readable Python code string that instantiates the object. """ - imports: Set[str] = set() + imports: set[str] = set() def _collect_imports(o: Any): if ( @@ -20,7 +20,7 @@ def _collect_imports(o: Any): if hasattr(o, "model_dump"): # Access model_fields from the class, not the instance - for name, field in o.__class__.model_fields.items(): + for name, _field in o.__class__.model_fields.items(): val = getattr(o, name) if val is not None: if isinstance(val, list): @@ -34,7 +34,6 @@ def _collect_imports(o: Any): # and maybe return imports separately? # Let's do the string generation directly. - lines = [] # We will build a set of required imports as we traverse required_classes = set() diff --git a/circe/cohortdefinition/cohort.py b/circe/cohortdefinition/cohort.py index 11b378af..98063627 100644 --- a/circe/cohortdefinition/cohort.py +++ b/circe/cohortdefinition/cohort.py @@ -8,8 +8,9 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +import contextlib import json -from typing import TYPE_CHECKING, Any, List, Optional, Union +from typing import TYPE_CHECKING, Any, Optional, Union from pydantic import ( AliasChoices, @@ -36,10 +37,8 @@ from .criteria import InclusionRule else: # Import at runtime to avoid circular dependencies - try: + with contextlib.suppress(ImportError): from ..check.warning import Warning - except ImportError: - pass # Import ConceptSet at runtime to avoid circular dependencies try: from ..vocabulary.concept import ConceptSet @@ -58,7 +57,7 @@ class CohortExpression(CirceBaseModel): Java equivalent: org.ohdsi.circe.cohortdefinition.CohortExpression """ - concept_sets: List[ConceptSet] = Field( + concept_sets: list[ConceptSet] = Field( default_factory=list, validation_alias=AliasChoices("ConceptSets", "conceptSets"), serialization_alias="ConceptSets", @@ -101,7 +100,7 @@ class CohortExpression(CirceBaseModel): validation_alias=AliasChoices("Title", "title"), serialization_alias="Title", ) - inclusion_rules: List[InclusionRule] = Field( + inclusion_rules: list[InclusionRule] = Field( default_factory=list, validation_alias=AliasChoices("InclusionRules", "inclusionRules"), serialization_alias="InclusionRules", @@ -111,7 +110,7 @@ class CohortExpression(CirceBaseModel): validation_alias=AliasChoices("CensorWindow", "censorWindow"), serialization_alias="CensorWindow", ) - censoring_criteria: List[CriteriaType] = Field( + censoring_criteria: list[CriteriaType] = Field( default_factory=list, validation_alias=AliasChoices( "CensoringCriteria", "censoring_criteria", "censoringCriteria" @@ -223,7 +222,7 @@ def deserialize_censoring_criteria(cls, v: Any) -> Any: # JSON format: {"ConditionOccurrence": {...}} - unwrap and deserialize criteria_type = None criteria_data = None - for key in item.keys(): + for key in item: if key in criteria_class_map: criteria_type = key criteria_data = item[key] @@ -340,13 +339,13 @@ def validate_expression(self) -> bool: return True - def get_concept_set_ids(self) -> List[int]: + def get_concept_set_ids(self) -> list[int]: """Get all concept set IDs used in this expression.""" if not self.concept_sets: return [] return [cs.id for cs in self.concept_sets if cs.id is not None] - def check(self) -> List["Warning"]: + def check(self) -> list["Warning"]: """Run validation checks on this cohort expression. This method runs all validation checks defined in the check module @@ -498,11 +497,7 @@ def has_inclusion_rule_by_name(self, name: str) -> bool: if not self.inclusion_rules: return False - for rule in self.inclusion_rules: - if getattr(rule, "name", None) == name: - return True - - return False + return any(getattr(rule, "name", None) == name for rule in self.inclusion_rules) def has_censoring_criteria(self) -> bool: """Check if cohort has censoring criteria. @@ -512,7 +507,7 @@ def has_censoring_criteria(self) -> bool: """ return bool(self.censoring_criteria and len(self.censoring_criteria) > 0) - def get_censoring_criteria_types(self) -> List[str]: + def get_censoring_criteria_types(self) -> list[str]: """Get list of censoring criteria class names. Returns: @@ -560,7 +555,7 @@ def get_end_strategy_type(self) -> Optional[str]: else: return class_name - def get_primary_criteria_types(self) -> List[str]: + def get_primary_criteria_types(self) -> list[str]: """Get list of primary criteria class names. Returns: diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index c98f1edf..ce3f9d65 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -9,7 +9,7 @@ """ import json -from typing import Any, List, Optional, Union +from typing import Any, Optional, Union from .builders import ( ConditionEraSqlBuilder, @@ -481,7 +481,7 @@ def get_occurrence_operator(self, occurrence_type: int) -> str: f"Invalid occurrence operator received: type={occurrence_type}" ) - def get_additional_columns(self, columns: List[CriteriaColumn], prefix: str) -> str: + def get_additional_columns(self, columns: list[CriteriaColumn], prefix: str) -> str: """Get additional columns string. Java equivalent: getAdditionalColumns() @@ -530,7 +530,7 @@ def wrap_criteria_query(self, query: str, group: CriteriaGroup) -> str: """ return wrapped_query - def get_codeset_query(self, concept_sets: List[Any]) -> str: + def get_codeset_query(self, concept_sets: list[Any]) -> str: """Get codeset query. Java equivalent: getCodesetQuery() @@ -554,7 +554,7 @@ def get_codeset_query(self, concept_sets: List[Any]) -> str: return self.CODESET_QUERY_TEMPLATE.replace("@codesetInserts", codeset_inserts) - def get_censoring_events_query(self, censoring_criteria: List[Criteria]) -> str: + def get_censoring_events_query(self, censoring_criteria: list[Criteria]) -> str: """Get censoring events query. Java equivalent: getCensoringEventsQuery() @@ -1235,7 +1235,7 @@ def _get_windowed_criteria_query_internal( criteria_type = None criteria_data = None - for key in inner_criteria.keys(): + for key in inner_criteria: criteria_type = key criteria_data = inner_criteria[key] break @@ -1569,7 +1569,7 @@ def get_criteria_sql( criteria_type = None criteria_data = None - for key in criteria.keys(): + for key in criteria: criteria_type = key criteria_data = criteria[key] break @@ -1779,7 +1779,7 @@ def _get_custom_era_strategy_sql( return strategy_sql def _get_additional_columns( - self, columns: List[CriteriaColumn], table_alias: str + self, columns: list[CriteriaColumn], table_alias: str ) -> str: """Get additional columns for SQL query.""" if not columns: diff --git a/circe/cohortdefinition/concept_set_expression_query_builder.py b/circe/cohortdefinition/concept_set_expression_query_builder.py index 991eeb26..59d476f5 100644 --- a/circe/cohortdefinition/concept_set_expression_query_builder.py +++ b/circe/cohortdefinition/concept_set_expression_query_builder.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List from ..vocabulary.concept import Concept, ConceptSetExpression from .builders.utils import BuilderUtils @@ -54,7 +53,7 @@ class ConceptSetExpressionQueryBuilder: MAX_IN_LENGTH = 1000 # Oracle limitation - def get_concept_ids(self, concepts: List[Concept]) -> List[int]: + def get_concept_ids(self, concepts: list[Concept]) -> list[int]: """Get concept IDs from concept list. Java equivalent: getConceptIds() @@ -64,7 +63,7 @@ def get_concept_ids(self, concepts: List[Concept]) -> List[int]: ] def build_concept_set_sub_query( - self, concepts: List[Concept], descendant_concepts: List[Concept] + self, concepts: list[Concept], descendant_concepts: list[Concept] ) -> str: """Build concept set sub-query. @@ -95,7 +94,7 @@ def build_concept_set_sub_query( return "\nUNION ".join(queries) def build_concept_set_mapped_query( - self, mapped_concepts: List[Concept], mapped_descendant_concepts: List[Concept] + self, mapped_concepts: list[Concept], mapped_descendant_concepts: list[Concept] ) -> str: """Build concept set mapped query. @@ -110,10 +109,10 @@ def build_concept_set_mapped_query( def build_concept_set_query( self, - concepts: List[Concept], - descendant_concepts: List[Concept], - mapped_concepts: List[Concept], - mapped_descendant_concepts: List[Concept], + concepts: list[Concept], + descendant_concepts: list[Concept], + mapped_concepts: list[Concept], + mapped_descendant_concepts: list[Concept], ) -> str: """Build concept set query. diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index f55546d2..92e55dc8 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -9,7 +9,7 @@ """ from enum import Enum -from typing import Any, List, Optional, Union +from typing import Any, Optional, Union from pydantic import ( AliasChoices, @@ -185,7 +185,7 @@ class DemographicCriteria(CirceBaseModel): Java equivalent: org.ohdsi.circe.cohortdefinition.DemographicCriteria """ - gender: Optional[List[Concept]] = Field( + gender: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("Gender", "gender"), serialization_alias="Gender", @@ -200,7 +200,7 @@ class DemographicCriteria(CirceBaseModel): validation_alias=AliasChoices("GenderCS", "genderCS"), serialization_alias="GenderCS", ) - race: Optional[List[Concept]] = Field( + race: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("Race", "race"), serialization_alias="Race", @@ -220,7 +220,7 @@ class DemographicCriteria(CirceBaseModel): validation_alias=AliasChoices("RaceCS", "raceCS"), serialization_alias="RaceCS", ) - ethnicity: Optional[List[Concept]] = Field( + ethnicity: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("Ethnicity", "ethnicity"), serialization_alias="Ethnicity", @@ -322,7 +322,7 @@ class ConditionOccurrence(Criteria): validation_alias=AliasChoices("OccurrenceEndDate", "occurrenceEndDate"), serialization_alias="OccurrenceEndDate", ) - condition_type: Optional[List[Concept]] = Field( + condition_type: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("ConditionType", "conditionType"), serialization_alias="ConditionType", @@ -354,13 +354,13 @@ class ConditionOccurrence(Criteria): validation_alias=AliasChoices("Age", "age"), serialization_alias="Age", ) - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field( default=None, validation_alias=AliasChoices("GenderCS", "genderCS"), serialization_alias="GenderCS", ) - provider_specialty: Optional[List[Concept]] = Field( + provider_specialty: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("ProviderSpecialty", "providerSpecialty"), serialization_alias="ProviderSpecialty", @@ -370,7 +370,7 @@ class ConditionOccurrence(Criteria): validation_alias=AliasChoices("ProviderSpecialtyCS", "providerSpecialtyCS"), serialization_alias="ProviderSpecialtyCS", ) - visit_type: Optional[List[Concept]] = Field( + visit_type: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("VisitType", "visitType"), serialization_alias="VisitType", @@ -380,7 +380,7 @@ class ConditionOccurrence(Criteria): validation_alias=AliasChoices("VisitTypeCS", "visitTypeCS"), serialization_alias="VisitTypeCS", ) - condition_status: Optional[List[Concept]] = Field( + condition_status: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("ConditionStatus", "conditionStatus"), serialization_alias="ConditionStatus", @@ -405,7 +405,7 @@ class DrugExposure(Criteria): Java equivalent: org.ohdsi.circe.cohortdefinition.DrugExposure """ - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") occurrence_end_date: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceEndDate", "occurrenceEndDate"), @@ -426,7 +426,7 @@ class DrugExposure(Criteria): validation_alias=AliasChoices("GenderCS", "genderCS"), serialization_alias="GenderCS", ) - drug_type: Optional[List[Concept]] = Field( + drug_type: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("DrugType", "drugType"), serialization_alias="DrugType", @@ -451,12 +451,12 @@ class DrugExposure(Criteria): validation_alias=AliasChoices("VisitTypeCS", "visitTypeCS"), serialization_alias="VisitTypeCS", ) - visit_type: Optional[List[Concept]] = Field( + visit_type: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("VisitType", "visitType"), serialization_alias="VisitType", ) - route_concept: Optional[List[Concept]] = Field( + route_concept: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("RouteConcept", "routeConcept"), serialization_alias="RouteConcept", @@ -476,7 +476,7 @@ class DrugExposure(Criteria): validation_alias=AliasChoices("First", "first"), serialization_alias="First", ) - provider_specialty: Optional[List[Concept]] = Field( + provider_specialty: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("ProviderSpecialty", "providerSpecialty"), serialization_alias="ProviderSpecialty", @@ -487,7 +487,7 @@ class DrugExposure(Criteria): validation_alias=AliasChoices("OccurrenceStartDate", "occurrenceStartDate"), serialization_alias="OccurrenceStartDate", ) - dose_unit: Optional[List[Concept]] = Field( + dose_unit: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("DoseUnit", "doseUnit"), serialization_alias="DoseUnit", @@ -532,7 +532,7 @@ class ProcedureOccurrence(Criteria): Java equivalent: org.ohdsi.circe.cohortdefinition.ProcedureOccurrence """ - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") occurrence_end_date: Optional[DateRange] = Field( default=None, alias="OccurrenceEndDate" ) @@ -540,7 +540,7 @@ class ProcedureOccurrence(Criteria): default=None, alias="ProcedureSourceConcept" ) gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - procedure_type: Optional[List[Concept]] = Field(default=None, alias="ProcedureType") + procedure_type: Optional[list[Concept]] = Field(default=None, alias="ProcedureType") procedure_type_cs: Optional[ConceptSetSelection] = Field( default=None, alias="ProcedureTypeCS" ) @@ -551,8 +551,8 @@ class ProcedureOccurrence(Criteria): visit_type_cs: Optional[ConceptSetSelection] = Field( default=None, alias="VisitTypeCS" ) - visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") - modifier: Optional[List[Concept]] = Field(default=None, alias="Modifier") + visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") + modifier: Optional[list[Concept]] = Field(default=None, alias="Modifier") modifier_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ModifierCS") codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field( @@ -560,7 +560,7 @@ class ProcedureOccurrence(Criteria): validation_alias=AliasChoices("First", "first"), serialization_alias="First", ) - provider_specialty: Optional[List[Concept]] = Field( + provider_specialty: Optional[list[Concept]] = Field( default=None, alias="ProviderSpecialty" ) age: Optional[NumericRange] = None @@ -580,12 +580,12 @@ class VisitOccurrence(Criteria): codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field(default=None, alias="First") - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") occurrence_end_date: Optional[DateRange] = Field( default=None, alias="OccurrenceEndDate" ) gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") + visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") visit_type_cs: Optional[ConceptSetSelection] = Field( default=None, alias="VisitTypeCS" ) @@ -597,10 +597,10 @@ class VisitOccurrence(Criteria): provider_specialty_cs: Optional[ConceptSetSelection] = Field( default=None, alias="ProviderSpecialtyCS" ) - provider_specialty: Optional[List[Concept]] = Field( + provider_specialty: Optional[list[Concept]] = Field( default=None, alias="ProviderSpecialty" ) - place_of_service: Optional[List[Concept]] = Field( + place_of_service: Optional[list[Concept]] = Field( default=None, alias="PlaceOfService" ) place_of_service_cs: Optional[ConceptSetSelection] = Field( @@ -623,7 +623,7 @@ class Observation(Criteria): Java equivalent: org.ohdsi.circe.cohortdefinition.Observation """ - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") occurrence_end_date: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceEndDate", "occurrenceEndDate"), @@ -641,7 +641,7 @@ class Observation(Criteria): validation_alias=AliasChoices("GenderCS", "genderCS"), serialization_alias="GenderCS", ) - observation_type: Optional[List[Concept]] = Field( + observation_type: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("ObservationType", "observationType"), serialization_alias="ObservationType", @@ -668,7 +668,7 @@ class Observation(Criteria): validation_alias=AliasChoices("VisitTypeCS", "visitTypeCS"), serialization_alias="VisitTypeCS", ) - visit_type: Optional[List[Concept]] = Field( + visit_type: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("VisitType", "visitType"), serialization_alias="VisitType", @@ -678,7 +678,7 @@ class Observation(Criteria): validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), serialization_alias="ValueAsNumber", ) - unit: Optional[List[Concept]] = Field( + unit: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("Unit", "unit"), serialization_alias="Unit", @@ -688,7 +688,7 @@ class Observation(Criteria): validation_alias=AliasChoices("UnitCS", "unitCS"), serialization_alias="UnitCS", ) - value_as_concept: Optional[List[Concept]] = Field( + value_as_concept: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("ValueAsConcept", "valueAsConcept"), serialization_alias="ValueAsConcept", @@ -698,7 +698,7 @@ class Observation(Criteria): validation_alias=AliasChoices("ValueAsConceptCS", "valueAsConceptCS"), serialization_alias="ValueAsConceptCS", ) - qualifier: Optional[List[Concept]] = Field( + qualifier: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("Qualifier", "qualifier"), serialization_alias="Qualifier", @@ -723,7 +723,7 @@ class Observation(Criteria): validation_alias=AliasChoices("First", "first"), serialization_alias="First", ) - provider_specialty: Optional[List[Concept]] = Field( + provider_specialty: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("ProviderSpecialty", "providerSpecialty"), serialization_alias="ProviderSpecialty", @@ -744,7 +744,7 @@ class Measurement(Criteria): Java equivalent: org.ohdsi.circe.cohortdefinition.Measurement """ - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") occurrence_end_date: Optional[DateRange] = Field( default=None, alias="OccurrenceEndDate" ) @@ -752,7 +752,7 @@ class Measurement(Criteria): default=None, alias="MeasurementSourceConcept" ) gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - measurement_type: Optional[List[Concept]] = Field( + measurement_type: Optional[list[Concept]] = Field( default=None, alias="MeasurementType" ) measurement_type_cs: Optional[ConceptSetSelection] = Field( @@ -765,11 +765,11 @@ class Measurement(Criteria): ), serialization_alias="MeasurementTypeExclude", ) - operator: Optional[List[Concept]] = None + operator: Optional[list[Concept]] = None operator_cs: Optional[ConceptSetSelection] = Field(default=None, alias="OperatorCS") value_as_number: Optional[NumericRange] = Field(default=None, alias="ValueAsNumber") value_as_string: Optional[TextFilter] = Field(default=None, alias="ValueAsString") - unit: Optional[List[Concept]] = Field(default=None, alias="Unit") + unit: Optional[list[Concept]] = Field(default=None, alias="Unit") unit_cs: Optional[ConceptSetSelection] = Field(default=None, alias="UnitCS") range_low: Optional[NumericRange] = Field(default=None, alias="RangeLow") range_high: Optional[NumericRange] = Field(default=None, alias="RangeHigh") @@ -779,13 +779,13 @@ class Measurement(Criteria): visit_type_cs: Optional[ConceptSetSelection] = Field( default=None, alias="VisitTypeCS" ) - visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") + visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") codeset_id: Optional[int] = Field( default=None, validation_alias=AliasChoices("CodesetId", "codesetId"), serialization_alias="CodesetId", ) - value_as_concept: Optional[List[Concept]] = Field( + value_as_concept: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("ValueAsConcept", "valueAsConcept"), serialization_alias="ValueAsConcept", @@ -810,22 +810,22 @@ class Measurement(Criteria): validation_alias=AliasChoices("RangeHighRatio", "rangeHighRatio"), serialization_alias="RangeHighRatio", ) - provider_specialty: Optional[List[Concept]] = Field( + provider_specialty: Optional[list[Concept]] = Field( default=None, alias="ProviderSpecialty" ) age: Optional[NumericRange] = None occurrence_start_date: Optional[DateRange] = Field( default=None, alias="OccurrenceStartDate" ) - visits: Optional[List[Concept]] = None # Placeholder if needed, but not in list - visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") + visits: Optional[list[Concept]] = None # Placeholder if needed, but not in list + visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), serialization_alias="First", ) - provider_specialty: Optional[List[Concept]] = Field( + provider_specialty: Optional[list[Concept]] = Field( default=None, alias="ProviderSpecialty" ) age: Optional[NumericRange] = None @@ -842,7 +842,7 @@ class DeviceExposure(Criteria): Java equivalent: org.ohdsi.circe.cohortdefinition.DeviceExposure """ - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") occurrence_end_date: Optional[DateRange] = Field( default=None, alias="OccurrenceEndDate" ) @@ -850,7 +850,7 @@ class DeviceExposure(Criteria): default=None, alias="DeviceSourceConcept" ) gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - device_type: Optional[List[Concept]] = Field(default=None, alias="DeviceType") + device_type: Optional[list[Concept]] = Field(default=None, alias="DeviceType") device_type_cs: Optional[ConceptSetSelection] = Field( default=None, alias="DeviceTypeCS" ) @@ -863,14 +863,14 @@ class DeviceExposure(Criteria): visit_type_cs: Optional[ConceptSetSelection] = Field( default=None, alias="VisitTypeCS" ) - visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") + visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field( default=None, validation_alias=AliasChoices("First", "first"), serialization_alias="First", ) - provider_specialty: Optional[List[Concept]] = Field( + provider_specialty: Optional[list[Concept]] = Field( default=None, alias="ProviderSpecialty" ) age: Optional[NumericRange] = Field(default=None, alias="Age") @@ -887,7 +887,7 @@ class Specimen(Criteria): Java equivalent: org.ohdsi.circe.cohortdefinition.Specimen """ - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") occurrence_end_date: Optional[DateRange] = Field( default=None, alias="OccurrenceEndDate" ) @@ -896,18 +896,18 @@ class Specimen(Criteria): ) source_id: Optional[TextFilter] = Field(default=None, alias="SourceId") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - specimen_type: Optional[List[Concept]] = Field(default=None, alias="SpecimenType") + specimen_type: Optional[list[Concept]] = Field(default=None, alias="SpecimenType") specimen_type_cs: Optional[ConceptSetSelection] = Field( default=None, alias="SpecimenTypeCS" ) specimen_type_exclude: bool = Field(default=False, alias="SpecimenTypeExclude") - unit: Optional[List[Concept]] = None + unit: Optional[list[Concept]] = None unit_cs: Optional[ConceptSetSelection] = Field(default=None, alias="UnitCS") - anatomic_site: Optional[List[Concept]] = Field(default=None, alias="AnatomicSite") + anatomic_site: Optional[list[Concept]] = Field(default=None, alias="AnatomicSite") anatomic_site_cs: Optional[ConceptSetSelection] = Field( default=None, alias="AnatomicSiteCS" ) - disease_status: Optional[List[Concept]] = Field(default=None, alias="DiseaseStatus") + disease_status: Optional[list[Concept]] = Field(default=None, alias="DiseaseStatus") disease_status_cs: Optional[ConceptSetSelection] = Field( default=None, alias="DiseaseStatusCS" ) @@ -932,7 +932,7 @@ class Death(Criteria): Java equivalent: org.ohdsi.circe.cohortdefinition.Death """ - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") occurrence_end_date: Optional[DateRange] = Field( default=None, alias="OccurrenceEndDate" ) @@ -940,7 +940,7 @@ class Death(Criteria): default=None, alias="DeathSourceConcept" ) gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - death_type: Optional[List[Concept]] = Field(default=None, alias="DeathType") + death_type: Optional[list[Concept]] = Field(default=None, alias="DeathType") death_type_cs: Optional[ConceptSetSelection] = Field( default=None, alias="DeathTypeCS" ) @@ -979,7 +979,7 @@ class VisitDetail(Criteria): visit_detail_end_date: Optional[DateRange] = Field( default=None, alias="VisitDetailEndDate" ) - visit_detail_type: Optional[List[Concept]] = Field( + visit_detail_type: Optional[list[Concept]] = Field( default=None, alias="VisitDetailType" ) visit_detail_type_cs: Optional[ConceptSetSelection] = Field( @@ -995,15 +995,15 @@ class VisitDetail(Criteria): default=None, alias="VisitDetailLength" ) age: Optional[NumericRange] = Field(default=None, alias="Age") - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - provider_specialty: Optional[List[Concept]] = Field( + provider_specialty: Optional[list[Concept]] = Field( default=None, alias="ProviderSpecialty" ) provider_specialty_cs: Optional[ConceptSetSelection] = Field( default=None, alias="ProviderSpecialtyCS" ) - place_of_service: Optional[List[Concept]] = Field( + place_of_service: Optional[list[Concept]] = Field( default=None, alias="PlaceOfService" ) place_of_service_cs: Optional[ConceptSetSelection] = Field( @@ -1012,7 +1012,7 @@ class VisitDetail(Criteria): place_of_service_location: Optional[int] = Field( default=None, alias="PlaceOfServiceLocation" ) - discharge_to: Optional[List[Concept]] = Field(default=None, alias="DischargeTo") + discharge_to: Optional[list[Concept]] = Field(default=None, alias="DischargeTo") discharge_to_cs: Optional[ConceptSetSelection] = Field( default=None, alias="DischargeToCS" ) @@ -1034,7 +1034,7 @@ class ObservationPeriod(Criteria): user_defined_period: Optional[Period] = Field( default=None, alias="UserDefinedPeriod" ) - period_type: Optional[List[Concept]] = Field(default=None, alias="PeriodType") + period_type: Optional[list[Concept]] = Field(default=None, alias="PeriodType") period_type_cs: Optional[ConceptSetSelection] = Field( default=None, alias="PeriodTypeCS" ) @@ -1062,7 +1062,7 @@ class PayerPlanPeriod(Criteria): period_length: Optional[NumericRange] = Field(default=None, alias="PeriodLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") payer_concept: Optional[int] = Field(default=None, alias="PayerConcept") plan_concept: Optional[int] = Field(default=None, alias="PlanConcept") @@ -1118,7 +1118,7 @@ class ConditionEra(Criteria): era_length: Optional[NumericRange] = Field(default=None, alias="EraLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") date_adjustment: Optional[DateAdjustment] = Field( default=None, alias="DateAdjustment" @@ -1148,7 +1148,7 @@ class DrugEra(Criteria): era_length: Optional[NumericRange] = Field(default=None, alias="EraLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") date_adjustment: Optional[DateAdjustment] = Field( default=None, alias="DateAdjustment" @@ -1167,13 +1167,13 @@ class DoseEra(Criteria): first: Optional[bool] = Field(default=None, alias="First") era_start_date: Optional[DateRange] = Field(default=None, alias="EraStartDate") era_end_date: Optional[DateRange] = Field(default=None, alias="EraEndDate") - unit: Optional[List[Concept]] = Field(default=None, alias="Unit") + unit: Optional[list[Concept]] = Field(default=None, alias="Unit") unit_cs: Optional[ConceptSetSelection] = Field(default=None, alias="UnitCS") dose_value: Optional[NumericRange] = Field(default=None, alias="DoseValue") era_length: Optional[NumericRange] = Field(default=None, alias="EraLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") - gender: Optional[List[Concept]] = Field(default=None, serialization_alias="gender") + gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") model_config = ConfigDict(populate_by_name=True) @@ -1204,7 +1204,7 @@ class CriteriaGroup(BaseModel): Java equivalent: org.ohdsi.circe.cohortdefinition.CriteriaGroup """ - criteria_list: List["CorelatedCriteria"] = Field( + criteria_list: list["CorelatedCriteria"] = Field( default_factory=list, validation_alias=AliasChoices("CriteriaList", "criteriaList"), serialization_alias="CriteriaList", @@ -1214,12 +1214,12 @@ class CriteriaGroup(BaseModel): validation_alias=AliasChoices("Count", "count"), serialization_alias="Count", ) - groups: List["CriteriaGroup"] = Field( + groups: list["CriteriaGroup"] = Field( default_factory=list, validation_alias=AliasChoices("Groups", "groups"), serialization_alias="Groups", ) - demographic_criteria_list: List[DemographicCriteria] = Field( + demographic_criteria_list: list[DemographicCriteria] = Field( default_factory=list, validation_alias=AliasChoices( "DemographicCriteriaList", "demographicCriteriaList" @@ -1579,7 +1579,7 @@ class PrimaryCriteria(BaseModel): Java equivalent: org.ohdsi.circe.cohortdefinition.PrimaryCriteria """ - criteria_list: List[CriteriaType] = Field( + criteria_list: list[CriteriaType] = Field( default_factory=list, validation_alias=AliasChoices("CriteriaList", "criteriaList"), serialization_alias="CriteriaList", diff --git a/circe/cohortdefinition/printfriendly/markdown_render.py b/circe/cohortdefinition/printfriendly/markdown_render.py index 380defb3..3b8eee98 100644 --- a/circe/cohortdefinition/printfriendly/markdown_render.py +++ b/circe/cohortdefinition/printfriendly/markdown_render.py @@ -14,7 +14,7 @@ import json from datetime import datetime from pathlib import Path -from typing import List, Optional, Union +from typing import Optional, Union import jinja2 @@ -34,7 +34,7 @@ class MarkdownRender: def __init__( self, - concept_sets: Optional[List[ConceptSet]] = None, + concept_sets: Optional[list[ConceptSet]] = None, include_concept_sets: bool = False, ): """Initialize the markdown renderer. @@ -112,7 +112,7 @@ def render_cohort_expression( ) def render_concept_set_list( - self, concept_sets: Union[List[ConceptSet], str] + self, concept_sets: Union[list[ConceptSet], str] ) -> str: """Render a list of concept sets to markdown format. diff --git a/circe/execution/build_context.py b/circe/execution/build_context.py index e2a2d40b..a491daec 100644 --- a/circe/execution/build_context.py +++ b/circe/execution/build_context.py @@ -2,10 +2,11 @@ import uuid import weakref +from collections.abc import Iterable from dataclasses import dataclass from functools import reduce from pathlib import Path -from typing import Callable, Iterable, Optional, Tuple, Union +from typing import Callable, Union import ibis import ibis.common.exceptions as ibis_exc @@ -14,7 +15,7 @@ from ..vocabulary.concept import ConceptSet from .ibis_compat import table_from_literal_list -Database = Union[str, Tuple[str, str]] +Database = Union[str, tuple[str, str]] def _qualify(database: Database | None, name: str) -> str: @@ -61,16 +62,16 @@ def _drop_table_safely( @dataclass(frozen=True) class CohortBuildOptions: - cdm_schema: Optional[str] = None - vocabulary_schema: Optional[str] = None - result_schema: Optional[str] = None - target_table: Optional[str] = None - cohort_id: Optional[int] = None + cdm_schema: str | None = None + vocabulary_schema: str | None = None + result_schema: str | None = None + target_table: str | None = None + cohort_id: int | None = None generate_stats: bool = False - temp_emulation_schema: Optional[str] = None - profile_dir: Optional[str] = None + temp_emulation_schema: str | None = None + profile_dir: str | None = None capture_sql: bool = False - backend: Optional[str] = None + backend: str | None = None materialize_stages: bool = True materialize_codesets: bool = True @@ -78,7 +79,7 @@ class CohortBuildOptions: @dataclass class CodesetResource: table: ir.Table - _dropper: Optional[Callable[[], None]] = None + _dropper: Callable[[], None] | None = None def cleanup(self): if self._dropper: @@ -115,7 +116,7 @@ def __init__( self._slice_cache: dict[str, ir.Table] = {} weakref.finalize(self, self.close) - def _table(self, database: Optional[str], name: str) -> ir.Table: + def _table(self, database: str | None, name: str) -> ir.Table: try: return _table(self._conn, database, name) except ( @@ -391,7 +392,7 @@ def _compile_single_codeset( concept_ancestor: ir.Table, concept_relationship: ir.Table, concept_set: ConceptSet, -) -> Optional[ir.Table]: +) -> ir.Table | None: expression = concept_set.expression if expression is None or not expression.items: return None @@ -469,7 +470,7 @@ def _compile_single_codeset( return include_expr.mutate(codeset_id=codeset_literal)[["codeset_id", "concept_id"]] -def _ids_memtable(ids: list[int]) -> Optional[ir.Table]: +def _ids_memtable(ids: list[int]) -> ir.Table | None: if not ids: return None return table_from_literal_list( @@ -479,7 +480,7 @@ def _ids_memtable(ids: list[int]) -> Optional[ir.Table]: def _descendants( concept: ir.Table, concept_ancestor: ir.Table, ancestor_ids: list[int] -) -> Optional[ir.Table]: +) -> ir.Table | None: if not ancestor_ids: return None return ( @@ -497,7 +498,7 @@ def _mapped_concepts( concept_relationship: ir.Table, concepts_to_map: list[int], concepts_with_descendants_to_map: list[int], -) -> Optional[ir.Table]: +) -> ir.Table | None: sources = _union_distinct( [ _ids_memtable(concepts_to_map), @@ -589,7 +590,7 @@ def _drop(): return resource -def _union_distinct(tables: Iterable[Optional[ir.Table]]) -> Optional[ir.Table]: +def _union_distinct(tables: Iterable[ir.Table | None]) -> ir.Table | None: valid_tables = [t for t in tables if t is not None] if not valid_tables: return None diff --git a/circe/execution/builders/common.py b/circe/execution/builders/common.py index 1f524965..8444743f 100644 --- a/circe/execution/builders/common.py +++ b/circe/execution/builders/common.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Callable, Optional, Sequence, cast +from collections.abc import Sequence +from typing import Any, Callable, cast import ibis import ibis.expr.types as ir @@ -97,7 +98,7 @@ def project_event_columns( def apply_codeset_filter( table: ir.Table, concept_column: str, - codeset_id: Optional[int], + codeset_id: int | None, ctx: BuildContext, ) -> ir.Table: if codeset_id is None: @@ -114,7 +115,7 @@ def apply_codeset_filter( def apply_concept_set_selection( table: ir.Table, column: str, - selection: Optional[ConceptSetSelection], + selection: ConceptSetSelection | None, ctx: BuildContext, ) -> ir.Table: if selection is None or selection.codeset_id is None: @@ -132,7 +133,7 @@ def apply_concept_set_selection( def coerce_concept_set_selection( value: object | None, -) -> Optional[ConceptSetSelection]: +) -> ConceptSetSelection | None: if value is None: return None if isinstance(value, ConceptSetSelection): @@ -150,7 +151,7 @@ def apply_concept_criteria( *, column: str, concepts: Sequence[Concept] | None, - selection: Optional[ConceptSetSelection], + selection: ConceptSetSelection | None, ctx: BuildContext, exclude: bool = False, ) -> ir.Table: @@ -159,7 +160,7 @@ def apply_concept_criteria( def apply_date_range( - table: ir.Table, column: str, date_range: Optional[DateRange] + table: ir.Table, column: str, date_range: DateRange | None ) -> ir.Table: if not date_range: return table @@ -178,7 +179,7 @@ def apply_date_range( def apply_numeric_range( - table: ir.Table, column, numeric_range: Optional[NumericRange] + table: ir.Table, column, numeric_range: NumericRange | None ) -> ir.Table: if not numeric_range or numeric_range.value is None: return table @@ -199,7 +200,7 @@ def apply_numeric_range( def apply_text_filter( - table: ir.Table, column: str, text_filter: Optional[TextFilter] + table: ir.Table, column: str, text_filter: TextFilter | None ) -> ir.Table: if not text_filter or not text_filter.text: return table @@ -221,7 +222,7 @@ def apply_interval_range( table: ir.Table, start_column: str, end_column: str, - interval_range: Optional[NumericRange], + interval_range: NumericRange | None, ) -> ir.Table: if not interval_range or interval_range.value is None: return table @@ -296,7 +297,7 @@ def apply_concept_filters( def apply_age_filter( table: ir.Table, - age_range: Optional[NumericRange], + age_range: NumericRange | None, ctx: BuildContext, start_column: str, ) -> ir.Table: @@ -316,7 +317,7 @@ def apply_age_filter( def apply_gender_filter( table: ir.Table, genders: list[Concept] | None, - gender_selection: Optional[ConceptSetSelection], + gender_selection: ConceptSetSelection | None, ctx: BuildContext, ) -> ir.Table: return _apply_person_concept_filter( @@ -331,7 +332,7 @@ def apply_gender_filter( def apply_race_filter( table: ir.Table, races: list[Concept] | None, - race_selection: Optional[ConceptSetSelection], + race_selection: ConceptSetSelection | None, ctx: BuildContext, ) -> ir.Table: return _apply_person_concept_filter( @@ -346,7 +347,7 @@ def apply_race_filter( def apply_ethnicity_filter( table: ir.Table, ethnicities: list[Concept] | None, - ethnicity_selection: Optional[ConceptSetSelection], + ethnicity_selection: ConceptSetSelection | None, ctx: BuildContext, ) -> ir.Table: return _apply_person_concept_filter( @@ -363,7 +364,7 @@ def _apply_person_concept_filter( *, person_column: str, concepts: Sequence[Concept] | None, - selection: Optional[ConceptSetSelection], + selection: ConceptSetSelection | None, ctx: BuildContext, ) -> ir.Table: if not concepts and not selection: @@ -429,7 +430,7 @@ def apply_first_event(table: ir.Table, start_column: str, primary_key: str) -> i def apply_visit_concept_filters( table: ir.Table, visit_types: list[Concept] | None, - visit_selection: Optional[ConceptSetSelection], + visit_selection: ConceptSetSelection | None, ctx: BuildContext, ) -> ir.Table: return apply_concept_criteria( @@ -444,7 +445,7 @@ def apply_visit_concept_filters( def apply_provider_specialty_filter( table: ir.Table, provider_specialties: list[Concept] | None, - provider_specialty_selection: Optional[ConceptSetSelection], + provider_specialty_selection: ConceptSetSelection | None, ctx: BuildContext, provider_column: str = "provider_id", ) -> ir.Table: @@ -464,7 +465,7 @@ def apply_provider_specialty_filter( def apply_care_site_filter( table: ir.Table, - place_of_service_selection: Optional[ConceptSetSelection], + place_of_service_selection: ConceptSetSelection | None, ctx: BuildContext, care_site_column: str = "care_site_id", ) -> ir.Table: @@ -482,7 +483,7 @@ def apply_location_region_filter( table: ir.Table, *, care_site_column: str, - location_codeset_id: Optional[int], + location_codeset_id: int | None, start_column: str, end_column: str, ctx: BuildContext, @@ -585,7 +586,7 @@ def _project_columns(table: ir.Table, column_names: Sequence[str]) -> ir.Table: def apply_end_strategy( events: ir.Table, - strategy: Optional[EndStrategy | DateOffsetStrategy | CustomEraStrategy], + strategy: EndStrategy | DateOffsetStrategy | CustomEraStrategy | None, ctx: BuildContext, ) -> ir.Table: date_offset, custom_era = _resolve_end_strategy_parts(strategy) @@ -618,15 +619,15 @@ def apply_end_strategy( def has_end_strategy( - strategy: Optional[EndStrategy | DateOffsetStrategy | CustomEraStrategy], + strategy: EndStrategy | DateOffsetStrategy | CustomEraStrategy | None, ) -> bool: date_offset, custom_era = _resolve_end_strategy_parts(strategy) return bool(date_offset or custom_era) def _resolve_end_strategy_parts( - strategy: Optional[EndStrategy | DateOffsetStrategy | CustomEraStrategy], -) -> tuple[Optional[DateOffsetStrategy], Optional[CustomEraStrategy]]: + strategy: EndStrategy | DateOffsetStrategy | CustomEraStrategy | None, +) -> tuple[DateOffsetStrategy | None, CustomEraStrategy | None]: if strategy is None: return None, None diff --git a/circe/execution/builders/groups.py b/circe/execution/builders/groups.py index da845113..6edf30a8 100644 --- a/circe/execution/builders/groups.py +++ b/circe/execution/builders/groups.py @@ -121,19 +121,18 @@ def _correlated_mask( if correlated.restrict_visit is None and isinstance(criteria_model, VisitDetail): require_same_visit = True - if require_same_visit: - if ( - "visit_occurrence_id" in index_events.columns - and "_corr_visit_occurrence_id" in criteria_events.columns - ): - join_condition &= ( - index_events.visit_occurrence_id.notnull() - & criteria_events._corr_visit_occurrence_id.notnull() - & ( - index_events.visit_occurrence_id - == criteria_events._corr_visit_occurrence_id - ) + if require_same_visit and ( + "visit_occurrence_id" in index_events.columns + and "_corr_visit_occurrence_id" in criteria_events.columns + ): + join_condition &= ( + index_events.visit_occurrence_id.notnull() + & criteria_events._corr_visit_occurrence_id.notnull() + & ( + index_events.visit_occurrence_id + == criteria_events._corr_visit_occurrence_id ) + ) joined = index_events.join(criteria_events, join_condition, how="left") diff --git a/circe/execution/builders/registry.py b/circe/execution/builders/registry.py index fdec2cae..68a0a633 100644 --- a/circe/execution/builders/registry.py +++ b/circe/execution/builders/registry.py @@ -2,14 +2,13 @@ import hashlib from collections.abc import Callable -from typing import Dict import ibis.expr.types as ir from ...cohortdefinition.criteria import Criteria from ..build_context import BuildContext -_REGISTRY: Dict[str, Callable[[Criteria, BuildContext], ir.Table]] = {} +_REGISTRY: dict[str, Callable[[Criteria, BuildContext], ir.Table]] = {} def register(criteria_name: str): diff --git a/circe/execution/ibis.py b/circe/execution/ibis.py index 3cf27571..b526730a 100644 --- a/circe/execution/ibis.py +++ b/circe/execution/ibis.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import replace -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Any from ..io import ExpressionInput, load_expression from .options import ExecutionOptions, SchemaName, schema_to_str @@ -22,10 +22,10 @@ class IbisExecutor: - Materialization happens in `to_polars()` / `to_pandas()` / `write()`. """ - def __init__(self, conn: Any, options: Optional[ExecutionOptions] = None): + def __init__(self, conn: Any, options: ExecutionOptions | None = None): self._conn = conn self._options = options or ExecutionOptions() - self._open_contexts: List[Any] = [] + self._open_contexts: list[Any] = [] @property def conn(self) -> Any: @@ -64,10 +64,10 @@ def write( expression: ExpressionInput, *, table: str, - schema: Optional[SchemaName] = None, + schema: SchemaName | None = None, overwrite: bool = True, append: bool = False, - cohort_id: Optional[int] = None, + cohort_id: int | None = None, ) -> Any: """Persist cohort rows to a cohort table and return a backend table handle.""" if append and overwrite: @@ -89,9 +89,9 @@ def write( append=append, ) - def captured_sql(self) -> List[tuple[str, str]]: + def captured_sql(self) -> list[tuple[str, str]]: """Return captured staged SQL snippets when capture_sql is enabled.""" - captured: List[tuple[str, str]] = [] + captured: list[tuple[str, str]] = [] for ctx in self._open_contexts: if hasattr(ctx, "captured_sql"): captured.extend(ctx.captured_sql()) @@ -118,7 +118,7 @@ def _build_native(self, cohort_expression: Any) -> Any: return events def _build_with_context_native( - self, cohort_expression: Any, cohort_id_override: Optional[int] = None + self, cohort_expression: Any, cohort_id_override: int | None = None ) -> Any: try: from .build_context import ( @@ -163,7 +163,7 @@ def _build_with_context_native( return events, ctx @staticmethod - def _infer_backend_name(conn: Any) -> Optional[str]: + def _infer_backend_name(conn: Any) -> str | None: backend_name = getattr(conn, "name", None) if isinstance(backend_name, str) and backend_name: return backend_name.lower() @@ -180,7 +180,7 @@ def _infer_backend_name(conn: Any) -> Optional[str]: def build_ibis( expression: ExpressionInput, conn: Any, - options: Optional[ExecutionOptions] = None, + options: ExecutionOptions | None = None, ) -> Any: """Convenience wrapper for IbisExecutor.build().""" with IbisExecutor(conn, options) as executor: @@ -190,7 +190,7 @@ def build_ibis( def to_polars( expression: ExpressionInput, conn: Any, - options: Optional[ExecutionOptions] = None, + options: ExecutionOptions | None = None, ) -> pl.DataFrame: """Convenience wrapper for IbisExecutor.to_polars().""" with IbisExecutor(conn, options) as executor: @@ -202,11 +202,11 @@ def write_cohort( conn: Any, *, table: str, - schema: Optional[SchemaName] = None, + schema: SchemaName | None = None, overwrite: bool = True, append: bool = False, - cohort_id: Optional[int] = None, - options: Optional[ExecutionOptions] = None, + cohort_id: int | None = None, + options: ExecutionOptions | None = None, ) -> Any: """Convenience wrapper for IbisExecutor.write().""" effective_options = options diff --git a/circe/execution/ibis_compat.py b/circe/execution/ibis_compat.py index ae2260bb..d5f215f9 100644 --- a/circe/execution/ibis_compat.py +++ b/circe/execution/ibis_compat.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable +from collections.abc import Iterable import ibis import ibis.expr.operations as ops diff --git a/circe/execution/options.py b/circe/execution/options.py index aa4220a1..b88f1a6f 100644 --- a/circe/execution/options.py +++ b/circe/execution/options.py @@ -3,9 +3,9 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional, Tuple, Union +from typing import Union -SchemaName = Union[str, Tuple[str, str]] +SchemaName = Union[str, tuple[str, str]] @dataclass(frozen=True) @@ -15,21 +15,21 @@ class ExecutionOptions: This API is experimental and may evolve while execution parity is built out. """ - cdm_schema: Optional[SchemaName] = None - vocabulary_schema: Optional[SchemaName] = None - result_schema: Optional[SchemaName] = None + cdm_schema: SchemaName | None = None + vocabulary_schema: SchemaName | None = None + result_schema: SchemaName | None = None - cohort_id: Optional[int] = None + cohort_id: int | None = None materialize_stages: bool = False materialize_codesets: bool = True - temp_emulation_schema: Optional[SchemaName] = None + temp_emulation_schema: SchemaName | None = None capture_sql: bool = False - profile_dir: Optional[str] = None + profile_dir: str | None = None -def schema_to_str(schema: Optional[SchemaName]) -> Optional[str]: +def schema_to_str(schema: SchemaName | None) -> str | None: """Normalize schema names to a string representation.""" if schema is None: return None diff --git a/circe/helper/cohort_modifiers.py b/circe/helper/cohort_modifiers.py index 629a6a78..55d36500 100644 --- a/circe/helper/cohort_modifiers.py +++ b/circe/helper/cohort_modifiers.py @@ -23,8 +23,8 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import date -from typing import List, Optional, Sequence, Union from ..cohortdefinition.cohort import CohortExpression from ..cohortdefinition.core import ( @@ -283,8 +283,8 @@ def set_cohort_era( def set_age_criteria( cohort_expression: CohortExpression, - min_age: Optional[int] = None, - max_age: Optional[int] = None, + min_age: int | None = None, + max_age: int | None = None, replace: bool = False, ) -> CohortExpression: """Restrict cohort entry to subjects within an age range at index date. @@ -362,7 +362,7 @@ def set_age_criteria( def set_gender_criteria( cohort_expression: CohortExpression, - gender_concept_ids: Union[int, Sequence[int]], + gender_concept_ids: int | Sequence[int], replace: bool = False, ) -> CohortExpression: """Restrict cohort entry to subjects of a specific gender. @@ -400,7 +400,7 @@ def set_gender_criteria( if replace: reset_gender_criteria(cohort_expression) - gender_concepts: List[Concept] = [] + gender_concepts: list[Concept] = [] for cid in gender_concept_ids: # Try to resolve well-known concepts by ID matched = False @@ -438,9 +438,9 @@ def set_gender_criteria( def set_end_date_strategy( cohort_expression: CohortExpression, strategy: str, - days: Optional[int] = None, + days: int | None = None, date_field: str = "StartDate", - drug_codeset_id: Optional[int] = None, + drug_codeset_id: int | None = None, gap_days: int = 0, offset: int = 0, ) -> CohortExpression: @@ -629,7 +629,7 @@ def set_clean_window( # Build one correlated criteria per primary criterion. # Each one says: "exactly 0 occurrences of this criterion in the # [-days, -1] day window before the index event." - correlated_list: List[CorelatedCriteria] = [] + correlated_list: list[CorelatedCriteria] = [] for criterion in pc.criteria_list: correlated = CorelatedCriteria( criteria=criterion, @@ -706,8 +706,8 @@ def reset_clean_window( def set_date_range( cohort_expression: CohortExpression, - start_date: Optional[Union[str, date]] = None, - end_date: Optional[Union[str, date]] = None, + start_date: str | date | None = None, + end_date: str | date | None = None, ) -> CohortExpression: """Limit cohort entries to a specific calendar date range. @@ -751,7 +751,7 @@ def set_date_range( def set_censor_event( cohort_expression: CohortExpression, - censor_criteria: Union[Criteria, CriteriaType], + censor_criteria: Criteria | CriteriaType, ) -> CohortExpression: """Add a censoring event that ends cohort membership when it occurs. @@ -906,11 +906,11 @@ def apply_standard_rules( post_observation_days: int = 0, first_event_only: bool = True, era_gap_days: int = 0, - min_age: Optional[int] = None, - max_age: Optional[int] = None, - gender_concept_ids: Optional[Union[int, Sequence[int]]] = None, - end_strategy: Optional[str] = None, - end_strategy_days: Optional[int] = None, + min_age: int | None = None, + max_age: int | None = None, + gender_concept_ids: int | Sequence[int] | None = None, + end_strategy: str | None = None, + end_strategy_days: int | None = None, ) -> CohortExpression: """Apply a common set of cohort rules in a single call. diff --git a/circe/io.py b/circe/io.py index 5f87a8b2..8e75ae7e 100644 --- a/circe/io.py +++ b/circe/io.py @@ -8,8 +8,9 @@ from __future__ import annotations import json +from collections.abc import Mapping from pathlib import Path -from typing import Any, Mapping, Union +from typing import Any, Union from .api import cohort_expression_from_json from .cohortdefinition import CohortExpression diff --git a/circe/vocabulary/concept.py b/circe/vocabulary/concept.py index 74e42b52..6b44781e 100644 --- a/circe/vocabulary/concept.py +++ b/circe/vocabulary/concept.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional +from typing import Optional from pydantic import AliasChoices, BaseModel, ConfigDict, Field @@ -100,7 +100,7 @@ class ConceptSetExpression(BaseModel): is_excluded: bool = Field(default=False, alias="isExcluded") include_mapped: bool = Field(default=False, alias="includeMapped") include_descendants: bool = Field(default=False, alias="includeDescendants") - items: Optional[List[ConceptSetItem]] = None + items: Optional[list[ConceptSetItem]] = None model_config = ConfigDict(populate_by_name=True) diff --git a/circe/vocabulary/concept_set_expression_query_builder.py b/circe/vocabulary/concept_set_expression_query_builder.py index 2f482658..d5f1d847 100644 --- a/circe/vocabulary/concept_set_expression_query_builder.py +++ b/circe/vocabulary/concept_set_expression_query_builder.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List from ..cohortdefinition.builders.utils import BuilderUtils from .concept import Concept, ConceptSetExpression @@ -51,7 +50,7 @@ class ConceptSetExpressionQueryBuilder: MAX_IN_LENGTH = 1000 # Oracle limitation - def get_concept_ids(self, concepts: List[Concept]) -> List[int]: + def get_concept_ids(self, concepts: list[Concept]) -> list[int]: """Get concept IDs from concept list. Java equivalent: getConceptIds() @@ -61,7 +60,7 @@ def get_concept_ids(self, concepts: List[Concept]) -> List[int]: ] def build_concept_set_sub_query( - self, concepts: List[Concept], descendant_concepts: List[Concept] + self, concepts: list[Concept], descendant_concepts: list[Concept] ) -> str: """Build concept set sub-query. @@ -92,7 +91,7 @@ def build_concept_set_sub_query( return " UNION ".join(queries) def build_concept_set_mapped_query( - self, mapped_concepts: List[Concept], mapped_descendant_concepts: List[Concept] + self, mapped_concepts: list[Concept], mapped_descendant_concepts: list[Concept] ) -> str: """Build concept set mapped query. @@ -107,10 +106,10 @@ def build_concept_set_mapped_query( def build_concept_set_query( self, - concepts: List[Concept], - descendant_concepts: List[Concept], - mapped_concepts: List[Concept], - mapped_descendant_concepts: List[Concept], + concepts: list[Concept], + descendant_concepts: list[Concept], + mapped_concepts: list[Concept], + mapped_descendant_concepts: list[Concept], ) -> str: """Build concept set query. diff --git a/debug_app/app.py b/debug_app/app.py index d1c3ea1f..e8fc6153 100644 --- a/debug_app/app.py +++ b/debug_app/app.py @@ -106,8 +106,8 @@ def cohort_view(filename): ) result["error"] = combined_error - ref_sql = ref_result["sql"] - ref_md = ref_result["markdown"] + ref_result["sql"] + ref_result["markdown"] # Check overrides is_user_ok = False diff --git a/debug_app/sandbox.py b/debug_app/sandbox.py index 53001ad0..95df3bdb 100644 --- a/debug_app/sandbox.py +++ b/debug_app/sandbox.py @@ -6,7 +6,7 @@ """ import re -from typing import Any, Dict +from typing import Any def validate_imports(code: str) -> tuple[bool, str]: @@ -38,7 +38,7 @@ def validate_imports(code: str) -> tuple[bool, str]: return True, "" -def execute_cohort_code(code: str) -> Dict[str, Any]: +def execute_cohort_code(code: str) -> dict[str, Any]: """ Execute Python code with strict cohort builder restrictions. diff --git a/examples/complex_cohort.py b/examples/complex_cohort.py index bb5219d1..974e74cb 100644 --- a/examples/complex_cohort.py +++ b/examples/complex_cohort.py @@ -294,7 +294,7 @@ def create_complex_cohort(): for rule in cohort.inclusion_rules: print(f" - {rule.name}") print(f"Censoring Criteria: {len(cohort.censoring_criteria)} events") - for i, criteria in enumerate(cohort.censoring_criteria, 1): + for _i, criteria in enumerate(cohort.censoring_criteria, 1): # Get the criteria type from the wrapped object criteria_dict = criteria.model_dump(by_alias=True) criteria_type = list(criteria_dict.keys())[0] diff --git a/scripts/generate_skill_backup.py b/scripts/generate_skill_backup.py index 53d524c6..3d37756c 100644 --- a/scripts/generate_skill_backup.py +++ b/scripts/generate_skill_backup.py @@ -15,7 +15,7 @@ import sys from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List +from typing import Any # Add project root to path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -38,7 +38,7 @@ class MethodInfo: signature: str return_type: str docstring: str - parameters: List[Dict[str, Any]] + parameters: list[dict[str, Any]] is_chainable: bool finalizes: bool # Returns parent builder (breaks chain) @@ -47,11 +47,11 @@ class SkillGenerator: """Generates SKILL.md from the cohort builder codebase.""" def __init__(self): - self.builder_methods: List[MethodInfo] = [] - self.entry_methods: List[MethodInfo] = [] - self.criteria_methods: List[MethodInfo] = [] - self.query_modifiers: Dict[str, List[MethodInfo]] = {} - self.time_windows: List[MethodInfo] = [] + self.builder_methods: list[MethodInfo] = [] + self.entry_methods: list[MethodInfo] = [] + self.criteria_methods: list[MethodInfo] = [] + self.query_modifiers: dict[str, list[MethodInfo]] = {} + self.time_windows: list[MethodInfo] = [] def extract_method_info(self, cls, method_name: str) -> MethodInfo: """Extract information about a method.""" @@ -114,7 +114,7 @@ def discover_methods(self): """Discover all public methods from the builder classes.""" # CohortBuilder entry methods - for name, method in inspect.getmembers( + for name, _method in inspect.getmembers( CohortBuilder, predicate=inspect.isfunction ): if name.startswith("_") or name == "with_concept_sets": @@ -125,7 +125,7 @@ def discover_methods(self): ) # CohortWithEntry methods - for name, method in inspect.getmembers( + for name, _method in inspect.getmembers( CohortWithEntry, predicate=inspect.isfunction ): if name.startswith("_"): @@ -149,7 +149,7 @@ def discover_methods(self): ) # CohortWithCriteria methods - for name, method in inspect.getmembers( + for name, _method in inspect.getmembers( CohortWithCriteria, predicate=inspect.isfunction ): if name.startswith("_"): @@ -175,7 +175,7 @@ def discover_methods(self): ) # BaseQuery time windows - for name, method in inspect.getmembers(BaseQuery, predicate=inspect.isfunction): + for name, _method in inspect.getmembers(BaseQuery, predicate=inspect.isfunction): if name in [ "within_days_before", "within_days_after", @@ -404,10 +404,7 @@ def update_system_prompt(self, skill_content: str, prompt_path: str): in_frontmatter = False for line in skill_lines: if line.strip() == "---": - if not in_frontmatter: - in_frontmatter = True - else: - in_frontmatter = False + in_frontmatter = bool(not in_frontmatter) continue if not in_frontmatter: skill_body.append(line) @@ -443,7 +440,7 @@ def update_system_prompt(self, skill_content: str, prompt_path: str): ("prompts/fast_models_prompt.md", "Fast Models"), ] - for prompt_path, model_type in prompts: + for prompt_path, _model_type in prompts: generator.update_system_prompt(skill_content, prompt_path) print("\n✅ All documentation updated!") diff --git a/tests/test_builders.py b/tests/test_builders.py index 1d43e432..1a4afc42 100644 --- a/tests/test_builders.py +++ b/tests/test_builders.py @@ -11,7 +11,6 @@ import sys import unittest from enum import Enum -from typing import Set sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) @@ -284,7 +283,7 @@ def get_table_column_for_criteria_column( def get_query_template(self) -> str: return "SELECT * FROM test" - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: return {CriteriaColumn.START_DATE} builder = TestBuilder() @@ -648,7 +647,7 @@ def test_criteria_column_consistency_across_builders(self): ProcedureOccurrenceSqlBuilder(), ] - criteria = Criteria() + Criteria() for builder in builders: # Test that all builders can handle all criteria columns diff --git a/tests/test_cli.py b/tests/test_cli.py index 631714c4..7b1af929 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,7 +25,7 @@ ] -@functools.lru_cache(maxsize=None) +@functools.cache def run_r_script_cached(cohort_file: Path) -> tuple[str, str]: """Run R CirceR script and return SQL and Markdown. Cached to avoid redundant slow R calls.""" import subprocess diff --git a/tests/test_code_generator.py b/tests/test_code_generator.py index c4c2bd17..f811b31c 100644 --- a/tests/test_code_generator.py +++ b/tests/test_code_generator.py @@ -48,7 +48,7 @@ def test_simple_object_generation(): """Test generation of a simple object.""" from circe.cohortdefinition.core import Period - p = Period( + Period( value=10, unit="d" ) # Note: Unit might be a string or enum depending on Period def # Let's check Period definition first, wait, I can assume it works if the main one works. diff --git a/tests/test_cohort_expression_query_builder_extended.py b/tests/test_cohort_expression_query_builder_extended.py index a2064051..a0333cc0 100644 --- a/tests/test_cohort_expression_query_builder_extended.py +++ b/tests/test_cohort_expression_query_builder_extended.py @@ -169,7 +169,7 @@ def test_get_windowed_criteria_query_basic(self): # Mock criteria acceptance with patch.object( ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria" - ) as mock_accept: + ): sql = self.builder.get_windowed_criteria_query(criteria, "#events") self.assertIn("SELECT * FROM Criteria", sql) diff --git a/tests/test_device_exposure_sql.py b/tests/test_device_exposure_sql.py index a085ab30..a10a6199 100644 --- a/tests/test_device_exposure_sql.py +++ b/tests/test_device_exposure_sql.py @@ -18,7 +18,7 @@ def test_basic_device_exposure(self): # We need to minimally test the resolved clauses where_clauses = builder.resolve_where_clauses(criteria, options) join_clauses = builder.resolve_join_clauses(criteria, options) - select_clauses = builder.resolve_select_clauses(criteria, options) + builder.resolve_select_clauses(criteria, options) self.assertTrue( any("C.start_date" in c for c in where_clauses), diff --git a/tests/test_kitchen_sink_cohort.py b/tests/test_kitchen_sink_cohort.py index 273c68c7..c7fde493 100644 --- a/tests/test_kitchen_sink_cohort.py +++ b/tests/test_kitchen_sink_cohort.py @@ -279,7 +279,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: ) # 3. Primary Criteria - primary_criteria = criteria = getattr( + getattr( # Need to construct PrimaryCriteria manually or via helper # But wait, PrimaryCriteria uses CriteriaList, not nested Criteria objects directly # The structure in core.py for PrimaryCriteria is: diff --git a/tests/test_real_example_cohorts.py b/tests/test_real_example_cohorts.py index cdbc4d46..281c45fd 100644 --- a/tests/test_real_example_cohorts.py +++ b/tests/test_real_example_cohorts.py @@ -13,7 +13,7 @@ import textwrap from difflib import unified_diff from pathlib import Path -from typing import Dict, Optional, Tuple +from typing import Optional import pytest @@ -71,7 +71,7 @@ def get_reference_sql(cohort_name: str) -> Optional[str]: return None -def generate_python_outputs(cohort_file: Path) -> Tuple[Optional[str], Optional[str]]: +def generate_python_outputs(cohort_file: Path) -> tuple[Optional[str], Optional[str]]: """ Run Python reference implementation to generate SQL. @@ -412,10 +412,10 @@ def test_sql_matches_reference(cohort_name): # ============================================================================= # Cache for generated markdown to avoid redundant work -_MARKDOWN_CACHE: Dict[str, Tuple[Optional[str], Optional[str]]] = {} +_MARKDOWN_CACHE: dict[str, tuple[Optional[str], Optional[str]]] = {} -def get_generated_markdown(cohort_name: str) -> Tuple[Optional[str], Optional[str]]: +def get_generated_markdown(cohort_name: str) -> tuple[Optional[str], Optional[str]]: """ Get generated markdown for a cohort, using cache if available. """ @@ -561,7 +561,7 @@ def analyze_markdown_differences(py_md: str, ref_md: str) -> list: py_normalized = normalize_markdown(py_md) - for pattern, name in sections: + for pattern, _name in sections: if pattern not in py_normalized: pass diff --git a/tests/test_simple_sql_builders.py b/tests/test_simple_sql_builders.py index 829ecb8a..fa130668 100644 --- a/tests/test_simple_sql_builders.py +++ b/tests/test_simple_sql_builders.py @@ -30,7 +30,7 @@ class TestBasicSqlBuilderFunctionality: def test_dose_era_sql_builder_basic(self): """Test basic DoseEraSqlBuilder functionality.""" builder = DoseEraSqlBuilder() - criteria = DoseEra(first=False) + DoseEra(first=False) # Test basic methods assert isinstance(builder.get_query_template(), str) @@ -50,7 +50,7 @@ def test_dose_era_sql_builder_basic(self): def test_observation_period_sql_builder_basic(self): """Test basic ObservationPeriodSqlBuilder functionality.""" builder = ObservationPeriodSqlBuilder() - criteria = ObservationPeriod() + ObservationPeriod() # Test basic methods assert isinstance(builder.get_query_template(), str) @@ -66,7 +66,7 @@ def test_observation_period_sql_builder_basic(self): def test_payer_plan_period_sql_builder_basic(self): """Test basic PayerPlanPeriodSqlBuilder functionality.""" builder = PayerPlanPeriodSqlBuilder() - criteria = PayerPlanPeriod() + PayerPlanPeriod() # Test basic methods assert isinstance(builder.get_query_template(), str) @@ -82,7 +82,7 @@ def test_payer_plan_period_sql_builder_basic(self): def test_visit_detail_sql_builder_basic(self): """Test basic VisitDetailSqlBuilder functionality.""" builder = VisitDetailSqlBuilder() - criteria = VisitDetail(visit_detail_type_exclude=False) + VisitDetail(visit_detail_type_exclude=False) # Test basic methods assert isinstance(builder.get_query_template(), str) @@ -102,7 +102,7 @@ def test_visit_detail_sql_builder_basic(self): def test_location_region_sql_builder_basic(self): """Test basic LocationRegionSqlBuilder functionality.""" builder = LocationRegionSqlBuilder() - criteria = LocationRegion() + LocationRegion() # Test basic methods assert isinstance(builder.get_query_template(), str) diff --git a/tests/test_utils_db.py b/tests/test_utils_db.py index 93e4e855..1025d6bf 100644 --- a/tests/test_utils_db.py +++ b/tests/test_utils_db.py @@ -1,4 +1,4 @@ -from typing import Any, List +from typing import Any import duckdb import pytest @@ -60,7 +60,7 @@ def translate_sql(self, sql: str) -> str: # Simple translation pipeline try: # Parse as T-SQL - expression = sqlglot.parse(sql, read="tsql") + sqlglot.parse(sql, read="tsql") # Additional transformations if needed for DuckDB specific quirks # (e.g. date math, string formatting) @@ -91,7 +91,7 @@ def execute_query(self, sql: str): return self.con.execute(translated) - def query(self, sql: str) -> List[Any]: + def query(self, sql: str) -> list[Any]: """Execute and return results.""" return self.execute_query(sql).fetchall() From e3423ff06813cb875f318547afbd5fd50476895f Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 12:01:35 -0700 Subject: [PATCH 12/62] more ruff reformatting and checks --- circe/__init__.py | 30 +++---------- circe/check/checker.py | 1 - circe/check/checkers/comparisons.py | 42 ++++++++++++------- circe/check/checkers/domain_type_check.py | 1 - .../checkers/duplicates_criteria_check.py | 20 ++++++--- .../checkers/events_progression_check.py | 5 +-- circe/check/checkers/incomplete_rule_check.py | 1 - circe/check/checkers/unused_concepts_check.py | 20 ++++----- .../operations/conditional_operations.py | 5 ++- .../check/operations/executive_operations.py | 9 ++-- circe/check/utils/criteria_name_helper.py | 9 ++-- .../builders/device_exposure.py | 1 - circe/cohortdefinition/code_generator.py | 1 - .../cohort_expression_query_builder.py | 32 ++++++-------- .../concept_set_expression_query_builder.py | 1 - circe/cohortdefinition/criteria.py | 10 ++--- .../printfriendly/markdown_render.py | 5 +-- circe/execution/build_context.py | 5 +-- circe/execution/builders/__init__.py | 34 +++++++-------- circe/execution/builders/pipeline.py | 30 ++++++------- .../concept_set_expression_query_builder.py | 1 - debug_app/app.py | 4 +- docs/conf.py | 4 +- examples/generate_sql.py | 5 +-- pyproject.toml | 6 +-- scripts/generate_skill_backup.py | 9 ++-- tests/test_cli.py | 17 ++++---- tests/test_documentation.py | 36 ---------------- tests/test_real_example_cohorts.py | 18 ++++---- tests/test_utils_db.py | 2 +- 30 files changed, 149 insertions(+), 215 deletions(-) diff --git a/circe/__init__.py b/circe/__init__.py index 924f4025..6d1044e5 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -19,7 +19,7 @@ License: Apache License 2.0 """ -__version__ = "0.1.0" +__version__ = "0.2.0" __author__ = "CIRCE Python Implementation Team" __email__ = "circe-python@ohdsi.org" __license__ = "Apache License 2.0" @@ -27,32 +27,26 @@ import importlib import inspect import pkgutil +from contextlib import suppress # --------------------------------------------------------------------- # Embedded interpreter (e.g. R reticulate) bootstrapping for Pydantic # --------------------------------------------------------------------- -import sys -from typing import Dict - from pydantic import BaseModel -import circe as package from circe.cohortdefinition import ( CohortExpression, CollapseSettings, - CollapseType, ConceptSetSelection, ConditionEra, ConditionOccurrence, CorelatedCriteria, Criteria, - CriteriaColumn, CriteriaGroup, CustomEraStrategy, DateAdjustment, DateOffsetStrategy, DateRange, - DateType, Death, DemographicCriteria, DeviceExposure, @@ -88,9 +82,6 @@ cohort_expression_from_json, cohort_print_friendly, ) - -# Main exports -from .cohortdefinition import CohortExpression from .execution import ( ExecutionOptions, IbisExecutor, @@ -108,30 +99,21 @@ def safe_model_rebuild(package): In embedded environments like R's reticulate, this avoids 'ValueError: call stack is not deep enough' during instantiation. """ - try: + with suppress(Exception): for _loader, module_name, _is_pkg in pkgutil.walk_packages( package.__path__, package.__name__ + "." ): - try: + with suppress(ImportError): mod = importlib.import_module(module_name) - except ImportError: - continue for _name, obj in inspect.getmembers(mod): if inspect.isclass(obj) and issubclass(obj, BaseModel): - try: + with suppress(Exception): # Rebuild Pydantic v2 models obj.model_rebuild(raise_errors=False) # Eager instantiation to trigger lazy resolution early - try: + with suppress(Exception): obj() - except Exception: - # Ignore models requiring mandatory args - pass - except Exception: - pass - except Exception: - pass def get_json_schema() -> dict: diff --git a/circe/check/checker.py b/circe/check/checker.py index 37de87e5..ecc65243 100644 --- a/circe/check/checker.py +++ b/circe/check/checker.py @@ -9,7 +9,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ - from .check import Check from .warning import Warning diff --git a/circe/check/checkers/comparisons.py b/circe/check/checkers/comparisons.py index 10d645c1..0dab831b 100644 --- a/circe/check/checkers/comparisons.py +++ b/circe/check/checkers/comparisons.py @@ -191,18 +191,17 @@ def compare_concept_set(source: "ConceptSet"): def compare_func(concept_set: "ConceptSet") -> bool: if concept_set.expression == source.expression: return True - if concept_set.expression and source.expression: - if len(concept_set.expression.items) == len(source.expression.items): - source_concepts = [item.concept for item in source.expression.items] - return all( - any( - Comparisons.compare_concept(concept)(source_concept) - for source_concept in source_concepts - ) - for concept in [ - item.concept for item in concept_set.expression.items - ] + if concept_set.expression and source.expression and len(concept_set.expression.items) == len(source.expression.items): + source_concepts = [item.concept for item in source.expression.items] + return all( + any( + Comparisons.compare_concept(concept)(source_concept) + for source_concept in source_concepts ) + for concept in [ + item.concept for item in concept_set.expression.items + ] + ) return False return compare_func @@ -238,7 +237,7 @@ def compare_criteria(c1: "Criteria", c2: "Criteria") -> bool: Returns: True if the criteria are the same type and have the same codeset ID """ - if type(c1) != type(c2): + if type(c1) is not type(c2): return False # Import here to avoid circular dependencies @@ -258,8 +257,23 @@ def compare_criteria(c1: "Criteria", c2: "Criteria") -> bool: VisitOccurrence, ) - if ( - isinstance(c1, (ConditionEra, ConditionOccurrence, Death, DeviceExposure, DoseEra, DrugEra, DrugExposure, Measurement, Observation, ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail)) + if isinstance( + c1, + ( + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + ), ): return c1.codeset_id == c2.codeset_id diff --git a/circe/check/checkers/domain_type_check.py b/circe/check/checkers/domain_type_check.py index 4dbe042b..15f7d477 100644 --- a/circe/check/checkers/domain_type_check.py +++ b/circe/check/checkers/domain_type_check.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ - from ..operations.operations import Operations from ..utils.criteria_name_helper import CriteriaNameHelper from ..warning_severity import WarningSeverity diff --git a/circe/check/checkers/duplicates_criteria_check.py b/circe/check/checkers/duplicates_criteria_check.py index e1c6eeac..3a981662 100644 --- a/circe/check/checkers/duplicates_criteria_check.py +++ b/circe/check/checkers/duplicates_criteria_check.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ - from ..utils.criteria_name_helper import CriteriaNameHelper from ..warning_severity import WarningSeverity from .base_criteria_check import BaseCriteriaCheck @@ -78,7 +77,7 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: Returns: True if the criteria are duplicates, False otherwise """ - if type(c1) != type(c2): + if type(c1) is not type(c2): return False # Import here to avoid circular dependencies @@ -107,8 +106,17 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: c1.codeset_id == c2.codeset_id and c1.condition_source_concept == c2.condition_source_concept ) - elif ( - isinstance(c1, (Death, DeviceExposure, DoseEra, DrugEra, DrugExposure, Measurement, Observation)) + elif isinstance( + c1, + ( + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ), ): return c1.codeset_id == c2.codeset_id elif isinstance(c1, ObservationPeriod): @@ -118,8 +126,8 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: and self._compare_objects(c1.period_end_date, c2.period_end_date) and self._compare_objects(c1.period_length, c2.period_length) ) - elif ( - isinstance(c1, (ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail)) + elif isinstance( + c1, (ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail) ): return c1.codeset_id == c2.codeset_id elif isinstance(c1, PayerPlanPeriod): diff --git a/circe/check/checkers/events_progression_check.py b/circe/check/checkers/events_progression_check.py index 72d92e7f..92f21d6d 100644 --- a/circe/check/checkers/events_progression_check.py +++ b/circe/check/checkers/events_progression_check.py @@ -114,10 +114,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N cohort_initial_weight = self._get_weight(expression.qualified_limit) # Qualifying limit is ignored when no additionalCriteria specified - if expression.additional_criteria is not None: - qualifying_weight = self._get_weight(expression.expression_limit) - else: - qualifying_weight = LimitType.NONE.weight + qualifying_weight = self._get_weight(expression.expression_limit) if expression.additional_criteria is not None else LimitType.NONE.weight if initial_weight - cohort_initial_weight < 0: reporter(self.WARNING, "Cohort of initial events") diff --git a/circe/check/checkers/incomplete_rule_check.py b/circe/check/checkers/incomplete_rule_check.py index 2f304419..0bfe417b 100644 --- a/circe/check/checkers/incomplete_rule_check.py +++ b/circe/check/checkers/incomplete_rule_check.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ - from ..warning import Warning from ..warning_severity import WarningSeverity from ..warnings.incomplete_rule_warning import IncompleteRuleWarning diff --git a/circe/check/checkers/unused_concepts_check.py b/circe/check/checkers/unused_concepts_check.py index 0ec13faf..8197a9fc 100644 --- a/circe/check/checkers/unused_concepts_check.py +++ b/circe/check/checkers/unused_concepts_check.py @@ -124,11 +124,10 @@ def _is_used( True if the concept set is used, False otherwise """ # Check primary criteria - if expression.primary_criteria and expression.primary_criteria.criteria_list: - if self._is_concept_set_used( - concept_set, expression.primary_criteria.criteria_list - ): - return True + if expression.primary_criteria and expression.primary_criteria.criteria_list and self._is_concept_set_used( + concept_set, expression.primary_criteria.criteria_list + ): + return True # Check additional criteria if self._is_concept_set_used(concept_set, additional_criteria): @@ -157,16 +156,11 @@ def _is_used( return True # Check end strategy (CustomEraStrategy) - if isinstance(expression.end_strategy, CustomEraStrategy): - if expression.end_strategy.drug_codeset_id == concept_set.id: - return True + if isinstance(expression.end_strategy, CustomEraStrategy) and expression.end_strategy.drug_codeset_id == concept_set.id: + return True # Check censoring criteria - if expression.censoring_criteria: - if self._is_concept_set_used(concept_set, expression.censoring_criteria): - return True - - return False + return bool(expression.censoring_criteria and self._is_concept_set_used(concept_set, expression.censoring_criteria)) def _is_concept_set_used(self, concept_set: "ConceptSet", target) -> bool: """Check if a concept set is used (supports both List[Criteria] and CriteriaGroup). diff --git a/circe/check/operations/conditional_operations.py b/circe/check/operations/conditional_operations.py index 74b1e22c..700aadbd 100644 --- a/circe/check/operations/conditional_operations.py +++ b/circe/check/operations/conditional_operations.py @@ -9,7 +9,10 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Callable, Generic, Protocol, TypeVar +from typing import TYPE_CHECKING, Callable, Generic, Protocol, TypeVar + +if TYPE_CHECKING: + from .executive_operations import ExecutiveOperations T = TypeVar("T") V = TypeVar("V") diff --git a/circe/check/operations/executive_operations.py b/circe/check/operations/executive_operations.py index 8d1c7387..aa1ce75a 100644 --- a/circe/check/operations/executive_operations.py +++ b/circe/check/operations/executive_operations.py @@ -9,13 +9,14 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Callable, Generic, Protocol, TypeVar +from typing import Callable, Generic, Protocol, TypeVar, overload -T = TypeVar("T") -V = TypeVar("V") from .conditional_operations import ConditionalOperations from .execution import Execution +T = TypeVar("T") +V = TypeVar("V") + class ExecutiveOperations(Protocol, Generic[T, V]): """Interface for executive operations in pattern matching. @@ -26,6 +27,7 @@ class ExecutiveOperations(Protocol, Generic[T, V]): pattern matching conditions are met. """ + @overload def then(self, consumer: Callable[[T], None]) -> ConditionalOperations[T, V]: """Execute a consumer function if the condition was met. @@ -37,6 +39,7 @@ def then(self, consumer: Callable[[T], None]) -> ConditionalOperations[T, V]: """ ... + @overload def then(self, execution: Execution) -> ConditionalOperations[T, V]: """Execute an Execution if the condition was met. diff --git a/circe/check/utils/criteria_name_helper.py b/circe/check/utils/criteria_name_helper.py index 539df5a4..295e5d83 100644 --- a/circe/check/utils/criteria_name_helper.py +++ b/circe/check/utils/criteria_name_helper.py @@ -9,11 +9,13 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from contextlib import suppress + from ..constants import Constants from ..operations.operations import Operations # Import at runtime to avoid circular dependencies -try: +with suppress(ImportError): from ...cohortdefinition.criteria import ( ConditionEra, ConditionOccurrence, @@ -32,11 +34,6 @@ VisitDetail, VisitOccurrence, ) -except ImportError: - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - pass class CriteriaNameHelper: diff --git a/circe/cohortdefinition/builders/device_exposure.py b/circe/cohortdefinition/builders/device_exposure.py index 12a6c5dd..c98a7b1d 100644 --- a/circe/cohortdefinition/builders/device_exposure.py +++ b/circe/cohortdefinition/builders/device_exposure.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ - from ..criteria import DeviceExposure from .base import CriteriaSqlBuilder from .utils import BuilderOptions, BuilderUtils, CriteriaColumn diff --git a/circe/cohortdefinition/code_generator.py b/circe/cohortdefinition/code_generator.py index c20a75e4..864bcbdf 100644 --- a/circe/cohortdefinition/code_generator.py +++ b/circe/cohortdefinition/code_generator.py @@ -34,7 +34,6 @@ def _collect_imports(o: Any): # and maybe return imports separately? # Let's do the string generation directly. - # We will build a set of required imports as we traverse required_classes = set() diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index ce3f9d65..af63dc17 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -92,7 +92,7 @@ def from_json(cls, json_str: str) -> "BuildExpressionQueryOptions": options.generate_stats = data.get("generateStats", False) return options except Exception as e: - raise RuntimeError("Error parsing expression query options", e) + raise RuntimeError("Error parsing expression query options") from e class CohortExpressionQueryBuilder( @@ -129,16 +129,16 @@ class CohortExpressionQueryBuilder( select person_id, start_date, end_date INTO #cohort_rows from ( -- first_ends - select F.person_id, F.start_date, F.end_date - FROM ( - select I.event_id, I.person_id, I.start_date, CE.end_date, row_number() over (partition by I.person_id, I.event_id order by CE.end_date) as ordinal - from #included_events I - join ( -- cohort_ends + select F.person_id, F.start_date, F.end_date + FROM ( + select I.event_id, I.person_id, I.start_date, CE.end_date, row_number() over (partition by I.person_id, I.event_id order by CE.end_date) as ordinal + from #included_events I + join ( -- cohort_ends -- cohort exit dates @cohort_end_unions ) CE on I.event_id = CE.event_id and I.person_id = CE.person_id and CE.end_date >= I.start_date - ) F - WHERE F.ordinal = 1 + ) F + WHERE F.ordinal = 1 ) FE; @@ -676,10 +676,7 @@ def get_inclusion_rule_table_sql(self, expression: CohortExpression) -> str: ] # Join with UNION ALL - match Java behavior (no UNION ALL for single rule) - if len(union_list) == 1: - union_query = union_list[0] - else: - union_query = " UNION ALL ".join(union_list) + union_query = union_list[0] if len(union_list) == 1 else " UNION ALL ".join(union_list) return self.INCLUSION_RULE_TEMP_TABLE_TEMPLATE.replace( "@inclusionRuleUnions", union_query @@ -1205,12 +1202,7 @@ def get_demographic_criteria_query( ) ) - if where_clauses: - query = query.replace( - "@whereClause", "WHERE " + " AND ".join(where_clauses) - ) - else: - query = query.replace("@whereClause", "") + query = query.replace("@whereClause", "WHERE " + " AND ".join(where_clauses)) if where_clauses else query.replace("@whereClause", "") return query @@ -1301,7 +1293,7 @@ def _get_windowed_criteria_query_internal( except Exception as e: raise ValueError( f"Failed to deserialize criteria from dict: {criteria_type} - {e}" - ) + ) from e else: raise ValueError(f"Unknown criteria type in dict: {criteria_type}") else: @@ -1633,7 +1625,7 @@ def get_criteria_sql( except Exception as e: raise ValueError( f"Failed to deserialize criteria from dict: {criteria_type} - {e}" - ) + ) from e else: raise ValueError(f"Unknown criteria type in dict: {criteria_type}") else: diff --git a/circe/cohortdefinition/concept_set_expression_query_builder.py b/circe/cohortdefinition/concept_set_expression_query_builder.py index 59d476f5..ccc134f5 100644 --- a/circe/cohortdefinition/concept_set_expression_query_builder.py +++ b/circe/cohortdefinition/concept_set_expression_query_builder.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ - from ..vocabulary.concept import Concept, ConceptSetExpression from .builders.utils import BuilderUtils diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index 92e55dc8..3709cede 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -1387,7 +1387,7 @@ def normalize_window(window_dict: dict) -> dict: c_data, strict=False ) item_copy["criteria"] = c_obj - except: + except Exception: pass if "Occurrence" in item_copy: @@ -1402,7 +1402,7 @@ def normalize_window(window_dict: dict) -> dict: try: deserialized.append(CorelatedCriteria.model_validate(item_copy)) - except: + except Exception: deserialized.append(item) elif any( @@ -1484,7 +1484,7 @@ def normalize_window(window_dict: dict) -> dict: deserialized.append( CorelatedCriteria.model_validate(corelated_dict) ) - except: + except Exception: deserialized.append(item) else: deserialized.append(item) @@ -1524,7 +1524,7 @@ def normalize_window(window_dict: dict) -> dict: deserialized.append( CorelatedCriteria.model_validate(corelated_dict) ) - except: + except Exception: deserialized.append(item) else: deserialized.append(item) @@ -1656,7 +1656,7 @@ def deserialize_criteria_list(cls, v: Any) -> Any: obj = NAMES_TO_CLASSES[c_type].model_validate(c_data, strict=False) deserialized.append(obj) - except: + except Exception: deserialized.append(item) else: deserialized.append(item) diff --git a/circe/cohortdefinition/printfriendly/markdown_render.py b/circe/cohortdefinition/printfriendly/markdown_render.py index 3b8eee98..af374447 100644 --- a/circe/cohortdefinition/printfriendly/markdown_render.py +++ b/circe/cohortdefinition/printfriendly/markdown_render.py @@ -127,10 +127,7 @@ def render_concept_set_list( # Handle JSON string input if isinstance(concept_sets, str): data = json.loads(concept_sets) - if isinstance(data, list): - concept_sets = [ConceptSet.model_validate(item) for item in data] - else: - concept_sets = [ConceptSet.model_validate(data)] + concept_sets = [ConceptSet.model_validate(item) for item in data] if isinstance(data, list) else [ConceptSet.model_validate(data)] if not concept_sets: return "No concept sets specified.\n" diff --git a/circe/execution/build_context.py b/circe/execution/build_context.py index a491daec..a72b727e 100644 --- a/circe/execution/build_context.py +++ b/circe/execution/build_context.py @@ -376,10 +376,7 @@ def compile_codesets( if compiled_expr is not None: compiled.append(compiled_expr) - if not compiled: - compiled_expr = _empty_codeset_table() - else: - compiled_expr = _union_all(compiled).distinct() + compiled_expr = _empty_codeset_table() if not compiled else _union_all(compiled).distinct() if not options.materialize_codesets: return CodesetResource(table=compiled_expr) diff --git a/circe/execution/builders/__init__.py b/circe/execution/builders/__init__.py index e194e216..4ebca4e9 100644 --- a/circe/execution/builders/__init__.py +++ b/circe/execution/builders/__init__.py @@ -1,19 +1,19 @@ from . import ( - condition_era, # noqa: F401 - condition_occurrence, # noqa: F401 - death, # noqa: F401 - device_exposure, # noqa: F401 - dose_era, # noqa: F401 - drug_era, # noqa: F401 - drug_exposure, # noqa: F401 - measurement, # noqa: F401 - observation, # noqa: F401 - observation_period, # noqa: F401 - payer_plan_period, # noqa: F401 - procedure_occurrence, # noqa: F401 - specimen, # noqa: F401 - visit_detail, # noqa: F401 - visit_occurrence, # noqa: F401 + condition_era, + condition_occurrence, + death, + device_exposure, + dose_era, + drug_era, + drug_exposure, + measurement, + observation, + observation_period, + payer_plan_period, + procedure_occurrence, + specimen, + visit_detail, + visit_occurrence, ) -from .pipeline import build_primary_events # noqa: F401 -from .registry import build_events, register # noqa: F401 +from .pipeline import build_primary_events +from .registry import build_events, register diff --git a/circe/execution/builders/pipeline.py b/circe/execution/builders/pipeline.py index d666cdec..c4d15018 100644 --- a/circe/execution/builders/pipeline.py +++ b/circe/execution/builders/pipeline.py @@ -8,21 +8,21 @@ from ...cohortdefinition import CohortExpression from ..build_context import BuildContext from . import ( - condition_era, # noqa: F401 - condition_occurrence, # noqa: F401 - death, # noqa: F401 - device_exposure, # noqa: F401 - dose_era, # noqa: F401 - drug_era, # noqa: F401 - drug_exposure, # noqa: F401 - measurement, # noqa: F401 - observation, # noqa: F401 - observation_period, # noqa: F401 - payer_plan_period, # noqa: F401 - procedure_occurrence, # noqa: F401 - specimen, # noqa: F401 - visit_detail, # noqa: F401 - visit_occurrence, # noqa: F401 + condition_era, + condition_occurrence, + death, + device_exposure, + dose_era, + drug_era, + drug_exposure, + measurement, + observation, + observation_period, + payer_plan_period, + procedure_occurrence, + specimen, + visit_detail, + visit_occurrence, ) from .common import ( apply_end_strategy, diff --git a/circe/vocabulary/concept_set_expression_query_builder.py b/circe/vocabulary/concept_set_expression_query_builder.py index d5f1d847..612a866e 100644 --- a/circe/vocabulary/concept_set_expression_query_builder.py +++ b/circe/vocabulary/concept_set_expression_query_builder.py @@ -8,7 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ - from ..cohortdefinition.builders.utils import BuilderUtils from .concept import Concept, ConceptSetExpression diff --git a/debug_app/app.py b/debug_app/app.py index e8fc6153..a0a44baf 100644 --- a/debug_app/app.py +++ b/debug_app/app.py @@ -73,7 +73,7 @@ def toggle_override(): try: with open(USER_OVERRIDES_FILE) as f: overrides = json.load(f) - except: + except Exception: pass overrides[filename] = is_ok @@ -116,7 +116,7 @@ def cohort_view(filename): with open(USER_OVERRIDES_FILE) as f: overrides = json.load(f) is_user_ok = overrides.get(filename, False) - except: + except Exception: pass return render_template( diff --git a/docs/conf.py b/docs/conf.py index 32a2b5f0..a04a5c6e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,8 +13,8 @@ project = "OHDSI CIRCE Python" copyright = "2024, OHDSI Community" author = "CIRCE Python Implementation Team" -release = "0.1.0" -version = "0.1.0" +release = "0.2.0" +version = "0.2.0" # -- General configuration --------------------------------------------------- extensions = [ diff --git a/examples/generate_sql.py b/examples/generate_sql.py index 5e5a84a5..abc79c83 100644 --- a/examples/generate_sql.py +++ b/examples/generate_sql.py @@ -73,10 +73,7 @@ def generate_sql_with_templates(cohort): primary_events_sql = builder.get_primary_events_query(cohort.primary_criteria) # Generate inclusion rules - if cohort.inclusion_rules: - inclusion_rules_sql = builder.get_inclusion_rule_table_sql(cohort) - else: - inclusion_rules_sql = "-- No inclusion rules defined" + inclusion_rules_sql = builder.get_inclusion_rule_table_sql(cohort) if cohort.inclusion_rules else "-- No inclusion rules defined" return { "codeset": codeset_sql, diff --git a/pyproject.toml b/pyproject.toml index 5c7e847e..515daf4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -171,9 +171,8 @@ markers = [ ] [tool.ruff] -# Same as Black. -line-length = 88 -target-version = "py39" +# Allow longer lines for code and docstrings. +line-length = 150 # Exclude directories extend-exclude = [ @@ -186,6 +185,7 @@ extend-exclude = [ "build", "dist", "circe-be", + "tests", ] [tool.ruff.lint] diff --git a/scripts/generate_skill_backup.py b/scripts/generate_skill_backup.py index 3d37756c..aa21ff7b 100644 --- a/scripts/generate_skill_backup.py +++ b/scripts/generate_skill_backup.py @@ -60,10 +60,7 @@ def extract_method_info(self, cls, method_name: str) -> MethodInfo: # Get return type return_annotation = sig.return_annotation - if return_annotation == inspect.Signature.empty: - return_type = "Unknown" - else: - return_type = str(return_annotation).replace("'", "") + return_type = "Unknown" if return_annotation == inspect.Signature.empty else str(return_annotation).replace("'", "") # Build parameter list params = [] @@ -175,7 +172,9 @@ def discover_methods(self): ) # BaseQuery time windows - for name, _method in inspect.getmembers(BaseQuery, predicate=inspect.isfunction): + for name, _method in inspect.getmembers( + BaseQuery, predicate=inspect.isfunction + ): if name in [ "within_days_before", "within_days_after", diff --git a/tests/test_cli.py b/tests/test_cli.py index 7b1af929..678f5a29 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -59,15 +59,14 @@ def run_python_cli_in_process(args: list[str]) -> tuple[int, str, str]: stdout = StringIO() stderr = StringIO() - with patch("sys.argv", ["circe"] + args): - with redirect_stdout(stdout), redirect_stderr(stderr): - try: - exit_code = main() or 0 - except SystemExit as e: - exit_code = e.code - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - exit_code = 1 + with patch("sys.argv", ["circe"] + args), redirect_stdout(stdout), redirect_stderr(stderr): + try: + exit_code = main() or 0 + except SystemExit as e: + exit_code = e.code + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + exit_code = 1 return exit_code, stdout.getvalue(), stderr.getvalue() diff --git a/tests/test_documentation.py b/tests/test_documentation.py index bd853e99..6a304b2e 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -108,42 +108,6 @@ def test_installation_instructions_present(self): assert "git clone" in contributing.lower() assert "pip install" in contributing.lower() - def test_pypi_marked_as_coming_soon(self): - """Verify PyPI installation is marked as coming soon, not as primary method.""" - root = self.get_project_root() - - files_to_check = [ - root / "README.md", - root / "INSTALLATION.md", - root / "examples" / "README.md", - ] - - for file_path in files_to_check: - if not file_path.exists(): - continue - - content = file_path.read_text() - - # If PyPI is mentioned, it should be marked as coming soon - if "pip install ohdsi-circe-python-alpha" in content: - # Find context around pip install ohdsi-circe-python-alpha - lines = content.split("\n") - for i, line in enumerate(lines): - if "pip install ohdsi-circe-python-alpha" in line: - # Check surrounding lines for "coming soon" or similar - context = "\n".join(lines[max(0, i - 5) : i + 5]) - assert any( - marker in context.lower() - for marker in [ - "coming soon", - "not yet available", - "future release", - "[!note]", - ] - ), ( - f"PyPI installation in {file_path.name} not clearly marked as coming soon (line {i + 1})" - ) - def test_internal_links_valid(self): """Verify internal documentation links are valid.""" root = self.get_project_root() diff --git a/tests/test_real_example_cohorts.py b/tests/test_real_example_cohorts.py index 281c45fd..ded23c7c 100644 --- a/tests/test_real_example_cohorts.py +++ b/tests/test_real_example_cohorts.py @@ -9,6 +9,7 @@ """ import difflib +import random import re import textwrap from difflib import unified_diff @@ -29,8 +30,6 @@ COHORTS_DIR = Path(__file__).parent / "cohorts" REFERENCE_DIR = COHORTS_DIR / "reference_outputs" -# Dynamic discovery of cohort files -import random def get_target_cohort_files(config): @@ -275,14 +274,13 @@ def analyze_sql_differences(py_sql: str, ref_sql: str) -> list: ) # Check for source concept handling - if "source_concept_id" in ref_sql.lower() or "source_value" in ref_sql.lower(): - if ( - "source_concept_id" not in py_sql.lower() - and "source_value" not in py_sql.lower() - ): - issues.append( - "Missing source concept handling - ConditionSourceConcept may not be implemented" - ) + if ("source_concept_id" in ref_sql.lower() or "source_value" in ref_sql.lower()) and ( + "source_concept_id" not in py_sql.lower() + and "source_value" not in py_sql.lower() + ): + issues.append( + "Missing source concept handling - ConditionSourceConcept may not be implemented" + ) return issues diff --git a/tests/test_utils_db.py b/tests/test_utils_db.py index 1025d6bf..64de2a19 100644 --- a/tests/test_utils_db.py +++ b/tests/test_utils_db.py @@ -74,7 +74,7 @@ def translate_sql(self, sql: str) -> str: return sqlglot.transpile(sql, read="tsql", write="duckdb")[0] except Exception as e: print(f"FAILED SQL:\n{sql}") - raise RuntimeError(f"Translation failed: {e}") + raise RuntimeError(f"Translation failed: {e}") from e def execute_query(self, sql: str): """Execute translated query.""" From 4dfbbc83e3f3d88acd315d9ff7cb53865c41112f Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 12:04:34 -0700 Subject: [PATCH 13/62] Actions fix --- .github/workflows/basic_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/basic_tests.yml b/.github/workflows/basic_tests.yml index 717a31ee..ff2afb5f 100644 --- a/.github/workflows/basic_tests.yml +++ b/.github/workflows/basic_tests.yml @@ -31,7 +31,7 @@ jobs: run: tox - name: Upload coverage to Codecov - if: secrets.CODECOV_TOKEN != '' + if: ${{ secrets.CODECOV_TOKEN != '' }} continue-on-error: true uses: codecov/codecov-action@v4 with: From 4492374a36aca785cd2b7a06f254e6b1666d0d75 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 12:05:53 -0700 Subject: [PATCH 14/62] Actions fix - again --- .github/workflows/basic_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/basic_tests.yml b/.github/workflows/basic_tests.yml index ff2afb5f..c392888e 100644 --- a/.github/workflows/basic_tests.yml +++ b/.github/workflows/basic_tests.yml @@ -31,7 +31,7 @@ jobs: run: tox - name: Upload coverage to Codecov - if: ${{ secrets.CODECOV_TOKEN != '' }} + if: ${{ secrets.CODECOV_TOKEN }} continue-on-error: true uses: codecov/codecov-action@v4 with: From 20bb89f61f822dfc14adbe6ebd0d1a6e9c1ce82d Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 12:07:20 -0700 Subject: [PATCH 15/62] remove if statement for actions --- .github/workflows/basic_tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/basic_tests.yml b/.github/workflows/basic_tests.yml index c392888e..41eb29ee 100644 --- a/.github/workflows/basic_tests.yml +++ b/.github/workflows/basic_tests.yml @@ -31,7 +31,6 @@ jobs: run: tox - name: Upload coverage to Codecov - if: ${{ secrets.CODECOV_TOKEN }} continue-on-error: true uses: codecov/codecov-action@v4 with: From 0c489cb59db3fa7fab32c29776c5eabfead080cb Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 12:48:50 -0700 Subject: [PATCH 16/62] most ruff problems resovled --- circe/check/checkers/base_criteria_check.py | 2 +- circe/check/checkers/base_value_check.py | 3 - .../checkers/concept_set_criteria_check.py | 21 +-- circe/check/checkers/domain_type_check.py | 18 +- .../checkers/duplicates_concept_set_check.py | 8 +- circe/check/checkers/ocurrence_check.py | 12 +- circe/check/checkers/range_check.py | 3 +- circe/check/checkers/range_checker_factory.py | 3 +- circe/check/utils/criteria_name_helper.py | 19 +-- .../builders/device_exposure.py | 3 +- circe/cohortdefinition/builders/utils.py | 27 ++- circe/cohortdefinition/code_generator.py | 4 +- circe/cohortdefinition/cohort.py | 12 +- .../cohort_expression_query_builder.py | 115 +++++++++---- circe/cohortdefinition/interfaces.py | 160 +++++------------- circe/execution/builders/__init__.py | 22 +++ circe/execution/builders/pipeline.py | 17 -- 17 files changed, 174 insertions(+), 275 deletions(-) diff --git a/circe/check/checkers/base_criteria_check.py b/circe/check/checkers/base_criteria_check.py index 5a1cf8de..ad369f42 100644 --- a/circe/check/checkers/base_criteria_check.py +++ b/circe/check/checkers/base_criteria_check.py @@ -14,7 +14,7 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import CorelatedCriteria, Criteria + from ...cohortdefinition.criteria import Criteria except ImportError: from typing import TYPE_CHECKING diff --git a/circe/check/checkers/base_value_check.py b/circe/check/checkers/base_value_check.py index b1c33063..1bba2111 100644 --- a/circe/check/checkers/base_value_check.py +++ b/circe/check/checkers/base_value_check.py @@ -18,10 +18,7 @@ try: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import ( - CorelatedCriteria, - Criteria, CriteriaGroup, - DemographicCriteria, PrimaryCriteria, ) except ImportError: diff --git a/circe/check/checkers/concept_set_criteria_check.py b/circe/check/checkers/concept_set_criteria_check.py index 26899f05..fa1ea016 100644 --- a/circe/check/checkers/concept_set_criteria_check.py +++ b/circe/check/checkers/concept_set_criteria_check.py @@ -17,29 +17,12 @@ # Import at runtime to avoid circular dependencies try: - from ...cohortdefinition.criteria import ( - ConditionEra, - ConditionOccurrence, - Criteria, - Death, - DeviceExposure, - DoseEra, - DrugEra, - DrugExposure, - Measurement, - Observation, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, - ) + from ...cohortdefinition.criteria import Criteria except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: - from ...cohortdefinition.criteria import ( - Criteria, - ) + from ...cohortdefinition.criteria import Criteria class ConceptSetCriteriaCheck(BaseCriteriaCheck): diff --git a/circe/check/checkers/domain_type_check.py b/circe/check/checkers/domain_type_check.py index 15f7d477..12596be4 100644 --- a/circe/check/checkers/domain_type_check.py +++ b/circe/check/checkers/domain_type_check.py @@ -17,27 +17,13 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import ( - ConditionOccurrence, - Criteria, - Death, - DeviceExposure, - DrugExposure, - Measurement, - Observation, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, - ) + from ...cohortdefinition.criteria import Criteria except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import ( - Criteria, - ) + from ...cohortdefinition.criteria import Criteria class DomainTypeCheck(BaseCriteriaCheck): diff --git a/circe/check/checkers/duplicates_concept_set_check.py b/circe/check/checkers/duplicates_concept_set_check.py index b30f7e37..f782f2df 100644 --- a/circe/check/checkers/duplicates_concept_set_check.py +++ b/circe/check/checkers/duplicates_concept_set_check.py @@ -8,6 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +import contextlib from typing import TYPE_CHECKING from ..warning_severity import WarningSeverity @@ -17,15 +18,10 @@ if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...vocabulary.concept import ConceptSet else: # Import at runtime to avoid circular dependencies - try: + with contextlib.suppress(ImportError): from ...cohortdefinition.cohort import CohortExpression - from ...vocabulary.concept import ConceptSet - except ImportError: - pass - class DuplicatesConceptSetCheck(BaseCheck): """Check for duplicate concept sets. diff --git a/circe/check/checkers/ocurrence_check.py b/circe/check/checkers/ocurrence_check.py index 0e1f2695..2ee2aa12 100644 --- a/circe/check/checkers/ocurrence_check.py +++ b/circe/check/checkers/ocurrence_check.py @@ -8,19 +8,15 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from typing import TYPE_CHECKING + from ..operations.operations import Operations from ..warning_severity import WarningSeverity from .base_corelated_criteria_check import BaseCorelatedCriteriaCheck from .warning_reporter import WarningReporter -# Import at runtime to avoid circular dependencies -try: - from ...cohortdefinition.criteria import CorelatedCriteria, Occurrence -except ImportError: - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from ...cohortdefinition.criteria import CorelatedCriteria +if TYPE_CHECKING: + from ...cohortdefinition.criteria import CorelatedCriteria class OcurrenceCheck(BaseCorelatedCriteriaCheck): diff --git a/circe/check/checkers/range_check.py b/circe/check/checkers/range_check.py index 4f6d522d..492dc8d8 100644 --- a/circe/check/checkers/range_check.py +++ b/circe/check/checkers/range_check.py @@ -17,8 +17,7 @@ # Import at runtime to avoid circular dependencies try: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.core import ObservationFilter, Window - from ...cohortdefinition.criteria import CorelatedCriteria + from ...cohortdefinition.core import ObservationFilter except ImportError: from typing import TYPE_CHECKING diff --git a/circe/check/checkers/range_checker_factory.py b/circe/check/checkers/range_checker_factory.py index 470591df..f0129592 100644 --- a/circe/check/checkers/range_checker_factory.py +++ b/circe/check/checkers/range_checker_factory.py @@ -18,8 +18,7 @@ # Import at runtime to avoid circular dependencies try: - from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.core import DateRange, NumericRange, Period + from ...cohortdefinition.core import Period from ...cohortdefinition.criteria import ( ConditionEra, ConditionOccurrence, diff --git a/circe/check/utils/criteria_name_helper.py b/circe/check/utils/criteria_name_helper.py index 295e5d83..fa3d4845 100644 --- a/circe/check/utils/criteria_name_helper.py +++ b/circe/check/utils/criteria_name_helper.py @@ -16,24 +16,7 @@ # Import at runtime to avoid circular dependencies with suppress(ImportError): - from ...cohortdefinition.criteria import ( - ConditionEra, - ConditionOccurrence, - Death, - DeviceExposure, - DoseEra, - DrugEra, - DrugExposure, - LocationRegion, - Measurement, - Observation, - ObservationPeriod, - PayerPlanPeriod, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, - ) + pass class CriteriaNameHelper: diff --git a/circe/cohortdefinition/builders/device_exposure.py b/circe/cohortdefinition/builders/device_exposure.py index c98a7b1d..0a2083f1 100644 --- a/circe/cohortdefinition/builders/device_exposure.py +++ b/circe/cohortdefinition/builders/device_exposure.py @@ -103,7 +103,8 @@ def resolve_select_clauses( ) else: select_cols.append( - "de.device_exposure_start_date as start_date, COALESCE(de.device_exposure_end_date, DATEADD(day,1,de.device_exposure_start_date)) as end_date" + "de.device_exposure_start_date as start_date, COALESCE(de.device_exposure_end_date, DATEADD(day,1," + \ + "de.device_exposure_start_date)) as end_date" ) return select_cols diff --git a/circe/cohortdefinition/builders/utils.py b/circe/cohortdefinition/builders/utils.py index b8dded9a..4b3a550e 100644 --- a/circe/cohortdefinition/builders/utils.py +++ b/circe/cohortdefinition/builders/utils.py @@ -230,21 +230,18 @@ def build_text_filter_clause( # Escape single quotes in text text = text.replace("'", "''") - if op == "eq": - return f"{column_name} = '{text}'" - elif op == "!eq": - return f"{column_name} <> '{text}'" - elif op == "startsWith": - return f"{column_name} LIKE '{text}%'" - elif op == "endsWith": - return f"{column_name} LIKE '%{text}'" - elif op == "contains": - return f"{column_name} LIKE '%{text}%'" - elif op == "!contains": - return f"{column_name} NOT LIKE '%{text}%'" - else: - # Default to exact match - return f"{column_name} = '{text}'" + # Map operators to SQL templates + operator_templates = { + "eq": f"{column_name} = '{text}'", + "!eq": f"{column_name} <> '{text}'", + "startsWith": f"{column_name} LIKE '{text}%'", + "endsWith": f"{column_name} LIKE '%{text}'", + "contains": f"{column_name} LIKE '%{text}%'", + "!contains": f"{column_name} NOT LIKE '%{text}%'", + } + + # Return template for operator, default to exact match + return operator_templates.get(op, f"{column_name} = '{text}'") @staticmethod def split_in_clause( diff --git a/circe/cohortdefinition/code_generator.py b/circe/cohortdefinition/code_generator.py index 864bcbdf..c069fd70 100644 --- a/circe/cohortdefinition/code_generator.py +++ b/circe/cohortdefinition/code_generator.py @@ -56,9 +56,7 @@ def _repr(o: Any, indent_level: int = 0) -> str: # Pydantic V2 doesn't have a simple "is_set" for fields without model_dump(exclude_unset) # But we want to preserve structure even if it matches default maybe? # Let's stick to non-None for now as per plan - if val is not None: - # Check if it equals default - if val != field_info.get_default(): + if val is not None and val != field_info.get_default(): fields[name] = val if not fields: diff --git a/circe/cohortdefinition/cohort.py b/circe/cohortdefinition/cohort.py index 98063627..af41c5b1 100644 --- a/circe/cohortdefinition/cohort.py +++ b/circe/cohortdefinition/cohort.py @@ -267,11 +267,9 @@ def normalize_before_validation(cls, data: Any) -> Any: Handles empty objects and other normalization needs. """ - if isinstance(data, dict): - # No longer dropping cdmVersionRange string since we now expect Optional[str] - if "censorWindow" in data and data["censorWindow"] == {}: - data = dict(data) - data.pop("censorWindow") + if isinstance(data, dict) and "censorWindow" in data and data["censorWindow"] == {}: + data = dict(data) + data.pop("censorWindow") return data @@ -399,9 +397,7 @@ def _normalize_for_checksum(self, data: Any) -> Any: """ if isinstance(data, dict): # Handle ConceptSet Expression Items - if "items" in data and isinstance(data["items"], list): - # Check if these look like ConceptSetItems (have 'concept') - if ( + if "items" in data and isinstance(data["items"], list) and ( data["items"] and isinstance(data["items"][0], dict) and "concept" in data["items"][0] diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index af63dc17..c3c38f12 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -200,17 +200,22 @@ class CohortExpressionQueryBuilder( ; """ - PRIMARY_EVENTS_SUBQUERY_TEMPLATE = """select P.ordinal as event_id, P.person_id, P.start_date, P.end_date, op_start_date, op_end_date, cast(P.visit_occurrence_id as bigint) as visit_occurrence_id + PRIMARY_EVENTS_SUBQUERY_TEMPLATE = """select P.ordinal as event_id, P.person_id, P.start_date, P.end_date, + op_start_date, op_end_date, cast(P.visit_occurrence_id as bigint) as visit_occurrence_id FROM ( select E.person_id, E.start_date, E.end_date, row_number() OVER (PARTITION BY E.person_id ORDER BY E.sort_date @EventSort, E.event_id) ordinal, - OP.observation_period_start_date as op_start_date, OP.observation_period_end_date as op_end_date, cast(E.visit_occurrence_id as bigint) as visit_occurrence_id + OP.observation_period_start_date as op_start_date, + OP.observation_period_end_date as op_end_date, + cast(E.visit_occurrence_id as bigint) as visit_occurrence_id FROM ( @criteriaQueries ) E - JOIN @cdm_database_schema.observation_period OP on E.person_id = OP.person_id and E.start_date >= OP.observation_period_start_date and E.start_date <= op.observation_period_end_date + JOIN @cdm_database_schema.observation_period OP on E.person_id = OP.person_id + and E.start_date >= OP.observation_period_start_date + and E.start_date <= op.observation_period_end_date WHERE @primaryEventsFilter ) P @primaryEventLimit""" @@ -326,9 +331,14 @@ class CohortExpressionQueryBuilder( ; -- calculate gain counts -delete from @results_database_schema.cohort_inclusion_stats where @cohort_id_field_name = @target_cohort_id and mode_id = @inclusionImpactMode; -insert into @results_database_schema.cohort_inclusion_stats (@cohort_id_field_name, rule_sequence, person_count, gain_count, person_total, mode_id) -select @target_cohort_id as @cohort_id_field_name, ir.rule_sequence, coalesce(T.person_count, 0) as person_count, coalesce(SR.person_count, 0) gain_count, EventTotal.total, @inclusionImpactMode as mode_id +delete from @results_database_schema.cohort_inclusion_stats +where @cohort_id_field_name = @target_cohort_id and mode_id = @inclusionImpactMode; +insert into @results_database_schema.cohort_inclusion_stats + (@cohort_id_field_name, rule_sequence, person_count, gain_count, person_total, mode_id) +select @target_cohort_id as @cohort_id_field_name, ir.rule_sequence, + coalesce(T.person_count, 0) as person_count, + coalesce(SR.person_count, 0) gain_count, EventTotal.total, + @inclusionImpactMode as mode_id from #inclusion_rules ir left join ( @@ -339,19 +349,27 @@ class CohortExpressionQueryBuilder( ) T on ir.rule_sequence = T.inclusion_rule_id CROSS JOIN (select count(*) as total_rules from #inclusion_rules) RuleTotal CROSS JOIN (select count_big(event_id) as total from @eventTable) EventTotal -LEFT JOIN @results_database_schema.cohort_inclusion_result SR on SR.mode_id = @inclusionImpactMode AND SR.@cohort_id_field_name = @target_cohort_id AND (POWER(cast(2 as bigint),RuleTotal.total_rules) - POWER(cast(2 as bigint),ir.rule_sequence) - 1) = SR.inclusion_rule_mask -- POWER(2,rule count) - POWER(2,rule sequence) - 1 is the mask for 'all except this rule' +LEFT JOIN @results_database_schema.cohort_inclusion_result SR + on SR.mode_id = @inclusionImpactMode + AND SR.@cohort_id_field_name = @target_cohort_id + AND (POWER(cast(2 as bigint),RuleTotal.total_rules) - POWER(cast(2 as bigint),ir.rule_sequence) - 1) = SR.inclusion_rule_mask + -- POWER(2,rule count) - POWER(2,rule sequence) - 1 is the mask for 'all except this rule' ; -- calculate totals -delete from @results_database_schema.cohort_summary_stats where @cohort_id_field_name = @target_cohort_id and mode_id = @inclusionImpactMode; -insert into @results_database_schema.cohort_summary_stats (@cohort_id_field_name, base_count, final_count, mode_id) -select @target_cohort_id as @cohort_id_field_name, PC.total as person_count, coalesce(FC.total, 0) as final_count, @inclusionImpactMode as mode_id +delete from @results_database_schema.cohort_summary_stats +where @cohort_id_field_name = @target_cohort_id and mode_id = @inclusionImpactMode; +insert into @results_database_schema.cohort_summary_stats + (@cohort_id_field_name, base_count, final_count, mode_id) +select @target_cohort_id as @cohort_id_field_name, PC.total as person_count, + coalesce(FC.total, 0) as final_count, @inclusionImpactMode as mode_id FROM (select count_big(event_id) as total from @eventTable) PC, (select sum(sr.person_count) as total from @results_database_schema.cohort_inclusion_result sr CROSS JOIN (select count(*) as total_rules from #inclusion_rules) RuleTotal - where sr.mode_id = @inclusionImpactMode and sr.@cohort_id_field_name = @target_cohort_id and sr.inclusion_rule_mask = POWER(cast(2 as bigint),RuleTotal.total_rules)-1 + where sr.mode_id = @inclusionImpactMode and sr.@cohort_id_field_name = @target_cohort_id + and sr.inclusion_rule_mask = POWER(cast(2 as bigint),RuleTotal.total_rules)-1 ) FC ; """ @@ -364,10 +382,12 @@ class CohortExpressionQueryBuilder( INCLUDED_EVENTS_TEMPLATE = """select event_id, person_id, start_date, end_date, op_start_date, op_end_date into #included_events FROM ( - SELECT event_id, person_id, start_date, end_date, op_start_date, op_end_date, row_number() over (partition by person_id order by start_date @IncludedEventSort) as ordinal + SELECT event_id, person_id, start_date, end_date, op_start_date, op_end_date, + row_number() over (partition by person_id order by start_date @IncludedEventSort) as ordinal from ( - select Q.event_id, Q.person_id, Q.start_date, Q.end_date, Q.op_start_date, Q.op_end_date, SUM(coalesce(POWER(cast(2 as bigint), I.inclusion_rule_id), 0)) as inclusion_rule_mask + select Q.event_id, Q.person_id, Q.start_date, Q.end_date, Q.op_start_date, Q.op_end_date, + SUM(coalesce(POWER(cast(2 as bigint), I.inclusion_rule_id), 0)) as inclusion_rule_mask from #qualified_events Q LEFT JOIN #inclusion_events I on I.person_id = Q.person_id and I.event_id = Q.event_id GROUP BY Q.event_id, Q.person_id, Q.start_date, Q.end_date, Q.op_start_date, Q.op_end_date @@ -417,12 +437,17 @@ class CohortExpressionQueryBuilder( JOIN ( - select person_id, min(start_date) as era_start_date, DATEADD(day,-1 * @gapDays, max(end_date)) as era_end_date + select person_id, min(start_date) as era_start_date, + DATEADD(day,-1 * @gapDays, max(end_date)) as era_end_date from ( - select person_id, start_date, end_date, sum(is_start) over (partition by person_id order by start_date, is_start desc rows unbounded preceding) group_idx + select person_id, start_date, end_date, + sum(is_start) over (partition by person_id order by start_date, is_start desc + rows unbounded preceding) group_idx from ( select person_id, start_date, end_date, - case when max(end_date) over (partition by person_id order by start_date rows between unbounded preceding and 1 preceding) >= start_date then 0 else 1 end is_start + case when max(end_date) over (partition by person_id order by start_date + rows between unbounded preceding and 1 preceding) >= start_date + then 0 else 1 end is_start from ( select person_id, drug_exposure_start_date as start_date, DATEADD(day,(@gapDays + @offset),DRUG_EXPOSURE_END_DATE) as end_date FROM #drugTarget @@ -437,7 +462,10 @@ class CohortExpressionQueryBuilder( DROP TABLE #drugTarget; """ - DEFAULT_DRUG_EXPOSURE_END_DATE_EXPRESSION = "COALESCE(DRUG_EXPOSURE_END_DATE, DATEADD(day,DAYS_SUPPLY,DRUG_EXPOSURE_START_DATE), DATEADD(day,1,DRUG_EXPOSURE_START_DATE))" + DEFAULT_DRUG_EXPOSURE_END_DATE_EXPRESSION = ( + "COALESCE(DRUG_EXPOSURE_END_DATE, DATEADD(day,DAYS_SUPPLY,DRUG_EXPOSURE_START_DATE), " + "DATEADD(day,1,DRUG_EXPOSURE_START_DATE))" + ) DEFAULT_COHORT_ID_FIELD_NAME = "cohort_definition_id" def __init__(self): @@ -501,7 +529,9 @@ def wrap_criteria_query(self, query: str, group: CriteriaGroup) -> str: """ # Step 1: Wrap base query with Q+OP join # This will be used as the event_table (becomes E in the GROUP_QUERY_TEMPLATE) - q_op_query = f"""SELECT Q.person_id, Q.event_id, Q.start_date, Q.end_date, Q.visit_occurrence_id, OP.observation_period_start_date as op_start_date, OP.observation_period_end_date as op_end_date + q_op_query = f"""SELECT Q.person_id, Q.event_id, Q.start_date, Q.end_date, Q.visit_occurrence_id, + OP.observation_period_start_date as op_start_date, + OP.observation_period_end_date as op_end_date FROM ( {query} ) Q @@ -597,7 +627,9 @@ def _get_primary_events_subquery(self, primary_criteria: PrimaryCriteria) -> str # Primary events filters primary_events_filters = [ - f"DATEADD(day,{primary_criteria.observation_window.prior_days},OP.OBSERVATION_PERIOD_START_DATE) <= E.START_DATE AND DATEADD(day,{primary_criteria.observation_window.post_days},E.START_DATE) <= OP.OBSERVATION_PERIOD_END_DATE" + (f"DATEADD(day,{primary_criteria.observation_window.prior_days},OP.OBSERVATION_PERIOD_START_DATE) " + f"<= E.START_DATE AND DATEADD(day,{primary_criteria.observation_window.post_days},E.START_DATE) " + f"<= OP.OBSERVATION_PERIOD_END_DATE") ] query = query.replace( @@ -718,9 +750,12 @@ def _build_inclusion_analysis_section(self, expression: CohortExpression) -> str into #best_events from #qualified_events Q join ( - SELECT R.person_id, R.event_id, ROW_NUMBER() OVER (PARTITION BY R.person_id ORDER BY R.rule_count DESC,R.min_rule_id ASC, R.start_date ASC) AS rank_value + SELECT R.person_id, R.event_id, + ROW_NUMBER() OVER (PARTITION BY R.person_id ORDER BY R.rule_count DESC, + R.min_rule_id ASC, R.start_date ASC) AS rank_value FROM ( - SELECT Q.person_id, Q.event_id, COALESCE(COUNT(DISTINCT I.inclusion_rule_id), 0) AS rule_count, COALESCE(MIN(I.inclusion_rule_id), 0) AS min_rule_id, Q.start_date + SELECT Q.person_id, Q.event_id, COALESCE(COUNT(DISTINCT I.inclusion_rule_id), 0) AS rule_count, + COALESCE(MIN(I.inclusion_rule_id), 0) AS min_rule_id, Q.start_date FROM #qualified_events Q LEFT JOIN #inclusion_events I ON q.person_id = i.person_id AND q.event_id = i.event_id GROUP BY Q.person_id, Q.event_id, Q.start_date @@ -764,22 +799,16 @@ def _build_inclusion_analysis_section(self, expression: CohortExpression) -> str """ def build_expression_query( - self, expression: str, options: BuildExpressionQueryOptions + self, expression: Union[str, CohortExpression], options: BuildExpressionQueryOptions ) -> str: - """Build expression query from JSON string. + """Build expression query from CohortExpression object or JSON string. Java equivalent: buildExpressionQuery(String, BuildExpressionQueryOptions) + buildExpressionQuery(CohortExpression, BuildExpressionQueryOptions) """ - cohort_expression = CohortExpression.model_validate_json(expression) - return self.build_expression_query(cohort_expression, options) + if isinstance(expression, str): + expression = CohortExpression.model_validate_json(expression) - def build_expression_query( - self, expression: CohortExpression, options: BuildExpressionQueryOptions - ) -> str: - """Build expression query from CohortExpression object. - - Java equivalent: buildExpressionQuery(CohortExpression, BuildExpressionQueryOptions) - """ result_sql = self.COHORT_QUERY_TEMPLATE # Codeset query @@ -920,7 +949,11 @@ def build_expression_query( # Inclusion rule mask filter - only apply if there are inclusion rules if expression.inclusion_rules and len(expression.inclusion_rules) > 0: rule_count = len(expression.inclusion_rules) - inclusion_rule_mask_filter = f"{{{rule_count} != 0}}?{{\n -- the matching group with all bits set ( POWER(2,# of inclusion rules) - 1 = inclusion_rule_mask\n WHERE (MG.inclusion_rule_mask = POWER(cast(2 as bigint),{rule_count})-1)\n}}" + inclusion_rule_mask_filter = ( + f"{{{rule_count} != 0}}?{{\n -- the matching group with all bits set " + f"( POWER(2,# of inclusion rules) - 1 = inclusion_rule_mask\n " + f"WHERE (MG.inclusion_rule_mask = POWER(cast(2 as bigint),{rule_count})-1)\n}}" + ) else: inclusion_rule_mask_filter = "" included_events_query = included_events_query.replace( @@ -988,7 +1021,11 @@ def build_expression_query( inclusion_analysis_query = "" if options and options.generate_stats: # Add censored stats wrapper (even if empty) - inclusion_analysis_query = "{1 != 0}?{\n-- BEGIN: Censored Stats\n\ndelete from @results_database_schema.cohort_censor_stats where @cohort_id_field_name = @target_cohort_id;\n\n-- END: Censored Stats\n}\n" + inclusion_analysis_query = ( + "{1 != 0}?{\n-- BEGIN: Censored Stats\n\n" + "delete from @results_database_schema.cohort_censor_stats " + "where @cohort_id_field_name = @target_cohort_id;\n\n-- END: Censored Stats\n}\n" + ) # Always generate inclusion analysis if stats are requested, even if no rules inclusion_analysis_query += self._build_inclusion_analysis_section( expression @@ -1513,7 +1550,9 @@ def get_corelated_criteria_query( if remove_outer and paren_count == 0: clean_event_table = clean_event_table[1:-1].strip() - event_table = f"""(SELECT Q.person_id, Q.event_id, Q.start_date, Q.end_date, Q.visit_occurrence_id, OP.observation_period_start_date as op_start_date, OP.observation_period_end_date as op_end_date + event_table = f"""(SELECT Q.person_id, Q.event_id, Q.start_date, Q.end_date, Q.visit_occurrence_id, + OP.observation_period_start_date as op_start_date, + OP.observation_period_end_date as op_end_date FROM ( {clean_event_table} ) Q @@ -1526,7 +1565,11 @@ def get_corelated_criteria_query( ) # Occurrence criteria - occurrence_criteria = f"HAVING COUNT({'DISTINCT ' if corelated_criteria.occurrence.is_distinct else ''}{count_column_expression}) {self.get_occurrence_operator(corelated_criteria.occurrence.type)} {corelated_criteria.occurrence.count}" + occurrence_criteria = ( + f"HAVING COUNT({'DISTINCT ' if corelated_criteria.occurrence.is_distinct else ''}" + f"{count_column_expression}) {self.get_occurrence_operator(corelated_criteria.occurrence.type)} " + f"{corelated_criteria.occurrence.count}" + ) query = query.replace("@occurrenceCriteria", occurrence_criteria) diff --git a/circe/cohortdefinition/interfaces.py b/circe/cohortdefinition/interfaces.py index 00d4a0ba..1ae17d50 100644 --- a/circe/cohortdefinition/interfaces.py +++ b/circe/cohortdefinition/interfaces.py @@ -10,7 +10,7 @@ """ from abc import ABC, abstractmethod -from typing import Optional +from typing import Optional, Union from .builders.utils import BuilderOptions from .core import CustomEraStrategy, DateOffsetStrategy @@ -33,6 +33,26 @@ VisitOccurrence, ) +# Type alias for all criteria types +Criteria = Union[ + LocationRegion, + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, +] + class IGetCriteriaSqlDispatcher(ABC): """Interface for dispatching SQL generation for different criteria types. @@ -42,125 +62,22 @@ class IGetCriteriaSqlDispatcher(ABC): @abstractmethod def get_criteria_sql( - self, location_region: LocationRegion, options: Optional[BuilderOptions] = None + self, criteria: Criteria, options: Optional[BuilderOptions] = None ) -> str: - """Generate SQL for location region criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, condition_era: ConditionEra, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for condition era criteria.""" - pass + """Generate SQL for various criteria types. - @abstractmethod - def get_criteria_sql( - self, - condition_occurrence: ConditionOccurrence, - options: Optional[BuilderOptions] = None, - ) -> str: - """Generate SQL for condition occurrence criteria.""" - pass + Args: + criteria: Any supported criteria type (LocationRegion, ConditionEra, etc.) + options: Optional builder options - @abstractmethod - def get_criteria_sql( - self, death: Death, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for death criteria.""" + Returns: + SQL string for the criteria + """ pass - @abstractmethod - def get_criteria_sql( - self, device_exposure: DeviceExposure, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for device exposure criteria.""" - pass - @abstractmethod - def get_criteria_sql( - self, dose_era: DoseEra, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for dose era criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, drug_era: DrugEra, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for drug era criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, drug_exposure: DrugExposure, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for drug exposure criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, measurement: Measurement, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for measurement criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, observation: Observation, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for observation criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, - observation_period: ObservationPeriod, - options: Optional[BuilderOptions] = None, - ) -> str: - """Generate SQL for observation period criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, - payer_plan_period: PayerPlanPeriod, - options: Optional[BuilderOptions] = None, - ) -> str: - """Generate SQL for payer plan period criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, - procedure_occurrence: ProcedureOccurrence, - options: Optional[BuilderOptions] = None, - ) -> str: - """Generate SQL for procedure occurrence criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, specimen: Specimen, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for specimen criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, - visit_occurrence: VisitOccurrence, - options: Optional[BuilderOptions] = None, - ) -> str: - """Generate SQL for visit occurrence criteria.""" - pass - - @abstractmethod - def get_criteria_sql( - self, visit_detail: VisitDetail, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for visit detail criteria.""" - pass +# Type alias for end strategies +EndStrategy = Union[DateOffsetStrategy, CustomEraStrategy] class IGetEndStrategySqlDispatcher(ABC): @@ -170,11 +87,14 @@ class IGetEndStrategySqlDispatcher(ABC): """ @abstractmethod - def get_strategy_sql(self, strategy: DateOffsetStrategy, event_table: str) -> str: - """Generate SQL for date offset strategy.""" - pass + def get_strategy_sql(self, strategy: EndStrategy, event_table: str) -> str: + """Generate SQL for end strategies. - @abstractmethod - def get_strategy_sql(self, strategy: CustomEraStrategy, event_table: str) -> str: - """Generate SQL for custom era strategy.""" + Args: + strategy: DateOffsetStrategy or CustomEraStrategy + event_table: The event table name + + Returns: + SQL string for the strategy + """ pass diff --git a/circe/execution/builders/__init__.py b/circe/execution/builders/__init__.py index 4ebca4e9..99de9e44 100644 --- a/circe/execution/builders/__init__.py +++ b/circe/execution/builders/__init__.py @@ -17,3 +17,25 @@ ) from .pipeline import build_primary_events from .registry import build_events, register + +__all__ = [ + "condition_era", + "condition_occurrence", + "death", + "device_exposure", + "dose_era", + "drug_era", + "drug_exposure", + "measurement", + "observation", + "observation_period", + "payer_plan_period", + "procedure_occurrence", + "specimen", + "visit_detail", + "visit_occurrence", + "build_primary_events", + "build_events", + "register", +] + diff --git a/circe/execution/builders/pipeline.py b/circe/execution/builders/pipeline.py index c4d15018..cddf4c96 100644 --- a/circe/execution/builders/pipeline.py +++ b/circe/execution/builders/pipeline.py @@ -7,23 +7,6 @@ from ...cohortdefinition import CohortExpression from ..build_context import BuildContext -from . import ( - condition_era, - condition_occurrence, - death, - device_exposure, - dose_era, - drug_era, - drug_exposure, - measurement, - observation, - observation_period, - payer_plan_period, - procedure_occurrence, - specimen, - visit_detail, - visit_occurrence, -) from .common import ( apply_end_strategy, apply_observation_window, From d4c43bac1796152520682745705637a22e7daf14 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 12:50:06 -0700 Subject: [PATCH 17/62] Ignoring line length errors for now --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 515daf4d..ee3263b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,7 +199,9 @@ select = [ "C4", # flake8-comprehensions "SIM", # flake8-simplify ] -ignore = [] +ignore = [ + "E501", # Line too long (handled by formatter) +] [tool.ruff.lint.isort] known-first-party = ["circe"] From 7da7dd1fae186678c7d0d393e8c8ca79a7362c6b Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 12:55:24 -0700 Subject: [PATCH 18/62] Install fixes for github actions --- pyproject.toml | 4 ++++ tox.ini | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ee3263b9..68c396b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,10 @@ dev = [ "ruff>=0.1.0", "sqlglot>=23.0.0", "duckdb>=0.9.0", + "ibis-framework[duckdb]>=11.0.0", + "polars>=0.20.0", + "deepdiff>=8.6.0", + "javalang>=0.13.0", ] docs = [ "sphinx>=5.0.0", diff --git a/tox.ini b/tox.ini index 3a504ccc..2d25e303 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py39, py310, py311, py312 +envlist = py39, py310, py311, py312, py313, py314 skip_missing_interpreters = true isolated_build = true @@ -11,6 +11,8 @@ deps = javalang>=0.13.0 sqlglot>=23.0.0 duckdb>=0.9.0 + ibis-framework[duckdb]>=11.0.0 + polars>=0.20.0 passenv = CI GITHUB_* From be3cf265761e6a8f8f217c4a18b2fbc081fbb8a3 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Mon, 16 Mar 2026 12:57:48 -0700 Subject: [PATCH 19/62] Format --- CONTRIBUTING.md | 4 + circe/__init__.py | 4 +- circe/api.py | 14 +- circe/chat.py | 22 +- circe/check/checkers/attribute_check.py | 4 +- .../checkers/attribute_checker_factory.py | 14 +- circe/check/checkers/base_check.py | 4 +- circe/check/checkers/base_checker_factory.py | 4 +- .../checkers/base_corelated_criteria_check.py | 45 +- circe/check/checkers/base_criteria_check.py | 31 +- circe/check/checkers/base_iterable_check.py | 12 +- circe/check/checkers/base_value_check.py | 46 +- circe/check/checkers/comparisons.py | 13 +- circe/check/checkers/concept_check.py | 4 +- .../check/checkers/concept_checker_factory.py | 52 +- .../checkers/concept_set_criteria_check.py | 120 +--- .../checkers/concept_set_selection_check.py | 4 +- .../concept_set_selection_checker_factory.py | 18 +- .../checkers/criteria_checker_factory.py | 38 +- .../checkers/criteria_contradictions_check.py | 16 +- .../check/checkers/death_time_window_check.py | 28 +- circe/check/checkers/domain_type_check.py | 80 +-- circe/check/checkers/drug_domain_check.py | 41 +- circe/check/checkers/drug_era_check.py | 4 +- .../checkers/duplicates_concept_set_check.py | 5 +- .../checkers/duplicates_criteria_check.py | 29 +- .../check/checkers/empty_concept_set_check.py | 6 +- .../checkers/events_progression_check.py | 5 +- circe/check/checkers/exit_criteria_check.py | 6 +- .../exit_criteria_days_offset_check.py | 10 +- circe/check/checkers/incomplete_rule_check.py | 18 +- circe/check/checkers/initial_event_check.py | 6 +- .../check/checkers/no_exit_criteria_check.py | 13 +- circe/check/checkers/ocurrence_check.py | 4 +- circe/check/checkers/range_check.py | 66 +-- circe/check/checkers/range_checker_factory.py | 79 +-- circe/check/checkers/text_checker_factory.py | 12 +- circe/check/checkers/time_pattern_check.py | 22 +- circe/check/checkers/time_window_check.py | 8 +- circe/check/checkers/unused_concepts_check.py | 97 +--- circe/check/operations/operations.py | 10 +- circe/cli.py | 52 +- circe/cohortdefinition/builders/base.py | 37 +- .../builders/condition_era.py | 74 +-- .../builders/condition_occurrence.py | 119 +--- circe/cohortdefinition/builders/death.py | 72 +-- .../builders/device_exposure.py | 124 +--- circe/cohortdefinition/builders/dose_era.py | 78 +-- circe/cohortdefinition/builders/drug_era.py | 78 +-- .../builders/drug_exposure.py | 178 ++---- .../builders/location_region.py | 20 +- .../cohortdefinition/builders/measurement.py | 173 ++---- .../cohortdefinition/builders/observation.py | 167 ++---- .../builders/observation_period.py | 111 +--- .../builders/payer_plan_period.py | 123 +--- .../builders/procedure_occurrence.py | 217 ++----- circe/cohortdefinition/builders/specimen.py | 110 +--- circe/cohortdefinition/builders/utils.py | 44 +- .../cohortdefinition/builders/visit_detail.py | 134 +---- .../builders/visit_occurrence.py | 150 ++--- circe/cohortdefinition/code_generator.py | 13 +- circe/cohortdefinition/cohort.py | 99 ++-- .../cohort_expression_query_builder.py | 529 ++++-------------- .../concept_set_expression_query_builder.py | 48 +- circe/cohortdefinition/core.py | 22 +- circe/cohortdefinition/criteria.py | 424 ++++---------- circe/cohortdefinition/interfaces.py | 4 +- .../printfriendly/markdown_render.py | 14 +- circe/execution/build_context.py | 50 +- circe/execution/builders/__init__.py | 1 - circe/execution/builders/common.py | 123 +--- circe/execution/builders/condition_era.py | 20 +- .../builders/condition_occurrence.py | 27 +- circe/execution/builders/death.py | 8 +- circe/execution/builders/device_exposure.py | 24 +- circe/execution/builders/dose_era.py | 8 +- circe/execution/builders/drug_era.py | 8 +- circe/execution/builders/drug_exposure.py | 28 +- circe/execution/builders/groups.py | 112 +--- circe/execution/builders/measurement.py | 32 +- circe/execution/builders/observation.py | 24 +- .../execution/builders/observation_period.py | 16 +- circe/execution/builders/payer_plan_period.py | 40 +- circe/execution/builders/pipeline.py | 18 +- circe/execution/builders/post_processing.py | 26 +- .../builders/procedure_occurrence.py | 24 +- circe/execution/builders/visit_detail.py | 16 +- circe/execution/builders/visit_occurrence.py | 20 +- circe/execution/criteria_compat.py | 4 +- circe/execution/ibis.py | 37 +- circe/execution/ibis_compat.py | 4 +- circe/helper/cohort_modifiers.py | 37 +- circe/io.py | 8 +- circe/vocabulary/concept.py | 16 +- .../concept_set_expression_query_builder.py | 52 +- cohort_definition.py | 4 +- debug_app/app.py | 4 +- debug_app/sandbox.py | 19 +- debug_app/utils.py | 28 +- examples/complex_cohort.py | 12 +- examples/generate_sql.py | 4 +- examples/type2_diabetes_cohort.ipynb | 4 +- examples/validate_cohort.py | 8 +- scripts/generate_skill_backup.py | 80 +-- 104 files changed, 1196 insertions(+), 3982 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2f514cb..56082a80 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,10 @@ class TestCohortExpression: pass ``` +## Use of AI Tools + +Contributors may use AI tools to assist with development. If AI materially influenced a PR, please mention it in the PR description. Do not share secrets or sensitive data. Contributors remain responsible for correctness and license compliance. + ## Pull Request Process 1. Create a feature branch from `main`: diff --git a/circe/__init__.py b/circe/__init__.py index 6d1044e5..914de487 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -100,9 +100,7 @@ def safe_model_rebuild(package): 'ValueError: call stack is not deep enough' during instantiation. """ with suppress(Exception): - for _loader, module_name, _is_pkg in pkgutil.walk_packages( - package.__path__, package.__name__ + "." - ): + for _loader, module_name, _is_pkg in pkgutil.walk_packages(package.__path__, package.__name__ + "."): with suppress(ImportError): mod = importlib.import_module(module_name) diff --git a/circe/api.py b/circe/api.py index 2e1f4313..12b05206 100644 --- a/circe/api.py +++ b/circe/api.py @@ -52,11 +52,7 @@ def cohort_expression_from_json(json_str: str) -> CohortExpression: # Ensure ConceptSetExpression objects have required fields if "conceptSets" in data and data["conceptSets"]: for concept_set in data["conceptSets"]: - if ( - isinstance(concept_set, dict) - and "expression" in concept_set - and concept_set["expression"] is not None - ): + if isinstance(concept_set, dict) and "expression" in concept_set and concept_set["expression"] is not None: expr = concept_set["expression"] if isinstance(expr, dict): if "isExcluded" not in expr: @@ -72,9 +68,7 @@ def cohort_expression_from_json(json_str: str) -> CohortExpression: raise ValueError(f"Invalid cohort expression JSON: {str(e)}") from e -def build_cohort_query( - expression: CohortExpression, options: Optional[BuildExpressionQueryOptions] = None -) -> str: +def build_cohort_query(expression: CohortExpression, options: Optional[BuildExpressionQueryOptions] = None) -> str: """Generate SQL query from a cohort expression. This is equivalent to R CirceR's `buildCohortQuery()` function. @@ -128,7 +122,5 @@ def cohort_print_friendly( if concept_sets is None: concept_sets = expression.concept_sets or [] - renderer = MarkdownRender( - concept_sets=concept_sets, include_concept_sets=include_concept_sets - ) + renderer = MarkdownRender(concept_sets=concept_sets, include_concept_sets=include_concept_sets) return renderer.render_cohort_expression(expression, title=title) diff --git a/circe/chat.py b/circe/chat.py index 159a6cc8..46fd5fc4 100644 --- a/circe/chat.py +++ b/circe/chat.py @@ -79,9 +79,7 @@ def start_chat( description=item.get("description"), ) ) - print( - f" Loaded {len(concept_sets_data)} concept sets from {concept_sets_file}" - ) + print(f" Loaded {len(concept_sets_data)} concept sets from {concept_sets_file}") except Exception as e: print(f"Error loading concept sets: {e}", file=sys.stderr) return 1 @@ -144,15 +142,11 @@ def start_chat( # Construct user message if len(messages) == 1: # First user message - format nicely - formatted_content = ( - f"\n---\n## User Task\n**Clinical Description:**\n{user_input}\n" - ) + formatted_content = f"\n---\n## User Task\n**Clinical Description:**\n{user_input}\n" if concept_sets_data: formatted_content += builder.format_concept_sets(concept_sets_data) else: - formatted_content += ( - "\nNo pre-defined concept sets provided. Please infer them." - ) + formatted_content += "\nNo pre-defined concept sets provided. Please infer them." messages.append({"role": "user", "content": formatted_content}) else: @@ -237,11 +231,7 @@ def _process_response_content(content: str, output_base: Optional[str]): json_output = None if hasattr(cohort_obj, "json"): # Pydantic v1/v2 - json_output = ( - cohort_obj.model_dump_json(indent=2) - if hasattr(cohort_obj, "model_dump_json") - else cohort_obj.json(indent=2) - ) + json_output = cohort_obj.model_dump_json(indent=2) if hasattr(cohort_obj, "model_dump_json") else cohort_obj.json(indent=2) elif hasattr(cohort_obj, "to_json"): json_output = cohort_obj.to_json() else: @@ -259,6 +249,4 @@ def _process_response_content(content: str, output_base: Optional[str]): except Exception as e: print(f" Error executing generated code: {e}") - print( - " (Ensure the generated code is valid and all dependencies are installed)" - ) + print(" (Ensure the generated code is valid and all dependencies are installed)") diff --git a/circe/check/checkers/attribute_check.py b/circe/check/checkers/attribute_check.py index c729cb27..9b2797da 100644 --- a/circe/check/checkers/attribute_check.py +++ b/circe/check/checkers/attribute_check.py @@ -28,9 +28,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.WARNING - def _get_factory( - self, reporter: WarningReporter, name: str - ) -> AttributeCheckerFactory: + def _get_factory(self, reporter: WarningReporter, name: str) -> AttributeCheckerFactory: """Get an attribute checker factory. Args: diff --git a/circe/check/checkers/attribute_checker_factory.py b/circe/check/checkers/attribute_checker_factory.py index d7264ae9..714f08ab 100644 --- a/circe/check/checkers/attribute_checker_factory.py +++ b/circe/check/checkers/attribute_checker_factory.py @@ -42,9 +42,7 @@ def __init__(self, reporter: WarningReporter, group_name: str): super().__init__(reporter, group_name) @staticmethod - def get_factory( - reporter: WarningReporter, group_name: str - ) -> "AttributeCheckerFactory": + def get_factory(reporter: WarningReporter, group_name: str) -> "AttributeCheckerFactory": """Get a factory instance. Args: @@ -67,9 +65,7 @@ def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], No """ return lambda c: None # Non-demographic criteria don't need attribute checks - def _get_check_demographic( - self, criteria: "DemographicCriteria" - ) -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. Args: @@ -86,11 +82,7 @@ def check(c: "DemographicCriteria") -> None: c.gender, c.race, c.ethnicity, - ( - c.occurrence_start_date - if hasattr(c, "occurrence_start_date") - else None - ), + (c.occurrence_start_date if hasattr(c, "occurrence_start_date") else None), c.occurrence_end_date if hasattr(c, "occurrence_end_date") else None, ) diff --git a/circe/check/checkers/base_check.py b/circe/check/checkers/base_check.py index dffb0b07..b6b957bc 100644 --- a/circe/check/checkers/base_check.py +++ b/circe/check/checkers/base_check.py @@ -83,9 +83,7 @@ def _define_reporter(self, warnings: list[Warning]) -> WarningReporter: """ return self._get_reporter(self._define_severity(), warnings) - def _get_reporter( - self, severity: WarningSeverity, warnings: list[Warning] - ) -> WarningReporter: + def _get_reporter(self, severity: WarningSeverity, warnings: list[Warning]) -> WarningReporter: """Get a warning reporter for the given severity level. Args: diff --git a/circe/check/checkers/base_checker_factory.py b/circe/check/checkers/base_checker_factory.py index e2b15b18..b29487dc 100644 --- a/circe/check/checkers/base_checker_factory.py +++ b/circe/check/checkers/base_checker_factory.py @@ -63,9 +63,7 @@ def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], No """ raise NotImplementedError("Subclasses must implement _get_check_criteria") - def _get_check_demographic( - self, criteria: "DemographicCriteria" - ) -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: """Get a checker function for a demographic criteria (to be implemented by subclasses). Args: diff --git a/circe/check/checkers/base_corelated_criteria_check.py b/circe/check/checkers/base_corelated_criteria_check.py index 84ccacd8..580cd773 100644 --- a/circe/check/checkers/base_corelated_criteria_check.py +++ b/circe/check/checkers/base_corelated_criteria_check.py @@ -32,9 +32,7 @@ class BaseCorelatedCriteriaCheck(BaseIterableCheck): in inclusion rules. """ - def _internal_check( - self, expression: "CohortExpression", reporter: WarningReporter - ) -> None: + def _internal_check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Internal check that iterates over corelated criteria. Args: @@ -43,10 +41,7 @@ def _internal_check( """ if expression.inclusion_rules: for inclusion_rule in expression.inclusion_rules: - if ( - inclusion_rule.expression - and inclusion_rule.expression.criteria_list - ): + if inclusion_rule.expression and inclusion_rule.expression.criteria_list: for criteria in inclusion_rule.expression.criteria_list: # Skip if criteria is still a dict (shouldn't happen after deserialization, but be defensive) if isinstance(criteria, dict): @@ -54,13 +49,9 @@ def _internal_check( group_name = f"{self.INCLUSION_RULE}{inclusion_rule.name}" self._check_criteria(criteria, group_name, reporter) if hasattr(criteria, "criteria") and criteria.criteria: - self._check_criteria_group( - criteria.criteria, group_name, reporter - ) + self._check_criteria_group(criteria.criteria, group_name, reporter) - def _check_criteria_group( - self, criteria: "Criteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria_group(self, criteria: "Criteria", group_name: str, reporter: WarningReporter) -> None: """Check correlated criteria groups. Args: @@ -80,13 +71,8 @@ def _check_criteria_group( if isinstance(corelated_criteria, dict): continue self._check_criteria(corelated_criteria, group_name, reporter) - if ( - hasattr(corelated_criteria, "criteria") - and corelated_criteria.criteria - ): - self._check_criteria_group( - corelated_criteria.criteria, group_name, reporter - ) + if hasattr(corelated_criteria, "criteria") and corelated_criteria.criteria: + self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) if hasattr(correlated, "groups") and correlated.groups: for group in correlated.groups: if hasattr(group, "criteria_list") and group.criteria_list: @@ -94,20 +80,11 @@ def _check_criteria_group( # Skip dicts if isinstance(corelated_criteria, dict): continue - self._check_criteria( - corelated_criteria, group_name, reporter - ) - if ( - hasattr(corelated_criteria, "criteria") - and corelated_criteria.criteria - ): - self._check_criteria_group( - corelated_criteria.criteria, group_name, reporter - ) - - def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter - ) -> None: + self._check_criteria(corelated_criteria, group_name, reporter) + if hasattr(corelated_criteria, "criteria") and corelated_criteria.criteria: + self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) + + def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: """Check a single corelated criteria (to be implemented by subclasses). Args: diff --git a/circe/check/checkers/base_criteria_check.py b/circe/check/checkers/base_criteria_check.py index ad369f42..3b918d2e 100644 --- a/circe/check/checkers/base_criteria_check.py +++ b/circe/check/checkers/base_criteria_check.py @@ -32,9 +32,7 @@ class BaseCriteriaCheck(BaseIterableCheck): primary criteria and inclusion rules. """ - def _internal_check( - self, expression: "CohortExpression", reporter: WarningReporter - ) -> None: + def _internal_check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Internal check that iterates over criteria. Args: @@ -47,25 +45,16 @@ def _internal_check( if expression.inclusion_rules: for inclusion_rule in expression.inclusion_rules: - if ( - inclusion_rule.expression - and inclusion_rule.expression.criteria_list - ): + if inclusion_rule.expression and inclusion_rule.expression.criteria_list: for criteria in inclusion_rule.expression.criteria_list: group_name = f"{self.INCLUSION_RULE}{inclusion_rule.name}" self._check_criteria_group( - ( - criteria.criteria - if hasattr(criteria, "criteria") - else criteria - ), + (criteria.criteria if hasattr(criteria, "criteria") else criteria), group_name, reporter, ) - def _check_criteria_group( - self, criteria: "Criteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria_group(self, criteria: "Criteria", group_name: str, reporter: WarningReporter) -> None: """Check a criteria and its correlated criteria. Args: @@ -80,20 +69,14 @@ def _check_criteria_group( correlated = criteria.correlated_criteria if hasattr(correlated, "criteria_list") and correlated.criteria_list: for corelated_criteria in correlated.criteria_list: - self._check_criteria_group( - corelated_criteria.criteria, group_name, reporter - ) + self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) if hasattr(correlated, "groups") and correlated.groups: for group in correlated.groups: if hasattr(group, "criteria_list") and group.criteria_list: for corelated_criteria in group.criteria_list: - self._check_criteria_group( - corelated_criteria.criteria, group_name, reporter - ) + self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) - def _check_criteria( - self, criteria: "Criteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria(self, criteria: "Criteria", group_name: str, reporter: WarningReporter) -> None: """Check a single criteria (to be implemented by subclasses). Args: diff --git a/circe/check/checkers/base_iterable_check.py b/circe/check/checkers/base_iterable_check.py index 1c44b947..8c122ed1 100644 --- a/circe/check/checkers/base_iterable_check.py +++ b/circe/check/checkers/base_iterable_check.py @@ -42,9 +42,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N self._internal_check(expression, reporter) self._after_check(reporter, expression) - def _before_check( - self, reporter: WarningReporter, expression: "CohortExpression" - ) -> None: + def _before_check(self, reporter: WarningReporter, expression: "CohortExpression") -> None: """Hook called before the internal check runs. Args: @@ -53,9 +51,7 @@ def _before_check( """ pass - def _after_check( - self, reporter: WarningReporter, expression: "CohortExpression" - ) -> None: + def _after_check(self, reporter: WarningReporter, expression: "CohortExpression") -> None: """Hook called after the internal check runs. Args: @@ -64,9 +60,7 @@ def _after_check( """ pass - def _internal_check( - self, expression: "CohortExpression", reporter: WarningReporter - ) -> None: + def _internal_check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Internal check method to be implemented by subclasses. Args: diff --git a/circe/check/checkers/base_value_check.py b/circe/check/checkers/base_value_check.py index 1bba2111..f6e87c34 100644 --- a/circe/check/checkers/base_value_check.py +++ b/circe/check/checkers/base_value_check.py @@ -59,9 +59,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N self._check_inclusion_rules(expression, reporter) self._check_censoring_criteria(expression, reporter) - def _check_primary_criteria( - self, primary_criteria: Optional["PrimaryCriteria"], reporter: WarningReporter - ) -> None: + def _check_primary_criteria(self, primary_criteria: Optional["PrimaryCriteria"], reporter: WarningReporter) -> None: """Check primary criteria. Args: @@ -72,9 +70,7 @@ def _check_primary_criteria( for criteria in primary_criteria.criteria_list: self._check_criteria(criteria, reporter, self.PRIMARY_CRITERIA) - def _check_additional_criteria( - self, criteria_group: Optional["CriteriaGroup"], reporter: WarningReporter - ) -> None: + def _check_additional_criteria(self, criteria_group: Optional["CriteriaGroup"], reporter: WarningReporter) -> None: """Check additional criteria. Args: @@ -82,25 +78,17 @@ def _check_additional_criteria( reporter: The warning reporter to use """ if criteria_group: - if ( - hasattr(criteria_group, "criteria_list") - and criteria_group.criteria_list - ): + if hasattr(criteria_group, "criteria_list") and criteria_group.criteria_list: for criteria in criteria_group.criteria_list: self._check_criteria(criteria, reporter, self.ADDITIONAL_CRITERIA) - if ( - hasattr(criteria_group, "demographic_criteria_list") - and criteria_group.demographic_criteria_list - ): + if hasattr(criteria_group, "demographic_criteria_list") and criteria_group.demographic_criteria_list: for criteria in criteria_group.demographic_criteria_list: self._check_criteria(criteria, reporter, self.ADDITIONAL_CRITERIA) if hasattr(criteria_group, "groups") and criteria_group.groups: for group in criteria_group.groups: self._check_additional_criteria(group, reporter) - def _check_censoring_criteria( - self, expression: "CohortExpression", reporter: WarningReporter - ) -> None: + def _check_censoring_criteria(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check censoring criteria. Args: @@ -111,9 +99,7 @@ def _check_censoring_criteria( for criteria in expression.censoring_criteria: self._check_criteria(criteria, reporter, self.CENSORING_CRITERIA) - def _check_inclusion_rules( - self, expression: "CohortExpression", reporter: WarningReporter - ) -> None: + def _check_inclusion_rules(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check inclusion rules. Args: @@ -124,16 +110,10 @@ def _check_inclusion_rules( for rule in expression.inclusion_rules: if rule.expression: rule_name = f'{self.INCLUSION_CRITERIA}"{rule.name}"' - if ( - hasattr(rule.expression, "criteria_list") - and rule.expression.criteria_list - ): + if hasattr(rule.expression, "criteria_list") and rule.expression.criteria_list: for criteria in rule.expression.criteria_list: self._check_criteria(criteria, reporter, rule_name) - if ( - hasattr(rule.expression, "demographic_criteria_list") - and rule.expression.demographic_criteria_list - ): + if hasattr(rule.expression, "demographic_criteria_list") and rule.expression.demographic_criteria_list: for criteria in rule.expression.demographic_criteria_list: self._check_criteria(criteria, reporter, rule_name) @@ -155,10 +135,7 @@ def _check_criteria(self, criteria, reporter: WarningReporter, name: str) -> Non # Check CriteriaGroup if isinstance(criteria, CriteriaGroup): - if ( - hasattr(criteria, "demographic_criteria_list") - and criteria.demographic_criteria_list - ): + if hasattr(criteria, "demographic_criteria_list") and criteria.demographic_criteria_list: for dem_criteria in criteria.demographic_criteria_list: self._check_criteria(dem_criteria, reporter, name) if hasattr(criteria, "criteria_list") and criteria.criteria_list: @@ -177,10 +154,7 @@ def _check_criteria(self, criteria, reporter: WarningReporter, name: str) -> Non factory.check(criteria) # Check Criteria (must be last as it's the base type) elif isinstance(criteria, Criteria): - if ( - hasattr(criteria, "correlated_criteria") - and criteria.correlated_criteria - ): + if hasattr(criteria, "correlated_criteria") and criteria.correlated_criteria: self._check_criteria(criteria.correlated_criteria, reporter, name) # Don't call factory.check for base Criteria - only specific criteria types have ranges to check # The factory's check method is for CohortExpression, not Criteria diff --git a/circe/check/checkers/comparisons.py b/circe/check/checkers/comparisons.py index 0dab831b..05e95cb6 100644 --- a/circe/check/checkers/comparisons.py +++ b/circe/check/checkers/comparisons.py @@ -145,9 +145,7 @@ def is_before(window: "Window") -> bool: """ if window is None: return False - return Comparisons.is_before_endpoint( - window.start - ) and not Comparisons.is_after_endpoint(window.end) + return Comparisons.is_before_endpoint(window.start) and not Comparisons.is_after_endpoint(window.end) @staticmethod def is_before_endpoint(endpoint: Optional["Window.Endpoint"]) -> bool: @@ -194,13 +192,8 @@ def compare_func(concept_set: "ConceptSet") -> bool: if concept_set.expression and source.expression and len(concept_set.expression.items) == len(source.expression.items): source_concepts = [item.concept for item in source.expression.items] return all( - any( - Comparisons.compare_concept(concept)(source_concept) - for source_concept in source_concepts - ) - for concept in [ - item.concept for item in concept_set.expression.items - ] + any(Comparisons.compare_concept(concept)(source_concept) for source_concept in source_concepts) + for concept in [item.concept for item in concept_set.expression.items] ) return False diff --git a/circe/check/checkers/concept_check.py b/circe/check/checkers/concept_check.py index 86e54d12..0505f663 100644 --- a/circe/check/checkers/concept_check.py +++ b/circe/check/checkers/concept_check.py @@ -19,9 +19,7 @@ class ConceptCheck(BaseValueCheck): Java equivalent: org.ohdsi.circe.check.checkers.ConceptCheck """ - def _get_factory( - self, reporter: WarningReporter, name: str - ) -> ConceptCheckerFactory: + def _get_factory(self, reporter: WarningReporter, name: str) -> ConceptCheckerFactory: """Get a concept checker factory. Args: diff --git a/circe/check/checkers/concept_checker_factory.py b/circe/check/checkers/concept_checker_factory.py index 8fbf6476..533c62d6 100644 --- a/circe/check/checkers/concept_checker_factory.py +++ b/circe/check/checkers/concept_checker_factory.py @@ -79,9 +79,7 @@ def __init__(self, reporter: WarningReporter, group_name: str): super().__init__(reporter, group_name) @staticmethod - def get_factory( - reporter: WarningReporter, group_name: str - ) -> "ConceptCheckerFactory": + def get_factory(reporter: WarningReporter, group_name: str) -> "ConceptCheckerFactory": """Get a factory instance. Args: @@ -155,9 +153,7 @@ def check_death(c: "Death") -> None: Constants.Criteria.DEATH, Constants.Attributes.DEATH_TYPE_ATTR, ) - self._check_concept( - c.gender, Constants.Criteria.DEATH, Constants.Attributes.GENDER_ATTR - ) + self._check_concept(c.gender, Constants.Criteria.DEATH, Constants.Attributes.GENDER_ATTR) def check_device_exposure(c: "DeviceExposure") -> None: self._check_concept( @@ -182,17 +178,11 @@ def check_device_exposure(c: "DeviceExposure") -> None: ) def check_dose_era(c: "DoseEra") -> None: - self._check_concept( - c.unit, Constants.Criteria.DOSE_ERA, Constants.Attributes.UNIT_ATTR - ) - self._check_concept( - c.gender, Constants.Criteria.DOSE_ERA, Constants.Attributes.GENDER_ATTR - ) + self._check_concept(c.unit, Constants.Criteria.DOSE_ERA, Constants.Attributes.UNIT_ATTR) + self._check_concept(c.gender, Constants.Criteria.DOSE_ERA, Constants.Attributes.GENDER_ATTR) def check_drug_era(c: "DrugEra") -> None: - self._check_concept( - c.gender, Constants.Criteria.DRUG_ERA, Constants.Attributes.GENDER_ATTR - ) + self._check_concept(c.gender, Constants.Criteria.DRUG_ERA, Constants.Attributes.GENDER_ATTR) def check_drug_exposure(c: "DrugExposure") -> None: self._check_concept( @@ -242,9 +232,7 @@ def check_measurement(c: "Measurement") -> None: Constants.Criteria.MEASUREMENT, Constants.Attributes.VALUE_AS_CONCEPT_ATTR, ) - self._check_concept( - c.unit, Constants.Criteria.MEASUREMENT, Constants.Attributes.UNIT_ATTR - ) + self._check_concept(c.unit, Constants.Criteria.MEASUREMENT, Constants.Attributes.UNIT_ATTR) self._check_concept( c.gender, Constants.Criteria.MEASUREMENT, @@ -277,9 +265,7 @@ def check_observation(c: "Observation") -> None: Constants.Criteria.OBSERVATION, Constants.Attributes.QUALIFIER_ATTR, ) - self._check_concept( - c.unit, Constants.Criteria.OBSERVATION, Constants.Attributes.UNIT_ATTR - ) + self._check_concept(c.unit, Constants.Criteria.OBSERVATION, Constants.Attributes.UNIT_ATTR) self._check_concept( c.gender, Constants.Criteria.OBSERVATION, @@ -336,9 +322,7 @@ def check_specimen(c: "Specimen") -> None: Constants.Criteria.SPECIMEN, Constants.Attributes.SPECIMEN_TYPE_ATTR, ) - self._check_concept( - c.unit, Constants.Criteria.SPECIMEN, Constants.Attributes.UNIT_ATTR - ) + self._check_concept(c.unit, Constants.Criteria.SPECIMEN, Constants.Attributes.UNIT_ATTR) self._check_concept( c.anatomic_site, Constants.Criteria.SPECIMEN, @@ -349,9 +333,7 @@ def check_specimen(c: "Specimen") -> None: Constants.Criteria.SPECIMEN, Constants.Attributes.DISEASE_STATUS_ATTR, ) - self._check_concept( - c.gender, Constants.Criteria.SPECIMEN, Constants.Attributes.GENDER_ATTR - ) + self._check_concept(c.gender, Constants.Criteria.SPECIMEN, Constants.Attributes.GENDER_ATTR) def check_visit_occurrence(c: "VisitOccurrence") -> None: self._check_concept( @@ -417,9 +399,7 @@ def default_check(c: "Criteria") -> None: else: return default_check - def _get_check_demographic( - self, criteria: "DemographicCriteria" - ) -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. Args: @@ -440,15 +420,11 @@ def check(c: "DemographicCriteria") -> None: Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.GENDER_ATTR, ) - self._check_concept( - c.race, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.RACE_ATTR - ) + self._check_concept(c.race, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.RACE_ATTR) return check - def _check_concept( - self, concepts: Optional[list["Concept"]], criteria_name: str, attribute: str - ) -> None: + def _check_concept(self, concepts: Optional[list["Concept"]], criteria_name: str, attribute: str) -> None: """Check if a concept array is empty. Args: @@ -460,6 +436,4 @@ def _check_concept( def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - Operations.match(concepts).when(lambda c: c is not None and len(c) == 0).then( - lambda c: warning(self.WARNING_EMPTY_VALUE) - ) + Operations.match(concepts).when(lambda c: c is not None and len(c) == 0).then(lambda c: warning(self.WARNING_EMPTY_VALUE)) diff --git a/circe/check/checkers/concept_set_criteria_check.py b/circe/check/checkers/concept_set_criteria_check.py index fa1ea016..dc568b65 100644 --- a/circe/check/checkers/concept_set_criteria_check.py +++ b/circe/check/checkers/concept_set_criteria_check.py @@ -31,9 +31,7 @@ class ConceptSetCriteriaCheck(BaseCriteriaCheck): Java equivalent: org.ohdsi.circe.check.checkers.ConceptSetCriteriaCheck """ - NO_CONCEPT_SET_ERROR = ( - "No concept set specified as part of a criteria at %s in %s criteria" - ) + NO_CONCEPT_SET_ERROR = "No concept set specified as part of a criteria at %s in %s criteria" def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. @@ -43,9 +41,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.WARNING - def _check_criteria( - self, criteria: "Criteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria(self, criteria: "Criteria", group_name: str, reporter: WarningReporter) -> None: """Check if a criteria has a concept set specified. Args: @@ -75,111 +71,25 @@ def _check_criteria( ) Operations.match(criteria).is_a(ConditionEra).then( - lambda c: ( - Operations.match(c) - .when(lambda ce: ce.codeset_id is None) - .then(add_warning) - ) + lambda c: Operations.match(c).when(lambda ce: ce.codeset_id is None).then(add_warning) ).is_a(ConditionOccurrence).then( - lambda c: ( - Operations.match(c) - .when( - lambda co: ( - co.codeset_id is None and co.condition_source_concept is None - ) - ) - .then(add_warning) - ) - ).is_a(Death).then( - lambda c: ( - Operations.match(c) - .when(lambda d: d.codeset_id is None) - .then(add_warning) - ) - ).is_a(DeviceExposure).then( - lambda c: ( - Operations.match(c) - .when( - lambda de: ( - de.codeset_id is None and de.device_source_concept is None - ) - ) - .then(add_warning) - ) - ).is_a(DoseEra).then( - lambda c: ( - Operations.match(c) - .when(lambda de: de.codeset_id is None) - .then(add_warning) - ) - ).is_a(DrugEra).then( - lambda c: ( - Operations.match(c) - .when(lambda de: de.codeset_id is None) - .then(add_warning) - ) + lambda c: Operations.match(c).when(lambda co: co.codeset_id is None and co.condition_source_concept is None).then(add_warning) + ).is_a(Death).then(lambda c: Operations.match(c).when(lambda d: d.codeset_id is None).then(add_warning)).is_a(DeviceExposure).then( + lambda c: Operations.match(c).when(lambda de: de.codeset_id is None and de.device_source_concept is None).then(add_warning) + ).is_a(DoseEra).then(lambda c: Operations.match(c).when(lambda de: de.codeset_id is None).then(add_warning)).is_a(DrugEra).then( + lambda c: Operations.match(c).when(lambda de: de.codeset_id is None).then(add_warning) ).is_a(DrugExposure).then( - lambda c: ( - Operations.match(c) - .when( - lambda de: de.codeset_id is None and de.drug_source_concept is None - ) - .then(add_warning) - ) + lambda c: Operations.match(c).when(lambda de: de.codeset_id is None and de.drug_source_concept is None).then(add_warning) ).is_a(Measurement).then( - lambda c: ( - Operations.match(c) - .when( - lambda m: ( - m.codeset_id is None and m.measurement_source_concept is None - ) - ) - .then(add_warning) - ) + lambda c: Operations.match(c).when(lambda m: m.codeset_id is None and m.measurement_source_concept is None).then(add_warning) ).is_a(Observation).then( - lambda c: ( - Operations.match(c) - .when( - lambda o: ( - o.codeset_id is None and o.observation_source_concept is None - ) - ) - .then(add_warning) - ) + lambda c: Operations.match(c).when(lambda o: o.codeset_id is None and o.observation_source_concept is None).then(add_warning) ).is_a(ProcedureOccurrence).then( - lambda c: ( - Operations.match(c) - .when( - lambda po: ( - po.codeset_id is None and po.procedure_source_concept is None - ) - ) - .then(add_warning) - ) + lambda c: Operations.match(c).when(lambda po: po.codeset_id is None and po.procedure_source_concept is None).then(add_warning) ).is_a(Specimen).then( - lambda c: ( - Operations.match(c) - .when( - lambda s: s.codeset_id is None and s.specimen_source_concept is None - ) - .then(add_warning) - ) + lambda c: Operations.match(c).when(lambda s: s.codeset_id is None and s.specimen_source_concept is None).then(add_warning) ).is_a(VisitOccurrence).then( - lambda c: ( - Operations.match(c) - .when( - lambda vo: vo.codeset_id is None and vo.visit_source_concept is None - ) - .then(add_warning) - ) + lambda c: Operations.match(c).when(lambda vo: vo.codeset_id is None and vo.visit_source_concept is None).then(add_warning) ).is_a(VisitDetail).then( - lambda c: ( - Operations.match(c) - .when( - lambda vd: ( - vd.codeset_id is None and vd.visit_detail_source_concept is None - ) - ) - .then(add_warning) - ) + lambda c: Operations.match(c).when(lambda vd: vd.codeset_id is None and vd.visit_detail_source_concept is None).then(add_warning) ) diff --git a/circe/check/checkers/concept_set_selection_check.py b/circe/check/checkers/concept_set_selection_check.py index 6580934e..628934ce 100644 --- a/circe/check/checkers/concept_set_selection_check.py +++ b/circe/check/checkers/concept_set_selection_check.py @@ -19,9 +19,7 @@ class ConceptSetSelectionCheck(BaseValueCheck): Java equivalent: org.ohdsi.circe.check.checkers.ConceptSetSelectionCheck """ - def _get_factory( - self, reporter: WarningReporter, name: str - ) -> ConceptSetSelectionCheckerFactory: + def _get_factory(self, reporter: WarningReporter, name: str) -> ConceptSetSelectionCheckerFactory: """Get a concept set selection checker factory. Args: diff --git a/circe/check/checkers/concept_set_selection_checker_factory.py b/circe/check/checkers/concept_set_selection_checker_factory.py index a60ab420..4d529f91 100644 --- a/circe/check/checkers/concept_set_selection_checker_factory.py +++ b/circe/check/checkers/concept_set_selection_checker_factory.py @@ -49,9 +49,7 @@ def __init__(self, reporter: WarningReporter, group_name: str): super().__init__(reporter, group_name) @staticmethod - def get_factory( - reporter: WarningReporter, group_name: str - ) -> "ConceptSetSelectionCheckerFactory": + def get_factory(reporter: WarningReporter, group_name: str) -> "ConceptSetSelectionCheckerFactory": """Get a factory instance. Args: @@ -101,13 +99,9 @@ def check(c: "VisitDetail") -> None: return check else: - return lambda c: ( - None - ) # No ConceptSetSelection checks for other criteria types + return lambda c: None # No ConceptSetSelection checks for other criteria types - def _get_check_demographic( - self, criteria: "DemographicCriteria" - ) -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. Args: @@ -135,6 +129,6 @@ def _check_concept_set_selection( def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - Operations.match(concept_set_selection).when( - lambda css: css is not None and css.codeset_id is None - ).then(lambda css: warning(self.WARNING_EMPTY_VALUE)) + Operations.match(concept_set_selection).when(lambda css: css is not None and css.codeset_id is None).then( + lambda css: warning(self.WARNING_EMPTY_VALUE) + ) diff --git a/circe/check/checkers/criteria_checker_factory.py b/circe/check/checkers/criteria_checker_factory.py index 941fad36..abc4276d 100644 --- a/circe/check/checkers/criteria_checker_factory.py +++ b/circe/check/checkers/criteria_checker_factory.py @@ -85,9 +85,7 @@ def get_factory(concept_set: "ConceptSet") -> "CriteriaCheckerFactory": """ return CriteriaCheckerFactory(concept_set) - def get_criteria_checker( - self, criteria: "Criteria" - ) -> Callable[["Criteria"], bool]: + def get_criteria_checker(self, criteria: "Criteria") -> Callable[["Criteria"], bool]: """Get a checker function that returns True if the criteria uses the concept set. Args: @@ -118,19 +116,13 @@ def check_condition_era(c: "ConditionEra") -> bool: return c.codeset_id == self._concept_set.id def check_condition_occurrence(c: "ConditionOccurrence") -> bool: - return ( - c.codeset_id == self._concept_set.id - or c.condition_source_concept == self._concept_set.id - ) + return c.codeset_id == self._concept_set.id or c.condition_source_concept == self._concept_set.id def check_death(c: "Death") -> bool: return c.codeset_id == self._concept_set.id def check_device_exposure(c: "DeviceExposure") -> bool: - return ( - c.codeset_id == self._concept_set.id - or c.device_source_concept == self._concept_set.id - ) + return c.codeset_id == self._concept_set.id or c.device_source_concept == self._concept_set.id def check_dose_era(c: "DoseEra") -> bool: return c.codeset_id == self._concept_set.id @@ -139,28 +131,16 @@ def check_drug_era(c: "DrugEra") -> bool: return c.codeset_id == self._concept_set.id def check_drug_exposure(c: "DrugExposure") -> bool: - return ( - c.codeset_id == self._concept_set.id - or c.drug_source_concept == self._concept_set.id - ) + return c.codeset_id == self._concept_set.id or c.drug_source_concept == self._concept_set.id def check_measurement(c: "Measurement") -> bool: - return ( - c.codeset_id == self._concept_set.id - or c.measurement_source_concept == self._concept_set.id - ) + return c.codeset_id == self._concept_set.id or c.measurement_source_concept == self._concept_set.id def check_observation(c: "Observation") -> bool: - return ( - c.codeset_id == self._concept_set.id - or c.observation_source_concept == self._concept_set.id - ) + return c.codeset_id == self._concept_set.id or c.observation_source_concept == self._concept_set.id def check_procedure_occurrence(c: "ProcedureOccurrence") -> bool: - return ( - c.codeset_id == self._concept_set.id - or c.procedure_source_concept == self._concept_set.id - ) + return c.codeset_id == self._concept_set.id or c.procedure_source_concept == self._concept_set.id def check_specimen(c: "Specimen") -> bool: return c.codeset_id == self._concept_set.id @@ -217,9 +197,7 @@ def default_check(c: "Criteria") -> bool: else: return default_check - def _get_concept_set_selection_suppliers( - self, criteria: "VisitDetail" - ) -> list[Callable[[], Optional["ConceptSetSelection"]]]: + def _get_concept_set_selection_suppliers(self, criteria: "VisitDetail") -> list[Callable[[], Optional["ConceptSetSelection"]]]: """Get suppliers for ConceptSetSelection fields in VisitDetail. Args: diff --git a/circe/check/checkers/criteria_contradictions_check.py b/circe/check/checkers/criteria_contradictions_check.py index b53f2ea0..fa757893 100644 --- a/circe/check/checkers/criteria_contradictions_check.py +++ b/circe/check/checkers/criteria_contradictions_check.py @@ -76,9 +76,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.WARNING - def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: """Collect criteria information. Args: @@ -89,9 +87,7 @@ def _check_criteria( name = f"{group_name} {CriteriaNameHelper.get_criteria_name(criteria.criteria)}" self._criteria_list.append(CriteriaInfo(name, criteria)) - def _after_check( - self, reporter: WarningReporter, expression: "CohortExpression" - ) -> None: + def _after_check(self, reporter: WarningReporter, expression: "CohortExpression") -> None: """Check for contradictions after all criteria have been collected. Args: @@ -103,16 +99,12 @@ def _after_check( for i in range(size - 1): info = self._criteria_list[i] for other_info in self._criteria_list[i + 1 :]: - if Comparisons.compare_criteria( - info.criteria.criteria, other_info.criteria.criteria - ) and self._check_contradiction( + if Comparisons.compare_criteria(info.criteria.criteria, other_info.criteria.criteria) and self._check_contradiction( info.criteria.occurrence, other_info.criteria.occurrence ): reporter(self.WARNING, info.name, other_info.name) - def _check_contradiction( - self, o1: Optional["Occurrence"], o2: Optional["Occurrence"] - ) -> bool: + def _check_contradiction(self, o1: Optional["Occurrence"], o2: Optional["Occurrence"]) -> bool: """Check if two occurrences contradict each other. Args: diff --git a/circe/check/checkers/death_time_window_check.py b/circe/check/checkers/death_time_window_check.py index f2c9e7f4..62f3da36 100644 --- a/circe/check/checkers/death_time_window_check.py +++ b/circe/check/checkers/death_time_window_check.py @@ -43,9 +43,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.WARNING - def _internal_check( - self, expression: "CohortExpression", reporter: WarningReporter - ) -> None: + def _internal_check(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check death criteria in inclusion rules and other locations. Args: @@ -64,13 +62,9 @@ def _internal_check( # Check primary criteria if expression.primary_criteria and expression.primary_criteria.criteria_list: - self._check_criteria_list( - expression.primary_criteria.criteria_list, self.INITIAL_EVENT, reporter - ) + self._check_criteria_list(expression.primary_criteria.criteria_list, self.INITIAL_EVENT, reporter) - def _check_criteria_list( - self, criteria_list, group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria_list(self, criteria_list, group_name: str, reporter: WarningReporter) -> None: """Check a list of criteria. Args: @@ -92,9 +86,7 @@ def _check_criteria_list( if criteria: self._check_criteria_group(criteria, group_name, reporter) - def _check_criteria_group( - self, criteria: "Criteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria_group(self, criteria: "Criteria", group_name: str, reporter: WarningReporter) -> None: """Check a criteria and its correlated criteria. Args: @@ -111,13 +103,9 @@ def _check_criteria_group( for group in correlated.groups: if hasattr(group, "criteria_list") and group.criteria_list: for corelated_criteria in group.criteria_list: - self._check_criteria( - corelated_criteria, group_name, reporter - ) + self._check_criteria(corelated_criteria, group_name, reporter) - def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: """Check a corelated criteria for death time window issues. Args: @@ -131,8 +119,6 @@ def _check_criteria( match_result.is_a(Death) match_result.then( lambda death: ( - Operations.match(criteria) - .when(lambda c: Comparisons.is_before(c.start_window)) - .then(lambda c: reporter(self.MESSAGE, name)) + Operations.match(criteria).when(lambda c: Comparisons.is_before(c.start_window)).then(lambda c: reporter(self.MESSAGE, name)) ) ) diff --git a/circe/check/checkers/domain_type_check.py b/circe/check/checkers/domain_type_check.py index 12596be4..71a6c00e 100644 --- a/circe/check/checkers/domain_type_check.py +++ b/circe/check/checkers/domain_type_check.py @@ -47,9 +47,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.INFO - def _check_criteria( - self, criteria: "Criteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria(self, criteria: "Criteria", group_name: str, reporter: WarningReporter) -> None: """Check if a criteria has a domain type specified. Args: @@ -77,70 +75,22 @@ def add_warning() -> None: ) Operations.match(criteria).is_a(ConditionOccurrence).then( - lambda c: ( - Operations.match(c) - .when(lambda co: co.condition_type is None) - .then(lambda co: add_warning()) - ) - ).is_a(Death).then( - lambda c: ( - Operations.match(c) - .when(lambda d: d.death_type is None) - .then(lambda d: add_warning()) - ) - ).is_a(DeviceExposure).then( - lambda c: ( - Operations.match(c) - .when(lambda de: de.device_type is None) - .then(lambda de: add_warning()) - ) - ).is_a(DrugExposure).then( - lambda c: ( - Operations.match(c) - .when(lambda de: de.drug_type is None) - .then(lambda de: add_warning()) - ) - ).is_a(Measurement).then( - lambda c: ( - Operations.match(c) - .when(lambda m: m.measurement_type is None) - .then(lambda m: add_warning()) - ) - ).is_a(Observation).then( - lambda c: ( - Operations.match(c) - .when(lambda o: o.observation_type is None) - .then(lambda o: add_warning()) - ) - ).is_a(ProcedureOccurrence).then( - lambda c: ( - Operations.match(c) - .when(lambda po: po.procedure_type is None) - .then(lambda po: add_warning()) - ) - ).is_a(Specimen).then( - lambda c: ( - Operations.match(c) - .when(lambda s: s.specimen_type is None) - .then(lambda s: add_warning()) - ) - ).is_a(VisitOccurrence).then( - lambda c: ( - Operations.match(c) - .when(lambda vo: vo.visit_type is None) - .then(lambda vo: add_warning()) - ) - ).is_a(VisitDetail).then( - lambda c: ( - Operations.match(c) - .when(lambda vd: vd.visit_detail_type_cs is None) - .then(lambda vd: add_warning()) - ) + lambda c: Operations.match(c).when(lambda co: co.condition_type is None).then(lambda co: add_warning()) + ).is_a(Death).then(lambda c: Operations.match(c).when(lambda d: d.death_type is None).then(lambda d: add_warning())).is_a( + DeviceExposure + ).then(lambda c: Operations.match(c).when(lambda de: de.device_type is None).then(lambda de: add_warning())).is_a(DrugExposure).then( + lambda c: Operations.match(c).when(lambda de: de.drug_type is None).then(lambda de: add_warning()) + ).is_a(Measurement).then(lambda c: Operations.match(c).when(lambda m: m.measurement_type is None).then(lambda m: add_warning())).is_a( + Observation + ).then(lambda c: Operations.match(c).when(lambda o: o.observation_type is None).then(lambda o: add_warning())).is_a(ProcedureOccurrence).then( + lambda c: Operations.match(c).when(lambda po: po.procedure_type is None).then(lambda po: add_warning()) + ).is_a(Specimen).then(lambda c: Operations.match(c).when(lambda s: s.specimen_type is None).then(lambda s: add_warning())).is_a( + VisitOccurrence + ).then(lambda c: Operations.match(c).when(lambda vo: vo.visit_type is None).then(lambda vo: add_warning())).is_a(VisitDetail).then( + lambda c: Operations.match(c).when(lambda vd: vd.visit_detail_type_cs is None).then(lambda vd: add_warning()) ) - def _after_check( - self, reporter: WarningReporter, expression: "CohortExpression" - ) -> None: + def _after_check(self, reporter: WarningReporter, expression: "CohortExpression") -> None: """Report warnings after all criteria have been checked. Args: diff --git a/circe/check/checkers/drug_domain_check.py b/circe/check/checkers/drug_domain_check.py index 84e57a25..087db6f6 100644 --- a/circe/check/checkers/drug_domain_check.py +++ b/circe/check/checkers/drug_domain_check.py @@ -54,19 +54,13 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N expression: The cohort expression to check reporter: The warning reporter to use """ - if ( - not expression.primary_criteria - or not expression.primary_criteria.criteria_list - ): + if not expression.primary_criteria or not expression.primary_criteria.criteria_list: return concept_sets: list[ConceptSet] = [] # Map criteria to codeset IDs - codeset_ids = [ - self._map_criteria(criteria) - for criteria in expression.primary_criteria.criteria_list - ] + codeset_ids = [self._map_criteria(criteria) for criteria in expression.primary_criteria.criteria_list] # Filter to only drug domain concept sets for codeset_id in codeset_ids: @@ -77,11 +71,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N # Filter out concept sets used in exit strategy if isinstance(expression.end_strategy, CustomEraStrategy): - concept_sets = [ - cs - for cs in concept_sets - if cs.id != expression.end_strategy.drug_codeset_id - ] + concept_sets = [cs for cs in concept_sets if cs.id != expression.end_strategy.drug_codeset_id] if concept_sets: names = ", ".join(cs.name for cs in concept_sets) @@ -145,9 +135,7 @@ def _map_criteria(self, criteria: "Criteria") -> Optional[int]: .value() ) - def _is_concept_in_drug_domain( - self, expression: "CohortExpression", codeset_id: int - ) -> bool: + def _is_concept_in_drug_domain(self, expression: "CohortExpression", codeset_id: int) -> bool: """Check if a concept set contains drug domain concepts. Args: @@ -160,26 +148,13 @@ def _is_concept_in_drug_domain( if not expression.concept_sets: return False - concept_set = next( - (cs for cs in expression.concept_sets if cs.id == codeset_id), None - ) - if ( - not concept_set - or not concept_set.expression - or not concept_set.expression.items - ): + concept_set = next((cs for cs in expression.concept_sets if cs.id == codeset_id), None) + if not concept_set or not concept_set.expression or not concept_set.expression.items: return False - return any( - item.concept - and item.concept.domain_id - and item.concept.domain_id.upper() == "DRUG" - for item in concept_set.expression.items - ) + return any(item.concept and item.concept.domain_id and item.concept.domain_id.upper() == "DRUG" for item in concept_set.expression.items) - def _map_concept_set( - self, expression: "CohortExpression", codeset_id: int - ) -> Optional["ConceptSet"]: + def _map_concept_set(self, expression: "CohortExpression", codeset_id: int) -> Optional["ConceptSet"]: """Map a codeset ID to a concept set. Args: diff --git a/circe/check/checkers/drug_era_check.py b/circe/check/checkers/drug_era_check.py index 80887f7f..c7645ccf 100644 --- a/circe/check/checkers/drug_era_check.py +++ b/circe/check/checkers/drug_era_check.py @@ -39,9 +39,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.INFO - def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: """Check drug era criteria for missing days supply information. Args: diff --git a/circe/check/checkers/duplicates_concept_set_check.py b/circe/check/checkers/duplicates_concept_set_check.py index f782f2df..58d5a138 100644 --- a/circe/check/checkers/duplicates_concept_set_check.py +++ b/circe/check/checkers/duplicates_concept_set_check.py @@ -23,6 +23,7 @@ with contextlib.suppress(ImportError): from ...cohortdefinition.cohort import CohortExpression + class DuplicatesConceptSetCheck(BaseCheck): """Check for duplicate concept sets. @@ -52,9 +53,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N concept_set = expression.concept_sets[i] # Create comparison function for this concept set compare_func = Comparisons.compare_concept_set(concept_set) - duplicates = [ - cs for cs in expression.concept_sets[i + 1 :] if compare_func(cs) - ] + duplicates = [cs for cs in expression.concept_sets[i + 1 :] if compare_func(cs)] if duplicates: names = ", ".join(cs.name for cs in duplicates) reporter(self.DUPLICATES_WARNING, concept_set.name, names) diff --git a/circe/check/checkers/duplicates_criteria_check.py b/circe/check/checkers/duplicates_criteria_check.py index 3a981662..1d37b793 100644 --- a/circe/check/checkers/duplicates_criteria_check.py +++ b/circe/check/checkers/duplicates_criteria_check.py @@ -38,9 +38,7 @@ def __init__(self): super().__init__() self._criteria_list: list[tuple[str, Criteria]] = [] - def _after_check( - self, reporter: WarningReporter, expression: "CohortExpression" - ) -> None: + def _after_check(self, reporter: WarningReporter, expression: "CohortExpression") -> None: """Check for duplicates after all criteria have been collected. Args: @@ -50,11 +48,7 @@ def _after_check( if len(self._criteria_list) > 1: for i in range(len(self._criteria_list) - 1): criteria, criteria_obj = self._criteria_list[i] - duplicates = [ - (name, obj) - for name, obj in self._criteria_list[i + 1 :] - if self._compare_criteria(criteria_obj, obj) - ] + duplicates = [(name, obj) for name, obj in self._criteria_list[i + 1 :] if self._compare_criteria(criteria_obj, obj)] if duplicates: names = ", ".join(name for name, _ in duplicates) reporter(self.DUPLICATE_WARNING, criteria, names) @@ -102,10 +96,7 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: if isinstance(c1, ConditionEra): return c1.codeset_id == c2.codeset_id elif isinstance(c1, ConditionOccurrence): - return ( - c1.codeset_id == c2.codeset_id - and c1.condition_source_concept == c2.condition_source_concept - ) + return c1.codeset_id == c2.codeset_id and c1.condition_source_concept == c2.condition_source_concept elif isinstance( c1, ( @@ -126,9 +117,7 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: and self._compare_objects(c1.period_end_date, c2.period_end_date) and self._compare_objects(c1.period_length, c2.period_length) ) - elif isinstance( - c1, (ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail) - ): + elif isinstance(c1, (ProcedureOccurrence, Specimen, VisitOccurrence, VisitDetail)): return c1.codeset_id == c2.codeset_id elif isinstance(c1, PayerPlanPeriod): return ( @@ -169,9 +158,7 @@ def _compare_objects_reflection(self, obj1, obj2) -> bool: """ return obj1 == obj2 - def _check_criteria( - self, criteria: "Criteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria(self, criteria: "Criteria", group_name: str, reporter: WarningReporter) -> None: """Collect criteria for duplicate checking. Args: @@ -179,9 +166,5 @@ def _check_criteria( group_name: The name of the group containing this criteria reporter: The warning reporter to use (not used here, but kept for interface) """ - criteria_name = ( - CriteriaNameHelper.get_criteria_name(criteria) - + " criteria in " - + group_name - ) + criteria_name = CriteriaNameHelper.get_criteria_name(criteria) + " criteria in " + group_name self._criteria_list.append((criteria_name, criteria)) diff --git a/circe/check/checkers/empty_concept_set_check.py b/circe/check/checkers/empty_concept_set_check.py index 56a28de6..7139a233 100644 --- a/circe/check/checkers/empty_concept_set_check.py +++ b/circe/check/checkers/empty_concept_set_check.py @@ -38,9 +38,5 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N """ if expression.concept_sets: for concept_set in expression.concept_sets: - if ( - not concept_set.expression - or not concept_set.expression.items - or len(concept_set.expression.items) == 0 - ): + if not concept_set.expression or not concept_set.expression.items or len(concept_set.expression.items) == 0: reporter(self.EMPTY_ERROR, concept_set.name) diff --git a/circe/check/checkers/events_progression_check.py b/circe/check/checkers/events_progression_check.py index 92f21d6d..f515266b 100644 --- a/circe/check/checkers/events_progression_check.py +++ b/circe/check/checkers/events_progression_check.py @@ -119,10 +119,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N if initial_weight - cohort_initial_weight < 0: reporter(self.WARNING, "Cohort of initial events") - if ( - cohort_initial_weight - qualifying_weight < 0 - or initial_weight - qualifying_weight < 0 - ): + if cohort_initial_weight - qualifying_weight < 0 or initial_weight - qualifying_weight < 0: reporter(self.WARNING, "Qualifying cohort") def _get_weight(self, limit: Optional["ResultLimit"]) -> int: diff --git a/circe/check/checkers/exit_criteria_check.py b/circe/check/checkers/exit_criteria_check.py index 5073d063..2eb24ed7 100644 --- a/circe/check/checkers/exit_criteria_check.py +++ b/circe/check/checkers/exit_criteria_check.py @@ -42,9 +42,5 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N match_result = Operations.match(expression.end_strategy) match_result.is_a(CustomEraStrategy) match_result.then( - lambda s: ( - Operations.match(s) - .when(lambda ces: ces.drug_codeset_id is None) - .then(lambda ces: reporter(self.DRUG_CONCEPT_EMPTY_ERROR)) - ) + lambda s: Operations.match(s).when(lambda ces: ces.drug_codeset_id is None).then(lambda ces: reporter(self.DRUG_CONCEPT_EMPTY_ERROR)) ) diff --git a/circe/check/checkers/exit_criteria_days_offset_check.py b/circe/check/checkers/exit_criteria_days_offset_check.py index fad45f54..b98d2353 100644 --- a/circe/check/checkers/exit_criteria_days_offset_check.py +++ b/circe/check/checkers/exit_criteria_days_offset_check.py @@ -31,9 +31,7 @@ class ExitCriteriaDaysOffsetCheck(BaseCheck): Java equivalent: org.ohdsi.circe.check.checkers.ExitCriteriaDaysOffsetCheck """ - DAYS_OFFSET_WARNING = ( - "Cohort Exit criteria: Days offset from start date should be greater than 0" - ) + DAYS_OFFSET_WARNING = "Cohort Exit criteria: Days offset from start date should be greater than 0" def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. @@ -55,11 +53,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N match_result.then( lambda s: ( Operations.match(s) - .when( - lambda dos: ( - dos.date_field == DateType.START_DATE and dos.offset == 0 - ) - ) + .when(lambda dos: dos.date_field == DateType.START_DATE and dos.offset == 0) .then(lambda dos: reporter(self.DAYS_OFFSET_WARNING)) ) ) diff --git a/circe/check/checkers/incomplete_rule_check.py b/circe/check/checkers/incomplete_rule_check.py index 0bfe417b..f32b7019 100644 --- a/circe/check/checkers/incomplete_rule_check.py +++ b/circe/check/checkers/incomplete_rule_check.py @@ -32,9 +32,7 @@ class IncompleteRuleCheck(BaseCheck): Java equivalent: org.ohdsi.circe.check.checkers.IncompleteRuleCheck """ - def _get_reporter( - self, severity: WarningSeverity, warnings: list[Warning] - ) -> WarningReporter: + def _get_reporter(self, severity: WarningSeverity, warnings: list[Warning]) -> WarningReporter: """Get a warning reporter that creates IncompleteRuleWarning instances. Args: @@ -61,9 +59,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N for rule in expression.inclusion_rules: self._check_inclusion_rule(rule, reporter) - def _check_inclusion_rule( - self, rule: "InclusionRule", reporter: WarningReporter - ) -> None: + def _check_inclusion_rule(self, rule: "InclusionRule", reporter: WarningReporter) -> None: """Check if an inclusion rule is incomplete. Args: @@ -72,14 +68,8 @@ def _check_inclusion_rule( """ # Check if expression is empty if not rule.expression or ( - ( - not hasattr(rule.expression, "criteria_list") - or not rule.expression.criteria_list - ) - and ( - not hasattr(rule.expression, "demographic_criteria_list") - or not rule.expression.demographic_criteria_list - ) + (not hasattr(rule.expression, "criteria_list") or not rule.expression.criteria_list) + and (not hasattr(rule.expression, "demographic_criteria_list") or not rule.expression.demographic_criteria_list) and (not hasattr(rule.expression, "groups") or not rule.expression.groups) ): reporter(rule.name) diff --git a/circe/check/checkers/initial_event_check.py b/circe/check/checkers/initial_event_check.py index 68fabcd4..e56543dd 100644 --- a/circe/check/checkers/initial_event_check.py +++ b/circe/check/checkers/initial_event_check.py @@ -39,10 +39,6 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N """ match_result = Operations.match(expression) match_result.when( - lambda e: ( - e.primary_criteria is None - or e.primary_criteria.criteria_list is None - or len(e.primary_criteria.criteria_list) == 0 - ) + lambda e: e.primary_criteria is None or e.primary_criteria.criteria_list is None or len(e.primary_criteria.criteria_list) == 0 ) match_result.then(lambda e: reporter(self.NO_INITIAL_EVENT_ERROR)) diff --git a/circe/check/checkers/no_exit_criteria_check.py b/circe/check/checkers/no_exit_criteria_check.py index 5cd4c773..f809212d 100644 --- a/circe/check/checkers/no_exit_criteria_check.py +++ b/circe/check/checkers/no_exit_criteria_check.py @@ -29,9 +29,7 @@ class NoExitCriteriaCheck(BaseCheck): Java equivalent: org.ohdsi.circe.check.checkers.NoExitCriteriaCheck """ - NO_EXIT_CRITERIA_WARNING = ( - ' "all events" are selected and cohort exit criteria has not been specified' - ) + NO_EXIT_CRITERIA_WARNING = ' "all events" are selected and cohort exit criteria has not been specified' def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. @@ -59,14 +57,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N and e.expression_limit and e.expression_limit.type and e.expression_limit.type.upper() == "ALL" - and ( - e.additional_criteria is None - or ( - e.qualified_limit - and e.qualified_limit.type - and e.qualified_limit.type.upper() == "ALL" - ) - ) + and (e.additional_criteria is None or (e.qualified_limit and e.qualified_limit.type and e.qualified_limit.type.upper() == "ALL")) ) ) match_result.then(lambda e: reporter(self.NO_EXIT_CRITERIA_WARNING)) diff --git a/circe/check/checkers/ocurrence_check.py b/circe/check/checkers/ocurrence_check.py index 2ee2aa12..67367861 100644 --- a/circe/check/checkers/ocurrence_check.py +++ b/circe/check/checkers/ocurrence_check.py @@ -36,9 +36,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.WARNING - def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: """Check occurrence for invalid values. Args: diff --git a/circe/check/checkers/range_check.py b/circe/check/checkers/range_check.py index 492dc8d8..a67058cb 100644 --- a/circe/check/checkers/range_check.py +++ b/circe/check/checkers/range_check.py @@ -42,9 +42,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N reporter: The warning reporter to use """ super()._check(expression, reporter) - RangeCheckerFactory.get_factory(reporter, self.PRIMARY_CRITERIA).check( - expression - ) + RangeCheckerFactory.get_factory(reporter, self.PRIMARY_CRITERIA).check(expression) if expression.primary_criteria: self._check_observation_filter( @@ -53,13 +51,9 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N "observation window", ) - RangeCheckerFactory.get_factory(reporter, self.PRIMARY_CRITERIA).check_range( - expression.censor_window, "cohort", "censor window" - ) + RangeCheckerFactory.get_factory(reporter, self.PRIMARY_CRITERIA).check_range(expression.censor_window, "cohort", "censor window") - def _check_inclusion_rules( - self, expression: "CohortExpression", reporter: WarningReporter - ) -> None: + def _check_inclusion_rules(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check inclusion rules for window issues. Args: @@ -74,19 +68,11 @@ def _check_inclusion_rules( for criteria in rule.expression.criteria_list: # Handle both dict and CorelatedCriteria objects if isinstance(criteria, dict): - start_window = criteria.get("startWindow") or criteria.get( - "start_window" - ) - end_window = criteria.get("endWindow") or criteria.get( - "end_window" - ) + start_window = criteria.get("startWindow") or criteria.get("start_window") + end_window = criteria.get("endWindow") or criteria.get("end_window") else: - start_window = getattr( - criteria, "start_window", None - ) or getattr(criteria, "startWindow", None) - end_window = getattr( - criteria, "end_window", None - ) or getattr(criteria, "endWindow", None) + start_window = getattr(criteria, "start_window", None) or getattr(criteria, "startWindow", None) + end_window = getattr(criteria, "end_window", None) or getattr(criteria, "endWindow", None) self._check_window(start_window, reporter, rule.name) self._check_window(end_window, reporter, rule.name) @@ -105,32 +91,18 @@ def _check_window(self, window, reporter: WarningReporter, name: str) -> None: end = window.get("end") or window.get("End") if start: - start_days = ( - start.get("days") - if isinstance(start, dict) - else getattr(start, "days", None) - ) + start_days = start.get("days") if isinstance(start, dict) else getattr(start, "days", None) if start_days is not None and start_days < 0: reporter(self.NEGATIVE_VALUE_ERROR, name, start_days, "start") if end: - end_days = ( - end.get("days") - if isinstance(end, dict) - else getattr(end, "days", None) - ) + end_days = end.get("days") if isinstance(end, dict) else getattr(end, "days", None) if end_days is not None and end_days < 0: reporter(self.NEGATIVE_VALUE_ERROR, name, end_days, "end") else: # Window object - if ( - window.start - and window.start.days is not None - and window.start.days < 0 - ): - reporter( - self.NEGATIVE_VALUE_ERROR, name, window.start.days, "start" - ) + if window.start and window.start.days is not None and window.start.days < 0: + reporter(self.NEGATIVE_VALUE_ERROR, name, window.start.days, "start") if window.end and window.end.days is not None and window.end.days < 0: reporter(self.NEGATIVE_VALUE_ERROR, name, window.end.days, "end") @@ -149,13 +121,9 @@ def _check_observation_filter( """ if filter_val: if filter_val.prior_days < 0: - reporter( - self.NEGATIVE_VALUE_ERROR, name, filter_val.prior_days, "prior days" - ) + reporter(self.NEGATIVE_VALUE_ERROR, name, filter_val.prior_days, "prior days") if filter_val.post_days < 0: - reporter( - self.NEGATIVE_VALUE_ERROR, name, filter_val.post_days, "post days" - ) + reporter(self.NEGATIVE_VALUE_ERROR, name, filter_val.post_days, "post days") def _check_criteria(self, criteria, reporter: WarningReporter, name: str) -> None: """Check a corelated criteria for window issues. @@ -173,12 +141,8 @@ def _check_criteria(self, criteria, reporter: WarningReporter, name: str) -> Non end_window = criteria.get("endWindow") or criteria.get("end_window") else: # CorelatedCriteria object - start_window = getattr(criteria, "start_window", None) or getattr( - criteria, "startWindow", None - ) - end_window = getattr(criteria, "end_window", None) or getattr( - criteria, "endWindow", None - ) + start_window = getattr(criteria, "start_window", None) or getattr(criteria, "startWindow", None) + end_window = getattr(criteria, "end_window", None) or getattr(criteria, "endWindow", None) self._check_window(start_window, reporter, name) self._check_window(end_window, reporter, name) diff --git a/circe/check/checkers/range_checker_factory.py b/circe/check/checkers/range_checker_factory.py index f0129592..8786825f 100644 --- a/circe/check/checkers/range_checker_factory.py +++ b/circe/check/checkers/range_checker_factory.py @@ -74,9 +74,7 @@ class RangeCheckerFactory(BaseCheckerFactory): WARNING_EMPTY_START_VALUE = "%s in the %s has empty %s start value" WARNING_EMPTY_END_VALUE = "%s in the %s has empty %s end value" - WARNING_START_GREATER_THAN_END = ( - "%s in the %s has start value greater than end in %s" - ) + WARNING_START_GREATER_THAN_END = "%s in the %s has start value greater than end in %s" WARNING_START_IS_NEGATIVE = "%s in the %s start value is negative at %s" WARNING_DATE_IS_INVALID = "%s in the %s has invalid date value at %s" ROOT_OBJECT = "root object" @@ -91,9 +89,7 @@ def __init__(self, reporter: WarningReporter, group_name: str): super().__init__(reporter, group_name) @staticmethod - def get_factory( - reporter: WarningReporter, group_name: str - ) -> "RangeCheckerFactory": + def get_factory(reporter: WarningReporter, group_name: str) -> "RangeCheckerFactory": """Get a factory instance. Args: @@ -192,9 +188,7 @@ def check(c: "ConditionOccurrence") -> None: elif isinstance(criteria, Death): def check(c: "Death") -> None: - self._check_range( - c.age, Constants.Criteria.DEATH, Constants.Attributes.AGE_ATTR - ) + self._check_range(c.age, Constants.Criteria.DEATH, Constants.Attributes.AGE_ATTR) self._check_range( c.occurrence_start_date, Constants.Criteria.DEATH, @@ -375,9 +369,7 @@ def check(c: "Measurement") -> None: Constants.Criteria.MEASUREMENT, Constants.Attributes.RANGE_HIGH_RATIO_ATTR, ) - self._check_range( - c.age, Constants.Criteria.MEASUREMENT, Constants.Attributes.AGE_ATTR - ) + self._check_range(c.age, Constants.Criteria.MEASUREMENT, Constants.Attributes.AGE_ATTR) return check elif isinstance(criteria, Observation): @@ -393,9 +385,7 @@ def check(c: "Observation") -> None: Constants.Criteria.OBSERVATION, Constants.Attributes.VALUE_AS_NUMBER_ATTR, ) - self._check_range( - c.age, Constants.Criteria.OBSERVATION, Constants.Attributes.AGE_ATTR - ) + self._check_range(c.age, Constants.Criteria.OBSERVATION, Constants.Attributes.AGE_ATTR) return check elif isinstance(criteria, ObservationPeriod): @@ -466,9 +456,7 @@ def check(c: "Specimen") -> None: Constants.Criteria.SPECIMEN, Constants.Attributes.QUANTITY_ATTR, ) - self._check_range( - c.age, Constants.Criteria.SPECIMEN, Constants.Attributes.AGE_ATTR - ) + self._check_range(c.age, Constants.Criteria.SPECIMEN, Constants.Attributes.AGE_ATTR) return check elif isinstance(criteria, VisitOccurrence): @@ -578,9 +566,7 @@ def default_check(c) -> None: return default_check - def _get_check_demographic( - self, criteria: "DemographicCriteria" - ) -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. Args: @@ -601,9 +587,7 @@ def check(c: "DemographicCriteria") -> None: Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.OCCURRENCE_START_DATE_ATTR, ) - self._check_range( - c.age, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.AGE_ATTR - ) + self._check_range(c.age, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.AGE_ATTR) return check @@ -627,9 +611,9 @@ def warning(template: str) -> None: if isinstance(range_val, DateRange): # Date range checks match_result = Operations.match(range_val) - match_result.when( - lambda r: r.value is not None and not Comparisons.is_date_valid(r.value) - ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) + match_result.when(lambda r: r.value is not None and not Comparisons.is_date_valid(r.value)).then( + lambda x: warning(self.WARNING_DATE_IS_INVALID) + ) match_result.when(lambda r: r.op is not None and r.op.endswith("bt")).then( lambda r: ( Operations.match(r) @@ -637,23 +621,14 @@ def warning(template: str) -> None: .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) .when(lambda x: x.extent is None) .then(lambda x: warning(self.WARNING_EMPTY_END_VALUE)) - .when( - lambda x: ( - x.extent is not None - and not Comparisons.is_date_valid(x.extent) - ) - ) + .when(lambda x: x.extent is not None and not Comparisons.is_date_valid(x.extent)) .then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) .when(Comparisons.start_is_greater_than_end) .then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) ) ) match_result.or_else( - lambda r: ( - Operations.match(r) - .when(lambda x: x.value is None) - .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) - ) + lambda r: Operations.match(r).when(lambda x: x.value is None).then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) ) elif isinstance(range_val, NumericRange): # Numeric range checks @@ -670,16 +645,10 @@ def warning(template: str) -> None: ) ) match_result.or_else( - lambda r: ( - Operations.match(r) - .when(lambda x: x.value is None) - .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) - ) + lambda r: Operations.match(r).when(lambda x: x.value is None).then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) ) - def check_range( - self, period: Optional["Period"], criteria_name: str, attribute: str - ) -> None: + def check_range(self, period: Optional["Period"], criteria_name: str, attribute: str) -> None: """Check a period. Args: @@ -694,19 +663,13 @@ def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) match_result = Operations.match(period) - match_result.when( - lambda x: ( - x.start_date is not None and not Comparisons.is_date_valid(x.start_date) - ) - ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) - match_result.when( - lambda x: ( - x.end_date is not None and not Comparisons.is_date_valid(x.end_date) - ) - ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) - match_result.when(Comparisons.start_is_greater_than_end).then( - lambda x: warning(self.WARNING_START_GREATER_THAN_END) + match_result.when(lambda x: x.start_date is not None and not Comparisons.is_date_valid(x.start_date)).then( + lambda x: warning(self.WARNING_DATE_IS_INVALID) + ) + match_result.when(lambda x: x.end_date is not None and not Comparisons.is_date_valid(x.end_date)).then( + lambda x: warning(self.WARNING_DATE_IS_INVALID) ) + match_result.when(Comparisons.start_is_greater_than_end).then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) def check(self, expression_or_criteria) -> None: """Check the cohort expression's censor window or individual criteria. diff --git a/circe/check/checkers/text_checker_factory.py b/circe/check/checkers/text_checker_factory.py index 9af4f21d..40a3c0a1 100644 --- a/circe/check/checkers/text_checker_factory.py +++ b/circe/check/checkers/text_checker_factory.py @@ -149,9 +149,7 @@ def check(c: "Specimen") -> None: else: return lambda c: None # No text checks for other criteria types - def _get_check_demographic( - self, criteria: "DemographicCriteria" - ) -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. Args: @@ -162,9 +160,7 @@ def _get_check_demographic( """ return lambda c: None # No text filters in demographic criteria - def _check_text( - self, text_filter: Optional["TextFilter"], criteria_name: str, attribute: str - ) -> None: + def _check_text(self, text_filter: Optional["TextFilter"], criteria_name: str, attribute: str) -> None: """Check if a TextFilter has an empty text value. Args: @@ -176,6 +172,4 @@ def _check_text( def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - Operations.match(text_filter).when( - lambda tf: tf is not None and tf.text is None - ).then(lambda tf: warning(self.WARNING_EMPTY_VALUE)) + Operations.match(text_filter).when(lambda tf: tf is not None and tf.text is None).then(lambda tf: warning(self.WARNING_EMPTY_VALUE)) diff --git a/circe/check/checkers/time_pattern_check.py b/circe/check/checkers/time_pattern_check.py index 54c62b0c..75b33cdc 100644 --- a/circe/check/checkers/time_pattern_check.py +++ b/circe/check/checkers/time_pattern_check.py @@ -83,9 +83,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.INFO - def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: """Collect time window information. Args: @@ -94,13 +92,9 @@ def _check_criteria( reporter: The warning reporter to use """ name = f"{CriteriaNameHelper.get_criteria_name(criteria.criteria)} criteria at {group_name}" - self._time_window_info_list.append( - TimeWindowInfo(name, criteria.start_window, criteria.end_window) - ) + self._time_window_info_list.append(TimeWindowInfo(name, criteria.start_window, criteria.end_window)) - def _after_check( - self, reporter: WarningReporter, expression: "CohortExpression" - ) -> None: + def _after_check(self, reporter: WarningReporter, expression: "CohortExpression") -> None: """Check for inconsistent time window patterns. Args: @@ -111,9 +105,7 @@ def _after_check( return # Calculate start days for each time window - start_days = [ - self._start_days(info.start) for info in self._time_window_info_list - ] + start_days = [self._start_days(info.start) for info in self._time_window_info_list] # Count frequency of each start day value freq = Counter(start_days) @@ -123,11 +115,7 @@ def _after_check( # Find the most common pattern most_common_value = max(freq, key=freq.get) most_common_info = next( - ( - info - for info in self._time_window_info_list - if self._start_days(info.start) == most_common_value - ), + (info for info in self._time_window_info_list if self._start_days(info.start) == most_common_value), None, ) diff --git a/circe/check/checkers/time_window_check.py b/circe/check/checkers/time_window_check.py index 0529ed4e..8734135f 100644 --- a/circe/check/checkers/time_window_check.py +++ b/circe/check/checkers/time_window_check.py @@ -52,9 +52,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.INFO - def _before_check( - self, reporter: WarningReporter, expression: "CohortExpression" - ) -> None: + def _before_check(self, reporter: WarningReporter, expression: "CohortExpression") -> None: """Store the observation filter before checking. Args: @@ -64,9 +62,7 @@ def _before_check( if expression.primary_criteria: self._observation_filter = expression.primary_criteria.observation_window - def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter - ) -> None: + def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: """Check criteria for time window issues. Args: diff --git a/circe/check/checkers/unused_concepts_check.py b/circe/check/checkers/unused_concepts_check.py index 8197a9fc..866277dd 100644 --- a/circe/check/checkers/unused_concepts_check.py +++ b/circe/check/checkers/unused_concepts_check.py @@ -50,9 +50,7 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.WARNING - def _get_reporter( - self, severity: WarningSeverity, warnings: list - ) -> WarningReporter: + def _get_reporter(self, severity: WarningSeverity, warnings: list) -> WarningReporter: """Get a warning reporter that creates ConceptSetWarning instances. Args: @@ -83,9 +81,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N if not self._is_used(expression, additional_criteria, concept_set): reporter('Concept Set "%s" is not used', concept_set) - def _get_additional_criteria( - self, expression: "CohortExpression" - ) -> list["Criteria"]: + def _get_additional_criteria(self, expression: "CohortExpression") -> list["Criteria"]: """Get all criteria from additional criteria. Args: @@ -96,15 +92,9 @@ def _get_additional_criteria( """ additional_criteria: list[Criteria] = [] if expression.additional_criteria: - additional_criteria.extend( - self._to_criteria_list(expression.additional_criteria.criteria_list) - ) + additional_criteria.extend(self._to_criteria_list(expression.additional_criteria.criteria_list)) if expression.additional_criteria.groups: - additional_criteria.extend( - self._to_criteria_list_from_groups( - expression.additional_criteria.groups - ) - ) + additional_criteria.extend(self._to_criteria_list_from_groups(expression.additional_criteria.groups)) return additional_criteria def _is_used( @@ -124,8 +114,10 @@ def _is_used( True if the concept set is used, False otherwise """ # Check primary criteria - if expression.primary_criteria and expression.primary_criteria.criteria_list and self._is_concept_set_used( - concept_set, expression.primary_criteria.criteria_list + if ( + expression.primary_criteria + and expression.primary_criteria.criteria_list + and self._is_concept_set_used(concept_set, expression.primary_criteria.criteria_list) ): return True @@ -139,20 +131,9 @@ def _is_used( if rule.expression: # Convert rule expression to criteria list rule_criteria_list = [] - if ( - hasattr(rule.expression, "criteria_list") - and rule.expression.criteria_list - ): - rule_criteria_list.extend( - [ - c.criteria - for c in rule.expression.criteria_list - if hasattr(c, "criteria") and c.criteria - ] - ) - if rule_criteria_list and self._is_concept_set_used_in_list( - concept_set, rule_criteria_list - ): + if hasattr(rule.expression, "criteria_list") and rule.expression.criteria_list: + rule_criteria_list.extend([c.criteria for c in rule.expression.criteria_list if hasattr(c, "criteria") and c.criteria]) + if rule_criteria_list and self._is_concept_set_used_in_list(concept_set, rule_criteria_list): return True # Check end strategy (CustomEraStrategy) @@ -181,10 +162,7 @@ def _is_concept_set_used(self, concept_set: "ConceptSet", target) -> bool: return True if target.groups: - return any( - self._is_concept_set_used(concept_set, group) - for group in target.groups - ) + return any(self._is_concept_set_used(concept_set, group) for group in target.groups) return False elif isinstance(target, list): @@ -193,9 +171,7 @@ def _is_concept_set_used(self, concept_set: "ConceptSet", target) -> bool: else: return False - def _is_concept_set_used_in_list( - self, concept_set: "ConceptSet", criteria_list: list["Criteria"] - ) -> bool: + def _is_concept_set_used_in_list(self, concept_set: "ConceptSet", criteria_list: list["Criteria"]) -> bool: """Check if a concept set is used in a criteria list. Args: @@ -206,24 +182,16 @@ def _is_concept_set_used_in_list( True if the concept set is used, False otherwise """ factory = CriteriaCheckerFactory.get_factory(concept_set) - main_check = any( - factory.get_criteria_checker(criteria)(criteria) - for criteria in criteria_list - ) + main_check = any(factory.get_criteria_checker(criteria)(criteria) for criteria in criteria_list) if main_check: return True # Check correlated criteria for criteria in criteria_list: - if ( - hasattr(criteria, "correlated_criteria") - and criteria.correlated_criteria - ): + if hasattr(criteria, "correlated_criteria") and criteria.correlated_criteria: # Convert correlated criteria to list and check - correlated_list = self._correlated_criteria_to_list( - criteria.correlated_criteria - ) + correlated_list = self._correlated_criteria_to_list(criteria.correlated_criteria) if self._is_concept_set_used_in_list(concept_set, correlated_list): return True @@ -239,32 +207,15 @@ def _correlated_criteria_to_list(self, correlated_criteria) -> list["Criteria"]: A list of Criteria """ criteria_list: list[Criteria] = [] - if ( - hasattr(correlated_criteria, "criteria_list") - and correlated_criteria.criteria_list - ): - criteria_list.extend( - [ - c.criteria - for c in correlated_criteria.criteria_list - if hasattr(c, "criteria") and c.criteria - ] - ) + if hasattr(correlated_criteria, "criteria_list") and correlated_criteria.criteria_list: + criteria_list.extend([c.criteria for c in correlated_criteria.criteria_list if hasattr(c, "criteria") and c.criteria]) if hasattr(correlated_criteria, "groups") and correlated_criteria.groups: for group in correlated_criteria.groups: if hasattr(group, "criteria_list") and group.criteria_list: - criteria_list.extend( - [ - c.criteria - for c in group.criteria_list - if hasattr(c, "criteria") and c.criteria - ] - ) + criteria_list.extend([c.criteria for c in group.criteria_list if hasattr(c, "criteria") and c.criteria]) return criteria_list - def _to_criteria_list( - self, criteria_list: Optional[list["CorelatedCriteria"]] - ) -> list["Criteria"]: + def _to_criteria_list(self, criteria_list: Optional[list["CorelatedCriteria"]]) -> list["Criteria"]: """Convert a list of CorelatedCriteria to a list of Criteria. Args: @@ -275,13 +226,9 @@ def _to_criteria_list( """ if not criteria_list: return [] - return [ - c.criteria for c in criteria_list if hasattr(c, "criteria") and c.criteria - ] + return [c.criteria for c in criteria_list if hasattr(c, "criteria") and c.criteria] - def _to_criteria_list_from_groups( - self, groups: Optional[list["CriteriaGroup"]] - ) -> list["Criteria"]: + def _to_criteria_list_from_groups(self, groups: Optional[list["CriteriaGroup"]]) -> list["Criteria"]: """Convert groups to a list of criteria. Args: diff --git a/circe/check/operations/operations.py b/circe/check/operations/operations.py index 2978bf2a..3e4f0ca6 100644 --- a/circe/check/operations/operations.py +++ b/circe/check/operations/operations.py @@ -70,11 +70,7 @@ def is_a(self, clazz: type) -> ExecutiveOperations[T, V]: Returns: An ExecutiveOperations instance for chaining """ - self._result = ( - clazz is not None - and self._value is not None - and isinstance(self._value, clazz) - ) + self._result = clazz is not None and self._value is not None and isinstance(self._value, clazz) return self def then(self, consumer: Any) -> ConditionalOperations[T, V]: @@ -88,9 +84,7 @@ def then(self, consumer: Any) -> ConditionalOperations[T, V]: """ if self._result: # Check if it's an Execution object (has apply method) - if hasattr(consumer, "apply") and callable( - getattr(consumer, "apply", None) - ): + if hasattr(consumer, "apply") and callable(getattr(consumer, "apply", None)): consumer.apply() else: # It's a callable function diff --git a/circe/cli.py b/circe/cli.py index e642c5f8..bf76280e 100644 --- a/circe/cli.py +++ b/circe/cli.py @@ -23,23 +23,15 @@ def main(): subparsers = parser.add_subparsers(dest="command", help="Available commands") # Validate command - validate_parser = subparsers.add_parser( - "validate", help="Validate a cohort definition" - ) + validate_parser = subparsers.add_parser("validate", help="Validate a cohort definition") validate_parser.add_argument("input", help="Input JSON file") - validate_parser.add_argument( - "--quiet", "-q", action="store_true", help="Only show errors" - ) + validate_parser.add_argument("--quiet", "-q", action="store_true", help="Only show errors") # Generate SQL command - sql_parser = subparsers.add_parser( - "generate-sql", help="Generate SQL from cohort definition" - ) + sql_parser = subparsers.add_parser("generate-sql", help="Generate SQL from cohort definition") sql_parser.add_argument("input", help="Input JSON file") sql_parser.add_argument("--output", "-o", help="Output SQL file (default: stdout)") - sql_parser.add_argument( - "--cdm-schema", default="@cdm_database_schema", help="CDM schema name" - ) + sql_parser.add_argument("--cdm-schema", default="@cdm_database_schema", help="CDM schema name") sql_parser.add_argument( "--target-table", default="@target_database_schema.@target_cohort_table", @@ -51,42 +43,26 @@ def main(): default=None, help="Cohort ID (default: @target_cohort_id placeholder)", ) - sql_parser.add_argument( - "--no-validate", action="store_true", help="Skip validation" - ) + sql_parser.add_argument("--no-validate", action="store_true", help="Skip validation") # Render markdown command - md_parser = subparsers.add_parser( - "render-markdown", help="Render cohort definition as Markdown" - ) + md_parser = subparsers.add_parser("render-markdown", help="Render cohort definition as Markdown") md_parser.add_argument("input", help="Input JSON file") - md_parser.add_argument( - "--output", "-o", help="Output Markdown file (default: stdout)" - ) + md_parser.add_argument("--output", "-o", help="Output Markdown file (default: stdout)") md_parser.add_argument("--no-validate", action="store_true", help="Skip validation") - md_parser.add_argument( - "--title", "-t", type=str, help="Title to add to markdown document" - ) + md_parser.add_argument("--title", "-t", type=str, help="Title to add to markdown document") # Generate source code command - source_parser = subparsers.add_parser( - "generate-source", help="Generate Python source code from cohort definition" - ) + source_parser = subparsers.add_parser("generate-source", help="Generate Python source code from cohort definition") source_parser.add_argument("input", help="Input JSON file") - source_parser.add_argument( - "--output", "-o", help="Output Python file (default: stdout)" - ) + source_parser.add_argument("--output", "-o", help="Output Python file (default: stdout)") # Process command (all-in-one) - process_parser = subparsers.add_parser( - "process", help="Validate, generate SQL and Markdown" - ) + process_parser = subparsers.add_parser("process", help="Validate, generate SQL and Markdown") process_parser.add_argument("input", help="Input JSON file") process_parser.add_argument("--sql-output", help="SQL output file") process_parser.add_argument("--md-output", help="Markdown output file") - process_parser.add_argument( - "--cdm-schema", default="@cdm_database_schema", help="CDM schema name" - ) + process_parser.add_argument("--cdm-schema", default="@cdm_database_schema", help="CDM schema name") process_parser.add_argument( "--target-table", default="@target_database_schema.@target_cohort_table", @@ -143,9 +119,7 @@ def validate_command(args): if not args.quiet: for warning in warnings: - severity = ( - warning.severity.name if hasattr(warning, "severity") else "WARNING" - ) + severity = warning.severity.name if hasattr(warning, "severity") else "WARNING" msg = str(warning) if not hasattr(warning, "message") else warning.message print(f"[{severity}] {msg}") diff --git a/circe/cohortdefinition/builders/base.py b/circe/cohortdefinition/builders/base.py index 401d7129..a13bfdaf 100644 --- a/circe/cohortdefinition/builders/base.py +++ b/circe/cohortdefinition/builders/base.py @@ -24,18 +24,14 @@ class CriteriaSqlBuilder(ABC, Generic[T]): Java equivalent: org.ohdsi.circe.cohortdefinition.builders.CriteriaSqlBuilder """ - def get_criteria_sql( - self, criteria: T, options: Optional[BuilderOptions] = None - ) -> str: + def get_criteria_sql(self, criteria: T, options: Optional[BuilderOptions] = None) -> str: """Get SQL query for criteria. Java equivalent: CriteriaSqlBuilder.getCriteriaSql(T criteria) """ return self.get_criteria_sql_with_options(criteria, options) - def get_criteria_sql_with_options( - self, criteria: T, options: Optional[BuilderOptions] - ) -> str: + def get_criteria_sql_with_options(self, criteria: T, options: Optional[BuilderOptions]) -> str: """Get SQL query for criteria with builder options. Java equivalent: CriteriaSqlBuilder.getCriteriaSql(T criteria, BuilderOptions options) @@ -58,11 +54,7 @@ def get_criteria_sql_with_options( query = self.embed_where_clauses(query, where_clauses) if options is not None: - filtered_columns = [ - column - for column in options.additional_columns - if column not in self.get_default_columns() - ] + filtered_columns = [column for column in options.additional_columns if column not in self.get_default_columns()] if filtered_columns: query = query.replace( "@additionalColumns", @@ -107,9 +99,7 @@ def embed_codeset_clause(self, query: str, criteria: T) -> str: # This would need to be implemented based on the Java logic return query.replace("@codesetClause", "") - def resolve_select_clauses( - self, criteria: T, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: T, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for criteria. Java equivalent: CriteriaSqlBuilder.resolveSelectClauses() @@ -117,9 +107,7 @@ def resolve_select_clauses( # This would need to be implemented based on the Java logic return [] - def resolve_join_clauses( - self, criteria: T, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: T, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for criteria. Java equivalent: CriteriaSqlBuilder.resolveJoinClauses() @@ -127,9 +115,7 @@ def resolve_join_clauses( # This would need to be implemented based on the Java logic return [] - def resolve_where_clauses( - self, criteria: T, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: T, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for criteria. Java equivalent: CriteriaSqlBuilder.resolveWhereClauses() @@ -137,9 +123,7 @@ def resolve_where_clauses( # This would need to be implemented based on the Java logic return [] - def embed_ordinal_expression( - self, query: str, criteria: T, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: T, where_clauses: list[str]) -> str: """Embed ordinal expression in query. Java equivalent: CriteriaSqlBuilder.embedOrdinalExpression() @@ -179,9 +163,4 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: CriteriaSqlBuilder.getAdditionalColumns() """ - return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] - ) + return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) diff --git a/circe/cohortdefinition/builders/condition_era.py b/circe/cohortdefinition/builders/condition_era.py index ac6913ba..0ba49707 100644 --- a/circe/cohortdefinition/builders/condition_era.py +++ b/circe/cohortdefinition/builders/condition_era.py @@ -52,9 +52,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for condition era criteria.""" return self.DEFAULT_COLUMNS - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.condition_concept_id", @@ -76,9 +74,7 @@ def embed_codeset_clause(self, query: str, criteria: ConditionEra) -> str: codeset_clause = f"where ce.condition_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.codeset_id})" return query.replace("@codesetClause", codeset_clause) - def embed_ordinal_expression( - self, query: str, criteria: ConditionEra, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: ConditionEra, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: @@ -91,39 +87,21 @@ def embed_ordinal_expression( query = query.replace("@ordinalExpression", "") return query - def resolve_select_clauses( - self, criteria: ConditionEra, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: ConditionEra, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for condition era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = ( - "ce.condition_era_start_date" - if criteria.date_adjustment.start_with == "start_date" - else "ce.condition_era_end_date" - ) - end_column = ( - "ce.condition_era_start_date" - if criteria.date_adjustment.end_with == "start_date" - else "ce.condition_era_end_date" - ) - select_cols.append( - BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, start_column, end_column - ) - ) + start_column = "ce.condition_era_start_date" if criteria.date_adjustment.start_with == "start_date" else "ce.condition_era_end_date" + end_column = "ce.condition_era_start_date" if criteria.date_adjustment.end_with == "start_date" else "ce.condition_era_end_date" + select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) else: - select_cols.append( - "ce.condition_era_start_date as start_date, ce.condition_era_end_date as end_date" - ) + select_cols.append("ce.condition_era_start_date as start_date, ce.condition_era_end_date as end_date") return select_cols - def resolve_join_clauses( - self, criteria: ConditionEra, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: ConditionEra, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for condition era criteria.""" join_clauses = [] @@ -134,63 +112,47 @@ def resolve_join_clauses( or (criteria.gender is not None and len(criteria.gender) > 0) or criteria.gender_cs is not None ): - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") return join_clauses - def resolve_where_clauses( - self, criteria: ConditionEra, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: ConditionEra, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for condition era criteria.""" where_clauses = [] # eraStartDate if criteria.era_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.era_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.era_start_date) if date_clause: where_clauses.append(date_clause) # eraEndDate if criteria.era_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.end_date", criteria.era_end_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.era_end_date) if date_clause: where_clauses.append(date_clause) # occurrenceCount if criteria.occurrence_count is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "C.condition_occurrence_count", criteria.occurrence_count - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("C.condition_occurrence_count", criteria.occurrence_count) if numeric_clause: where_clauses.append(numeric_clause) # eraLength if criteria.era_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length) if numeric_clause: where_clauses.append(numeric_clause) # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) if numeric_clause: where_clauses.append(numeric_clause) # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) if numeric_clause: where_clauses.append(numeric_clause) @@ -198,9 +160,7 @@ def resolve_where_clauses( if criteria.gender is not None and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") # genderCS if criteria.gender_cs is not None: diff --git a/circe/cohortdefinition/builders/condition_occurrence.py b/circe/cohortdefinition/builders/condition_occurrence.py index 658f6820..915cf039 100644 --- a/circe/cohortdefinition/builders/condition_occurrence.py +++ b/circe/cohortdefinition/builders/condition_occurrence.py @@ -57,9 +57,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for condition occurrence criteria.""" return self.DEFAULT_COLUMNS - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.condition_concept_id", @@ -85,9 +83,7 @@ def embed_codeset_clause(self, query: str, criteria: ConditionOccurrence) -> str ), ) - def embed_ordinal_expression( - self, query: str, criteria: ConditionOccurrence, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: ConditionOccurrence, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: @@ -100,16 +96,12 @@ def embed_ordinal_expression( query = query.replace("@ordinalExpression", "") return query - def resolve_select_clauses( - self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for condition occurrence criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) # Condition Type - if ( - criteria.condition_type is not None and len(criteria.condition_type) > 0 - ) or criteria.condition_type_cs is not None: + if (criteria.condition_type is not None and len(criteria.condition_type) > 0) or criteria.condition_type_cs is not None: select_cols.append("co.condition_type_concept_id") # Stop Reason @@ -117,16 +109,11 @@ def resolve_select_clauses( select_cols.append("co.stop_reason") # providerSpecialty - if ( - criteria.provider_specialty is not None - and len(criteria.provider_specialty) > 0 - ) or criteria.provider_specialty_cs is not None: + if (criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs is not None: select_cols.append("co.provider_id") # conditionStatus - if ( - criteria.condition_status is not None and len(criteria.condition_status) > 0 - ) or criteria.condition_status_cs is not None: + if (criteria.condition_status is not None and len(criteria.condition_status) > 0) or criteria.condition_status_cs is not None: select_cols.append("co.condition_status_concept_id") # dateAdjustment or default start/end dates @@ -141,11 +128,7 @@ def resolve_select_clauses( if criteria.date_adjustment.end_with == "start_date" else "COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date))" ) - select_cols.append( - BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, start_column, end_column - ) - ) + select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) else: select_cols.append( "co.condition_start_date as start_date, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)) as end_date" @@ -153,73 +136,48 @@ def resolve_select_clauses( return select_cols - def resolve_join_clauses( - self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for condition occurrence criteria.""" join_clauses = [] # join to PERSON - if ( - criteria.age is not None - or (criteria.gender is not None and len(criteria.gender) > 0) - or criteria.gender_cs is not None - ): - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + if criteria.age is not None or (criteria.gender is not None and len(criteria.gender) > 0) or criteria.gender_cs is not None: + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") # join to VISIT_OCCURRENCE - if ( - criteria.visit_type is not None and len(criteria.visit_type) > 0 - ) or criteria.visit_type_cs is not None: + if (criteria.visit_type is not None and len(criteria.visit_type) > 0) or criteria.visit_type_cs is not None: join_clauses.append( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" ) # join to PROVIDER - if ( - criteria.provider_specialty is not None - and len(criteria.provider_specialty) > 0 - ) or criteria.provider_specialty_cs is not None: - join_clauses.append( - "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" - ) + if (criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs is not None: + join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") return join_clauses - def resolve_where_clauses( - self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for condition occurrence criteria.""" where_clauses = [] # occurrenceStartDate if criteria.occurrence_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.occurrence_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date) if date_clause: where_clauses.append(date_clause) # occurrenceEndDate if criteria.occurrence_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.end_date", criteria.occurrence_end_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.occurrence_end_date) if date_clause: where_clauses.append(date_clause) # conditionType if criteria.condition_type is not None and len(criteria.condition_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.condition_type - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.condition_type) if concept_ids: exclude_clause = "not" if criteria.condition_type_exclude else "" - where_clauses.append( - f"C.condition_type_concept_id {exclude_clause} in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.condition_type_concept_id {exclude_clause} in ({','.join(map(str, concept_ids))})") # conditionTypeCS if criteria.condition_type_cs is not None: @@ -233,17 +191,13 @@ def resolve_where_clauses( # Stop Reason if criteria.stop_reason is not None: - text_clause = BuilderUtils.build_text_filter_clause( - criteria.stop_reason, "C.stop_reason" - ) + text_clause = BuilderUtils.build_text_filter_clause(criteria.stop_reason, "C.stop_reason") if text_clause: where_clauses.append(text_clause) # age if criteria.age is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age) if numeric_clause: where_clauses.append(numeric_clause) @@ -251,9 +205,7 @@ def resolve_where_clauses( if criteria.gender is not None and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") # genderCS if criteria.gender_cs is not None: @@ -266,17 +218,10 @@ def resolve_where_clauses( where_clauses.append(codeset_clause) # providerSpecialty - if ( - criteria.provider_specialty is not None - and len(criteria.provider_specialty) > 0 - ): - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.provider_specialty - ) + if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) if concept_ids: - where_clauses.append( - f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") # providerSpecialtyCS if criteria.provider_specialty_cs is not None: @@ -290,13 +235,9 @@ def resolve_where_clauses( # visitType if criteria.visit_type is not None and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.visit_type - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) if concept_ids: - where_clauses.append( - f"V.visit_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"V.visit_concept_id in ({','.join(map(str, concept_ids))})") # visitTypeCS if criteria.visit_type_cs is not None: @@ -310,13 +251,9 @@ def resolve_where_clauses( # conditionStatus if criteria.condition_status is not None and len(criteria.condition_status) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.condition_status - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.condition_status) if concept_ids: - where_clauses.append( - f"C.condition_status_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.condition_status_concept_id in ({','.join(map(str, concept_ids))})") # conditionStatusCS if criteria.condition_status_cs is not None: diff --git a/circe/cohortdefinition/builders/death.py b/circe/cohortdefinition/builders/death.py index 94b8e7b3..0f37ca1f 100644 --- a/circe/cohortdefinition/builders/death.py +++ b/circe/cohortdefinition/builders/death.py @@ -50,9 +50,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: CriteriaColumn.VISIT_ID, } - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "coalesce(C.cause_concept_id,0)", @@ -74,9 +72,7 @@ def embed_codeset_clause(self, query: str, criteria: Death) -> str: ), ) - def embed_ordinal_expression( - self, query: str, criteria: Death, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: Death, where_clauses: list[str]) -> str: """Embed ordinal expression in query. Java DeathSqlBuilder overrides this to return query as is. @@ -85,16 +81,12 @@ def embed_ordinal_expression( """ return query - def resolve_select_clauses( - self, criteria: Death, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: Death, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for death criteria.""" select_cols = ["d.person_id", "d.cause_concept_id"] # deathType - if (criteria.death_type and len(criteria.death_type) > 0) or ( - criteria.death_type_cs and criteria.death_type_cs.codeset_id - ): + if (criteria.death_type and len(criteria.death_type) > 0) or (criteria.death_type_cs and criteria.death_type_cs.codeset_id): select_cols.append("d.death_type_concept_id") # dateAdjustment or default start/end dates @@ -108,83 +100,51 @@ def resolve_select_clauses( ) else: # FIX: Added 'as start_date' to align with outer query expectation - select_cols.append( - "d.death_date as start_date, DATEADD(day,1,d.death_date) as end_date" - ) + select_cols.append("d.death_date as start_date, DATEADD(day,1,d.death_date) as end_date") return select_cols - def resolve_join_clauses( - self, criteria: Death, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: Death, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for death criteria.""" joins = [] # join to PERSON - if ( - criteria.age - or (criteria.gender and len(criteria.gender) > 0) - or (criteria.gender_cs and criteria.gender_cs.codeset_id) - ): - joins.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + joins.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") return joins - def resolve_where_clauses( - self, criteria: Death, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: Death, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for death criteria.""" where_clauses = super().resolve_where_clauses(criteria) # occurrenceStartDate if criteria.occurrence_start_date: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.occurrence_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date) if date_clause: where_clauses.append(date_clause) # deathType if criteria.death_type and len(criteria.death_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.death_type - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.death_type) op = "not in" if criteria.death_type_exclude else "in" - where_clauses.append( - f"C.death_type_concept_id {op} ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.death_type_concept_id {op} ({','.join(map(str, concept_ids))})") # deathTypeCS if criteria.death_type_cs and criteria.death_type_cs.codeset_id: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.death_type_cs.codeset_id, "C.death_type_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.death_type_cs.codeset_id, "C.death_type_concept_id")) # age if criteria.age: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) # gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") # genderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.gender_cs.codeset_id, "P.gender_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) return where_clauses diff --git a/circe/cohortdefinition/builders/device_exposure.py b/circe/cohortdefinition/builders/device_exposure.py index 0a2083f1..91998326 100644 --- a/circe/cohortdefinition/builders/device_exposure.py +++ b/circe/cohortdefinition/builders/device_exposure.py @@ -42,9 +42,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: CriteriaColumn.VISIT_ID, } - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.START_DATE: "C.start_date", @@ -56,9 +54,7 @@ def get_table_column_for_criteria_column( } return column_mapping.get(criteria_column, "NULL") - def resolve_select_clauses( - self, criteria: DeviceExposure, options: BuilderOptions - ) -> list[str]: + def resolve_select_clauses(self, criteria: DeviceExposure, options: BuilderOptions) -> list[str]: """Resolve select clauses for device exposure criteria.""" select_cols = [ "de.person_id", @@ -69,9 +65,7 @@ def resolve_select_clauses( ] # Device Type - if (criteria.device_type and len(criteria.device_type) > 0) or ( - criteria.device_type_cs and criteria.device_type_cs.codeset_id - ): + if (criteria.device_type and len(criteria.device_type) > 0) or (criteria.device_type_cs and criteria.device_type_cs.codeset_id): select_cols.append("de.device_type_concept_id") # unique_device_id @@ -103,32 +97,22 @@ def resolve_select_clauses( ) else: select_cols.append( - "de.device_exposure_start_date as start_date, COALESCE(de.device_exposure_end_date, DATEADD(day,1," + \ - "de.device_exposure_start_date)) as end_date" + "de.device_exposure_start_date as start_date, COALESCE(de.device_exposure_end_date, DATEADD(day,1," + + "de.device_exposure_start_date)) as end_date" ) return select_cols - def resolve_join_clauses( - self, criteria: DeviceExposure, options: BuilderOptions - ) -> list[str]: + def resolve_join_clauses(self, criteria: DeviceExposure, options: BuilderOptions) -> list[str]: """Resolve join clauses for device exposure criteria.""" joins = [] # Join to PERSON - if ( - criteria.age - or (criteria.gender and len(criteria.gender) > 0) - or (criteria.gender_cs and criteria.gender_cs.codeset_id) - ): - joins.append( - "JOIN @cdm_database_schema.PERSON P ON C.person_id = P.person_id" - ) + if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + joins.append("JOIN @cdm_database_schema.PERSON P ON C.person_id = P.person_id") # Join to VISIT_OCCURRENCE - if (criteria.visit_type and len(criteria.visit_type) > 0) or ( - criteria.visit_type_cs and criteria.visit_type_cs.codeset_id - ): + if (criteria.visit_type and len(criteria.visit_type) > 0) or (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): joins.append( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V ON C.visit_occurrence_id = V.visit_occurrence_id AND C.person_id = V.person_id" ) @@ -137,9 +121,7 @@ def resolve_join_clauses( if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id ): - joins.append( - "LEFT JOIN @cdm_database_schema.PROVIDER PR ON C.provider_id = PR.provider_id" - ) + joins.append("LEFT JOIN @cdm_database_schema.PROVIDER PR ON C.provider_id = PR.provider_id") return joins @@ -155,131 +137,83 @@ def embed_codeset_clause(self, query: str, criteria: DeviceExposure) -> str: ), ) - def resolve_where_clauses( - self, criteria: DeviceExposure, options: BuilderOptions - ) -> list[str]: + def resolve_where_clauses(self, criteria: DeviceExposure, options: BuilderOptions) -> list[str]: """Resolve where clauses for device exposure criteria.""" conditions = [] # Add date range conditions if criteria.occurrence_start_date: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.occurrence_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date) if date_clause: conditions.append(date_clause) if criteria.occurrence_end_date: - date_clause = BuilderUtils.build_date_range_clause( - "C.end_date", criteria.occurrence_end_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.occurrence_end_date) if date_clause: conditions.append(date_clause) # deviceType if criteria.device_type and len(criteria.device_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.device_type - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.device_type) op = "NOT IN" if criteria.device_type_exclude else "IN" - conditions.append( - f"C.device_type_concept_id {op} ({','.join(map(str, concept_ids))})" - ) + conditions.append(f"C.device_type_concept_id {op} ({','.join(map(str, concept_ids))})") # deviceTypeCS if criteria.device_type_cs and criteria.device_type_cs.codeset_id: - conditions.append( - BuilderUtils.get_codeset_in_expression( - criteria.device_type_cs.codeset_id, "C.device_type_concept_id" - ) - ) + conditions.append(BuilderUtils.get_codeset_in_expression(criteria.device_type_cs.codeset_id, "C.device_type_concept_id")) # Add unique device ID condition if criteria.unique_device_id: - device_id_clause = BuilderUtils.build_text_filter_clause( - criteria.unique_device_id, "C.unique_device_id" - ) + device_id_clause = BuilderUtils.build_text_filter_clause(criteria.unique_device_id, "C.unique_device_id") if device_id_clause: conditions.append(device_id_clause) # Add quantity condition if criteria.quantity: - quantity_clause = BuilderUtils.build_numeric_range_clause( - "C.quantity", criteria.quantity - ) + quantity_clause = BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity) if quantity_clause: conditions.append(quantity_clause) # Age if criteria.age: - conditions.append( - BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - ) - ) + conditions.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) # Gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - conditions.append( - f"P.gender_concept_id IN ({','.join(map(str, concept_ids))})" - ) + conditions.append(f"P.gender_concept_id IN ({','.join(map(str, concept_ids))})") # GenderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: - conditions.append( - BuilderUtils.get_codeset_in_expression( - criteria.gender_cs.codeset_id, "P.gender_concept_id" - ) - ) + conditions.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) # Provider Specialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.provider_specialty - ) - conditions.append( - f"PR.specialty_concept_id IN ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) + conditions.append(f"PR.specialty_concept_id IN ({','.join(map(str, concept_ids))})") # Provider Specialty CS if criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id: - conditions.append( - BuilderUtils.get_codeset_in_expression( - criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id" - ) - ) + conditions.append(BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id")) # Visit Type if criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.visit_type - ) - conditions.append( - f"V.visit_concept_id IN ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) + conditions.append(f"V.visit_concept_id IN ({','.join(map(str, concept_ids))})") # Visit Type CS if criteria.visit_type_cs and criteria.visit_type_cs.codeset_id: - conditions.append( - BuilderUtils.get_codeset_in_expression( - criteria.visit_type_cs.codeset_id, "V.visit_concept_id" - ) - ) + conditions.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) return conditions - def resolve_ordinal_expression( - self, criteria: DeviceExposure, options: BuilderOptions - ) -> str: + def resolve_ordinal_expression(self, criteria: DeviceExposure, options: BuilderOptions) -> str: """Resolve ordinal expression for device exposure criteria.""" if criteria.first: return ", row_number() over (PARTITION BY de.person_id ORDER BY de.device_exposure_start_date, de.device_exposure_id) as ordinal" return "" - def get_ordinal_expression_where_clause( - self, criteria: DeviceExposure, options: BuilderOptions - ) -> list[str]: + def get_ordinal_expression_where_clause(self, criteria: DeviceExposure, options: BuilderOptions) -> list[str]: if criteria.first: return ["C.ordinal = 1"] return [] diff --git a/circe/cohortdefinition/builders/dose_era.py b/circe/cohortdefinition/builders/dose_era.py index 21b235f7..99829870 100644 --- a/circe/cohortdefinition/builders/dose_era.py +++ b/circe/cohortdefinition/builders/dose_era.py @@ -57,9 +57,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for dose era criteria.""" return self.DEFAULT_COLUMNS - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.drug_concept_id", @@ -83,9 +81,7 @@ def embed_codeset_clause(self, query: str, criteria: DoseEra) -> str: codeset_clause = f"where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.codeset_id})" return query.replace("@codesetClause", codeset_clause) - def embed_ordinal_expression( - self, query: str, criteria: DoseEra, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: DoseEra, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: @@ -98,39 +94,21 @@ def embed_ordinal_expression( query = query.replace("@ordinalExpression", "") return query - def resolve_select_clauses( - self, criteria: DoseEra, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: DoseEra, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for dose era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = ( - "de.dose_era_start_date" - if criteria.date_adjustment.start_with == "start_date" - else "de.dose_era_end_date" - ) - end_column = ( - "de.dose_era_start_date" - if criteria.date_adjustment.end_with == "start_date" - else "de.dose_era_end_date" - ) - select_cols.append( - BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, start_column, end_column - ) - ) + start_column = "de.dose_era_start_date" if criteria.date_adjustment.start_with == "start_date" else "de.dose_era_end_date" + end_column = "de.dose_era_start_date" if criteria.date_adjustment.end_with == "start_date" else "de.dose_era_end_date" + select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) else: - select_cols.append( - "de.dose_era_start_date as start_date, de.dose_era_end_date as end_date" - ) + select_cols.append("de.dose_era_start_date as start_date, de.dose_era_end_date as end_date") return select_cols - def resolve_join_clauses( - self, criteria: DoseEra, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: DoseEra, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for dose era criteria.""" join_clauses = [] @@ -141,31 +119,23 @@ def resolve_join_clauses( or (criteria.gender is not None and len(criteria.gender) > 0) or criteria.gender_cs is not None ): - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") return join_clauses - def resolve_where_clauses( - self, criteria: DoseEra, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: DoseEra, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for dose era criteria.""" where_clauses = [] # eraStartDate if criteria.era_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.era_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.era_start_date) if date_clause: where_clauses.append(date_clause) # eraEndDate if criteria.era_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.end_date", criteria.era_end_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.era_end_date) if date_clause: where_clauses.append(date_clause) @@ -173,9 +143,7 @@ def resolve_where_clauses( if criteria.unit is not None and len(criteria.unit) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.unit) if concept_ids: - where_clauses.append( - f"C.unit_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.unit_concept_id in ({','.join(map(str, concept_ids))})") # unitCS if criteria.unit_cs is not None: @@ -189,33 +157,25 @@ def resolve_where_clauses( # doseValue if criteria.dose_value is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "C.dose_value", criteria.dose_value, ".4f" - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("C.dose_value", criteria.dose_value, ".4f") if numeric_clause: where_clauses.append(numeric_clause) # eraLength if criteria.era_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length) if numeric_clause: where_clauses.append(numeric_clause) # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) if numeric_clause: where_clauses.append(numeric_clause) # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) if numeric_clause: where_clauses.append(numeric_clause) @@ -223,9 +183,7 @@ def resolve_where_clauses( if criteria.gender is not None and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") # genderCS if criteria.gender_cs is not None: diff --git a/circe/cohortdefinition/builders/drug_era.py b/circe/cohortdefinition/builders/drug_era.py index e528229b..98c86086 100644 --- a/circe/cohortdefinition/builders/drug_era.py +++ b/circe/cohortdefinition/builders/drug_era.py @@ -61,9 +61,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for drug era criteria.""" return self.DEFAULT_COLUMNS - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.drug_concept_id", @@ -87,9 +85,7 @@ def embed_codeset_clause(self, query: str, criteria: DrugEra) -> str: codeset_clause = f"where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.codeset_id})" return query.replace("@codesetClause", codeset_clause) - def embed_ordinal_expression( - self, query: str, criteria: DrugEra, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: DrugEra, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: @@ -102,9 +98,7 @@ def embed_ordinal_expression( query = query.replace("@ordinalExpression", "") return query - def resolve_select_clauses( - self, criteria: DrugEra, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: DrugEra, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for drug era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -112,31 +106,15 @@ def resolve_select_clauses( # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = ( - "de.drug_era_start_date" - if criteria.date_adjustment.start_with == "start_date" - else "de.drug_era_end_date" - ) - end_column = ( - "de.drug_era_start_date" - if criteria.date_adjustment.end_with == "start_date" - else "de.drug_era_end_date" - ) - select_cols.append( - BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, start_column, end_column - ) - ) + start_column = "de.drug_era_start_date" if criteria.date_adjustment.start_with == "start_date" else "de.drug_era_end_date" + end_column = "de.drug_era_start_date" if criteria.date_adjustment.end_with == "start_date" else "de.drug_era_end_date" + select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) else: - select_cols.append( - "de.drug_era_start_date as start_date, de.drug_era_end_date as end_date" - ) + select_cols.append("de.drug_era_start_date as start_date, de.drug_era_end_date as end_date") return select_cols - def resolve_join_clauses( - self, criteria: DrugEra, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: DrugEra, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for drug era criteria.""" join_clauses = [] @@ -147,71 +125,53 @@ def resolve_join_clauses( or (criteria.gender is not None and len(criteria.gender) > 0) or criteria.gender_cs is not None ): - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") return join_clauses - def resolve_where_clauses( - self, criteria: DrugEra, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: DrugEra, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for drug era criteria.""" where_clauses = [] # eraStartDate if criteria.era_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.era_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.era_start_date) if date_clause: where_clauses.append(date_clause) # eraEndDate if criteria.era_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.end_date", criteria.era_end_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.era_end_date) if date_clause: where_clauses.append(date_clause) # occurrenceCount if criteria.occurrence_count is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "C.drug_exposure_count", criteria.occurrence_count - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("C.drug_exposure_count", criteria.occurrence_count) if numeric_clause: where_clauses.append(numeric_clause) # eraLength if criteria.era_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length) if numeric_clause: where_clauses.append(numeric_clause) # gapDays - Replicating Java bug: uses era_length instead of gap_days if criteria.gap_days is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "C.gap_days", criteria.era_length - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("C.gap_days", criteria.era_length) if numeric_clause: where_clauses.append(numeric_clause) # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) if numeric_clause: where_clauses.append(numeric_clause) # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) if numeric_clause: where_clauses.append(numeric_clause) @@ -219,9 +179,7 @@ def resolve_where_clauses( if criteria.gender is not None and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") # genderCS if criteria.gender_cs is not None: diff --git a/circe/cohortdefinition/builders/drug_exposure.py b/circe/cohortdefinition/builders/drug_exposure.py index b44aab86..0032332d 100644 --- a/circe/cohortdefinition/builders/drug_exposure.py +++ b/circe/cohortdefinition/builders/drug_exposure.py @@ -102,9 +102,7 @@ def embed_codeset_clause(self, query: str, criteria: DrugExposure) -> str: ), ) - def embed_ordinal_expression( - self, query: str, criteria: DrugExposure, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: DrugExposure, where_clauses: list[str]) -> str: """Embed ordinal expression in query. Java equivalent: DrugExposureSqlBuilder.embedOrdinalExpression() @@ -121,9 +119,7 @@ def embed_ordinal_expression( return query - def resolve_select_clauses( - self, criteria: DrugExposure, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: DrugExposure, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveSelectClauses() @@ -140,9 +136,7 @@ def resolve_select_clauses( ] # drugType - if ( - criteria.drug_type and len(criteria.drug_type) > 0 - ) or criteria.drug_type_cs: + if (criteria.drug_type and len(criteria.drug_type) > 0) or criteria.drug_type_cs: select_cols.append("de.drug_type_concept_id") # stopReason @@ -150,21 +144,15 @@ def resolve_select_clauses( select_cols.append("de.stop_reason") # routeConcept - if ( - criteria.route_concept and len(criteria.route_concept) > 0 - ) or criteria.route_concept_cs: + if (criteria.route_concept and len(criteria.route_concept) > 0) or criteria.route_concept_cs: select_cols.append("de.route_concept_id") # providerSpecialty - if ( - criteria.provider_specialty and len(criteria.provider_specialty) > 0 - ) or criteria.provider_specialty_cs: + if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: select_cols.append("de.provider_id") # doseUnit - if ( - criteria.dose_unit and len(criteria.dose_unit) > 0 - ) or criteria.dose_unit_cs: + if (criteria.dose_unit and len(criteria.dose_unit) > 0) or criteria.dose_unit_cs: select_cols.append("de.dose_unit_concept_id") # LotNumber @@ -176,16 +164,8 @@ def resolve_select_clauses( select_cols.append( BuilderUtils.get_date_adjustment_expression( criteria.date_adjustment, - ( - "de.drug_exposure_start_date" - if criteria.date_adjustment.start_with == "start_date" - else "de.drug_exposure_end_date" - ), - ( - "de.drug_exposure_start_date" - if criteria.date_adjustment.end_with == "start_date" - else "de.drug_exposure_end_date" - ), + ("de.drug_exposure_start_date" if criteria.date_adjustment.start_with == "start_date" else "de.drug_exposure_end_date"), + ("de.drug_exposure_start_date" if criteria.date_adjustment.end_with == "start_date" else "de.drug_exposure_end_date"), ) ) else: @@ -195,9 +175,7 @@ def resolve_select_clauses( return select_cols - def resolve_join_clauses( - self, criteria: DrugExposure, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: DrugExposure, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveJoinClauses() @@ -205,36 +183,22 @@ def resolve_join_clauses( join_clauses = [] # Join to PERSON if age or gender conditions are present - if ( - criteria.age - or (criteria.gender and len(criteria.gender) > 0) - or criteria.gender_cs - ): - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + if criteria.age or (criteria.gender and len(criteria.gender) > 0) or criteria.gender_cs: + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") # Join to VISIT_OCCURRENCE - if ( - criteria.visit_type and len(criteria.visit_type) > 0 - ) or criteria.visit_type_cs: + if (criteria.visit_type and len(criteria.visit_type) > 0) or criteria.visit_type_cs: join_clauses.append( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" ) # Join to PROVIDER if provider specialty conditions are present - if ( - criteria.provider_specialty and len(criteria.provider_specialty) > 0 - ) or criteria.provider_specialty_cs: - join_clauses.append( - "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" - ) + if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: + join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") return join_clauses - def resolve_where_clauses( - self, criteria: DrugExposure, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveWhereClauses() @@ -245,16 +209,12 @@ def resolve_where_clauses( # Add occurrence dates if criteria.occurrence_start_date: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.occurrence_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date) if date_clause: where_clauses.append(date_clause) if criteria.occurrence_end_date: - date_clause = BuilderUtils.build_date_range_clause( - "C.end_date", criteria.occurrence_end_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.occurrence_end_date) if date_clause: where_clauses.append(date_clause) @@ -262,141 +222,79 @@ def resolve_where_clauses( if criteria.drug_type and len(criteria.drug_type) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.drug_type) operator = "not in" if criteria.drug_type_exclude else "in" - where_clauses.append( - f"C.drug_type_concept_id {operator} ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.drug_type_concept_id {operator} ({','.join(map(str, concept_ids))})") # drugTypeCS if criteria.drug_type_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.drug_type_cs.codeset_id, "C.drug_type_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.drug_type_cs.codeset_id, "C.drug_type_concept_id")) # stopReason if criteria.stop_reason: - where_clauses.append( - BuilderUtils.build_text_filter_clause( - criteria.stop_reason, "C.stop_reason" - ) - ) + where_clauses.append(BuilderUtils.build_text_filter_clause(criteria.stop_reason, "C.stop_reason")) # routeConcept if criteria.route_concept and len(criteria.route_concept) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.route_concept - ) - where_clauses.append( - f"C.route_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.route_concept) + where_clauses.append(f"C.route_concept_id in ({','.join(map(str, concept_ids))})") # routeConceptCS if criteria.route_concept_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.route_concept_cs.codeset_id, "C.route_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.route_concept_cs.codeset_id, "C.route_concept_id")) # doseUnit if criteria.dose_unit and len(criteria.dose_unit) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.dose_unit) - where_clauses.append( - f"C.dose_unit_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.dose_unit_concept_id in ({','.join(map(str, concept_ids))})") # doseUnitCS if criteria.dose_unit_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.dose_unit_cs.codeset_id, "C.dose_unit_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.dose_unit_cs.codeset_id, "C.dose_unit_concept_id")) # LotNumber if criteria.lot_number: - where_clauses.append( - BuilderUtils.build_text_filter_clause( - criteria.lot_number, "C.lot_number" - ) - ) + where_clauses.append(BuilderUtils.build_text_filter_clause(criteria.lot_number, "C.lot_number")) # refills if criteria.refills: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.refills", criteria.refills) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.refills", criteria.refills)) # quantity if criteria.quantity: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity)) # daysSupply if criteria.days_supply: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "C.days_supply", criteria.days_supply - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.days_supply", criteria.days_supply)) # age if criteria.age: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) # gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") # genderCS if criteria.gender_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.gender_cs.codeset_id, "P.gender_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) # providerSpecialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.provider_specialty - ) - where_clauses.append( - f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) + where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") # providerSpecialtyCS if criteria.provider_specialty_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id")) # visitType if criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.visit_type - ) - where_clauses.append( - f"V.visit_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) + where_clauses.append(f"V.visit_concept_id in ({','.join(map(str, concept_ids))})") # visitTypeCS if criteria.visit_type_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.visit_type_cs.codeset_id, "V.visit_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) return [c for c in where_clauses if c] # Filter out None values diff --git a/circe/cohortdefinition/builders/location_region.py b/circe/cohortdefinition/builders/location_region.py index aaea2f85..4680d33d 100644 --- a/circe/cohortdefinition/builders/location_region.py +++ b/circe/cohortdefinition/builders/location_region.py @@ -55,9 +55,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for location region criteria.""" return self.DEFAULT_COLUMNS - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.region_concept_id", @@ -75,15 +73,11 @@ def embed_codeset_clause(self, query: str, criteria: LocationRegion) -> str: codeset_clause = f"AND l.region_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.codeset_id})" return query.replace("@codesetClause", codeset_clause) - def embed_ordinal_expression( - self, query: str, criteria: LocationRegion, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: LocationRegion, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") - def resolve_select_clauses( - self, criteria: LocationRegion, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: LocationRegion, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for location region criteria.""" # Default select columns that are always returned select_cols = ["C.person_id", "C.location_id", "C.region_concept_id"] @@ -100,14 +94,10 @@ def resolve_select_clauses( return select_cols - def resolve_join_clauses( - self, criteria: LocationRegion, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: LocationRegion, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for location region criteria.""" return [] - def resolve_where_clauses( - self, criteria: LocationRegion, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: LocationRegion, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for location region criteria.""" return [] diff --git a/circe/cohortdefinition/builders/measurement.py b/circe/cohortdefinition/builders/measurement.py index 675ccab1..1a6d891f 100644 --- a/circe/cohortdefinition/builders/measurement.py +++ b/circe/cohortdefinition/builders/measurement.py @@ -46,9 +46,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: CriteriaColumn.VISIT_ID, } - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.START_DATE: "C.start_date", @@ -59,9 +57,7 @@ def get_table_column_for_criteria_column( } return column_mapping.get(criteria_column, "NULL") - def embed_ordinal_expression( - self, query: str, criteria: Measurement, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: Measurement, where_clauses: list[str]) -> str: """Embed ordinal expression in query. Java equivalent: MeasurementSqlBuilder.embedOrdinalExpression() @@ -90,9 +86,7 @@ def embed_codeset_clause(self, query: str, criteria: Measurement) -> str: ), ) - def resolve_select_clauses( - self, criteria: Measurement, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveSelectClauses() @@ -109,9 +103,7 @@ def resolve_select_clauses( ] # measurementType - if ( - criteria.measurement_type and len(criteria.measurement_type) > 0 - ) or criteria.measurement_type_cs: + if (criteria.measurement_type and len(criteria.measurement_type) > 0) or criteria.measurement_type_cs: select_cols.append("m.measurement_type_concept_id") # operator @@ -119,9 +111,7 @@ def resolve_select_clauses( select_cols.append("m.operator_concept_id") # valueAsConcept - if ( - criteria.value_as_concept and len(criteria.value_as_concept) > 0 - ) or criteria.value_as_concept_cs: + if (criteria.value_as_concept and len(criteria.value_as_concept) > 0) or criteria.value_as_concept_cs: select_cols.append("m.value_as_concept_id") # unit @@ -129,9 +119,7 @@ def resolve_select_clauses( select_cols.append("m.unit_concept_id") # providerSpecialty - if ( - criteria.provider_specialty and len(criteria.provider_specialty) > 0 - ) or criteria.provider_specialty_cs: + if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: select_cols.append("m.provider_id") # dateAdjustment or default start/end dates @@ -139,28 +127,16 @@ def resolve_select_clauses( select_cols.append( BuilderUtils.get_date_adjustment_expression( criteria.date_adjustment, - ( - "m.measurement_date" - if criteria.date_adjustment.start_with == "start_date" - else "DATEADD(day,1,m.measurement_date)" - ), - ( - "m.measurement_date" - if criteria.date_adjustment.end_with == "start_date" - else "DATEADD(day,1,m.measurement_date)" - ), + ("m.measurement_date" if criteria.date_adjustment.start_with == "start_date" else "DATEADD(day,1,m.measurement_date)"), + ("m.measurement_date" if criteria.date_adjustment.end_with == "start_date" else "DATEADD(day,1,m.measurement_date)"), ) ) else: - select_cols.append( - "m.measurement_date as start_date, DATEADD(day,1,m.measurement_date) as end_date" - ) + select_cols.append("m.measurement_date as start_date, DATEADD(day,1,m.measurement_date) as end_date") return select_cols - def resolve_join_clauses( - self, criteria: Measurement, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveJoinClauses() @@ -168,19 +144,11 @@ def resolve_join_clauses( join_clauses = [] # Join to PERSON if age or gender conditions are present - if ( - criteria.age - or (criteria.gender and len(criteria.gender) > 0) - or (criteria.gender_cs and criteria.gender_cs.codeset_id) - ): - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") # Join to VISIT_OCCURRENCE - if (criteria.visit_type and len(criteria.visit_type) > 0) or ( - criteria.visit_type_cs and criteria.visit_type_cs.codeset_id - ): + if (criteria.visit_type and len(criteria.visit_type) > 0) or (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): join_clauses.append( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" ) @@ -190,23 +158,17 @@ def resolve_join_clauses( if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id ): - join_clauses.append( - "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" - ) + join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") return join_clauses - def resolve_ordinal_expression( - self, criteria: Measurement, options: Optional[BuilderOptions] = None - ) -> str: + def resolve_ordinal_expression(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> str: """Resolve ordinal expression for measurement criteria.""" if criteria.first: return "ORDER BY m.measurement_date, m.measurement_id ASC" return "" - def resolve_where_clauses( - self, criteria: Measurement, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveWhereClauses() @@ -217,21 +179,15 @@ def resolve_where_clauses( # Add occurrence start date condition if criteria.occurrence_start_date: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.occurrence_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date) if date_clause: where_clauses.append(date_clause) # measurementType if criteria.measurement_type and len(criteria.measurement_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.measurement_type - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.measurement_type) operator = "not in" if criteria.measurement_type_exclude else "in" - where_clauses.append( - f"C.measurement_type_concept_id {operator} ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.measurement_type_concept_id {operator} ({','.join(map(str, concept_ids))})") # measurementTypeCS if criteria.measurement_type_cs: @@ -245,74 +201,42 @@ def resolve_where_clauses( # operator if criteria.operator and len(criteria.operator) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.operator) - where_clauses.append( - f"C.operator_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.operator_concept_id in ({','.join(map(str, concept_ids))})") # operatorCS if criteria.operator_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.operator_cs.codeset_id, "C.operator_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.operator_cs.codeset_id, "C.operator_concept_id")) # valueAsNumber if criteria.value_as_number: # Java uses .4f - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "C.value_as_number", criteria.value_as_number, ".4f" - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number, ".4f")) # valueAsConcept if criteria.value_as_concept and len(criteria.value_as_concept) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.value_as_concept - ) - where_clauses.append( - f"C.value_as_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.value_as_concept) + where_clauses.append(f"C.value_as_concept_id in ({','.join(map(str, concept_ids))})") # valueAsConceptCS if criteria.value_as_concept_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id")) # unit if criteria.unit and len(criteria.unit) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.unit) - where_clauses.append( - f"C.unit_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.unit_concept_id in ({','.join(map(str, concept_ids))})") # unitCS if criteria.unit_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.unit_cs.codeset_id, "C.unit_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id")) # rangeLow if criteria.range_low: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "C.range_low", criteria.range_low, ".4f" - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.range_low", criteria.range_low, ".4f")) # rangeHigh if criteria.range_high: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "C.range_high", criteria.range_high, ".4f" - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.range_high", criteria.range_high, ".4f")) # rangeLowRatio if criteria.range_low_ratio: @@ -342,18 +266,12 @@ def resolve_where_clauses( # age if criteria.age: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) # gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") if criteria.gender_cs: where_clauses.append( @@ -366,12 +284,8 @@ def resolve_where_clauses( # providerSpecialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.provider_specialty - ) - where_clauses.append( - f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) + where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") # providerSpecialtyCS if criteria.provider_specialty_cs: @@ -385,20 +299,12 @@ def resolve_where_clauses( # visitType if criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.visit_type - ) - where_clauses.append( - f"V.visit_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) + where_clauses.append(f"V.visit_concept_id in ({','.join(map(str, concept_ids))})") # visitTypeCS if criteria.visit_type_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.visit_type_cs.codeset_id, "V.visit_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) return where_clauses @@ -407,9 +313,4 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: MeasurementSqlBuilder.getAdditionalColumns() """ - return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] - ) + return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) diff --git a/circe/cohortdefinition/builders/observation.py b/circe/cohortdefinition/builders/observation.py index 3267f482..7f6ef83f 100644 --- a/circe/cohortdefinition/builders/observation.py +++ b/circe/cohortdefinition/builders/observation.py @@ -46,9 +46,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: CriteriaColumn.VISIT_ID, } - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.START_DATE: "C.start_date", @@ -71,9 +69,7 @@ def embed_codeset_clause(self, query: str, criteria: Observation) -> str: ), ) - def resolve_select_clauses( - self, criteria: Observation, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: Observation, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for observation criteria. Java equivalent: ObservationSqlBuilder.resolveSelectClauses() @@ -91,21 +87,15 @@ def resolve_select_clauses( ] # observationType - if ( - criteria.observation_type and len(criteria.observation_type) > 0 - ) or criteria.observation_type_cs: + if (criteria.observation_type and len(criteria.observation_type) > 0) or criteria.observation_type_cs: select_cols.append("o.observation_type_concept_id") # qualifier - if ( - criteria.qualifier and len(criteria.qualifier) > 0 - ) or criteria.qualifier_cs: + if (criteria.qualifier and len(criteria.qualifier) > 0) or criteria.qualifier_cs: select_cols.append("o.qualifier_concept_id") # providerSpecialty - if ( - criteria.provider_specialty and len(criteria.provider_specialty) > 0 - ) or criteria.provider_specialty_cs: + if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: select_cols.append("o.provider_id") # Add date columns (start_date and end_date) @@ -114,9 +104,7 @@ def resolve_select_clauses( return select_cols - def resolve_join_clauses( - self, criteria: Observation, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: Observation, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for observation criteria. Java equivalent: ObservationSqlBuilder.resolveJoinClauses() @@ -124,64 +112,44 @@ def resolve_join_clauses( join_clauses = [] # Join to PERSON if age or gender conditions are present - if ( - criteria.age - or (criteria.gender and len(criteria.gender) > 0) - or (criteria.gender_cs and criteria.gender_cs.codeset_id) - ): - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") # Join to PROVIDER if provider specialty conditions are present # Always use PR alias for PROVIDER to match Java implementation if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id ): - join_clauses.append( - "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" - ) + join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") # Join to VISIT_OCCURRENCE if visit type conditions are present - if (criteria.visit_type and len(criteria.visit_type) > 0) or ( - criteria.visit_type_cs and criteria.visit_type_cs.codeset_id - ): + if (criteria.visit_type and len(criteria.visit_type) > 0) or (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): join_clauses.append( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" ) return join_clauses - def resolve_where_clauses( - self, criteria: Observation, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: Observation, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for observation criteria.""" where_clauses = super().resolve_where_clauses(criteria) # Add date range conditions if criteria.occurrence_start_date: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.occurrence_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date) if date_clause: where_clauses.append(date_clause) if criteria.occurrence_end_date: - date_clause = BuilderUtils.build_date_range_clause( - "C.end_date", criteria.occurrence_end_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.occurrence_end_date) if date_clause: where_clauses.append(date_clause) # observationType if criteria.observation_type and len(criteria.observation_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.observation_type - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.observation_type) operator = "not in" if criteria.observation_type_exclude else "in" - where_clauses.append( - f"C.observation_type_concept_id {operator} ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.observation_type_concept_id {operator} ({','.join(map(str, concept_ids))})") # observationTypeCS if criteria.observation_type_cs: @@ -194,89 +162,47 @@ def resolve_where_clauses( # valueAsNumber if hasattr(criteria, "value_as_number") and criteria.value_as_number: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "C.value_as_number", criteria.value_as_number, ".4f" - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number, ".4f")) # valueAsString if criteria.value_as_string: - where_clauses.append( - BuilderUtils.build_text_filter_clause( - criteria.value_as_string, "C.value_as_string" - ) - ) + where_clauses.append(BuilderUtils.build_text_filter_clause(criteria.value_as_string, "C.value_as_string")) # valueAsConcept - if ( - hasattr(criteria, "value_as_concept") - and criteria.value_as_concept - and len(criteria.value_as_concept) > 0 - ): - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.value_as_concept - ) - where_clauses.append( - f"C.value_as_concept_id in ({','.join(map(str, concept_ids))})" - ) + if hasattr(criteria, "value_as_concept") and criteria.value_as_concept and len(criteria.value_as_concept) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.value_as_concept) + where_clauses.append(f"C.value_as_concept_id in ({','.join(map(str, concept_ids))})") # valueAsConceptCS if hasattr(criteria, "value_as_concept_cs") and criteria.value_as_concept_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id")) # unit if hasattr(criteria, "unit") and criteria.unit and len(criteria.unit) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.unit) - where_clauses.append( - f"C.unit_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.unit_concept_id in ({','.join(map(str, concept_ids))})") # unitCS if hasattr(criteria, "unit_cs") and criteria.unit_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.unit_cs.codeset_id, "C.unit_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id")) # qualifier - if ( - hasattr(criteria, "qualifier") - and criteria.qualifier - and len(criteria.qualifier) > 0 - ): + if hasattr(criteria, "qualifier") and criteria.qualifier and len(criteria.qualifier) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.qualifier) - where_clauses.append( - f"C.qualifier_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.qualifier_concept_id in ({','.join(map(str, concept_ids))})") # qualifierCS if hasattr(criteria, "qualifier_cs") and criteria.qualifier_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.qualifier_cs.codeset_id, "C.qualifier_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.qualifier_cs.codeset_id, "C.qualifier_concept_id")) # age if criteria.age: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) # gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") if criteria.gender_cs: where_clauses.append( @@ -289,12 +215,8 @@ def resolve_where_clauses( # providerSpecialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.provider_specialty - ) - where_clauses.append( - f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) + where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") # providerSpecialtyCS if criteria.provider_specialty_cs: @@ -308,20 +230,12 @@ def resolve_where_clauses( # visitType if criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.visit_type - ) - where_clauses.append( - f"V.visit_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) + where_clauses.append(f"V.visit_concept_id in ({','.join(map(str, concept_ids))})") # visitTypeCS if criteria.visit_type_cs: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.visit_type_cs.codeset_id, "V.visit_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) return where_clauses @@ -330,16 +244,9 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: ObservationSqlBuilder.getAdditionalColumns() """ - return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] - ) + return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) - def embed_ordinal_expression( - self, query: str, criteria: Observation, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: Observation, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: @@ -352,9 +259,7 @@ def embed_ordinal_expression( query = query.replace("@ordinalExpression", "") return query - def resolve_ordinal_expression( - self, criteria: Observation, options: BuilderOptions - ) -> str: + def resolve_ordinal_expression(self, criteria: Observation, options: BuilderOptions) -> str: """Resolve ordinal expression for observation criteria.""" if criteria.first: return ", row_number() over (PARTITION BY o.person_id ORDER BY o.observation_date, o.observation_id) as ordinal" diff --git a/circe/cohortdefinition/builders/observation_period.py b/circe/cohortdefinition/builders/observation_period.py index 05b3e1f8..f06d8eda 100644 --- a/circe/cohortdefinition/builders/observation_period.py +++ b/circe/cohortdefinition/builders/observation_period.py @@ -58,9 +58,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for observation period criteria.""" return self.DEFAULT_COLUMNS - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.period_type_concept_id", @@ -72,25 +70,21 @@ def get_table_column_for_criteria_column( } return column_mapping.get(criteria_column, "NULL") - def get_criteria_sql_with_options( - self, criteria: ObservationPeriod, options: Optional[BuilderOptions] - ) -> str: + def get_criteria_sql_with_options(self, criteria: ObservationPeriod, options: Optional[BuilderOptions]) -> str: """Get SQL query for criteria with builder options.""" query = super().get_criteria_sql_with_options(criteria, options) # Override user defined dates in select start_date_expression = ( BuilderUtils.date_string_to_sql(criteria.user_defined_period.start_date) - if criteria.user_defined_period is not None - and criteria.user_defined_period.start_date is not None + if criteria.user_defined_period is not None and criteria.user_defined_period.start_date is not None else "C.start_date" ) query = query.replace("@startDateExpression", start_date_expression) end_date_expression = ( BuilderUtils.date_string_to_sql(criteria.user_defined_period.end_date) - if criteria.user_defined_period is not None - and criteria.user_defined_period.end_date is not None + if criteria.user_defined_period is not None and criteria.user_defined_period.end_date is not None else "C.end_date" ) query = query.replace("@endDateExpression", end_date_expression) @@ -101,15 +95,11 @@ def embed_codeset_clause(self, query: str, criteria: ObservationPeriod) -> str: """Embed codeset clause in query.""" return query.replace("@codesetClause", "") - def embed_ordinal_expression( - self, query: str, criteria: ObservationPeriod, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: ObservationPeriod, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") - def resolve_select_clauses( - self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for observation period criteria. Note: The outer SELECT in the template handles event_id, start_date, end_date, visit_occurrence_id, sort_date. @@ -120,44 +110,26 @@ def resolve_select_clauses( # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: start_column = ( - "op.observation_period_start_date" - if criteria.date_adjustment.start_with == "start_date" - else "op.observation_period_end_date" - ) - end_column = ( - "op.observation_period_start_date" - if criteria.date_adjustment.end_with == "start_date" - else "op.observation_period_end_date" - ) - select_cols.append( - BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, start_column, end_column - ) + "op.observation_period_start_date" if criteria.date_adjustment.start_with == "start_date" else "op.observation_period_end_date" ) + end_column = "op.observation_period_start_date" if criteria.date_adjustment.end_with == "start_date" else "op.observation_period_end_date" + select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) else: - select_cols.append( - "op.observation_period_start_date as start_date, op.observation_period_end_date as end_date" - ) + select_cols.append("op.observation_period_start_date as start_date, op.observation_period_end_date as end_date") return select_cols - def resolve_join_clauses( - self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for observation period criteria.""" join_clauses = [] # join to PERSON if criteria.age_at_start is not None or criteria.age_at_end is not None: - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") return join_clauses - def resolve_where_clauses( - self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for observation period criteria.""" where_clauses = [] @@ -169,50 +141,30 @@ def resolve_where_clauses( user_defined_period = criteria.user_defined_period if user_defined_period.start_date is not None: - start_date_expression = BuilderUtils.date_string_to_sql( - user_defined_period.start_date - ) - where_clauses.append( - f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}" - ) + start_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.start_date) + where_clauses.append(f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}") if user_defined_period.end_date is not None: - end_date_expression = BuilderUtils.date_string_to_sql( - user_defined_period.end_date - ) - where_clauses.append( - f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}" - ) + end_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.end_date) + where_clauses.append(f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}") # periodStartDate if criteria.period_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.period_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.period_start_date) if date_clause: where_clauses.append(date_clause) # periodEndDate if criteria.period_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.end_date", criteria.period_end_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.period_end_date) if date_clause: where_clauses.append(date_clause) # periodType - if ( - criteria.period_type is not None - and hasattr(criteria.period_type, "__len__") - and len(criteria.period_type) > 0 - ): - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.period_type - ) + if criteria.period_type is not None and hasattr(criteria.period_type, "__len__") and len(criteria.period_type) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.period_type) if concept_ids: - where_clauses.append( - f"C.period_type_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.period_type_concept_id in ({','.join(map(str, concept_ids))})") # periodTypeCS if criteria.period_type_cs is not None: @@ -226,25 +178,19 @@ def resolve_where_clauses( # periodLength if criteria.period_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length) if numeric_clause: where_clauses.append(numeric_clause) # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) if numeric_clause: where_clauses.append(numeric_clause) # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) if numeric_clause: where_clauses.append(numeric_clause) @@ -255,9 +201,4 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: ObservationPeriodSqlBuilder.getAdditionalColumns() """ - return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] - ) + return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) diff --git a/circe/cohortdefinition/builders/payer_plan_period.py b/circe/cohortdefinition/builders/payer_plan_period.py index 73b1c870..58996390 100644 --- a/circe/cohortdefinition/builders/payer_plan_period.py +++ b/circe/cohortdefinition/builders/payer_plan_period.py @@ -63,9 +63,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for payer plan period criteria.""" return self.DEFAULT_COLUMNS - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.payer_concept_id", @@ -76,24 +74,20 @@ def get_table_column_for_criteria_column( } return column_mapping.get(criteria_column, "NULL") - def get_criteria_sql_with_options( - self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] - ) -> str: + def get_criteria_sql_with_options(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions]) -> str: """Get SQL query for criteria with builder options.""" query = super().get_criteria_sql_with_options(criteria, options) start_date_expression = ( BuilderUtils.date_string_to_sql(criteria.user_defined_period.start_date) - if criteria.user_defined_period is not None - and criteria.user_defined_period.start_date is not None + if criteria.user_defined_period is not None and criteria.user_defined_period.start_date is not None else "C.start_date" ) query = query.replace("@startDateExpression", start_date_expression) end_date_expression = ( BuilderUtils.date_string_to_sql(criteria.user_defined_period.end_date) - if criteria.user_defined_period is not None - and criteria.user_defined_period.end_date is not None + if criteria.user_defined_period is not None and criteria.user_defined_period.end_date is not None else "C.end_date" ) query = query.replace("@endDateExpression", end_date_expression) @@ -104,15 +98,11 @@ def embed_codeset_clause(self, query: str, criteria: PayerPlanPeriod) -> str: """Embed codeset clause in query.""" return query.replace("@codesetClause", "") - def embed_ordinal_expression( - self, query: str, criteria: PayerPlanPeriod, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: PayerPlanPeriod, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") - def resolve_select_clauses( - self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for payer plan period criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -151,20 +141,10 @@ def resolve_select_clauses( # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: start_column = ( - "ppp.payer_plan_period_start_date" - if criteria.date_adjustment.start_with == "start_date" - else "ppp.payer_plan_period_end_date" - ) - end_column = ( - "ppp.payer_plan_period_start_date" - if criteria.date_adjustment.end_with == "start_date" - else "ppp.payer_plan_period_end_date" - ) - select_cols.append( - BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, start_column, end_column - ) + "ppp.payer_plan_period_start_date" if criteria.date_adjustment.start_with == "start_date" else "ppp.payer_plan_period_end_date" ) + end_column = "ppp.payer_plan_period_start_date" if criteria.date_adjustment.end_with == "start_date" else "ppp.payer_plan_period_end_date" + select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) else: select_cols.append("ppp.payer_plan_period_start_date as start_date") select_cols.append("ppp.payer_plan_period_end_date as end_date") @@ -177,9 +157,7 @@ def resolve_select_clauses( return select_cols - def resolve_join_clauses( - self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for payer plan period criteria.""" join_clauses = [] @@ -189,15 +167,11 @@ def resolve_join_clauses( or (criteria.gender is not None and len(criteria.gender) > 0) or criteria.gender_cs is not None ): - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") return join_clauses - def resolve_where_clauses( - self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for payer plan period criteria.""" where_clauses = [] @@ -210,72 +184,48 @@ def resolve_where_clauses( user_defined_period = criteria.user_defined_period if user_defined_period.start_date is not None: - start_date_expression = BuilderUtils.date_string_to_sql( - user_defined_period.start_date - ) - where_clauses.append( - f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}" - ) + start_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.start_date) + where_clauses.append(f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}") if user_defined_period.end_date is not None: - end_date_expression = BuilderUtils.date_string_to_sql( - user_defined_period.end_date - ) - where_clauses.append( - f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}" - ) + end_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.end_date) + where_clauses.append(f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}") # periodStartDate if criteria.period_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.period_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.period_start_date) if date_clause: where_clauses.append(date_clause) # periodEndDate if criteria.period_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.end_date", criteria.period_end_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.period_end_date) if date_clause: where_clauses.append(date_clause) # periodLength if criteria.period_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length) if numeric_clause: where_clauses.append(numeric_clause) # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) if numeric_clause: where_clauses.append(numeric_clause) # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) if numeric_clause: where_clauses.append(numeric_clause) # gender - if ( - criteria.gender is not None - and hasattr(criteria.gender, "__len__") - and len(criteria.gender) > 0 - ): + if criteria.gender is not None and hasattr(criteria.gender, "__len__") and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") # genderCS if criteria.gender_cs is not None: @@ -289,27 +239,19 @@ def resolve_where_clauses( # payer concept if criteria.payer_concept is not None: - where_clauses.append( - f"C.payer_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.payer_concept})" - ) + where_clauses.append(f"C.payer_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.payer_concept})") # plan concept if criteria.plan_concept is not None: - where_clauses.append( - f"C.plan_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_concept})" - ) + where_clauses.append(f"C.plan_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_concept})") # sponsor concept if criteria.sponsor_concept is not None: - where_clauses.append( - f"C.sponsor_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.sponsor_concept})" - ) + where_clauses.append(f"C.sponsor_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.sponsor_concept})") # stop reason concept if criteria.stop_reason_concept is not None: - where_clauses.append( - f"C.stop_reason_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.stop_reason_concept})" - ) + where_clauses.append(f"C.stop_reason_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.stop_reason_concept})") # payer SourceConcept if criteria.payer_source_concept is not None: @@ -319,9 +261,7 @@ def resolve_where_clauses( # plan SourceConcept if criteria.plan_source_concept is not None: - where_clauses.append( - f"C.plan_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_source_concept})" - ) + where_clauses.append(f"C.plan_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_source_concept})") # sponsor SourceConcept if criteria.sponsor_source_concept is not None: @@ -342,9 +282,4 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: PayerPlanPeriodSqlBuilder.getAdditionalColumns() """ - return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] - ) + return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) diff --git a/circe/cohortdefinition/builders/procedure_occurrence.py b/circe/cohortdefinition/builders/procedure_occurrence.py index f6a62b70..fd29dd8b 100644 --- a/circe/cohortdefinition/builders/procedure_occurrence.py +++ b/circe/cohortdefinition/builders/procedure_occurrence.py @@ -90,9 +90,7 @@ def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: else: return f"C.{column.value}" - def embed_ordinal_expression( - self, query: str, criteria: Criteria, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: Criteria, where_clauses: list[str]) -> str: """Embed ordinal expression in query. Java equivalent: ProcedureOccurrenceSqlBuilder.embedOrdinalExpression() @@ -119,18 +117,12 @@ def embed_codeset_clause(self, query: str, criteria: Criteria) -> str: BuilderUtils.get_codeset_join_expression( criteria.codeset_id if hasattr(criteria, "codeset_id") else None, "po.procedure_concept_id", - ( - criteria.procedure_source_concept - if hasattr(criteria, "procedure_source_concept") - else None - ), + (criteria.procedure_source_concept if hasattr(criteria, "procedure_source_concept") else None), "po.procedure_source_concept_id", ), ) - def resolve_select_clauses( - self, criteria: Criteria, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for criteria. Java equivalent: ProcedureOccurrenceSqlBuilder.resolveSelectClauses() @@ -138,32 +130,20 @@ def resolve_select_clauses( select_cols = list(self.DEFAULT_SELECT_COLUMNS) # procedureType - if ( - hasattr(criteria, "procedure_type") - and criteria.procedure_type - and len(criteria.procedure_type) > 0 - ) or ( - hasattr(criteria, "procedure_type_cs") - and criteria.procedure_type_cs is not None + if (hasattr(criteria, "procedure_type") and criteria.procedure_type and len(criteria.procedure_type) > 0) or ( + hasattr(criteria, "procedure_type_cs") and criteria.procedure_type_cs is not None ): select_cols.append("po.procedure_type_concept_id") # modifier - if ( - hasattr(criteria, "modifier") - and criteria.modifier - and len(criteria.modifier) > 0 - ) or (hasattr(criteria, "modifier_cs") and criteria.modifier_cs is not None): + if (hasattr(criteria, "modifier") and criteria.modifier and len(criteria.modifier) > 0) or ( + hasattr(criteria, "modifier_cs") and criteria.modifier_cs is not None + ): select_cols.append("po.modifier_concept_id") # providerSpecialty - if ( - hasattr(criteria, "provider_specialty") - and criteria.provider_specialty - and len(criteria.provider_specialty) > 0 - ) or ( - hasattr(criteria, "provider_specialty_cs") - and criteria.provider_specialty_cs is not None + if (hasattr(criteria, "provider_specialty") and criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( + hasattr(criteria, "provider_specialty_cs") and criteria.provider_specialty_cs is not None ): select_cols.append("po.provider_id") @@ -172,28 +152,16 @@ def resolve_select_clauses( select_cols.append( BuilderUtils.get_date_adjustment_expression( criteria.date_adjustment, - ( - "po.procedure_date" - if criteria.date_adjustment.start_with == "start_date" - else "DATEADD(day,1,po.procedure_date)" - ), - ( - "po.procedure_date" - if criteria.date_adjustment.end_with == "start_date" - else "DATEADD(day,1,po.procedure_date)" - ), + ("po.procedure_date" if criteria.date_adjustment.start_with == "start_date" else "DATEADD(day,1,po.procedure_date)"), + ("po.procedure_date" if criteria.date_adjustment.end_with == "start_date" else "DATEADD(day,1,po.procedure_date)"), ) ) else: - select_cols.append( - "po.procedure_date as start_date, DATEADD(day,1,po.procedure_date) as end_date" - ) + select_cols.append("po.procedure_date as start_date, DATEADD(day,1,po.procedure_date) as end_date") return select_cols - def resolve_join_clauses( - self, criteria: Criteria, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for criteria. Java equivalent: ProcedureOccurrenceSqlBuilder.resolveJoinClauses() @@ -203,23 +171,13 @@ def resolve_join_clauses( # join to PERSON if ( (hasattr(criteria, "age") and criteria.age) - or ( - hasattr(criteria, "gender") - and criteria.gender - and len(criteria.gender) > 0 - ) + or (hasattr(criteria, "gender") and criteria.gender and len(criteria.gender) > 0) or (hasattr(criteria, "gender_cs") and criteria.gender_cs is not None) ): - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") # visitType - if ( - hasattr(criteria, "visit_type") - and criteria.visit_type - and len(criteria.visit_type) > 0 - ) or ( + if (hasattr(criteria, "visit_type") and criteria.visit_type and len(criteria.visit_type) > 0) or ( hasattr(criteria, "visit_type_cs") and criteria.visit_type_cs is not None ): join_clauses.append( @@ -227,23 +185,14 @@ def resolve_join_clauses( ) # providerSpecialty - if ( - hasattr(criteria, "provider_specialty") - and criteria.provider_specialty - and len(criteria.provider_specialty) > 0 - ) or ( - hasattr(criteria, "provider_specialty_cs") - and criteria.provider_specialty_cs is not None + if (hasattr(criteria, "provider_specialty") and criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( + hasattr(criteria, "provider_specialty_cs") and criteria.provider_specialty_cs is not None ): - join_clauses.append( - "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" - ) + join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") return join_clauses - def resolve_where_clauses( - self, criteria: Criteria, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for criteria. Java equivalent: ProcedureOccurrenceSqlBuilder.resolveWhereClauses() @@ -251,137 +200,61 @@ def resolve_where_clauses( where_clauses = list(super().resolve_where_clauses(criteria, options)) # occurrenceStartDate - if ( - hasattr(criteria, "occurrence_start_date") - and criteria.occurrence_start_date - ): - where_clauses.append( - BuilderUtils.build_date_range_clause( - "C.start_date", criteria.occurrence_start_date - ) - ) + if hasattr(criteria, "occurrence_start_date") and criteria.occurrence_start_date: + where_clauses.append(BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date)) # procedureType - if ( - hasattr(criteria, "procedure_type") - and criteria.procedure_type - and len(criteria.procedure_type) > 0 - ): - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.procedure_type - ) - exclude = ( - "not " - if hasattr(criteria, "procedure_type_exclude") - and criteria.procedure_type_exclude - else "" - ) - where_clauses.append( - f"C.procedure_type_concept_id {exclude}in ({','.join(map(str, concept_ids))})" - ) + if hasattr(criteria, "procedure_type") and criteria.procedure_type and len(criteria.procedure_type) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.procedure_type) + exclude = "not " if hasattr(criteria, "procedure_type_exclude") and criteria.procedure_type_exclude else "" + where_clauses.append(f"C.procedure_type_concept_id {exclude}in ({','.join(map(str, concept_ids))})") # procedureTypeCS - if ( - hasattr(criteria, "procedure_type_cs") - and criteria.procedure_type_cs is not None - ): - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.procedure_type_cs.codeset_id, "C.procedure_type_concept_id" - ) - ) + if hasattr(criteria, "procedure_type_cs") and criteria.procedure_type_cs is not None: + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.procedure_type_cs.codeset_id, "C.procedure_type_concept_id")) # modifier - if ( - hasattr(criteria, "modifier") - and criteria.modifier - and len(criteria.modifier) > 0 - ): + if hasattr(criteria, "modifier") and criteria.modifier and len(criteria.modifier) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.modifier) - where_clauses.append( - f"C.modifier_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.modifier_concept_id in ({','.join(map(str, concept_ids))})") # modifierCS if hasattr(criteria, "modifier_cs") and criteria.modifier_cs is not None: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.modifier_cs.codeset_id, "C.modifier_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.modifier_cs.codeset_id, "C.modifier_concept_id")) # quantity if hasattr(criteria, "quantity") and criteria.quantity: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity)) # age if hasattr(criteria, "age") and criteria.age: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) # gender if hasattr(criteria, "gender") and criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") # genderCS if hasattr(criteria, "gender_cs") and criteria.gender_cs is not None: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.gender_cs.codeset_id, "P.gender_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) # providerSpecialty - if ( - hasattr(criteria, "provider_specialty") - and criteria.provider_specialty - and len(criteria.provider_specialty) > 0 - ): - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.provider_specialty - ) - where_clauses.append( - f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" - ) + if hasattr(criteria, "provider_specialty") and criteria.provider_specialty and len(criteria.provider_specialty) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) + where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") # providerSpecialtyCS - if ( - hasattr(criteria, "provider_specialty_cs") - and criteria.provider_specialty_cs is not None - ): - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id" - ) - ) + if hasattr(criteria, "provider_specialty_cs") and criteria.provider_specialty_cs is not None: + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id")) # visitType - if ( - hasattr(criteria, "visit_type") - and criteria.visit_type - and len(criteria.visit_type) > 0 - ): - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.visit_type - ) - where_clauses.append( - f"V.visit_concept_id in ({','.join(map(str, concept_ids))})" - ) + if hasattr(criteria, "visit_type") and criteria.visit_type and len(criteria.visit_type) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) + where_clauses.append(f"V.visit_concept_id in ({','.join(map(str, concept_ids))})") # visitTypeCS if hasattr(criteria, "visit_type_cs") and criteria.visit_type_cs is not None: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.visit_type_cs.codeset_id, "V.visit_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) return where_clauses diff --git a/circe/cohortdefinition/builders/specimen.py b/circe/cohortdefinition/builders/specimen.py index af4073c5..6b27bc27 100644 --- a/circe/cohortdefinition/builders/specimen.py +++ b/circe/cohortdefinition/builders/specimen.py @@ -45,9 +45,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: CriteriaColumn.VISIT_ID, } - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.specimen_concept_id", @@ -72,9 +70,7 @@ def embed_codeset_clause(self, query: str, criteria: Specimen) -> str: ), ) - def embed_ordinal_expression( - self, query: str, criteria: Specimen, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: Specimen, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" if criteria.first: where_clauses.append("C.ordinal = 1") @@ -86,142 +82,82 @@ def embed_ordinal_expression( query = query.replace("@ordinalExpression", "") return query - def resolve_join_clauses( - self, criteria: Specimen, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: Specimen, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for specimen criteria.""" joins = [] # join to PERSON - if ( - criteria.age - or (criteria.gender and len(criteria.gender) > 0) - or (criteria.gender_cs and criteria.gender_cs.codeset_id) - ): - joins.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + joins.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") return joins - def resolve_where_clauses( - self, criteria: Specimen, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: Specimen, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for specimen criteria.""" where_clauses = [] # occurrenceStartDate if criteria.occurrence_start_date: - date_clause = BuilderUtils.build_date_range_clause( - "C.specimen_date", criteria.occurrence_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.specimen_date", criteria.occurrence_start_date) if date_clause: where_clauses.append(date_clause) # specimenType if criteria.specimen_type and len(criteria.specimen_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.specimen_type - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.specimen_type) op = "not in" if criteria.specimen_type_exclude else "in" - where_clauses.append( - f"C.specimen_type_concept_id {op} ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.specimen_type_concept_id {op} ({','.join(map(str, concept_ids))})") # specimenTypeCS if criteria.specimen_type_cs and criteria.specimen_type_cs.codeset_id: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.specimen_type_cs.codeset_id, "C.specimen_type_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.specimen_type_cs.codeset_id, "C.specimen_type_concept_id")) # quantity if criteria.quantity: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "C.quantity", criteria.quantity, ".4f" - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity, ".4f")) # unit if criteria.unit and len(criteria.unit) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.unit) - where_clauses.append( - f"C.unit_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.unit_concept_id in ({','.join(map(str, concept_ids))})") # unitCS if criteria.unit_cs and criteria.unit_cs.codeset_id: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.unit_cs.codeset_id, "C.unit_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id")) # anatomicSite if criteria.anatomic_site and len(criteria.anatomic_site) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.anatomic_site - ) - where_clauses.append( - f"C.anatomic_site_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.anatomic_site) + where_clauses.append(f"C.anatomic_site_concept_id in ({','.join(map(str, concept_ids))})") # anatomicSiteCS if criteria.anatomic_site_cs and criteria.anatomic_site_cs.codeset_id: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.anatomic_site_cs.codeset_id, "C.anatomic_site_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.anatomic_site_cs.codeset_id, "C.anatomic_site_concept_id")) # diseaseStatus if criteria.disease_status and len(criteria.disease_status) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.disease_status - ) - where_clauses.append( - f"C.disease_status_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.disease_status) + where_clauses.append(f"C.disease_status_concept_id in ({','.join(map(str, concept_ids))})") # diseaseStatusCS if criteria.disease_status_cs and criteria.disease_status_cs.codeset_id: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.disease_status_cs.codeset_id, "C.disease_status_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.disease_status_cs.codeset_id, "C.disease_status_concept_id")) # sourceId if criteria.source_id: - where_clauses.append( - BuilderUtils.build_text_filter_clause( - criteria.source_id, "C.specimen_source_id" - ) - ) + where_clauses.append(BuilderUtils.build_text_filter_clause(criteria.source_id, "C.specimen_source_id")) # age if criteria.age: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "YEAR(C.specimen_date) - P.year_of_birth", criteria.age - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.specimen_date) - P.year_of_birth", criteria.age)) # gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") # genderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: - where_clauses.append( - BuilderUtils.get_codeset_in_expression( - criteria.gender_cs.codeset_id, "P.gender_concept_id" - ) - ) + where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) return where_clauses diff --git a/circe/cohortdefinition/builders/utils.py b/circe/cohortdefinition/builders/utils.py index 4b3a550e..f29c2641 100644 --- a/circe/cohortdefinition/builders/utils.py +++ b/circe/cohortdefinition/builders/utils.py @@ -33,26 +33,18 @@ class BuilderUtils: """ # SQL templates - equivalent to Java constants - CODESET_JOIN_TEMPLATE = ( - "JOIN #Codesets {} on ({} = {}.concept_id and {}.codeset_id = {})" - ) - CODESET_IN_TEMPLATE = ( - "{} {} in (select concept_id from #Codesets where codeset_id = {})" - ) + CODESET_JOIN_TEMPLATE = "JOIN #Codesets {} on ({} = {}.concept_id and {}.codeset_id = {})" + CODESET_IN_TEMPLATE = "{} {} in (select concept_id from #Codesets where codeset_id = {})" CODESET_NULL_TEMPLATE = "{} is {} null" # Date adjustment template - equivalent to Java ResourceHelper.GetResourceAsString - DATE_ADJUSTMENT_TEMPLATE = ( - "DATEADD(day,{}, {}) as start_date, DATEADD(day,{}, {}) as end_date" - ) + DATE_ADJUSTMENT_TEMPLATE = "DATEADD(day,{}, {}) as start_date, DATEADD(day,{}, {}) as end_date" STANDARD_ALIAS = "cs" NON_STANDARD_ALIAS = "cns" @staticmethod - def get_date_adjustment_expression( - date_adjustment: DateAdjustment, start_column: str, end_column: str - ) -> str: + def get_date_adjustment_expression(date_adjustment: DateAdjustment, start_column: str, end_column: str) -> str: """Get date adjustment expression for SQL. Java equivalent: BuilderUtils.getDateAdjustmentExpression() @@ -102,17 +94,13 @@ def get_codeset_join_expression( return " ".join(codeset_clauses) @staticmethod - def get_codeset_in_expression( - codeset_id: int, column_name: str, is_exclusion: bool = False - ) -> str: + def get_codeset_in_expression(codeset_id: int, column_name: str, is_exclusion: bool = False) -> str: """Get codeset IN expression for SQL. Java equivalent: BuilderUtils.getCodesetInExpression() """ operator = "not" if is_exclusion else "" - return BuilderUtils.CODESET_IN_TEMPLATE.format( - operator, column_name, codeset_id - ) + return BuilderUtils.CODESET_IN_TEMPLATE.format(operator, column_name, codeset_id) @staticmethod def get_concept_ids_from_concepts(concepts: list[Concept]) -> list[int]: @@ -120,9 +108,7 @@ def get_concept_ids_from_concepts(concepts: list[Concept]) -> list[int]: Java equivalent: BuilderUtils.getConceptIdsFromConcepts() """ - return [ - concept.concept_id for concept in concepts if concept.concept_id is not None - ] + return [concept.concept_id for concept in concepts if concept.concept_id is not None] @staticmethod def get_operator(op: str) -> str: @@ -143,9 +129,7 @@ def get_operator(op: str) -> str: raise RuntimeError(f"Unknown operator type: {op}") @staticmethod - def build_date_range_clause( - sql_expression: str, date_range: Optional[DateRange] - ) -> Optional[str]: + def build_date_range_clause(sql_expression: str, date_range: Optional[DateRange]) -> Optional[str]: """Build date range clause for SQL. Java equivalent: BuilderUtils.buildDateRangeClause(String sqlExpression, DateRange range) @@ -205,9 +189,7 @@ def build_numeric_range_clause( return f"{sql_expression} {BuilderUtils.get_operator(op)} {int(numeric_range.value)}" @staticmethod - def build_text_filter_clause( - text_filter: Optional[Any], column_name: str - ) -> Optional[str]: + def build_text_filter_clause(text_filter: Optional[Any], column_name: str) -> Optional[str]: """Build text filter clause for SQL. Java equivalent: BuilderUtils.buildTextFilterClause() @@ -244,9 +226,7 @@ def build_text_filter_clause( return operator_templates.get(op, f"{column_name} = '{text}'") @staticmethod - def split_in_clause( - column_name: str, values: list[int], max_length: int = 1000 - ) -> str: + def split_in_clause(column_name: str, values: list[int], max_length: int = 1000) -> str: """Split IN clause for large value lists. Java equivalent: BuilderUtils.splitInClause() @@ -272,7 +252,5 @@ def date_string_to_sql(date_string: str) -> str: """ parts = date_string.split("-") if len(parts) != 3: - raise ValueError( - f"Invalid date format: {date_string}. Expected YYYY-MM-DD." - ) + raise ValueError(f"Invalid date format: {date_string}. Expected YYYY-MM-DD.") return f"DATEFROMPARTS({int(parts[0])}, {int(parts[1])}, {int(parts[2])})" diff --git a/circe/cohortdefinition/builders/visit_detail.py b/circe/cohortdefinition/builders/visit_detail.py index 8d0c2fa7..f7740593 100644 --- a/circe/cohortdefinition/builders/visit_detail.py +++ b/circe/cohortdefinition/builders/visit_detail.py @@ -65,9 +65,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: """Get default columns for visit detail criteria.""" return self.DEFAULT_COLUMNS - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" column_mapping = { CriteriaColumn.DOMAIN_CONCEPT: "C.visit_detail_concept_id", @@ -88,9 +86,7 @@ def embed_codeset_clause(self, query: str, criteria: VisitDetail) -> str: ) return query.replace("@codesetClause", codeset_clause) - def embed_ordinal_expression( - self, query: str, criteria: VisitDetail, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: VisitDetail, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: @@ -103,9 +99,7 @@ def embed_ordinal_expression( query = query.replace("@ordinalExpression", "") return query - def resolve_select_clauses( - self, criteria: VisitDetail, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: VisitDetail, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for visit detail criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -123,21 +117,9 @@ def resolve_select_clauses( # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = ( - "vd.visit_detail_start_date" - if criteria.date_adjustment.start_with == "start_date" - else "vd.visit_detail_end_date" - ) - end_column = ( - "vd.visit_detail_start_date" - if criteria.date_adjustment.end_with == "start_date" - else "vd.visit_detail_end_date" - ) - select_cols.append( - BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, start_column, end_column - ) - ) + start_column = "vd.visit_detail_start_date" if criteria.date_adjustment.start_with == "start_date" else "vd.visit_detail_end_date" + end_column = "vd.visit_detail_start_date" if criteria.date_adjustment.end_with == "start_date" else "vd.visit_detail_end_date" + select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) else: select_cols.append("vd.visit_detail_start_date as start_date") select_cols.append("vd.visit_detail_end_date as end_date") @@ -150,60 +132,37 @@ def resolve_select_clauses( return select_cols - def resolve_join_clauses( - self, criteria: VisitDetail, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: VisitDetail, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for visit detail criteria.""" join_clauses = [] - if ( - criteria.age is not None - or criteria.gender_cs is not None - or criteria.gender is not None - ): # join to PERSON - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + if criteria.age is not None or criteria.gender_cs is not None or criteria.gender is not None: # join to PERSON + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") - if ( - criteria.place_of_service_cs is not None - or criteria.place_of_service_location is not None - ): - join_clauses.append( - "JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id" - ) + if criteria.place_of_service_cs is not None or criteria.place_of_service_location is not None: + join_clauses.append("JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id") if criteria.provider_specialty_cs is not None: - join_clauses.append( - "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" - ) + join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") if criteria.place_of_service_location is not None: - self.add_filtering_by_care_site_location_region( - join_clauses, criteria.place_of_service_location - ) + self.add_filtering_by_care_site_location_region(join_clauses, criteria.place_of_service_location) return join_clauses - def resolve_where_clauses( - self, criteria: VisitDetail, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: VisitDetail, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for visit detail criteria.""" where_clauses = [] # occurrenceStartDate if criteria.visit_detail_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.start_date", criteria.visit_detail_start_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.visit_detail_start_date) if date_clause: where_clauses.append(date_clause) # occurrenceEndDate if criteria.visit_detail_end_date is not None: - date_clause = BuilderUtils.build_date_range_clause( - "C.end_date", criteria.visit_detail_end_date - ) + date_clause = BuilderUtils.build_date_range_clause("C.end_date", criteria.visit_detail_end_date) if date_clause: where_clauses.append(date_clause) @@ -218,17 +177,13 @@ def resolve_where_clauses( # visitLength if criteria.visit_detail_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_detail_length - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_detail_length) if numeric_clause: where_clauses.append(numeric_clause) # age if criteria.age is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause( - "YEAR(C.end_date) - P.year_of_birth", criteria.age - ) + numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age) if numeric_clause: where_clauses.append(numeric_clause) @@ -236,20 +191,14 @@ def resolve_where_clauses( if criteria.gender is not None and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) if concept_ids: - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") if criteria.gender_cs is not None: - self.add_where_clause( - where_clauses, criteria.gender_cs, "P.gender_concept_id" - ) + self.add_where_clause(where_clauses, criteria.gender_cs, "P.gender_concept_id") # providerSpecialty if criteria.provider_specialty_cs is not None: - self.add_where_clause( - where_clauses, criteria.provider_specialty_cs, "PR.specialty_concept_id" - ) + self.add_where_clause(where_clauses, criteria.provider_specialty_cs, "PR.specialty_concept_id") # placeOfService if criteria.place_of_service_cs is not None: @@ -266,23 +215,12 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: VisitDetailSqlBuilder.getAdditionalColumns() """ - return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] - ) + return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) - def add_filtering_by_care_site_location_region( - self, join_clauses: list[str], codeset_id: int - ): + def add_filtering_by_care_site_location_region(self, join_clauses: list[str], codeset_id: int): """Add filtering by care site location region.""" - join_clauses.append( - self.get_location_history_join("LH", "CARE_SITE", "C.care_site_id") - ) - join_clauses.append( - "JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id" - ) + join_clauses.append(self.get_location_history_join("LH", "CARE_SITE", "C.care_site_id")) + join_clauses.append("JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id") self.add_filtering(join_clauses, codeset_id, "LOC.region_concept_id") def add_where_clause( @@ -293,28 +231,16 @@ def add_where_clause( exclude: Optional[bool] = None, ): """Add where clause for concept set selection.""" - is_exclusion = ( - exclude if exclude is not None else concept_set_selection.is_exclusion - ) - codeset_clause = BuilderUtils.get_codeset_in_expression( - concept_set_selection.codeset_id, concept_column, is_exclusion - ) + is_exclusion = exclude if exclude is not None else concept_set_selection.is_exclusion + codeset_clause = BuilderUtils.get_codeset_in_expression(concept_set_selection.codeset_id, concept_column, is_exclusion) if codeset_clause: where_clauses.append(codeset_clause) - def add_filtering( - self, join_clauses: list[str], codeset_id: int, standard_concept_column: str - ): + def add_filtering(self, join_clauses: list[str], codeset_id: int, standard_concept_column: str): """Add filtering join clause.""" - join_clauses.append( - BuilderUtils.get_codeset_join_expression( - codeset_id, standard_concept_column, None, None - ) - ) + join_clauses.append(BuilderUtils.get_codeset_join_expression(codeset_id, standard_concept_column, None, None)) - def get_location_history_join( - self, alias: str, domain: str, entity_id_field: str - ) -> str: + def get_location_history_join(self, alias: str, domain: str, entity_id_field: str) -> str: """Get location history join clause.""" return f"""JOIN @cdm_database_schema.LOCATION_HISTORY {alias} on {alias}.entity_id = {entity_id_field} diff --git a/circe/cohortdefinition/builders/visit_occurrence.py b/circe/cohortdefinition/builders/visit_occurrence.py index 9ef55f39..06386ec7 100644 --- a/circe/cohortdefinition/builders/visit_occurrence.py +++ b/circe/cohortdefinition/builders/visit_occurrence.py @@ -46,9 +46,7 @@ def get_default_columns(self) -> set[CriteriaColumn]: CriteriaColumn.VISIT_ID, } - def get_table_column_for_criteria_column( - self, criteria_column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) -> str: """Get table column for criteria column.""" if criteria_column == CriteriaColumn.DOMAIN_CONCEPT: return "C.visit_concept_id" @@ -61,9 +59,7 @@ def get_table_column_for_criteria_column( elif criteria_column == CriteriaColumn.VISIT_ID: return "C.visit_occurrence_id" else: - raise ValueError( - f"Invalid CriteriaColumn for Visit Occurrence: {criteria_column}" - ) + raise ValueError(f"Invalid CriteriaColumn for Visit Occurrence: {criteria_column}") def embed_codeset_clause(self, query: str, criteria: VisitOccurrence) -> str: """Embed codeset clause for visit occurrence criteria.""" @@ -75,17 +71,13 @@ def embed_codeset_clause(self, query: str, criteria: VisitOccurrence) -> str: ) return query.replace("@codesetClause", codeset_clause) - def resolve_select_clauses( - self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_select_clauses(self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve select clauses for visit occurrence criteria.""" # Default select columns that are always returned select_cols = ["vo.person_id", "vo.visit_occurrence_id", "vo.visit_concept_id"] # visitType - if (criteria.visit_type and len(criteria.visit_type) > 0) or ( - criteria.visit_type_cs and criteria.visit_type_cs.codeset_id - ): + if (criteria.visit_type and len(criteria.visit_type) > 0) or (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): select_cols.append("vo.visit_type_concept_id") # providerSpecialty @@ -106,102 +98,58 @@ def resolve_select_clauses( # BuilderUtils.getDateAdjustmentExpression(criteria.dateAdjustment, # criteria.dateAdjustment.startWith == DateAdjustment.DateType.START_DATE ? "vo.visit_start_date" : "vo.visit_end_date", # criteria.dateAdjustment.endWith == DateAdjustment.DateType.START_DATE ? "vo.visit_start_date" : "vo.visit_end_date") - start_col = ( - "vo.visit_start_date" - if criteria.date_adjustment.start_with == "START_DATE" - else "vo.visit_end_date" - ) - end_col = ( - "vo.visit_start_date" - if criteria.date_adjustment.end_with == "START_DATE" - else "vo.visit_end_date" - ) - select_cols.append( - BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, start_col, end_col - ) - ) + start_col = "vo.visit_start_date" if criteria.date_adjustment.start_with == "START_DATE" else "vo.visit_end_date" + end_col = "vo.visit_start_date" if criteria.date_adjustment.end_with == "START_DATE" else "vo.visit_end_date" + select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_col, end_col)) else: - select_cols.append( - "vo.visit_start_date as start_date, vo.visit_end_date as end_date" - ) + select_cols.append("vo.visit_start_date as start_date, vo.visit_end_date as end_date") return select_cols - def resolve_join_clauses( - self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_join_clauses(self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve join clauses for visit occurrence criteria.""" join_clauses = [] # Join to PERSON if age or gender conditions are present - if ( - criteria.age - or (criteria.gender and len(criteria.gender) > 0) - or (criteria.gender_cs and criteria.gender_cs.codeset_id) - ): - join_clauses.append( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - ) + if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") # Join to CARE_SITE if place of service conditions are present if ( (criteria.place_of_service and len(criteria.place_of_service) > 0) - or ( - criteria.place_of_service_cs and criteria.place_of_service_cs.codeset_id - ) + or (criteria.place_of_service_cs and criteria.place_of_service_cs.codeset_id) or criteria.place_of_service_location is not None ): - join_clauses.append( - "JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id" - ) + join_clauses.append("JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id") # Join to PROVIDER if provider specialty conditions are present if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id ): - join_clauses.append( - "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" - ) + join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") if criteria.place_of_service_location is not None: - self._add_filtering_by_care_site_location_region( - join_clauses, criteria.place_of_service_location - ) + self._add_filtering_by_care_site_location_region(join_clauses, criteria.place_of_service_location) return join_clauses - def resolve_where_clauses( - self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None - ) -> list[str]: + def resolve_where_clauses(self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: """Resolve where clauses for visit occurrence criteria.""" where_clauses = super().resolve_where_clauses(criteria, options) # occurrenceStartDate if criteria.occurrence_start_date: - where_clauses.append( - BuilderUtils.build_date_range_clause( - "C.start_date", criteria.occurrence_start_date - ) - ) + where_clauses.append(BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date)) # occurrenceEndDate if criteria.occurrence_end_date: - where_clauses.append( - BuilderUtils.build_date_range_clause( - "C.end_date", criteria.occurrence_end_date - ) - ) + where_clauses.append(BuilderUtils.build_date_range_clause("C.end_date", criteria.occurrence_end_date)) # visitType if criteria.visit_type and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.visit_type - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) exclude = "not " if criteria.visit_type_exclude else "" - where_clauses.append( - f"C.visit_type_concept_id {exclude}in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"C.visit_type_concept_id {exclude}in ({','.join(map(str, concept_ids))})") # visitTypeCS if criteria.visit_type_cs and criteria.visit_type_cs.codeset_id: @@ -215,26 +163,16 @@ def resolve_where_clauses( # visitLength if criteria.visit_length: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_length - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_length)) # age if criteria.age: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "YEAR(C.start_date) - P.year_of_birth", criteria.age - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) # gender if criteria.gender and len(criteria.gender) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append( - f"P.gender_concept_id in ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id in ({','.join(map(str, concept_ids))})") # genderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: @@ -248,12 +186,8 @@ def resolve_where_clauses( # providerSpecialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.provider_specialty - ) - where_clauses.append( - f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) + where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") # providerSpecialtyCS if criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id: @@ -267,12 +201,8 @@ def resolve_where_clauses( # placeOfService if criteria.place_of_service and len(criteria.place_of_service) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.place_of_service - ) - where_clauses.append( - f"CS.place_of_service_concept_id in ({','.join(map(str, concept_ids))})" - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.place_of_service) + where_clauses.append(f"CS.place_of_service_concept_id in ({','.join(map(str, concept_ids))})") # placeOfServiceCS if criteria.place_of_service_cs and criteria.place_of_service_cs.codeset_id: @@ -288,9 +218,7 @@ def resolve_where_clauses( return where_clauses - def embed_ordinal_expression( - self, query: str, criteria: VisitOccurrence, where_clauses: list[str] - ) -> str: + def embed_ordinal_expression(self, query: str, criteria: VisitOccurrence, where_clauses: list[str]) -> str: """Embed ordinal expression for visit occurrence criteria.""" if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") @@ -299,25 +227,13 @@ def embed_ordinal_expression( else: return query.replace("@ordinalExpression", "") - def _add_filtering_by_care_site_location_region( - self, join_clauses: list[str], codeset_id: int - ): + def _add_filtering_by_care_site_location_region(self, join_clauses: list[str], codeset_id: int): """Add joins for filtering by care site location region.""" - join_clauses.append( - self._get_location_history_join("LH", "CARE_SITE", "C.care_site_id") - ) - join_clauses.append( - "JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id" - ) - join_clauses.append( - BuilderUtils.get_codeset_join_expression( - codeset_id, "LOC.region_concept_id", None, None - ) - ) + join_clauses.append(self._get_location_history_join("LH", "CARE_SITE", "C.care_site_id")) + join_clauses.append("JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id") + join_clauses.append(BuilderUtils.get_codeset_join_expression(codeset_id, "LOC.region_concept_id", None, None)) - def _get_location_history_join( - self, alias: str, domain: str, entity_id_field: str - ) -> str: + def _get_location_history_join(self, alias: str, domain: str, entity_id_field: str) -> str: """Get location history join expression.""" return ( "JOIN @cdm_database_schema.LOCATION_HISTORY " diff --git a/circe/cohortdefinition/code_generator.py b/circe/cohortdefinition/code_generator.py index c069fd70..718595c1 100644 --- a/circe/cohortdefinition/code_generator.py +++ b/circe/cohortdefinition/code_generator.py @@ -10,11 +10,7 @@ def to_python_code(obj: Any) -> str: imports: set[str] = set() def _collect_imports(o: Any): - if ( - hasattr(o, "__module__") - and hasattr(o, "__name__") - and o.__module__.startswith("circe.") - ): + if hasattr(o, "__module__") and hasattr(o, "__name__") and o.__module__.startswith("circe."): # Try to import from the top level class map if possible, but for now specific modules imports.add(f"from {o.__module__} import {o.__class__.__name__}") @@ -57,7 +53,7 @@ def _repr(o: Any, indent_level: int = 0) -> str: # But we want to preserve structure even if it matches default maybe? # Let's stick to non-None for now as per plan if val is not None and val != field_info.get_default(): - fields[name] = val + fields[name] = val if not fields: return f"{cls_name}()" @@ -74,10 +70,7 @@ def _repr(o: Any, indent_level: int = 0) -> str: inner_str = ", ".join(args) if len(inner_str) > 80 or "\n" in inner_str: joiner = f",\n{indent} " - field_strs = [ - f"{name}={_repr(val, indent_level + 1)}" - for name, val in fields.items() - ] + field_strs = [f"{name}={_repr(val, indent_level + 1)}" for name, val in fields.items()] return f"{cls_name}(\n{indent} {joiner.join(field_strs)}\n{indent})" else: return f"{cls_name}({inner_str})" diff --git a/circe/cohortdefinition/cohort.py b/circe/cohortdefinition/cohort.py index af41c5b1..97e289cd 100644 --- a/circe/cohortdefinition/cohort.py +++ b/circe/cohortdefinition/cohort.py @@ -72,9 +72,7 @@ class CohortExpression(CirceBaseModel): validation_alias=AliasChoices("AdditionalCriteria", "additionalCriteria"), serialization_alias="AdditionalCriteria", ) - end_strategy: Optional[ - Union[EndStrategy, DateOffsetStrategy, CustomEraStrategy] - ] = Field( + end_strategy: Optional[Union[EndStrategy, DateOffsetStrategy, CustomEraStrategy]] = Field( default=None, validation_alias=AliasChoices("EndStrategy", "endStrategy"), serialization_alias="EndStrategy", @@ -112,9 +110,7 @@ class CohortExpression(CirceBaseModel): ) censoring_criteria: list[CriteriaType] = Field( default_factory=list, - validation_alias=AliasChoices( - "CensoringCriteria", "censoring_criteria", "censoringCriteria" - ), + validation_alias=AliasChoices("CensoringCriteria", "censoring_criteria", "censoringCriteria"), serialization_alias="CensoringCriteria", ) @@ -232,28 +228,14 @@ def deserialize_censoring_criteria(cls, v: Any) -> Any: data_copy = dict(criteria_data) if "First" not in data_copy and "first" not in data_copy: data_copy["First"] = False - if ( - criteria_type == "Measurement" - and "MeasurementTypeExclude" not in data_copy - and "measurementTypeExclude" not in data_copy - ): + if criteria_type == "Measurement" and "MeasurementTypeExclude" not in data_copy and "measurementTypeExclude" not in data_copy: data_copy["MeasurementTypeExclude"] = False - if ( - criteria_type == "Observation" - and "ObservationTypeExclude" not in data_copy - and "observationTypeExclude" not in data_copy - ): + if criteria_type == "Observation" and "ObservationTypeExclude" not in data_copy and "observationTypeExclude" not in data_copy: data_copy["ObservationTypeExclude"] = False - if ( - criteria_type == "ConditionOccurrence" - and "ConditionTypeExclude" not in data_copy - and "conditionTypeExclude" not in data_copy - ): + if criteria_type == "ConditionOccurrence" and "ConditionTypeExclude" not in data_copy and "conditionTypeExclude" not in data_copy: data_copy["ConditionTypeExclude"] = False - criteria_obj = criteria_class_map[criteria_type].model_validate( - data_copy, strict=False - ) + criteria_obj = criteria_class_map[criteria_type].model_validate(data_copy, strict=False) deserialized.append(criteria_obj) else: deserialized.append(item) @@ -301,9 +283,7 @@ def remove_inclusion_rule_by_name(self, name: str) -> None: Removes an inclusion rule by its name """ if self.inclusion_rules: - self.inclusion_rules = [ - r for r in self.inclusion_rules if getattr(r, "name", None) != name - ] + self.inclusion_rules = [r for r in self.inclusion_rules if getattr(r, "name", None) != name] def add_censoring_criteria(self, criteria: Criteria) -> None: """ @@ -318,11 +298,7 @@ def remove_censoring_criteria_by_type(self, criteria_type: str) -> None: Removes a censoring criteria by its type """ if self.censoring_criteria: - self.censoring_criteria = [ - c - for c in self.censoring_criteria - if c.__class__.__name__ != criteria_type - ] + self.censoring_criteria = [c for c in self.censoring_criteria if c.__class__.__name__ != criteria_type] def validate_expression(self) -> bool: """Validate the cohort expression.""" @@ -397,33 +373,33 @@ def _normalize_for_checksum(self, data: Any) -> Any: """ if isinstance(data, dict): # Handle ConceptSet Expression Items - if "items" in data and isinstance(data["items"], list) and ( - data["items"] - and isinstance(data["items"][0], dict) - and "concept" in data["items"][0] - ): - normalized_items = [] - seen_items = set() - - for item in data["items"]: - # Normalize the item first - norm_item = self._normalize_for_checksum(item) - - # Create a sortable/hashable representation for deduplication - # We need to sort keys to ensure tuple order is consistent - item_json = json.dumps(norm_item, sort_keys=True) - - if item_json not in seen_items: - seen_items.add(item_json) - normalized_items.append(norm_item) - - # Sort items to ensure list order doesn't affect hash - # Sort by the JSON string representation - normalized_items.sort(key=lambda x: json.dumps(x, sort_keys=True)) - - new_data = data.copy() - new_data["items"] = normalized_items - return new_data + if ( + "items" in data + and isinstance(data["items"], list) + and (data["items"] and isinstance(data["items"][0], dict) and "concept" in data["items"][0]) + ): + normalized_items = [] + seen_items = set() + + for item in data["items"]: + # Normalize the item first + norm_item = self._normalize_for_checksum(item) + + # Create a sortable/hashable representation for deduplication + # We need to sort keys to ensure tuple order is consistent + item_json = json.dumps(norm_item, sort_keys=True) + + if item_json not in seen_items: + seen_items.add(item_json) + normalized_items.append(norm_item) + + # Sort items to ensure list order doesn't affect hash + # Sort by the JSON string representation + normalized_items.sort(key=lambda x: json.dumps(x, sort_keys=True)) + + new_data = data.copy() + new_data["items"] = normalized_items + return new_data # Handle Concept Objects (heuristically by fields) if "CONCEPT_ID" in data: @@ -560,10 +536,7 @@ def get_primary_criteria_types(self) -> list[str]: if not self.primary_criteria or not self.primary_criteria.criteria_list: return [] - return [ - criteria.__class__.__name__ - for criteria in self.primary_criteria.criteria_list - ] + return [criteria.__class__.__name__ for criteria in self.primary_criteria.criteria_list] def has_observation_window(self) -> bool: """Check if observation window is defined in primary criteria. diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index c3c38f12..d494b90a 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -95,9 +95,7 @@ def from_json(cls, json_str: str) -> "BuildExpressionQueryOptions": raise RuntimeError("Error parsing expression query options") from e -class CohortExpressionQueryBuilder( - IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher -): +class CohortExpressionQueryBuilder(IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher): """Main SQL query builder for cohort expressions. Java equivalent: org.ohdsi.circe.cohortdefinition.CohortExpressionQueryBuilder @@ -463,8 +461,7 @@ class CohortExpressionQueryBuilder( """ DEFAULT_DRUG_EXPOSURE_END_DATE_EXPRESSION = ( - "COALESCE(DRUG_EXPOSURE_END_DATE, DATEADD(day,DAYS_SUPPLY,DRUG_EXPOSURE_START_DATE), " - "DATEADD(day,1,DRUG_EXPOSURE_START_DATE))" + "COALESCE(DRUG_EXPOSURE_END_DATE, DATEADD(day,DAYS_SUPPLY,DRUG_EXPOSURE_START_DATE), DATEADD(day,1,DRUG_EXPOSURE_START_DATE))" ) DEFAULT_COHORT_ID_FIELD_NAME = "cohort_definition_id" @@ -505,9 +502,7 @@ def get_occurrence_operator(self, occurrence_type: int) -> str: elif occurrence_type == 2: return ">=" else: - raise RuntimeError( - f"Invalid occurrence operator received: type={occurrence_type}" - ) + raise RuntimeError(f"Invalid occurrence operator received: type={occurrence_type}") def get_additional_columns(self, columns: list[CriteriaColumn], prefix: str) -> str: """Get additional columns string. @@ -571,16 +566,12 @@ def get_codeset_query(self, concept_sets: list[Any]) -> str: union_selects = [] for cs in concept_sets: if hasattr(cs, "id") and hasattr(cs, "expression"): - expression_query = ( - self.concept_set_query_builder.build_expression_query(cs.expression) - ) + expression_query = self.concept_set_query_builder.build_expression_query(cs.expression) union_select = f"SELECT {cs.id} as codeset_id, c.concept_id FROM ({expression_query}\n) C" union_selects.append(union_select) union_query = " UNION ALL \n".join(union_selects) - codeset_inserts = ( - f"INSERT INTO #Codesets (codeset_id, concept_id)\n{union_query};" - ) + codeset_inserts = f"INSERT INTO #Codesets (codeset_id, concept_id)\n{union_query};" return self.CODESET_QUERY_TEMPLATE.replace("@codesetInserts", codeset_inserts) @@ -592,16 +583,12 @@ def get_censoring_events_query(self, censoring_criteria: list[Criteria]) -> str: criteria_queries = [] for criteria in censoring_criteria: criteria_query = self.get_criteria_sql(criteria) - censoring_query = self.CENSORING_QUERY_TEMPLATE.replace( - "@criteriaQuery", criteria_query - ) + censoring_query = self.CENSORING_QUERY_TEMPLATE.replace("@criteriaQuery", criteria_query) criteria_queries.append(censoring_query) return " UNION ALL ".join(criteria_queries) - def get_primary_events_query( - self, primary_criteria: PrimaryCriteria, subquery: Optional[str] = None - ) -> str: + def get_primary_events_query(self, primary_criteria: PrimaryCriteria, subquery: Optional[str] = None) -> str: """Get primary events query. Java equivalent: getPrimaryEventsQuery() @@ -621,29 +608,23 @@ def _get_primary_events_subquery(self, primary_criteria: PrimaryCriteria) -> str for criteria in primary_criteria.criteria_list: criteria_queries.append(self.get_criteria_sql(criteria)) - query = query.replace( - "@criteriaQueries", "\nUNION ALL\n".join(criteria_queries) - ) + query = query.replace("@criteriaQueries", "\nUNION ALL\n".join(criteria_queries)) # Primary events filters primary_events_filters = [ - (f"DATEADD(day,{primary_criteria.observation_window.prior_days},OP.OBSERVATION_PERIOD_START_DATE) " - f"<= E.START_DATE AND DATEADD(day,{primary_criteria.observation_window.post_days},E.START_DATE) " - f"<= OP.OBSERVATION_PERIOD_END_DATE") + ( + f"DATEADD(day,{primary_criteria.observation_window.prior_days},OP.OBSERVATION_PERIOD_START_DATE) " + f"<= E.START_DATE AND DATEADD(day,{primary_criteria.observation_window.post_days},E.START_DATE) " + f"<= OP.OBSERVATION_PERIOD_END_DATE" + ) ] - query = query.replace( - "@primaryEventsFilter", " AND ".join(primary_events_filters) - ) + query = query.replace("@primaryEventsFilter", " AND ".join(primary_events_filters)) # Event sort event_sort = ( "DESC" - if ( - primary_criteria.primary_limit - and primary_criteria.primary_limit.type - and str(primary_criteria.primary_limit.type).upper() == "LAST" - ) + if (primary_criteria.primary_limit and primary_criteria.primary_limit.type and str(primary_criteria.primary_limit.type).upper() == "LAST") else "ASC" ) query = query.replace("@EventSort", event_sort) @@ -651,11 +632,7 @@ def _get_primary_events_subquery(self, primary_criteria: PrimaryCriteria) -> str # Primary event limit - this filters P.ordinal primary_event_limit = ( "" - if ( - primary_criteria.primary_limit - and primary_criteria.primary_limit.type - and str(primary_criteria.primary_limit.type).upper() == "ALL" - ) + if (primary_criteria.primary_limit and primary_criteria.primary_limit.type and str(primary_criteria.primary_limit.type).upper() == "ALL") else "WHERE P.ordinal = 1" ) query = query.replace("@primaryEventLimit", primary_event_limit) @@ -674,14 +651,10 @@ def get_final_cohort_query(self, censor_window: Optional[Period]) -> str: if censor_window and (censor_window.start_date or censor_window.end_date): if censor_window.start_date: - censor_start_date = BuilderUtils.date_string_to_sql( - censor_window.start_date - ) + censor_start_date = BuilderUtils.date_string_to_sql(censor_window.start_date) start_date = f"CASE WHEN start_date > {censor_start_date} THEN start_date ELSE {censor_start_date} END" if censor_window.end_date: - censor_end_date = BuilderUtils.date_string_to_sql( - censor_window.end_date - ) + censor_end_date = BuilderUtils.date_string_to_sql(censor_window.end_date) end_date = f"CASE WHEN end_date < {censor_end_date} THEN end_date ELSE {censor_end_date} END" query += "\nWHERE @start_date <= @end_date" @@ -703,16 +676,12 @@ def get_inclusion_rule_table_sql(self, expression: CohortExpression) -> str: return empty_table union_template = "SELECT CAST({} as int) as rule_sequence" - union_list = [ - union_template.format(i) for i in range(len(expression.inclusion_rules)) - ] + union_list = [union_template.format(i) for i in range(len(expression.inclusion_rules))] # Join with UNION ALL - match Java behavior (no UNION ALL for single rule) union_query = union_list[0] if len(union_list) == 1 else " UNION ALL ".join(union_list) - return self.INCLUSION_RULE_TEMP_TABLE_TEMPLATE.replace( - "@inclusionRuleUnions", union_query - ) + return self.INCLUSION_RULE_TEMP_TABLE_TEMPLATE.replace("@inclusionRuleUnions", union_query) def get_inclusion_analysis_query(self, event_table: str, mode_id: int) -> str: """Get inclusion analysis query. @@ -735,9 +704,7 @@ def _build_inclusion_analysis_section(self, expression: CohortExpression) -> str Java equivalent: Part of generateCohort.sql template with @generateStats != 0 & @ruleTotal != 0 """ - rule_total = ( - len(expression.inclusion_rules) if expression.inclusion_rules else 0 - ) + rule_total = len(expression.inclusion_rules) if expression.inclusion_rules else 0 inclusion_rule_table = self.get_inclusion_rule_table_sql(expression) @@ -765,9 +732,7 @@ def _build_inclusion_analysis_section(self, expression: CohortExpression) -> str ; """ - inclusion_impact_event = self.get_inclusion_analysis_query( - "#qualified_events", 0 - ) + inclusion_impact_event = self.get_inclusion_analysis_query("#qualified_events", 0) inclusion_impact_person = self.get_inclusion_analysis_query("#best_events", 1) cleanup = """ @@ -798,9 +763,7 @@ def _build_inclusion_analysis_section(self, expression: CohortExpression) -> str {cleanup}}} """ - def build_expression_query( - self, expression: Union[str, CohortExpression], options: BuildExpressionQueryOptions - ) -> str: + def build_expression_query(self, expression: Union[str, CohortExpression], options: BuildExpressionQueryOptions) -> str: """Build expression query from CohortExpression object or JSON string. Java equivalent: buildExpressionQuery(String, BuildExpressionQueryOptions) @@ -816,14 +779,10 @@ def build_expression_query( result_sql = result_sql.replace("@codesetQuery", codeset_query) # Get inner primary events subquery (logic only) - primary_events_subquery = self._get_primary_events_subquery( - expression.primary_criteria - ) + primary_events_subquery = self._get_primary_events_subquery(expression.primary_criteria) # Primary events query (full wrapper) - primary_events_query = self.get_primary_events_query( - expression.primary_criteria, primary_events_subquery - ) + primary_events_query = self.get_primary_events_query(expression.primary_criteria, primary_events_subquery) result_sql = result_sql.replace("@primaryEventsQuery", primary_events_query) # Additional criteria query - this filters primary events based on additional conditions @@ -831,28 +790,20 @@ def build_expression_query( # Generate criteria group query that joins with the pe (primary events) subquery # The pe subquery is defined in PRIMARY_EVENTS_TEMPLATE and has columns: # event_id, person_id, start_date, end_date, op_start_date, op_end_date, visit_occurrence_id - additional_criteria_group_query = self.get_criteria_group_query( - expression.additional_criteria, f"({primary_events_subquery})" - ) + additional_criteria_group_query = self.get_criteria_group_query(expression.additional_criteria, f"({primary_events_subquery})") # Create a JOIN clause that filters pe events based on the additional criteria additional_criteria_sql = f"\nJOIN (\n{additional_criteria_group_query}) AC ON AC.person_id = pe.person_id AND AC.event_id = pe.event_id" additional_criteria_sql = additional_criteria_sql.replace("@indexId", "0") - result_sql = result_sql.replace( - "@additionalCriteriaQuery", additional_criteria_sql - ) + result_sql = result_sql.replace("@additionalCriteriaQuery", additional_criteria_sql) else: result_sql = result_sql.replace("@additionalCriteriaQuery", "") # Qualified event sort qualified_event_sort = ( "DESC" - if ( - expression.qualified_limit - and expression.qualified_limit.type - and str(expression.qualified_limit.type).upper() == "LAST" - ) + if (expression.qualified_limit and expression.qualified_limit.type and str(expression.qualified_limit.type).upper() == "LAST") else "ASC" ) result_sql = result_sql.replace("@QualifiedEventSort", qualified_event_sort) @@ -864,9 +815,7 @@ def build_expression_query( and expression.qualified_limit.type and str(expression.qualified_limit.type).upper() != "ALL" ): - result_sql = result_sql.replace( - "@QualifiedLimitFilter", "WHERE QE.ordinal = 1" - ) + result_sql = result_sql.replace("@QualifiedLimitFilter", "WHERE QE.ordinal = 1") else: result_sql = result_sql.replace("@QualifiedLimitFilter", "") @@ -878,33 +827,19 @@ def build_expression_query( for i, inclusion_rule in enumerate(expression.inclusion_rules): cg = inclusion_rule.expression inclusion_rule_insert = self.get_inclusion_rule_query(cg) - inclusion_rule_insert = inclusion_rule_insert.replace( - "@inclusion_rule_id", str(i) - ) + inclusion_rule_insert = inclusion_rule_insert.replace("@inclusion_rule_id", str(i)) inclusion_rule_inserts.append(inclusion_rule_insert) inclusion_rule_temp_tables.append(f"#Inclusion_{i}") ir_temp_union = "\nUNION ALL\n".join( - [ - f"select inclusion_rule_id, person_id, event_id from {table}" - for table in inclusion_rule_temp_tables - ] + [f"select inclusion_rule_id, person_id, event_id from {table}" for table in inclusion_rule_temp_tables] ) - inclusion_rule_inserts.append( - f"SELECT inclusion_rule_id, person_id, event_id\nINTO #inclusion_events\nFROM ({ir_temp_union}) I;" - ) + inclusion_rule_inserts.append(f"SELECT inclusion_rule_id, person_id, event_id\nINTO #inclusion_events\nFROM ({ir_temp_union}) I;") - inclusion_rule_inserts.extend( - [ - f"TRUNCATE TABLE {table};\nDROP TABLE {table};\n" - for table in inclusion_rule_temp_tables - ] - ) + inclusion_rule_inserts.extend([f"TRUNCATE TABLE {table};\nDROP TABLE {table};\n" for table in inclusion_rule_temp_tables]) - result_sql = result_sql.replace( - "@inclusionCohortInserts", "\n".join(inclusion_rule_inserts) - ) + result_sql = result_sql.replace("@inclusionCohortInserts", "\n".join(inclusion_rule_inserts)) else: result_sql = result_sql.replace( "@inclusionCohortInserts", @@ -922,29 +857,17 @@ def build_expression_query( # Included event sort - determine sort order based on expression limit included_event_sort = ( "DESC" - if ( - expression.expression_limit - and expression.expression_limit.type - and str(expression.expression_limit.type).upper() == "LAST" - ) + if (expression.expression_limit and expression.expression_limit.type and str(expression.expression_limit.type).upper() == "LAST") else "ASC" ) - included_events_query = included_events_query.replace( - "@IncludedEventSort", included_event_sort - ) + included_events_query = included_events_query.replace("@IncludedEventSort", included_event_sort) # Result limit filter - if ( - expression.expression_limit - and expression.expression_limit.type - and str(expression.expression_limit.type).upper() != "ALL" - ): + if expression.expression_limit and expression.expression_limit.type and str(expression.expression_limit.type).upper() != "ALL": result_limit_filter = "WHERE Results.ordinal = 1" else: result_limit_filter = "" - included_events_query = included_events_query.replace( - "@ResultLimitFilter", result_limit_filter - ) + included_events_query = included_events_query.replace("@ResultLimitFilter", result_limit_filter) # Inclusion rule mask filter - only apply if there are inclusion rules if expression.inclusion_rules and len(expression.inclusion_rules) > 0: @@ -956,9 +879,7 @@ def build_expression_query( ) else: inclusion_rule_mask_filter = "" - included_events_query = included_events_query.replace( - "@InclusionRuleMaskFilter", inclusion_rule_mask_filter - ) + included_events_query = included_events_query.replace("@InclusionRuleMaskFilter", inclusion_rule_mask_filter) result_sql = result_sql.replace("@includedEventsQuery", included_events_query) @@ -974,9 +895,7 @@ def build_expression_query( if expression.end_strategy: # Only DateOffsetStrategy and CustomEraStrategy have accept method - if isinstance( - expression.end_strategy, (DateOffsetStrategy, CustomEraStrategy) - ): + if isinstance(expression.end_strategy, (DateOffsetStrategy, CustomEraStrategy)): result_sql = result_sql.replace( "@strategy_ends_temp_tables", expression.end_strategy.accept(self, "#included_events"), @@ -986,9 +905,7 @@ def build_expression_query( "TRUNCATE TABLE #strategy_ends;\nDROP TABLE #strategy_ends;\n", ) - strategy_select = ( - "SELECT event_id, person_id, end_date FROM #strategy_ends" - ) + strategy_select = "SELECT event_id, person_id, end_date FROM #strategy_ends" end_date_selects.append(f"-- End Date Strategy\n{strategy_select}") else: result_sql = result_sql.replace("@strategy_ends_temp_tables", "") @@ -998,23 +915,16 @@ def build_expression_query( result_sql = result_sql.replace("@strategy_ends_cleanup", "") if expression.censoring_criteria: - end_date_selects.append( - f"-- Censor Events\n{self.get_censoring_events_query(expression.censoring_criteria)}" - ) + end_date_selects.append(f"-- Censor Events\n{self.get_censoring_events_query(expression.censoring_criteria)}") final_cohort_query = self.get_final_cohort_query(expression.censor_window) result_sql = result_sql.replace("@finalCohortQuery", final_cohort_query) - result_sql = result_sql.replace( - "@cohort_end_unions", "\nUNION ALL\n".join(end_date_selects) - ) + result_sql = result_sql.replace("@cohort_end_unions", "\nUNION ALL\n".join(end_date_selects)) # Handle optional collapse_settings era_pad = "0" - if ( - expression.collapse_settings - and expression.collapse_settings.era_pad is not None - ): + if expression.collapse_settings and expression.collapse_settings.era_pad is not None: era_pad = str(expression.collapse_settings.era_pad) result_sql = result_sql.replace("@eraconstructorpad", era_pad) # Build inclusion analysis query (for stats generation) @@ -1027,52 +937,30 @@ def build_expression_query( "where @cohort_id_field_name = @target_cohort_id;\n\n-- END: Censored Stats\n}\n" ) # Always generate inclusion analysis if stats are requested, even if no rules - inclusion_analysis_query += self._build_inclusion_analysis_section( - expression - ) - result_sql = result_sql.replace( - "@inclusionAnalysisQuery", inclusion_analysis_query - ) + inclusion_analysis_query += self._build_inclusion_analysis_section(expression) + result_sql = result_sql.replace("@inclusionAnalysisQuery", inclusion_analysis_query) # Replace query parameters with tokens if options: if options.cdm_schema: - result_sql = result_sql.replace( - "@cdm_database_schema", options.cdm_schema - ) + result_sql = result_sql.replace("@cdm_database_schema", options.cdm_schema) if options.target_table: - result_sql = result_sql.replace( - "@target_database_schema.@target_cohort_table", options.target_table - ) + result_sql = result_sql.replace("@target_database_schema.@target_cohort_table", options.target_table) if options.result_schema: - result_sql = result_sql.replace( - "@results_database_schema", options.result_schema - ) + result_sql = result_sql.replace("@results_database_schema", options.result_schema) if options.vocabulary_schema: - result_sql = result_sql.replace( - "@vocabulary_database_schema", options.vocabulary_schema - ) + result_sql = result_sql.replace("@vocabulary_database_schema", options.vocabulary_schema) if options.cohort_id is not None: - result_sql = result_sql.replace( - "@target_cohort_id", str(options.cohort_id) - ) + result_sql = result_sql.replace("@target_cohort_id", str(options.cohort_id)) - result_sql = result_sql.replace( - "@generateStats", "1" if options.generate_stats else "0" - ) + result_sql = result_sql.replace("@generateStats", "1" if options.generate_stats else "0") if options.cohort_id_field_name: - result_sql = result_sql.replace( - "@cohort_id_field_name", options.cohort_id_field_name - ) + result_sql = result_sql.replace("@cohort_id_field_name", options.cohort_id_field_name) else: - result_sql = result_sql.replace( - "@cohort_id_field_name", self.DEFAULT_COHORT_ID_FIELD_NAME - ) + result_sql = result_sql.replace("@cohort_id_field_name", self.DEFAULT_COHORT_ID_FIELD_NAME) else: - result_sql = result_sql.replace( - "@cohort_id_field_name", self.DEFAULT_COHORT_ID_FIELD_NAME - ) + result_sql = result_sql.replace("@cohort_id_field_name", self.DEFAULT_COHORT_ID_FIELD_NAME) return result_sql @@ -1108,9 +996,7 @@ def get_criteria_group_query(self, group: CriteriaGroup, event_table: str) -> st index_id += 1 if not group.is_empty(): - query = query.replace( - "@criteriaQueries", "\nUNION ALL\n".join(additional_criteria_queries) - ) + query = query.replace("@criteriaQueries", "\nUNION ALL\n".join(additional_criteria_queries)) occurrence_count_clause = "HAVING COUNT(index_id) " if group.type and str(group.type).upper() == "ALL": @@ -1141,20 +1027,14 @@ def get_inclusion_rule_query(self, inclusion_rule: CriteriaGroup) -> str: Java equivalent: getInclusionRuleQuery() """ result_sql = self.INCLUSION_RULE_QUERY_TEMPLATE - criteria_group_sql = self.get_criteria_group_query( - inclusion_rule, "#qualified_events" - ) + criteria_group_sql = self.get_criteria_group_query(inclusion_rule, "#qualified_events") criteria_group_sql = criteria_group_sql.replace("@indexId", "0") additional_criteria_query = f"\nJOIN (\n{criteria_group_sql}) AC on AC.person_id = pe.person_id AND AC.event_id = pe.event_id" - result_sql = result_sql.replace( - "@additionalCriteriaQuery", additional_criteria_query - ) + result_sql = result_sql.replace("@additionalCriteriaQuery", additional_criteria_query) result_sql = result_sql.replace("@eventTable", "#qualified_events") return result_sql - def get_demographic_criteria_query( - self, criteria: DemographicCriteria, event_table: str - ) -> str: + def get_demographic_criteria_query(self, criteria: DemographicCriteria, event_table: str) -> str: """Get demographic criteria query. Java equivalent: getDemographicCriteriaQuery() @@ -1166,18 +1046,12 @@ def get_demographic_criteria_query( # Age if criteria.age: - where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "YEAR(E.start_date) - P.year_of_birth", criteria.age - ) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(E.start_date) - P.year_of_birth", criteria.age)) # Gender if criteria.gender: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - where_clauses.append( - f"P.gender_concept_id IN ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.gender_concept_id IN ({','.join(map(str, concept_ids))})") # GenderCS if criteria.gender_cs: @@ -1192,9 +1066,7 @@ def get_demographic_criteria_query( # Race if criteria.race: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.race) - where_clauses.append( - f"P.race_concept_id IN ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.race_concept_id IN ({','.join(map(str, concept_ids))})") # RaceCS if criteria.race_cs: @@ -1209,9 +1081,7 @@ def get_demographic_criteria_query( # Ethnicity if criteria.ethnicity: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.ethnicity) - where_clauses.append( - f"P.ethnicity_concept_id IN ({','.join(map(str, concept_ids))})" - ) + where_clauses.append(f"P.ethnicity_concept_id IN ({','.join(map(str, concept_ids))})") # EthnicityCS if criteria.ethnicity_cs: @@ -1225,19 +1095,11 @@ def get_demographic_criteria_query( # OccurrenceStartDate if criteria.occurrence_start_date: - where_clauses.append( - BuilderUtils.build_date_range_clause( - "E.start_date", criteria.occurrence_start_date - ) - ) + where_clauses.append(BuilderUtils.build_date_range_clause("E.start_date", criteria.occurrence_start_date)) # OccurrenceEndDate if criteria.occurrence_end_date: - where_clauses.append( - BuilderUtils.build_date_range_clause( - "E.end_date", criteria.occurrence_end_date - ) - ) + where_clauses.append(BuilderUtils.build_date_range_clause("E.end_date", criteria.occurrence_end_date)) query = query.replace("@whereClause", "WHERE " + " AND ".join(where_clauses)) if where_clauses else query.replace("@whereClause", "") @@ -1296,41 +1158,22 @@ def _get_windowed_criteria_query_internal( # Make a mutable copy to add defaults criteria_data = dict(criteria_data) if criteria_data else {} # Set default values for required fields that might be missing - if ( - criteria_type == "Measurement" - and "measurementTypeExclude" not in criteria_data - ): + if criteria_type == "Measurement" and "measurementTypeExclude" not in criteria_data: criteria_data["measurementTypeExclude"] = False - if ( - criteria_type == "Observation" - and "observationTypeExclude" not in criteria_data - ): + if criteria_type == "Observation" and "observationTypeExclude" not in criteria_data: criteria_data["observationTypeExclude"] = False - if ( - criteria_type == "ProcedureOccurrence" - and "procedureTypeExclude" not in criteria_data - ): + if criteria_type == "ProcedureOccurrence" and "procedureTypeExclude" not in criteria_data: criteria_data["procedureTypeExclude"] = False - if ( - criteria_type == "DrugExposure" - and "drugTypeExclude" not in criteria_data - ): + if criteria_type == "DrugExposure" and "drugTypeExclude" not in criteria_data: criteria_data["drugTypeExclude"] = False # Most criteria types require 'first' field - if ( - "first" not in criteria_data - or criteria_data.get("first") is None - ): + if "first" not in criteria_data or criteria_data.get("first") is None: criteria_data["first"] = False - inner_criteria = criteria_class_map[ - criteria_type - ].model_validate(criteria_data, strict=False) + inner_criteria = criteria_class_map[criteria_type].model_validate(criteria_data, strict=False) # Update the criteria object criteria.criteria = inner_criteria except Exception as e: - raise ValueError( - f"Failed to deserialize criteria from dict: {criteria_type} - {e}" - ) from e + raise ValueError(f"Failed to deserialize criteria from dict: {criteria_type} - {e}") from e else: raise ValueError(f"Unknown criteria type in dict: {criteria_type}") else: @@ -1351,40 +1194,22 @@ def _get_windowed_criteria_query_internal( # Build index date window expression clauses = [] if check_observation_period: - clauses.append( - "A.START_DATE >= P.OP_START_DATE AND A.START_DATE <= P.OP_END_DATE" - ) + clauses.append("A.START_DATE >= P.OP_START_DATE AND A.START_DATE <= P.OP_END_DATE") # StartWindow start_window = criteria.start_window if start_window: # Java: (useIndexEnd != null && useIndexEnd) - true only if not null AND true - start_index_date_expression = ( - "P.END_DATE" - if ( - start_window.use_index_end is not None - and start_window.use_index_end - ) - else "P.START_DATE" - ) + start_index_date_expression = "P.END_DATE" if (start_window.use_index_end is not None and start_window.use_index_end) else "P.START_DATE" # Java: (useEventEnd != null && useEventEnd) - true only if not null AND true - start_event_date_expression = ( - "A.END_DATE" - if ( - start_window.use_event_end is not None - and start_window.use_event_end - ) - else "A.START_DATE" - ) + start_event_date_expression = "A.END_DATE" if (start_window.use_event_end is not None and start_window.use_event_end) else "A.START_DATE" if start_window.start and start_window.start.days is not None: start_expression = f"DATEADD(day,{start_window.start.coeff * start_window.start.days},{start_index_date_expression})" else: start_expression = ( "P.OP_START_DATE" - if check_observation_period - and start_window.start - and start_window.start.coeff == -1 + if check_observation_period and start_window.start and start_window.start.coeff == -1 else "P.OP_END_DATE" if check_observation_period else None @@ -1398,9 +1223,7 @@ def _get_windowed_criteria_query_internal( else: end_expression = ( "P.OP_START_DATE" - if check_observation_period - and start_window.end - and start_window.end.coeff == -1 + if check_observation_period and start_window.end and start_window.end.coeff == -1 else "P.OP_END_DATE" if check_observation_period else None @@ -1413,17 +1236,9 @@ def _get_windowed_criteria_query_internal( end_window = criteria.end_window if end_window: # Java: (useIndexEnd != null && useIndexEnd) - true only if not null AND true - end_index_date_expression = ( - "P.END_DATE" - if (end_window.use_index_end is not None and end_window.use_index_end) - else "P.START_DATE" - ) + end_index_date_expression = "P.END_DATE" if (end_window.use_index_end is not None and end_window.use_index_end) else "P.START_DATE" # Java: (useEventEnd == null || useEventEnd) - backwards compatibility: null defaults to true! - end_event_date_expression = ( - "A.END_DATE" - if (end_window.use_event_end is None or end_window.use_event_end) - else "A.START_DATE" - ) + end_event_date_expression = "A.END_DATE" if (end_window.use_event_end is None or end_window.use_event_end) else "A.START_DATE" if end_window.start.days is not None: start_expression = f"DATEADD(day,{end_window.start.coeff * end_window.start.days},{end_index_date_expression})" @@ -1457,26 +1272,18 @@ def _get_windowed_criteria_query_internal( if criteria.restrict_visit: clauses.append("A.visit_occurrence_id = P.visit_occurrence_id") - query = query.replace( - "@windowCriteria", " AND " + " AND ".join(clauses) if clauses else "" - ) + query = query.replace("@windowCriteria", " AND " + " AND ".join(clauses) if clauses else "") return query - def get_windowed_criteria_query( - self, criteria: Any, event_table: str, options: Optional[BuilderOptions] = None - ) -> str: + def get_windowed_criteria_query(self, criteria: Any, event_table: str, options: Optional[BuilderOptions] = None) -> str: """Get windowed criteria query. Java equivalent: getWindowedCriteriaQuery(WindowedCriteria, String) and getWindowedCriteriaQuery(WindowedCriteria, String, BuilderOptions) """ - return self._get_windowed_criteria_query_internal( - self.WINDOWED_CRITERIA_TEMPLATE, criteria, event_table, options - ) + return self._get_windowed_criteria_query_internal(self.WINDOWED_CRITERIA_TEMPLATE, criteria, event_table, options) - def get_corelated_criteria_query( - self, corelated_criteria: CorelatedCriteria, event_table: str - ) -> str: + def get_corelated_criteria_query(self, corelated_criteria: CorelatedCriteria, event_table: str) -> str: """Get corelated criteria query. Java equivalent: getCorelatedlCriteriaQuery() @@ -1486,16 +1293,13 @@ def get_corelated_criteria_query( if corelated_criteria.occurrence is None: from .criteria import Occurrence as Occ - corelated_criteria.occurrence = Occ( - type=Occ._AT_LEAST, count=1, is_distinct=False - ) + corelated_criteria.occurrence = Occ(type=Occ._AT_LEAST, count=1, is_distinct=False) from .criteria import Occurrence as Occ query = ( self.ADDITIONAL_CRITERIA_LEFT_TEMPLATE - if corelated_criteria.occurrence.type == Occ._AT_MOST - or corelated_criteria.occurrence.count == 0 + if corelated_criteria.occurrence.type == Occ._AT_MOST or corelated_criteria.occurrence.count == 0 else self.ADDITIONAL_CRITERIA_INNER_TEMPLATE ) @@ -1507,12 +1311,8 @@ def get_corelated_criteria_query( builder_options.additional_columns.append(CriteriaColumn.DOMAIN_CONCEPT) count_column_expression = f"cc.{CriteriaColumn.DOMAIN_CONCEPT.value}" else: - builder_options.additional_columns.append( - corelated_criteria.occurrence.count_column - ) - count_column_expression = ( - f"cc.{corelated_criteria.occurrence.count_column.value}" - ) + builder_options.additional_columns.append(corelated_criteria.occurrence.count_column) + count_column_expression = f"cc.{corelated_criteria.occurrence.count_column.value}" # If event_table is a query (not a temp table name like #qualified_events), # wrap it with observation period join to match reference SQL structure @@ -1520,11 +1320,7 @@ def get_corelated_criteria_query( # Check if event_table is a query (contains SELECT or FROM) vs a temp table name # Temp tables start with #, queries contain SELECT/FROM or are wrapped in parentheses is_temp_table = event_table.strip().startswith("#") - is_query = not is_temp_table and ( - "SELECT" in event_table.upper() - or "FROM" in event_table.upper() - or "(" in event_table - ) + is_query = not is_temp_table and ("SELECT" in event_table.upper() or "FROM" in event_table.upper() or "(" in event_table) # Add observation period join to event table when it's a query (matches reference SQL) # BUT only if it doesn't already have op_start_date (to avoid double-wrapping) @@ -1560,9 +1356,7 @@ def get_corelated_criteria_query( and OP.observation_period_start_date <= Q.start_date and OP.observation_period_end_date >= Q.start_date )""" - query = self._get_windowed_criteria_query_internal( - query, corelated_criteria, event_table, builder_options - ) + query = self._get_windowed_criteria_query_internal(query, corelated_criteria, event_table, builder_options) # Occurrence criteria occurrence_criteria = ( @@ -1575,9 +1369,7 @@ def get_corelated_criteria_query( return query - def get_criteria_sql( - self, criteria: Criteria, options: Optional[BuilderOptions] = None - ) -> str: + def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> str: """Get criteria SQL for any criteria type. Java equivalent: Various getCriteriaSql methods @@ -1636,39 +1428,20 @@ def get_criteria_sql( # Make a mutable copy to add defaults criteria_data = dict(criteria_data) if criteria_data else {} # Set default values for required fields that might be missing - if ( - criteria_type == "Measurement" - and "measurementTypeExclude" not in criteria_data - ): + if criteria_type == "Measurement" and "measurementTypeExclude" not in criteria_data: criteria_data["measurementTypeExclude"] = False - if ( - criteria_type == "Observation" - and "observationTypeExclude" not in criteria_data - ): + if criteria_type == "Observation" and "observationTypeExclude" not in criteria_data: criteria_data["observationTypeExclude"] = False - if ( - criteria_type == "ProcedureOccurrence" - and "procedureTypeExclude" not in criteria_data - ): + if criteria_type == "ProcedureOccurrence" and "procedureTypeExclude" not in criteria_data: criteria_data["procedureTypeExclude"] = False - if ( - criteria_type == "DrugExposure" - and "drugTypeExclude" not in criteria_data - ): + if criteria_type == "DrugExposure" and "drugTypeExclude" not in criteria_data: criteria_data["drugTypeExclude"] = False # Most criteria types require 'first' field - if ( - "first" not in criteria_data - or criteria_data.get("first") is None - ): + if "first" not in criteria_data or criteria_data.get("first") is None: criteria_data["first"] = False - criteria = criteria_class_map[criteria_type].model_validate( - criteria_data, strict=False - ) + criteria = criteria_class_map[criteria_type].model_validate(criteria_data, strict=False) except Exception as e: - raise ValueError( - f"Failed to deserialize criteria from dict: {criteria_type} - {e}" - ) from e + raise ValueError(f"Failed to deserialize criteria from dict: {criteria_type} - {e}") from e else: raise ValueError(f"Unknown criteria type in dict: {criteria_type}") else: @@ -1676,75 +1449,41 @@ def get_criteria_sql( # Import here to avoid circular dependency - use the already imported names if isinstance(criteria, ConditionOccurrence): - return self._get_criteria_sql_from_builder( - self.condition_occurrence_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.condition_occurrence_sql_builder, criteria, options) elif isinstance(criteria, Death): - return self._get_criteria_sql_from_builder( - self.death_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.death_sql_builder, criteria, options) elif isinstance(criteria, DeviceExposure): - return self._get_criteria_sql_from_builder( - self.device_exposure_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.device_exposure_sql_builder, criteria, options) elif isinstance(criteria, Measurement): - return self._get_criteria_sql_from_builder( - self.measurement_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.measurement_sql_builder, criteria, options) elif isinstance(criteria, Observation): - return self._get_criteria_sql_from_builder( - self.observation_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.observation_sql_builder, criteria, options) elif isinstance(criteria, Specimen): - return self._get_criteria_sql_from_builder( - self.specimen_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.specimen_sql_builder, criteria, options) elif isinstance(criteria, VisitOccurrence): - return self._get_criteria_sql_from_builder( - self.visit_occurrence_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.visit_occurrence_sql_builder, criteria, options) elif isinstance(criteria, DrugExposure): - return self._get_criteria_sql_from_builder( - self.drug_exposure_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.drug_exposure_sql_builder, criteria, options) elif isinstance(criteria, ProcedureOccurrence): - return self._get_criteria_sql_from_builder( - self.procedure_occurrence_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.procedure_occurrence_sql_builder, criteria, options) elif isinstance(criteria, DrugEra): - return self._get_criteria_sql_from_builder( - self.drug_era_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.drug_era_sql_builder, criteria, options) elif isinstance(criteria, ConditionEra): - return self._get_criteria_sql_from_builder( - self.condition_era_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.condition_era_sql_builder, criteria, options) elif isinstance(criteria, DoseEra): - return self._get_criteria_sql_from_builder( - self.dose_era_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.dose_era_sql_builder, criteria, options) elif isinstance(criteria, ObservationPeriod): - return self._get_criteria_sql_from_builder( - self.observation_period_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.observation_period_sql_builder, criteria, options) elif isinstance(criteria, PayerPlanPeriod): - return self._get_criteria_sql_from_builder( - self.payer_plan_period_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.payer_plan_period_sql_builder, criteria, options) elif isinstance(criteria, VisitDetail): - return self._get_criteria_sql_from_builder( - self.visit_detail_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.visit_detail_sql_builder, criteria, options) elif isinstance(criteria, LocationRegion): - return self._get_criteria_sql_from_builder( - self.location_region_sql_builder, criteria, options - ) + return self._get_criteria_sql_from_builder(self.location_region_sql_builder, criteria, options) else: raise ValueError(f"Unsupported criteria type: {type(criteria)}") - def _get_criteria_sql_from_builder( - self, builder: Any, criteria: Criteria, options: Optional[BuilderOptions] - ) -> str: + def _get_criteria_sql_from_builder(self, builder: Any, criteria: Criteria, options: Optional[BuilderOptions]) -> str: """Generic method to get criteria SQL from builder.""" query = builder.get_criteria_sql_with_options(criteria, options) return self.process_correlated_criteria(query, criteria) @@ -1764,9 +1503,7 @@ def get_date_field_for_offset_strategy(self, date_field: str) -> str: return "end_date" return "start_date" - def get_strategy_sql( - self, strategy: Union[DateOffsetStrategy, CustomEraStrategy], event_table: str - ) -> str: + def get_strategy_sql(self, strategy: Union[DateOffsetStrategy, CustomEraStrategy], event_table: str) -> str: """Get strategy SQL for date offset or custom era strategy.""" if isinstance(strategy, DateOffsetStrategy): return self._get_date_offset_strategy_sql(strategy, event_table) @@ -1775,47 +1512,29 @@ def get_strategy_sql( else: raise ValueError(f"Unsupported strategy type: {type(strategy)}") - def _get_date_offset_strategy_sql( - self, strategy: DateOffsetStrategy, event_table: str - ) -> str: + def _get_date_offset_strategy_sql(self, strategy: DateOffsetStrategy, event_table: str) -> str: """Get strategy SQL for date offset strategy.""" - strategy_sql = self.DATE_OFFSET_STRATEGY_TEMPLATE.replace( - "@eventTable", event_table - ) + strategy_sql = self.DATE_OFFSET_STRATEGY_TEMPLATE.replace("@eventTable", event_table) strategy_sql = strategy_sql.replace("@offset", str(strategy.offset)) - strategy_sql = strategy_sql.replace( - "@dateField", self.get_date_field_for_offset_strategy(strategy.date_field) - ) + strategy_sql = strategy_sql.replace("@dateField", self.get_date_field_for_offset_strategy(strategy.date_field)) return strategy_sql - def _get_custom_era_strategy_sql( - self, strategy: CustomEraStrategy, event_table: str - ) -> str: + def _get_custom_era_strategy_sql(self, strategy: CustomEraStrategy, event_table: str) -> str: """Get strategy SQL for custom era strategy.""" if strategy.drug_codeset_id is None: raise RuntimeError("Drug Codeset ID cannot be NULL.") - drug_exposure_end_date_expression = ( - self.DEFAULT_DRUG_EXPOSURE_END_DATE_EXPRESSION - ) + drug_exposure_end_date_expression = self.DEFAULT_DRUG_EXPOSURE_END_DATE_EXPRESSION - strategy_sql = self.CUSTOM_ERA_STRATEGY_TEMPLATE.replace( - "@eventTable", event_table - ) - strategy_sql = strategy_sql.replace( - "@drugCodesetId", str(strategy.drug_codeset_id) - ) + strategy_sql = self.CUSTOM_ERA_STRATEGY_TEMPLATE.replace("@eventTable", event_table) + strategy_sql = strategy_sql.replace("@drugCodesetId", str(strategy.drug_codeset_id)) strategy_sql = strategy_sql.replace("@gapDays", str(strategy.gap_days)) strategy_sql = strategy_sql.replace("@offset", str(strategy.offset)) - strategy_sql = strategy_sql.replace( - "@drugExposureEndDateExpression", drug_exposure_end_date_expression - ) + strategy_sql = strategy_sql.replace("@drugExposureEndDateExpression", drug_exposure_end_date_expression) return strategy_sql - def _get_additional_columns( - self, columns: list[CriteriaColumn], table_alias: str - ) -> str: + def _get_additional_columns(self, columns: list[CriteriaColumn], table_alias: str) -> str: """Get additional columns for SQL query.""" if not columns: return "" diff --git a/circe/cohortdefinition/concept_set_expression_query_builder.py b/circe/cohortdefinition/concept_set_expression_query_builder.py index ccc134f5..f2de202b 100644 --- a/circe/cohortdefinition/concept_set_expression_query_builder.py +++ b/circe/cohortdefinition/concept_set_expression_query_builder.py @@ -57,13 +57,9 @@ def get_concept_ids(self, concepts: list[Concept]) -> list[int]: Java equivalent: getConceptIds() """ - return [ - concept.concept_id for concept in concepts if concept.concept_id is not None - ] + return [concept.concept_id for concept in concepts if concept.concept_id is not None] - def build_concept_set_sub_query( - self, concepts: list[Concept], descendant_concepts: list[Concept] - ) -> str: + def build_concept_set_sub_query(self, concepts: list[Concept], descendant_concepts: list[Concept]) -> str: """Build concept set sub-query. Java equivalent: buildConceptSetSubQuery() @@ -72,39 +68,25 @@ def build_concept_set_sub_query( if concepts: concept_ids = self.get_concept_ids(concepts) - concept_id_in = BuilderUtils.split_in_clause( - "concept_id", concept_ids, self.MAX_IN_LENGTH - ) - query = self.CONCEPT_SET_QUERY_TEMPLATE.replace( - "@conceptIdIn", concept_id_in - ) + concept_id_in = BuilderUtils.split_in_clause("concept_id", concept_ids, self.MAX_IN_LENGTH) + query = self.CONCEPT_SET_QUERY_TEMPLATE.replace("@conceptIdIn", concept_id_in) queries.append(query) if descendant_concepts: descendant_ids = self.get_concept_ids(descendant_concepts) - concept_id_in = BuilderUtils.split_in_clause( - "ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH - ) - query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace( - "@conceptIdIn", concept_id_in - ) + concept_id_in = BuilderUtils.split_in_clause("ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH) + query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace("@conceptIdIn", concept_id_in) queries.append(query) return "\nUNION ".join(queries) - def build_concept_set_mapped_query( - self, mapped_concepts: list[Concept], mapped_descendant_concepts: list[Concept] - ) -> str: + def build_concept_set_mapped_query(self, mapped_concepts: list[Concept], mapped_descendant_concepts: list[Concept]) -> str: """Build concept set mapped query. Java equivalent: buildConceptSetMappedQuery() """ - concept_set_query = self.build_concept_set_sub_query( - mapped_concepts, mapped_descendant_concepts - ) - return self.CONCEPT_SET_MAPPED_TEMPLATE.replace( - "@conceptsetQuery", concept_set_query - ) + concept_set_query = self.build_concept_set_sub_query(mapped_concepts, mapped_descendant_concepts) + return self.CONCEPT_SET_MAPPED_TEMPLATE.replace("@conceptsetQuery", concept_set_query) def build_concept_set_query( self, @@ -118,18 +100,12 @@ def build_concept_set_query( Java equivalent: buildConceptSetQuery() """ if not concepts: - return ( - "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1" - ) + return "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1" - concept_set_query = self.build_concept_set_sub_query( - concepts, descendant_concepts - ) + concept_set_query = self.build_concept_set_sub_query(concepts, descendant_concepts) if mapped_concepts or mapped_descendant_concepts: - mapped_query = self.build_concept_set_mapped_query( - mapped_concepts, mapped_descendant_concepts - ) + mapped_query = self.build_concept_set_mapped_query(mapped_concepts, mapped_descendant_concepts) concept_set_query += " UNION " + mapped_query return concept_set_query diff --git a/circe/cohortdefinition/core.py b/circe/cohortdefinition/core.py index b29c13ec..714da872 100644 --- a/circe/cohortdefinition/core.py +++ b/circe/cohortdefinition/core.py @@ -62,10 +62,7 @@ class CollapseType(str, Enum): def _missing_(cls, value): if isinstance(value, str): for member in cls: - if ( - member.name.upper() == value.upper() - or member.value.upper() == value.upper() - ): + if member.name.upper() == value.upper() or member.value.upper() == value.upper(): return member return super()._missing_(value) @@ -83,10 +80,7 @@ class DateType(str, Enum): def _missing_(cls, value): if isinstance(value, str): for member in cls: - if ( - member.name.upper() == value.upper() - or member.value.upper() == value.upper() - ): + if member.name.upper() == value.upper() or member.value.upper() == value.upper(): return member return super()._missing_(value) @@ -214,9 +208,7 @@ class CollapseSettings(CirceBaseModel): Java equivalent: org.ohdsi.circe.cohortdefinition.CollapseSettings """ - era_pad: int = Field( - validation_alias=AliasChoices("EraPad", "eraPad"), serialization_alias="EraPad" - ) + era_pad: int = Field(validation_alias=AliasChoices("EraPad", "eraPad"), serialization_alias="EraPad") collapse_type: Optional[CollapseType] = Field( default=CollapseType.ERA, validation_alias=AliasChoices("CollapseType", "collapseType"), @@ -289,9 +281,7 @@ class WindowBound(CirceBaseModel): Java equivalent: org.ohdsi.circe.cohortdefinition.WindowBound """ - coeff: int = Field( - validation_alias=AliasChoices("Coeff", "coeff"), serialization_alias="Coeff" - ) + coeff: int = Field(validation_alias=AliasChoices("Coeff", "coeff"), serialization_alias="Coeff") days: Optional[int] = Field( default=None, validation_alias=AliasChoices("Days", "days"), @@ -337,9 +327,7 @@ class DateOffsetStrategy(EndStrategy): Java equivalent: org.ohdsi.circe.cohortdefinition.DateOffsetStrategy """ - offset: int = Field( - validation_alias=AliasChoices("Offset", "offset"), serialization_alias="Offset" - ) + offset: int = Field(validation_alias=AliasChoices("Offset", "offset"), serialization_alias="Offset") date_field: str = Field( validation_alias=AliasChoices("DateField", "dateField"), serialization_alias="DateField", diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index 3709cede..277ebe78 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -101,12 +101,8 @@ class Occurrence(CirceBaseModel): AT_LEAST: int = Field(default=2, alias="AT_LEAST", exclude=True) EXACTLY: int = Field(default=0, alias="EXACTLY", exclude=True) - type: int = Field( - validation_alias=AliasChoices("Type", "type"), serialization_alias="Type" - ) - count: int = Field( - validation_alias=AliasChoices("Count", "count"), serialization_alias="Count" - ) + type: int = Field(validation_alias=AliasChoices("Type", "type"), serialization_alias="Type") + count: int = Field(validation_alias=AliasChoices("Count", "count"), serialization_alias="Count") is_distinct: bool = Field( default=False, validation_alias=AliasChoices("IsDistinct", "isDistinct"), @@ -155,9 +151,7 @@ class WindowedCriteria(CirceBaseModel): ) ignore_observation_period: bool = Field( default=False, - validation_alias=AliasChoices( - "IgnoreObservationPeriod", "ignoreObservationPeriod" - ), + validation_alias=AliasChoices("IgnoreObservationPeriod", "ignoreObservationPeriod"), serialization_alias="IgnoreObservationPeriod", ) @@ -344,9 +338,7 @@ class ConditionOccurrence(Criteria): ) condition_source_concept: Optional[int] = Field( default=None, - validation_alias=AliasChoices( - "ConditionSourceConcept", "conditionSourceConcept" - ), + validation_alias=AliasChoices("ConditionSourceConcept", "conditionSourceConcept"), serialization_alias="ConditionSourceConcept", ) age: Optional[NumericRange] = Field( @@ -533,24 +525,14 @@ class ProcedureOccurrence(Criteria): """ gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceEndDate" - ) - procedure_source_concept: Optional[int] = Field( - default=None, alias="ProcedureSourceConcept" - ) + occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") + procedure_source_concept: Optional[int] = Field(default=None, alias="ProcedureSourceConcept") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") procedure_type: Optional[list[Concept]] = Field(default=None, alias="ProcedureType") - procedure_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="ProcedureTypeCS" - ) + procedure_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProcedureTypeCS") procedure_type_exclude: bool = Field(default=False, alias="ProcedureTypeExclude") - provider_specialty_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="ProviderSpecialtyCS" - ) - visit_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="VisitTypeCS" - ) + provider_specialty_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProviderSpecialtyCS") + visit_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="VisitTypeCS") visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") modifier: Optional[list[Concept]] = Field(default=None, alias="Modifier") modifier_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ModifierCS") @@ -560,14 +542,10 @@ class ProcedureOccurrence(Criteria): validation_alias=AliasChoices("First", "first"), serialization_alias="First", ) - provider_specialty: Optional[list[Concept]] = Field( - default=None, alias="ProviderSpecialty" - ) + provider_specialty: Optional[list[Concept]] = Field(default=None, alias="ProviderSpecialty") age: Optional[NumericRange] = None quantity: Optional[NumericRange] = Field(default=None, alias="Quantity") - occurrence_start_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceStartDate" - ) + occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") model_config = ConfigDict(populate_by_name=True) @@ -581,38 +559,20 @@ class VisitOccurrence(Criteria): codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field(default=None, alias="First") gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceEndDate" - ) + occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") - visit_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="VisitTypeCS" - ) + visit_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="VisitTypeCS") visit_type_exclude: bool = Field(default=False, alias="VisitTypeExclude") - visit_source_concept: Optional[int] = Field( - default=None, alias="VisitSourceConcept" - ) + visit_source_concept: Optional[int] = Field(default=None, alias="VisitSourceConcept") visit_length: Optional[NumericRange] = Field(default=None, alias="VisitLength") - provider_specialty_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="ProviderSpecialtyCS" - ) - provider_specialty: Optional[list[Concept]] = Field( - default=None, alias="ProviderSpecialty" - ) - place_of_service: Optional[list[Concept]] = Field( - default=None, alias="PlaceOfService" - ) - place_of_service_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="PlaceOfServiceCS" - ) - place_of_service_location: Optional[int] = Field( - default=None, alias="PlaceOfServiceLocation" - ) + provider_specialty_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProviderSpecialtyCS") + provider_specialty: Optional[list[Concept]] = Field(default=None, alias="ProviderSpecialty") + place_of_service: Optional[list[Concept]] = Field(default=None, alias="PlaceOfService") + place_of_service_cs: Optional[ConceptSetSelection] = Field(default=None, alias="PlaceOfServiceCS") + place_of_service_location: Optional[int] = Field(default=None, alias="PlaceOfServiceLocation") age: Optional[NumericRange] = None - occurrence_start_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceStartDate" - ) + occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") model_config = ConfigDict(populate_by_name=True) @@ -631,9 +591,7 @@ class Observation(Criteria): ) observation_source_concept: Optional[int] = Field( default=None, - validation_alias=AliasChoices( - "ObservationSourceConcept", "observationSourceConcept" - ), + validation_alias=AliasChoices("ObservationSourceConcept", "observationSourceConcept"), serialization_alias="ObservationSourceConcept", ) gender_cs: Optional[ConceptSetSelection] = Field( @@ -653,9 +611,7 @@ class Observation(Criteria): ) observation_type_exclude: bool = Field( default=False, - validation_alias=AliasChoices( - "ObservationTypeExclude", "observationTypeExclude" - ), + validation_alias=AliasChoices("ObservationTypeExclude", "observationTypeExclude"), serialization_alias="ObservationTypeExclude", ) provider_specialty_cs: Optional[ConceptSetSelection] = Field( @@ -745,24 +701,14 @@ class Measurement(Criteria): """ gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceEndDate" - ) - measurement_source_concept: Optional[int] = Field( - default=None, alias="MeasurementSourceConcept" - ) + occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") + measurement_source_concept: Optional[int] = Field(default=None, alias="MeasurementSourceConcept") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - measurement_type: Optional[list[Concept]] = Field( - default=None, alias="MeasurementType" - ) - measurement_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="MeasurementTypeCS" - ) + measurement_type: Optional[list[Concept]] = Field(default=None, alias="MeasurementType") + measurement_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="MeasurementTypeCS") measurement_type_exclude: bool = Field( default=False, - validation_alias=AliasChoices( - "MeasurementTypeExclude", "measurementTypeExclude" - ), + validation_alias=AliasChoices("MeasurementTypeExclude", "measurementTypeExclude"), serialization_alias="MeasurementTypeExclude", ) operator: Optional[list[Concept]] = None @@ -773,12 +719,8 @@ class Measurement(Criteria): unit_cs: Optional[ConceptSetSelection] = Field(default=None, alias="UnitCS") range_low: Optional[NumericRange] = Field(default=None, alias="RangeLow") range_high: Optional[NumericRange] = Field(default=None, alias="RangeHigh") - provider_specialty_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="ProviderSpecialtyCS" - ) - visit_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="VisitTypeCS" - ) + provider_specialty_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProviderSpecialtyCS") + visit_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="VisitTypeCS") visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") codeset_id: Optional[int] = Field( default=None, @@ -810,13 +752,9 @@ class Measurement(Criteria): validation_alias=AliasChoices("RangeHighRatio", "rangeHighRatio"), serialization_alias="RangeHighRatio", ) - provider_specialty: Optional[list[Concept]] = Field( - default=None, alias="ProviderSpecialty" - ) + provider_specialty: Optional[list[Concept]] = Field(default=None, alias="ProviderSpecialty") age: Optional[NumericRange] = None - occurrence_start_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceStartDate" - ) + occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") visits: Optional[list[Concept]] = None # Placeholder if needed, but not in list visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") @@ -825,13 +763,9 @@ class Measurement(Criteria): validation_alias=AliasChoices("First", "first"), serialization_alias="First", ) - provider_specialty: Optional[list[Concept]] = Field( - default=None, alias="ProviderSpecialty" - ) + provider_specialty: Optional[list[Concept]] = Field(default=None, alias="ProviderSpecialty") age: Optional[NumericRange] = None - occurrence_start_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceStartDate" - ) + occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") model_config = ConfigDict(populate_by_name=True) @@ -843,26 +777,16 @@ class DeviceExposure(Criteria): """ gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceEndDate" - ) - device_source_concept: Optional[int] = Field( - default=None, alias="DeviceSourceConcept" - ) + occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") + device_source_concept: Optional[int] = Field(default=None, alias="DeviceSourceConcept") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") device_type: Optional[list[Concept]] = Field(default=None, alias="DeviceType") - device_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="DeviceTypeCS" - ) + device_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="DeviceTypeCS") device_type_exclude: bool = Field(default=False, alias="DeviceTypeExclude") unique_device_id: Optional[TextFilter] = Field(default=None, alias="UniqueDeviceId") quantity: Optional[NumericRange] = None - provider_specialty_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="ProviderSpecialtyCS" - ) - visit_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="VisitTypeCS" - ) + provider_specialty_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProviderSpecialtyCS") + visit_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="VisitTypeCS") visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field( @@ -870,13 +794,9 @@ class DeviceExposure(Criteria): validation_alias=AliasChoices("First", "first"), serialization_alias="First", ) - provider_specialty: Optional[list[Concept]] = Field( - default=None, alias="ProviderSpecialty" - ) + provider_specialty: Optional[list[Concept]] = Field(default=None, alias="ProviderSpecialty") age: Optional[NumericRange] = Field(default=None, alias="Age") - occurrence_start_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceStartDate" - ) + occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") model_config = ConfigDict(populate_by_name=True) @@ -888,29 +808,19 @@ class Specimen(Criteria): """ gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceEndDate" - ) - specimen_source_concept: Optional[int] = Field( - default=None, alias="SpecimenSourceConcept" - ) + occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") + specimen_source_concept: Optional[int] = Field(default=None, alias="SpecimenSourceConcept") source_id: Optional[TextFilter] = Field(default=None, alias="SourceId") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") specimen_type: Optional[list[Concept]] = Field(default=None, alias="SpecimenType") - specimen_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="SpecimenTypeCS" - ) + specimen_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="SpecimenTypeCS") specimen_type_exclude: bool = Field(default=False, alias="SpecimenTypeExclude") unit: Optional[list[Concept]] = None unit_cs: Optional[ConceptSetSelection] = Field(default=None, alias="UnitCS") anatomic_site: Optional[list[Concept]] = Field(default=None, alias="AnatomicSite") - anatomic_site_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="AnatomicSiteCS" - ) + anatomic_site_cs: Optional[ConceptSetSelection] = Field(default=None, alias="AnatomicSiteCS") disease_status: Optional[list[Concept]] = Field(default=None, alias="DiseaseStatus") - disease_status_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="DiseaseStatusCS" - ) + disease_status_cs: Optional[ConceptSetSelection] = Field(default=None, alias="DiseaseStatusCS") quantity: Optional[NumericRange] = None codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field( @@ -919,9 +829,7 @@ class Specimen(Criteria): serialization_alias="First", ) age: Optional[NumericRange] = None - occurrence_start_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceStartDate" - ) + occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") model_config = ConfigDict(populate_by_name=True) @@ -933,34 +841,22 @@ class Death(Criteria): """ gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") - occurrence_end_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceEndDate" - ) - death_source_concept: Optional[int] = Field( - default=None, alias="DeathSourceConcept" - ) + occurrence_end_date: Optional[DateRange] = Field(default=None, alias="OccurrenceEndDate") + death_source_concept: Optional[int] = Field(default=None, alias="DeathSourceConcept") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") death_type: Optional[list[Concept]] = Field(default=None, alias="DeathType") - death_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="DeathTypeCS" - ) + death_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="DeathTypeCS") death_type_exclude: bool = Field( default=False, validation_alias=AliasChoices("DeathTypeExclude", "deathTypeExclude"), serialization_alias="DeathTypeExclude", ) - cause_source_concept: Optional[int] = Field( - default=None, alias="CauseSourceConcept" - ) - cause_source_concept_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="CauseSourceConceptCS" - ) + cause_source_concept: Optional[int] = Field(default=None, alias="CauseSourceConcept") + cause_source_concept_cs: Optional[ConceptSetSelection] = Field(default=None, alias="CauseSourceConceptCS") codeset_id: Optional[int] = Field(default=None, alias="CodesetId") age: Optional[NumericRange] = None - occurrence_start_date: Optional[DateRange] = Field( - default=None, alias="OccurrenceStartDate" - ) + occurrence_start_date: Optional[DateRange] = Field(default=None, alias="OccurrenceStartDate") model_config = ConfigDict(populate_by_name=True) @@ -973,49 +869,23 @@ class VisitDetail(Criteria): codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field(default=None, alias="First") - visit_detail_start_date: Optional[DateRange] = Field( - default=None, alias="VisitDetailStartDate" - ) - visit_detail_end_date: Optional[DateRange] = Field( - default=None, alias="VisitDetailEndDate" - ) - visit_detail_type: Optional[list[Concept]] = Field( - default=None, alias="VisitDetailType" - ) - visit_detail_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="VisitDetailTypeCS" - ) - visit_detail_type_exclude: bool = Field( - default=False, alias="VisitDetailTypeExclude" - ) - visit_detail_source_concept: Optional[int] = Field( - default=None, alias="VisitDetailSourceConcept" - ) - visit_detail_length: Optional[NumericRange] = Field( - default=None, alias="VisitDetailLength" - ) + visit_detail_start_date: Optional[DateRange] = Field(default=None, alias="VisitDetailStartDate") + visit_detail_end_date: Optional[DateRange] = Field(default=None, alias="VisitDetailEndDate") + visit_detail_type: Optional[list[Concept]] = Field(default=None, alias="VisitDetailType") + visit_detail_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="VisitDetailTypeCS") + visit_detail_type_exclude: bool = Field(default=False, alias="VisitDetailTypeExclude") + visit_detail_source_concept: Optional[int] = Field(default=None, alias="VisitDetailSourceConcept") + visit_detail_length: Optional[NumericRange] = Field(default=None, alias="VisitDetailLength") age: Optional[NumericRange] = Field(default=None, alias="Age") gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - provider_specialty: Optional[list[Concept]] = Field( - default=None, alias="ProviderSpecialty" - ) - provider_specialty_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="ProviderSpecialtyCS" - ) - place_of_service: Optional[list[Concept]] = Field( - default=None, alias="PlaceOfService" - ) - place_of_service_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="PlaceOfServiceCS" - ) - place_of_service_location: Optional[int] = Field( - default=None, alias="PlaceOfServiceLocation" - ) + provider_specialty: Optional[list[Concept]] = Field(default=None, alias="ProviderSpecialty") + provider_specialty_cs: Optional[ConceptSetSelection] = Field(default=None, alias="ProviderSpecialtyCS") + place_of_service: Optional[list[Concept]] = Field(default=None, alias="PlaceOfService") + place_of_service_cs: Optional[ConceptSetSelection] = Field(default=None, alias="PlaceOfServiceCS") + place_of_service_location: Optional[int] = Field(default=None, alias="PlaceOfServiceLocation") discharge_to: Optional[list[Concept]] = Field(default=None, alias="DischargeTo") - discharge_to_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="DischargeToCS" - ) + discharge_to_cs: Optional[ConceptSetSelection] = Field(default=None, alias="DischargeToCS") model_config = ConfigDict(populate_by_name=True) @@ -1027,17 +897,11 @@ class ObservationPeriod(Criteria): """ first: Optional[bool] = Field(default=None, alias="First") - period_start_date: Optional[DateRange] = Field( - default=None, alias="PeriodStartDate" - ) + period_start_date: Optional[DateRange] = Field(default=None, alias="PeriodStartDate") period_end_date: Optional[DateRange] = Field(default=None, alias="PeriodEndDate") - user_defined_period: Optional[Period] = Field( - default=None, alias="UserDefinedPeriod" - ) + user_defined_period: Optional[Period] = Field(default=None, alias="UserDefinedPeriod") period_type: Optional[list[Concept]] = Field(default=None, alias="PeriodType") - period_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="PeriodTypeCS" - ) + period_type_cs: Optional[ConceptSetSelection] = Field(default=None, alias="PeriodTypeCS") period_length: Optional[NumericRange] = Field(default=None, alias="PeriodLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") @@ -1052,13 +916,9 @@ class PayerPlanPeriod(Criteria): """ first: Optional[bool] = Field(default=None, alias="First") - period_start_date: Optional[DateRange] = Field( - default=None, alias="PeriodStartDate" - ) + period_start_date: Optional[DateRange] = Field(default=None, alias="PeriodStartDate") period_end_date: Optional[DateRange] = Field(default=None, alias="PeriodEndDate") - user_defined_period: Optional[Period] = Field( - default=None, alias="UserDefinedPeriod" - ) + user_defined_period: Optional[Period] = Field(default=None, alias="UserDefinedPeriod") period_length: Optional[NumericRange] = Field(default=None, alias="PeriodLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") @@ -1068,16 +928,10 @@ class PayerPlanPeriod(Criteria): plan_concept: Optional[int] = Field(default=None, alias="PlanConcept") sponsor_concept: Optional[int] = Field(default=None, alias="SponsorConcept") stop_reason_concept: Optional[int] = Field(default=None, alias="StopReasonConcept") - payer_source_concept: Optional[int] = Field( - default=None, alias="PayerSourceConcept" - ) + payer_source_concept: Optional[int] = Field(default=None, alias="PayerSourceConcept") plan_source_concept: Optional[int] = Field(default=None, alias="PlanSourceConcept") - sponsor_source_concept: Optional[int] = Field( - default=None, alias="SponsorSourceConcept" - ) - stop_reason_source_concept: Optional[int] = Field( - default=None, alias="StopReasonSourceConcept" - ) + sponsor_source_concept: Optional[int] = Field(default=None, alias="SponsorSourceConcept") + stop_reason_source_concept: Optional[int] = Field(default=None, alias="StopReasonSourceConcept") model_config = ConfigDict(populate_by_name=True) @@ -1112,17 +966,13 @@ class ConditionEra(Criteria): ) era_start_date: Optional[DateRange] = Field(default=None, alias="EraStartDate") era_end_date: Optional[DateRange] = Field(default=None, alias="EraEndDate") - occurrence_count: Optional[NumericRange] = Field( - default=None, alias="OccurrenceCount" - ) + occurrence_count: Optional[NumericRange] = Field(default=None, alias="OccurrenceCount") era_length: Optional[NumericRange] = Field(default=None, alias="EraLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - date_adjustment: Optional[DateAdjustment] = Field( - default=None, alias="DateAdjustment" - ) + date_adjustment: Optional[DateAdjustment] = Field(default=None, alias="DateAdjustment") model_config = ConfigDict(populate_by_name=True) @@ -1141,18 +991,14 @@ class DrugEra(Criteria): ) era_start_date: Optional[DateRange] = Field(default=None, alias="EraStartDate") era_end_date: Optional[DateRange] = Field(default=None, alias="EraEndDate") - occurrence_count: Optional[NumericRange] = Field( - default=None, alias="OccurrenceCount" - ) + occurrence_count: Optional[NumericRange] = Field(default=None, alias="OccurrenceCount") gap_days: Optional[NumericRange] = Field(default=None, alias="GapDays") era_length: Optional[NumericRange] = Field(default=None, alias="EraLength") age_at_start: Optional[NumericRange] = Field(default=None, alias="AgeAtStart") age_at_end: Optional[NumericRange] = Field(default=None, alias="AgeAtEnd") gender: Optional[list[Concept]] = Field(default=None, serialization_alias="gender") gender_cs: Optional[ConceptSetSelection] = Field(default=None, alias="GenderCS") - date_adjustment: Optional[DateAdjustment] = Field( - default=None, alias="DateAdjustment" - ) + date_adjustment: Optional[DateAdjustment] = Field(default=None, alias="DateAdjustment") model_config = ConfigDict(populate_by_name=True) @@ -1221,9 +1067,7 @@ class CriteriaGroup(BaseModel): ) demographic_criteria_list: list[DemographicCriteria] = Field( default_factory=list, - validation_alias=AliasChoices( - "DemographicCriteriaList", "demographicCriteriaList" - ), + validation_alias=AliasChoices("DemographicCriteriaList", "demographicCriteriaList"), serialization_alias="DemographicCriteriaList", ) type: Optional[str] = Field( @@ -1238,9 +1082,7 @@ def is_empty(self) -> bool: """Check if the criteria group is empty.""" has_criteria = self.criteria_list and len(self.criteria_list) > 0 has_groups = self.groups and len(self.groups) > 0 - has_demographic = ( - self.demographic_criteria_list and len(self.demographic_criteria_list) > 0 - ) + has_demographic = self.demographic_criteria_list and len(self.demographic_criteria_list) > 0 return not (has_criteria or has_groups or has_demographic) @field_validator("demographic_criteria_list", mode="before") @@ -1300,11 +1142,7 @@ def normalize_window(window_dict: dict) -> dict: if "Start" in window_dict: start = window_dict["Start"] if isinstance(start, dict): - coeff = ( - start.get("Coeff") - if "Coeff" in start - else start.get("coeff", 0) - ) + coeff = start.get("Coeff") if "Coeff" in start else start.get("coeff", 0) days = start.get("Days") if "Days" in start else start.get("days") normalized["start"] = {"coeff": coeff, "days": days} else: @@ -1319,10 +1157,7 @@ def normalize_window(window_dict: dict) -> dict: normalized["end"] = end if "coeff" not in normalized and "start" in normalized: - if ( - isinstance(normalized["start"], dict) - and "coeff" in normalized["start"] - ): + if isinstance(normalized["start"], dict) and "coeff" in normalized["start"]: normalized["coeff"] = normalized["start"]["coeff"] else: normalized["coeff"] = 0 @@ -1362,43 +1197,25 @@ def normalize_window(window_dict: dict) -> dict: try: c_data = dict(c_dict[c_type]) # PascalCase defaults - if ( - c_type == "Measurement" - and "MeasurementTypeExclude" not in c_data - and "measurementTypeExclude" not in c_data - ): + if c_type == "Measurement" and "MeasurementTypeExclude" not in c_data and "measurementTypeExclude" not in c_data: c_data["MeasurementTypeExclude"] = False - if ( - c_type == "Observation" - and "ObservationTypeExclude" not in c_data - and "observationTypeExclude" not in c_data - ): + if c_type == "Observation" and "ObservationTypeExclude" not in c_data and "observationTypeExclude" not in c_data: c_data["ObservationTypeExclude"] = False - if ( - c_type == "ConditionOccurrence" - and "ConditionTypeExclude" not in c_data - and "conditionTypeExclude" not in c_data - ): + if c_type == "ConditionOccurrence" and "ConditionTypeExclude" not in c_data and "conditionTypeExclude" not in c_data: c_data["ConditionTypeExclude"] = False if "First" not in c_data and "first" not in c_data: c_data["First"] = False - c_obj = NAMES_TO_CLASSES[c_type].model_validate( - c_data, strict=False - ) + c_obj = NAMES_TO_CLASSES[c_type].model_validate(c_data, strict=False) item_copy["criteria"] = c_obj except Exception: pass if "Occurrence" in item_copy: occ = item_copy.pop("Occurrence") - item_copy["occurrence"] = ( - Occurrence.model_validate(occ) if isinstance(occ, dict) else occ - ) + item_copy["occurrence"] = Occurrence.model_validate(occ) if isinstance(occ, dict) else occ elif "occurrence" not in item_copy: - item_copy["occurrence"] = Occurrence( - type=Occurrence._AT_LEAST, count=1, is_distinct=False - ) + item_copy["occurrence"] = Occurrence(type=Occurrence._AT_LEAST, count=1, is_distinct=False) try: deserialized.append(CorelatedCriteria.model_validate(item_copy)) @@ -1436,30 +1253,16 @@ def normalize_window(window_dict: dict) -> dict: # Explicitly deserialize inner criteria to avoid Pydantic union ambiguity try: # PascalCase defaults for specific types - if ( - c_type == "Measurement" - and "MeasurementTypeExclude" not in c_data - and "measurementTypeExclude" not in c_data - ): + if c_type == "Measurement" and "MeasurementTypeExclude" not in c_data and "measurementTypeExclude" not in c_data: c_data["MeasurementTypeExclude"] = False - if ( - c_type == "Observation" - and "ObservationTypeExclude" not in c_data - and "observationTypeExclude" not in c_data - ): + if c_type == "Observation" and "ObservationTypeExclude" not in c_data and "observationTypeExclude" not in c_data: c_data["ObservationTypeExclude"] = False - if ( - c_type == "ConditionOccurrence" - and "ConditionTypeExclude" not in c_data - and "conditionTypeExclude" not in c_data - ): + if c_type == "ConditionOccurrence" and "ConditionTypeExclude" not in c_data and "conditionTypeExclude" not in c_data: c_data["ConditionTypeExclude"] = False if "First" not in c_data and "first" not in c_data: c_data["First"] = False - c_obj = NAMES_TO_CLASSES[c_type].model_validate( - c_data, strict=False - ) + c_obj = NAMES_TO_CLASSES[c_type].model_validate(c_data, strict=False) corelated_dict = { "criteria": c_obj, @@ -1481,9 +1284,7 @@ def normalize_window(window_dict: dict) -> dict: if f in item_copy: corelated_dict[f] = item_copy[f] - deserialized.append( - CorelatedCriteria.model_validate(corelated_dict) - ) + deserialized.append(CorelatedCriteria.model_validate(corelated_dict)) except Exception: deserialized.append(item) else: @@ -1496,34 +1297,18 @@ def normalize_window(window_dict: dict) -> dict: try: c_data = item_copy[c_type] # PascalCase defaults - if ( - c_type == "Measurement" - and "MeasurementTypeExclude" not in c_data - and "measurementTypeExclude" not in c_data - ): + if c_type == "Measurement" and "MeasurementTypeExclude" not in c_data and "measurementTypeExclude" not in c_data: c_data["MeasurementTypeExclude"] = False - if ( - c_type == "Observation" - and "ObservationTypeExclude" not in c_data - and "observationTypeExclude" not in c_data - ): + if c_type == "Observation" and "ObservationTypeExclude" not in c_data and "observationTypeExclude" not in c_data: c_data["ObservationTypeExclude"] = False - if ( - c_type == "ConditionOccurrence" - and "ConditionTypeExclude" not in c_data - and "conditionTypeExclude" not in c_data - ): + if c_type == "ConditionOccurrence" and "ConditionTypeExclude" not in c_data and "conditionTypeExclude" not in c_data: c_data["ConditionTypeExclude"] = False if "First" not in c_data and "first" not in c_data: c_data["First"] = False - c_obj = NAMES_TO_CLASSES[c_type].model_validate( - c_data, strict=False - ) + c_obj = NAMES_TO_CLASSES[c_type].model_validate(c_data, strict=False) corelated_dict = {"criteria": c_obj} - deserialized.append( - CorelatedCriteria.model_validate(corelated_dict) - ) + deserialized.append(CorelatedCriteria.model_validate(corelated_dict)) except Exception: deserialized.append(item) else: @@ -1636,20 +1421,11 @@ def deserialize_criteria_list(cls, v: Any) -> Any: if c_type: try: c_data = dict(item[c_type_raw]) - if ( - c_type == "Measurement" - and "MeasurementTypeExclude" not in c_data - ): + if c_type == "Measurement" and "MeasurementTypeExclude" not in c_data: c_data["MeasurementTypeExclude"] = False - if ( - c_type == "Observation" - and "ObservationTypeExclude" not in c_data - ): + if c_type == "Observation" and "ObservationTypeExclude" not in c_data: c_data["ObservationTypeExclude"] = False - if ( - c_type == "ConditionOccurrence" - and "ConditionTypeExclude" not in c_data - ): + if c_type == "ConditionOccurrence" and "ConditionTypeExclude" not in c_data: c_data["ConditionTypeExclude"] = False if "First" not in c_data: c_data["First"] = False diff --git a/circe/cohortdefinition/interfaces.py b/circe/cohortdefinition/interfaces.py index 1ae17d50..0492ab66 100644 --- a/circe/cohortdefinition/interfaces.py +++ b/circe/cohortdefinition/interfaces.py @@ -61,9 +61,7 @@ class IGetCriteriaSqlDispatcher(ABC): """ @abstractmethod - def get_criteria_sql( - self, criteria: Criteria, options: Optional[BuilderOptions] = None - ) -> str: + def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> str: """Generate SQL for various criteria types. Args: diff --git a/circe/cohortdefinition/printfriendly/markdown_render.py b/circe/cohortdefinition/printfriendly/markdown_render.py index af374447..eabfe330 100644 --- a/circe/cohortdefinition/printfriendly/markdown_render.py +++ b/circe/cohortdefinition/printfriendly/markdown_render.py @@ -95,11 +95,7 @@ def render_cohort_expression( self._concept_sets = cohort_expression.concept_sets # Determine whether to include concept sets - should_include = ( - include_concept_sets - if include_concept_sets is not None - else self._include_concept_sets - ) + should_include = include_concept_sets if include_concept_sets is not None else self._include_concept_sets # Load and render the main template template = self._env.get_template("cohort_expression.j2") @@ -111,9 +107,7 @@ def render_cohort_expression( include_concept_sets=should_include, ) - def render_concept_set_list( - self, concept_sets: Union[list[ConceptSet], str] - ) -> str: + def render_concept_set_list(self, concept_sets: Union[list[ConceptSet], str]) -> str: """Render a list of concept sets to markdown format. Java equivalent: renderConceptSetList(ConceptSet[]) @@ -162,9 +156,7 @@ def render_concept_set(self, concept_set: Union[ConceptSet, str]) -> str: # Custom Filters and Functions (matching Java utils.ftl) # ========================================================================= - def _codeset_name( - self, codeset_id: Optional[int], default_name: str = "any" - ) -> str: + def _codeset_name(self, codeset_id: Optional[int], default_name: str = "any") -> str: """Get concept set name from codeset ID, or return default. Java equivalent: utils.codesetName() diff --git a/circe/execution/build_context.py b/circe/execution/build_context.py index a72b727e..58d8fbf3 100644 --- a/circe/execution/build_context.py +++ b/circe/execution/build_context.py @@ -35,9 +35,7 @@ def _warn(message: str) -> None: print(f"Warning: {message}") -def _analyze_table( - conn: ibis.BaseBackend, *, backend: str | None, qualified_name: str -) -> None: +def _analyze_table(conn: ibis.BaseBackend, *, backend: str | None, qualified_name: str) -> None: if not backend: return if backend in ("postgres", "duckdb"): @@ -166,18 +164,14 @@ def materialize( # "temp emulation" means: create a *real* table in a chosen database/schema. use_temp_emulation = temp and self._options.temp_emulation_schema is not None - database: Database | None = ( - self._options.temp_emulation_schema if use_temp_emulation else None - ) + database: Database | None = self._options.temp_emulation_schema if use_temp_emulation else None temp_flag = False if use_temp_emulation else temp # duckdb profiling setup for local dev profile_filename: Path | None = None profiling_enabled = False if backend == "duckdb" and self._profile_dir is not None: - profile_filename = ( - self._profile_dir / f"ibis_profile_{label}_{step_id}.json" - ).resolve() + profile_filename = (self._profile_dir / f"ibis_profile_{label}_{step_id}.json").resolve() try: escaped = str(profile_filename).replace("'", "''") self._conn.raw_sql(f"SET profiling_output='{escaped}'") @@ -256,26 +250,16 @@ def write_cohort_table( (cohort_definition_id, subject_id, cohort_start_date, cohort_end_date) """ if append and overwrite: - raise ValueError( - "`append=True` and `overwrite=True` cannot be used together." - ) + raise ValueError("`append=True` and `overwrite=True` cannot be used together.") target_table = table_name or self._options.target_table if not target_table: - raise ValueError( - "target_table must be set (argument or CohortBuildOptions.target_table)" - ) + raise ValueError("target_table must be set (argument or CohortBuildOptions.target_table)") target_db = database if database is not None else self._options.result_schema if target_db is None: - raise ValueError( - "result_schema must be set (argument or CohortBuildOptions.result_schema)" - ) + raise ValueError("result_schema must be set (argument or CohortBuildOptions.result_schema)") cohort_id = self._options.cohort_id - cohort_id_expr = ( - ibis.literal(int(cohort_id), type="int64") - if cohort_id is not None - else ibis.null().cast("int64") - ) + cohort_id_expr = ibis.literal(int(cohort_id), type="int64") if cohort_id is not None else ibis.null().cast("int64") result = events.select( cohort_id_expr.name("cohort_definition_id"), @@ -370,9 +354,7 @@ def compile_codesets( compiled = [] for concept_set in concept_sets or []: - compiled_expr = _compile_single_codeset( - concept, concept_ancestor, concept_relationship, concept_set - ) + compiled_expr = _compile_single_codeset(concept, concept_ancestor, concept_relationship, concept_set) if compiled_expr is not None: compiled.append(compiled_expr) @@ -470,14 +452,10 @@ def _compile_single_codeset( def _ids_memtable(ids: list[int]) -> ir.Table | None: if not ids: return None - return table_from_literal_list( - ids, column_name="concept_id", element_type="int64" - ).distinct() + return table_from_literal_list(ids, column_name="concept_id", element_type="int64").distinct() -def _descendants( - concept: ir.Table, concept_ancestor: ir.Table, ancestor_ids: list[int] -) -> ir.Table | None: +def _descendants(concept: ir.Table, concept_ancestor: ir.Table, ancestor_ids: list[int]) -> ir.Table | None: if not ancestor_ids: return None return ( @@ -514,18 +492,14 @@ def _mapped_concepts( ) return ( - sources.join( - valid_relationships, sources.concept_id == valid_relationships.concept_id_2 - ) + sources.join(valid_relationships, sources.concept_id == valid_relationships.concept_id_2) .select(valid_relationships.concept_id_1.cast("int64").name("concept_id")) .distinct() ) def _empty_codeset_table() -> ir.Table: - empty_concepts = table_from_literal_list( - [], column_name="concept_id", element_type="int64" - ) + empty_concepts = table_from_literal_list([], column_name="concept_id", element_type="int64") empty_codesets = empty_concepts.mutate( codeset_id=ibis.null().cast("int64"), ) diff --git a/circe/execution/builders/__init__.py b/circe/execution/builders/__init__.py index 99de9e44..b625e93a 100644 --- a/circe/execution/builders/__init__.py +++ b/circe/execution/builders/__init__.py @@ -38,4 +38,3 @@ "build_events", "register", ] - diff --git a/circe/execution/builders/common.py b/circe/execution/builders/common.py index 8444743f..6e892597 100644 --- a/circe/execution/builders/common.py +++ b/circe/execution/builders/common.py @@ -53,14 +53,10 @@ def standardize_output( end_expr = start_expr needs_offset = ibis.literal(True) one_day = ibis.interval(days=1) - end_expr = ibis.ifelse(needs_offset, cast(Any, end_expr) + one_day, end_expr).cast( - "timestamp" + end_expr = ibis.ifelse(needs_offset, cast(Any, end_expr) + one_day, end_expr).cast("timestamp") + visit_expr = (table.visit_occurrence_id.cast("int64") if "visit_occurrence_id" in table.columns else ibis.null().cast("int64")).name( + "visit_occurrence_id" ) - visit_expr = ( - table.visit_occurrence_id.cast("int64") - if "visit_occurrence_id" in table.columns - else ibis.null().cast("int64") - ).name("visit_occurrence_id") return table.select( table.person_id.cast("int64").name("person_id"), table[primary_key].cast("int64").name("event_id"), @@ -79,19 +75,11 @@ def project_event_columns( include_visit_occurrence: bool = False, ) -> ir.Table: keep = ["person_id", primary_key, start_column] - if ( - end_column in table.columns - or include_visit_occurrence - and start_column != end_column - ): + if end_column in table.columns or include_visit_occurrence and start_column != end_column: keep.append(end_column) if include_visit_occurrence and "visit_occurrence_id" in table.columns: keep.append("visit_occurrence_id") - unique_keep = [ - col - for i, col in enumerate(keep) - if col in table.columns and col not in keep[:i] - ] + unique_keep = [col for i, col in enumerate(keep) if col in table.columns and col not in keep[:i]] return table.select(*(table[col] for col in unique_keep)) @@ -105,9 +93,7 @@ def apply_codeset_filter( return table base_columns = table.columns left = table.view() - concepts = ctx.codesets.filter( - ctx.codesets["codeset_id"] == ibis.literal(codeset_id) - ).view() + concepts = ctx.codesets.filter(ctx.codesets["codeset_id"] == ibis.literal(codeset_id)).view() joined = left.join(concepts, [left[concept_column] == concepts["concept_id"]]) return _project_columns(joined, base_columns) @@ -122,9 +108,7 @@ def apply_concept_set_selection( return table base_columns = table.columns left = table.view() - codeset_table = ctx.codesets.filter( - ctx.codesets["codeset_id"] == ibis.literal(selection.codeset_id) - ).view() + codeset_table = ctx.codesets.filter(ctx.codesets["codeset_id"] == ibis.literal(selection.codeset_id)).view() if selection.is_exclusion: return left.anti_join(codeset_table, [left[column] == codeset_table.concept_id]) joined = left.join(codeset_table, [left[column] == codeset_table.concept_id]) @@ -159,9 +143,7 @@ def apply_concept_criteria( return apply_concept_set_selection(table, column, selection, ctx) -def apply_date_range( - table: ir.Table, column: str, date_range: DateRange | None -) -> ir.Table: +def apply_date_range(table: ir.Table, column: str, date_range: DateRange | None) -> ir.Table: if not date_range: return table expr = table[column] @@ -178,9 +160,7 @@ def apply_date_range( return table.filter(predicate) -def apply_numeric_range( - table: ir.Table, column, numeric_range: NumericRange | None -) -> ir.Table: +def apply_numeric_range(table: ir.Table, column, numeric_range: NumericRange | None) -> ir.Table: if not numeric_range or numeric_range.value is None: return table op = numeric_range.op or "eq" @@ -199,9 +179,7 @@ def apply_numeric_range( return table.filter(predicate) -def apply_text_filter( - table: ir.Table, column: str, text_filter: TextFilter | None -) -> ir.Table: +def apply_text_filter(table: ir.Table, column: str, text_filter: TextFilter | None) -> ir.Table: if not text_filter or not text_filter.text: return table op = text_filter.op or "contains" @@ -389,9 +367,7 @@ def apply_observation_window( ) -> ir.Table: if observation_window is None: return events - observation = ctx.table("observation_period").select( - "person_id", "observation_period_start_date", "observation_period_end_date" - ) + observation = ctx.table("observation_period").select("person_id", "observation_period_start_date", "observation_period_end_date") # Use a view to ensure subsequent joins don't mix incompatible relations. left = events.view() joined = left.join(observation, ["person_id"]) @@ -401,15 +377,9 @@ def apply_observation_window( end_col = _ensure_timestamp(joined.observation_period_end_date) start_bound = start_col + cast(Any, prior_days) end_bound = end_col - cast(Any, post_days) - filtered = joined.filter( - (joined.start_date >= start_bound) & (joined.start_date <= end_bound) - ) + filtered = joined.filter((joined.start_date >= start_bound) & (joined.start_date <= end_bound)) base_projection = [filtered[col] for col in events.columns] - base_projection.extend( - filtered[col] - for col in ("observation_period_start_date", "observation_period_end_date") - if col in filtered.columns - ) + base_projection.extend(filtered[col] for col in ("observation_period_start_date", "observation_period_end_date") if col in filtered.columns) return filtered.select(*base_projection) @@ -472,9 +442,7 @@ def apply_care_site_filter( if not place_of_service_selection: return table care_site = ctx.table("care_site") - filtered = apply_concept_set_selection( - care_site, "place_of_service_concept_id", place_of_service_selection, ctx - ) + filtered = apply_concept_set_selection(care_site, "place_of_service_concept_id", place_of_service_selection, ctx) filtered = filtered.select(filtered.care_site_id) return table.semi_join(filtered, [table[care_site_column] == filtered.care_site_id]) @@ -502,16 +470,11 @@ def apply_location_region_filter( (joined[care_site_column] == lh.entity_id) & (lh.domain_id == ibis.literal("CARE_SITE")) & (start_expr >= lh.start_date) - & ( - end_expr - <= ibis.coalesce(lh.end_date, ibis.literal("2099-12-31").cast("date")) - ) + & (end_expr <= ibis.coalesce(lh.end_date, ibis.literal("2099-12-31").cast("date"))) ) joined = joined.join(lh, [lh_condition]) joined = joined.join(location, [joined.location_id == location.location_id]) - codeset = ctx.codesets.filter( - ctx.codesets.codeset_id == ibis.literal(location_codeset_id) - ) + codeset = ctx.codesets.filter(ctx.codesets.codeset_id == ibis.literal(location_codeset_id)) filtered = joined.join(codeset, [location.region_concept_id == codeset.concept_id]) return _project_columns(filtered, base_columns) @@ -592,9 +555,7 @@ def apply_end_strategy( date_offset, custom_era = _resolve_end_strategy_parts(strategy) if not date_offset and not custom_era: if "observation_period_end_date" in events.columns: - op_end = _cast_like( - _ensure_timestamp(events.observation_period_end_date), events.end_date - ) + op_end = _cast_like(_ensure_timestamp(events.observation_period_end_date), events.end_date) return events.mutate(end_date=op_end) return events result = events @@ -603,11 +564,7 @@ def apply_end_strategy( if date_offset: interval = ibis.interval(days=int(date_offset.offset)) date_field = str(date_offset.date_field or "StartDate").lower() - anchor = ( - _ensure_timestamp(result.start_date) - if date_field == "startdate" - else _ensure_timestamp(result.end_date) - ) + anchor = _ensure_timestamp(result.start_date) if date_field == "startdate" else _ensure_timestamp(result.end_date) shifted = anchor + cast(Any, interval) if "observation_period_end_date" in result.columns: shifted = ibis.least( @@ -687,18 +644,14 @@ def collapse_events(events: ir.Table, settings: CollapseSettings | None) -> ir.T end_date=(max_end - pad_interval), visit_occurrence_id=grouped.visit_occurrence_id.max(), ) - final_window = ibis.window( - order_by=[collapsed.person_id, collapsed.start_date, collapsed.end_date] + final_window = ibis.window(order_by=[collapsed.person_id, collapsed.start_date, collapsed.end_date]) + collapsed = collapsed.mutate(event_id=(ibis.row_number().over(final_window) + 1)).select( + "person_id", "event_id", "start_date", "end_date", "visit_occurrence_id" ) - collapsed = collapsed.mutate( - event_id=(ibis.row_number().over(final_window) + 1) - ).select("person_id", "event_id", "start_date", "end_date", "visit_occurrence_id") return collapsed -def _apply_custom_era_strategy( - events: ir.Table, strategy: CustomEraStrategy, ctx: BuildContext -) -> ir.Table: +def _apply_custom_era_strategy(events: ir.Table, strategy: CustomEraStrategy, ctx: BuildContext) -> ir.Table: if strategy.drug_codeset_id is None: raise ValueError("Custom era strategy requires a drug codeset id.") @@ -713,15 +666,11 @@ def _exposure_query(concept_column: str) -> ir.Table: .select( drug_exposure.person_id, drug_exposure.drug_exposure_start_date.name("drug_exposure_start_date"), - _drug_exposure_end(drug_exposure, strategy).name( - "drug_exposure_end_date" - ), + _drug_exposure_end(drug_exposure, strategy).name("drug_exposure_end_date"), ) ) - exposures = _exposure_query("drug_concept_id").union( - _exposure_query("drug_source_concept_id"), distinct=False - ) + exposures = _exposure_query("drug_concept_id").union(_exposure_query("drug_source_concept_id"), distinct=False) gap = int(strategy.gap_days or 0) offset = int(strategy.offset or 0) @@ -739,13 +688,9 @@ def _exposure_query(concept_column: str) -> ir.Table: preceding=(None, 1), ) prev_running_max = dt.extended_end.max().over(prev_max_window) - is_start = ibis.ifelse( - prev_running_max.notnull() & (prev_running_max >= dt.start_date), 0, 1 - ) + is_start = ibis.ifelse(prev_running_max.notnull() & (prev_running_max >= dt.start_date), 0, 1) staged = dt.mutate(is_start=is_start).view() - cumsum_window = ibis.window( - group_by=staged.person_id, order_by=[staged.start_date, staged.extended_end] - ) + cumsum_window = ibis.window(group_by=staged.person_id, order_by=[staged.start_date, staged.extended_end]) group_idx = staged.is_start.cumsum().over(cumsum_window) annotated = staged.mutate(group_idx=group_idx) @@ -754,19 +699,11 @@ def _exposure_query(concept_column: str) -> ir.Table: era_end=(annotated.extended_end.max() - ibis.interval(days=gap)), ) - join_condition = ( - (events.person_id == eras.person_id) - & (events.start_date >= eras.era_start) - & (events.start_date <= eras.era_end) - ) + join_condition = (events.person_id == eras.person_id) & (events.start_date >= eras.era_start) & (events.start_date <= eras.era_end) joined = events.join(eras, join_condition, how="inner") if not joined.columns: return events.limit(0) - supplemental = [ - joined[column] - for column in ("observation_period_start_date", "observation_period_end_date") - if column in joined.columns - ] + supplemental = [joined[column] for column in ("observation_period_start_date", "observation_period_end_date") if column in joined.columns] return joined.select( joined.person_id, joined.event_id, @@ -777,9 +714,7 @@ def _exposure_query(concept_column: str) -> ir.Table: ) -def _drug_exposure_end( - drug_exposure: ir.Table, strategy: CustomEraStrategy -) -> ir.Value: +def _drug_exposure_end(drug_exposure: ir.Table, strategy: CustomEraStrategy) -> ir.Value: start = drug_exposure.drug_exposure_start_date if strategy.days_supply_override is not None: return start + ibis.interval(days=int(strategy.days_supply_override)) diff --git a/circe/execution/builders/condition_era.py b/circe/execution/builders/condition_era.py index 277e90ae..259e8198 100644 --- a/circe/execution/builders/condition_era.py +++ b/circe/execution/builders/condition_era.py @@ -20,26 +20,16 @@ def build_condition_era(criteria: ConditionEra, ctx: BuildContext): table = ctx.table("condition_era") - table = apply_codeset_filter( - table, "condition_concept_id", criteria.codeset_id, ctx - ) + table = apply_codeset_filter(table, "condition_concept_id", criteria.codeset_id, ctx) table = apply_date_range(table, "condition_era_start_date", criteria.era_start_date) table = apply_date_range(table, "condition_era_end_date", criteria.era_end_date) - table = apply_numeric_range( - table, "condition_occurrence_count", criteria.occurrence_count - ) - table = apply_interval_range( - table, "condition_era_start_date", "condition_era_end_date", criteria.era_length - ) + table = apply_numeric_range(table, "condition_occurrence_count", criteria.occurrence_count) + table = apply_interval_range(table, "condition_era_start_date", "condition_era_end_date", criteria.era_length) if criteria.age_at_start: - table = apply_age_filter( - table, criteria.age_at_start, ctx, "condition_era_start_date" - ) + table = apply_age_filter(table, criteria.age_at_start, ctx, "condition_era_start_date") if criteria.age_at_end: - table = apply_age_filter( - table, criteria.age_at_end, ctx, "condition_era_end_date" - ) + table = apply_age_filter(table, criteria.age_at_end, ctx, "condition_era_end_date") table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) diff --git a/circe/execution/builders/condition_occurrence.py b/circe/execution/builders/condition_occurrence.py index 29b041be..73129f26 100644 --- a/circe/execution/builders/condition_occurrence.py +++ b/circe/execution/builders/condition_occurrence.py @@ -24,16 +24,10 @@ def build_condition_occurrence(criteria: ConditionOccurrence, ctx: BuildContext) concept_column = criteria.get_concept_id_column() table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) if criteria.first: - table = apply_first_event( - table, criteria.get_start_date_column(), criteria.get_primary_key_column() - ) + table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - table = apply_date_range( - table, criteria.get_start_date_column(), criteria.occurrence_start_date - ) - table = apply_date_range( - table, criteria.get_end_date_column(), criteria.occurrence_end_date - ) + table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) + table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) table = apply_concept_criteria( table, @@ -53,9 +47,7 @@ def build_condition_occurrence(criteria: ConditionOccurrence, ctx: BuildContext) ) if criteria.age: - table = apply_age_filter( - table, criteria.age, ctx, criteria.get_start_date_column() - ) + table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) source_filter = getattr(criteria, "condition_source_concept", None) @@ -70,9 +62,7 @@ def build_condition_occurrence(criteria: ConditionOccurrence, ctx: BuildContext) ) visit_source = getattr(criteria, "visit_source_concept", None) - needs_visit_filters = bool( - criteria.visit_type or criteria.visit_type_cs or visit_source is not None - ) + needs_visit_filters = bool(criteria.visit_type or criteria.visit_type_cs or visit_source is not None) if needs_visit_filters: visit = ctx.table("visit_occurrence").select( "person_id", @@ -82,12 +72,9 @@ def build_condition_occurrence(criteria: ConditionOccurrence, ctx: BuildContext) ) table = table.join( visit, - (table.visit_occurrence_id == visit.visit_occurrence_id) - & (table.person_id == visit.person_id), - ) - table = apply_visit_concept_filters( - table, criteria.visit_type, criteria.visit_type_cs, ctx + (table.visit_occurrence_id == visit.visit_occurrence_id) & (table.person_id == visit.person_id), ) + table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) if visit_source is not None: table = table.filter(table.visit_source_concept_id == int(visit_source)) diff --git a/circe/execution/builders/death.py b/circe/execution/builders/death.py index ff2a76f7..847398db 100644 --- a/circe/execution/builders/death.py +++ b/circe/execution/builders/death.py @@ -22,9 +22,7 @@ def build_death(criteria: Death, ctx: BuildContext): table = apply_codeset_filter(table, "cause_concept_id", criteria.codeset_id, ctx) - table = apply_date_range( - table, "death_date", getattr(criteria, "occurrence_start_date", None) - ) + table = apply_date_range(table, "death_date", getattr(criteria, "occurrence_start_date", None)) table = apply_concept_criteria( table, @@ -44,9 +42,7 @@ def build_death(criteria: Death, ctx: BuildContext): ) if criteria.age: - table = apply_age_filter( - table, criteria.age, ctx, criteria.get_start_date_column() - ) + table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) window = ibis.window(order_by=[table.person_id, table.death_date]) diff --git a/circe/execution/builders/device_exposure.py b/circe/execution/builders/device_exposure.py index 9ef9cffd..ac34fdf0 100644 --- a/circe/execution/builders/device_exposure.py +++ b/circe/execution/builders/device_exposure.py @@ -26,12 +26,8 @@ def build_device_exposure(criteria: DeviceExposure, ctx: BuildContext): concept_column = criteria.get_concept_id_column() table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) - table = apply_date_range( - table, criteria.get_start_date_column(), criteria.occurrence_start_date - ) - table = apply_date_range( - table, criteria.get_end_date_column(), criteria.occurrence_end_date - ) + table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) + table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) table = apply_concept_criteria( table, @@ -43,14 +39,10 @@ def build_device_exposure(criteria: DeviceExposure, ctx: BuildContext): ) table = apply_numeric_range(table, "quantity", criteria.quantity) - table = apply_text_filter( - table, "unique_device_id", getattr(criteria, "unique_device_id", None) - ) + table = apply_text_filter(table, "unique_device_id", getattr(criteria, "unique_device_id", None)) if criteria.age: - table = apply_age_filter( - table, criteria.age, ctx, criteria.get_start_date_column() - ) + table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) table = apply_provider_specialty_filter( table, @@ -59,9 +51,7 @@ def build_device_exposure(criteria: DeviceExposure, ctx: BuildContext): ctx, provider_column="provider_id", ) - table = apply_visit_concept_filters( - table, criteria.visit_type, criteria.visit_type_cs, ctx - ) + table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) if criteria.device_source_concept is not None: table = apply_codeset_filter( table, @@ -71,9 +61,7 @@ def build_device_exposure(criteria: DeviceExposure, ctx: BuildContext): ) if criteria.first: - table = apply_first_event( - table, criteria.get_start_date_column(), criteria.get_primary_key_column() - ) + table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) events = standardize_output( table, diff --git a/circe/execution/builders/dose_era.py b/circe/execution/builders/dose_era.py index c70ea978..6aa0f469 100644 --- a/circe/execution/builders/dose_era.py +++ b/circe/execution/builders/dose_era.py @@ -34,14 +34,10 @@ def build_dose_era(criteria: DoseEra, ctx: BuildContext): ) table = apply_numeric_range(table, "dose_value", criteria.dose_value) - table = apply_interval_range( - table, "dose_era_start_date", "dose_era_end_date", criteria.era_length - ) + table = apply_interval_range(table, "dose_era_start_date", "dose_era_end_date", criteria.era_length) if criteria.age_at_start: - table = apply_age_filter( - table, criteria.age_at_start, ctx, "dose_era_start_date" - ) + table = apply_age_filter(table, criteria.age_at_start, ctx, "dose_era_start_date") if criteria.age_at_end: table = apply_age_filter(table, criteria.age_at_end, ctx, "dose_era_end_date") table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) diff --git a/circe/execution/builders/drug_era.py b/circe/execution/builders/drug_era.py index 5a65b0e3..f2e99a03 100644 --- a/circe/execution/builders/drug_era.py +++ b/circe/execution/builders/drug_era.py @@ -25,14 +25,10 @@ def build_drug_era(criteria: DrugEra, ctx: BuildContext): table = apply_date_range(table, "drug_era_end_date", criteria.era_end_date) table = apply_numeric_range(table, "drug_exposure_count", criteria.occurrence_count) table = apply_numeric_range(table, "gap_days", criteria.gap_days) - table = apply_interval_range( - table, "drug_era_start_date", "drug_era_end_date", criteria.era_length - ) + table = apply_interval_range(table, "drug_era_start_date", "drug_era_end_date", criteria.era_length) if criteria.age_at_start: - table = apply_age_filter( - table, criteria.age_at_start, ctx, "drug_era_start_date" - ) + table = apply_age_filter(table, criteria.age_at_start, ctx, "drug_era_start_date") if criteria.age_at_end: table = apply_age_filter(table, criteria.age_at_end, ctx, "drug_era_end_date") diff --git a/circe/execution/builders/drug_exposure.py b/circe/execution/builders/drug_exposure.py index d00fb27e..665f9f4c 100644 --- a/circe/execution/builders/drug_exposure.py +++ b/circe/execution/builders/drug_exposure.py @@ -27,16 +27,10 @@ def build_drug_exposure(criteria: DrugExposure, ctx: BuildContext): concept_column = criteria.get_concept_id_column() table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) if criteria.first: - table = apply_first_event( - table, criteria.get_start_date_column(), criteria.get_primary_key_column() - ) + table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - table = apply_date_range( - table, criteria.get_start_date_column(), criteria.occurrence_start_date - ) - table = apply_date_range( - table, criteria.get_end_date_column(), criteria.occurrence_end_date - ) + table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) + table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) table = apply_concept_criteria( table, @@ -64,17 +58,11 @@ def build_drug_exposure(criteria: DrugExposure, ctx: BuildContext): table = apply_numeric_range(table, "quantity", criteria.quantity) table = apply_numeric_range(table, "days_supply", criteria.days_supply) table = apply_numeric_range(table, "refills", criteria.refills) - table = apply_text_filter( - table, "stop_reason", getattr(criteria, "stop_reason", None) - ) - table = apply_text_filter( - table, "lot_number", getattr(criteria, "lot_number", None) - ) + table = apply_text_filter(table, "stop_reason", getattr(criteria, "stop_reason", None)) + table = apply_text_filter(table, "lot_number", getattr(criteria, "lot_number", None)) if criteria.age: - table = apply_age_filter( - table, criteria.age, ctx, criteria.get_start_date_column() - ) + table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) table = apply_provider_specialty_filter( table, @@ -83,9 +71,7 @@ def build_drug_exposure(criteria: DrugExposure, ctx: BuildContext): ctx, provider_column="provider_id", ) - table = apply_visit_concept_filters( - table, criteria.visit_type, criteria.visit_type_cs, ctx - ) + table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) source_filter = getattr(criteria, "drug_source_concept", None) selection = coerce_concept_set_selection(source_filter) diff --git a/circe/execution/builders/groups.py b/circe/execution/builders/groups.py index 6edf30a8..79b1ff60 100644 --- a/circe/execution/builders/groups.py +++ b/circe/execution/builders/groups.py @@ -31,18 +31,14 @@ from .registry import build_events -def apply_criteria_group( - events: ir.Table, group: CriteriaGroup | None, ctx: BuildContext -) -> ir.Table: +def apply_criteria_group(events: ir.Table, group: CriteriaGroup | None, ctx: BuildContext) -> ir.Table: mask = _group_mask(events, group, ctx) if mask is None: return events return events.filter(mask) -def _correlated_mask( - events: ir.Table, correlated: CorrelatedCriteria, ctx: BuildContext -) -> ir.Value: +def _correlated_mask(events: ir.Table, correlated: CorrelatedCriteria, ctx: BuildContext) -> ir.Value: criteria_model = correlated.criteria if criteria_model and not isinstance(criteria_model, ir.Expr): criteria_model = parse_single_criteria(criteria_model) @@ -68,8 +64,7 @@ def _correlated_mask( index_events = events if not correlated.ignore_observation_period: missing_observation_bounds = ( - "observation_period_start_date" not in index_events.columns - or "observation_period_end_date" not in index_events.columns + "observation_period_start_date" not in index_events.columns or "observation_period_end_date" not in index_events.columns ) if missing_observation_bounds: zero_window = zero_window or ObservationFilter(prior_days=0, post_days=0) @@ -82,9 +77,7 @@ def _correlated_mask( base_events.end_date.name("_corr_end_date"), ] if "visit_occurrence_id" in base_events.columns: - select_fields.append( - base_events.visit_occurrence_id.name("_corr_visit_occurrence_id") - ) + select_fields.append(base_events.visit_occurrence_id.name("_corr_visit_occurrence_id")) if count_column_name and count_column_name in base_events.columns: select_fields.append(base_events[count_column_name]) @@ -92,23 +85,12 @@ def _correlated_mask( join_condition = index_events.person_id == criteria_events.person_id if not correlated.ignore_observation_period: if "observation_period_start_date" in index_events.columns: - join_condition &= ( - criteria_events._corr_start_date - >= index_events.observation_period_start_date - ) + join_condition &= criteria_events._corr_start_date >= index_events.observation_period_start_date if "observation_period_end_date" in index_events.columns: - join_condition &= ( - criteria_events._corr_start_date - <= index_events.observation_period_end_date - ) + join_condition &= criteria_events._corr_start_date <= index_events.observation_period_end_date if requires_corr_end_alignment: - join_condition &= ( - criteria_events._corr_end_date - <= index_events.observation_period_end_date - ) - window_condition = _build_window_condition( - index_events, criteria_events, correlated - ) + join_condition &= criteria_events._corr_end_date <= index_events.observation_period_end_date + window_condition = _build_window_condition(index_events, criteria_events, correlated) if window_condition is not None: join_condition &= window_condition @@ -121,17 +103,11 @@ def _correlated_mask( if correlated.restrict_visit is None and isinstance(criteria_model, VisitDetail): require_same_visit = True - if require_same_visit and ( - "visit_occurrence_id" in index_events.columns - and "_corr_visit_occurrence_id" in criteria_events.columns - ): + if require_same_visit and ("visit_occurrence_id" in index_events.columns and "_corr_visit_occurrence_id" in criteria_events.columns): join_condition &= ( index_events.visit_occurrence_id.notnull() & criteria_events._corr_visit_occurrence_id.notnull() - & ( - index_events.visit_occurrence_id - == criteria_events._corr_visit_occurrence_id - ) + & (index_events.visit_occurrence_id == criteria_events._corr_visit_occurrence_id) ) joined = index_events.join(criteria_events, join_condition, how="left") @@ -150,19 +126,13 @@ def _correlated_mask( else: aggregator = joined._corr_match_value.count() - aggregated = joined.group_by(joined.person_id, joined.event_id).aggregate( - match_count=aggregator - ) + aggregated = joined.group_by(joined.person_id, joined.event_id).aggregate(match_count=aggregator) predicate = _occurrence_predicate(aggregated.match_count, correlated.occurrence) - matching_ids = ( - aggregated.filter(predicate).select("person_id", "event_id").distinct() - ) + matching_ids = aggregated.filter(predicate).select("person_id", "event_id").distinct() return _event_membership_mask(events, matching_ids) -def _group_mask( - events: ir.Table, group: CriteriaGroup | None, ctx: BuildContext -) -> ir.Value | None: +def _group_mask(events: ir.Table, group: CriteriaGroup | None, ctx: BuildContext) -> ir.Value | None: if not group or group.is_empty(): return None @@ -210,13 +180,9 @@ def _combine_any(masks: list[ir.Value]) -> ir.Value: return combined -def _combine_threshold( - masks: list[ir.Value], threshold: int, *, at_least: bool -) -> ir.Value: +def _combine_threshold(masks: list[ir.Value], threshold: int, *, at_least: bool) -> ir.Value: def _to_int(mask: ir.Value) -> ir.Value: - return ibis.ifelse( - mask, ibis.literal(1, type="int64"), ibis.literal(0, type="int64") - ) + return ibis.ifelse(mask, ibis.literal(1, type="int64"), ibis.literal(0, type="int64")) total = _to_int(masks[0]) for mask in masks[1:]: @@ -224,9 +190,7 @@ def _to_int(mask: ir.Value) -> ir.Value: return total >= threshold if at_least else total <= threshold -def _demographic_mask( - events: ir.Table, demographic: DemoGraphicCriteria, ctx: BuildContext -) -> ir.Value | None: +def _demographic_mask(events: ir.Table, demographic: DemoGraphicCriteria, ctx: BuildContext) -> ir.Value | None: if demographic is None: return None @@ -236,29 +200,19 @@ def _demographic_mask( filtered = apply_age_filter(filtered, demographic.age, ctx, "start_date") applied = True if demographic.gender or demographic.gender_cs: - filtered = apply_gender_filter( - filtered, demographic.gender, demographic.gender_cs, ctx - ) + filtered = apply_gender_filter(filtered, demographic.gender, demographic.gender_cs, ctx) applied = True if demographic.race or demographic.race_cs: - filtered = apply_race_filter( - filtered, demographic.race, demographic.race_cs, ctx - ) + filtered = apply_race_filter(filtered, demographic.race, demographic.race_cs, ctx) applied = True if demographic.ethnicity or demographic.ethnicity_cs: - filtered = apply_ethnicity_filter( - filtered, demographic.ethnicity, demographic.ethnicity_cs, ctx - ) + filtered = apply_ethnicity_filter(filtered, demographic.ethnicity, demographic.ethnicity_cs, ctx) applied = True if demographic.occurrence_start_date: - filtered = apply_date_range( - filtered, "start_date", demographic.occurrence_start_date - ) + filtered = apply_date_range(filtered, "start_date", demographic.occurrence_start_date) applied = True if demographic.occurrence_end_date: - filtered = apply_date_range( - filtered, "end_date", demographic.occurrence_end_date - ) + filtered = apply_date_range(filtered, "end_date", demographic.occurrence_end_date) applied = True if not applied: @@ -274,11 +228,7 @@ def _event_membership_mask(events: ir.Table, ids: ir.Table) -> ir.Value: def _event_key_expr(table: ir.Table) -> ir.Value: - return ( - table.person_id.cast("string") - + ibis.literal(":") - + table.event_id.cast("string") - ) + return table.person_id.cast("string") + ibis.literal(":") + table.event_id.cast("string") def _occurrence_predicate(count_expr: ir.Value, occurrence) -> ir.Value: @@ -298,9 +248,7 @@ def _occurrence_predicate(count_expr: ir.Value, occurrence) -> ir.Value: return count_expr > 0 -def _build_window_condition( - index_events: ir.Table, correlated_events: ir.Table, correlated: CorrelatedCriteria -) -> ir.Value: +def _build_window_condition(index_events: ir.Table, correlated_events: ir.Table, correlated: CorrelatedCriteria) -> ir.Value: cond = ibis.literal(True) if correlated.start_window: @@ -357,11 +305,7 @@ def _apply_endpoint_anchor( *, default_to_index_end: bool = False, ): - anchor = ( - events.end_date - if (use_index_end or (use_index_end is None and default_to_index_end)) - else events.start_date - ) + anchor = events.end_date if (use_index_end or (use_index_end is None and default_to_index_end)) else events.start_date if not endpoint or endpoint.days is None: return None days = ibis.interval(days=int(endpoint.days)) @@ -395,9 +339,7 @@ def _correlated_window_value( _COUNT_COLUMN_SOURCES: dict[CriteriaColumn, Callable[[Criteria], str]] = { CriteriaColumn.DOMAIN_CONCEPT: lambda criteria: criteria.get_concept_id_column(), - CriteriaColumn.DOMAIN_SOURCE_CONCEPT: lambda criteria: _source_concept_column( - criteria - ), + CriteriaColumn.DOMAIN_SOURCE_CONCEPT: lambda criteria: _source_concept_column(criteria), } @@ -464,9 +406,7 @@ def _attach_count_columns( domain_table[primary_key].name("_corr_join_key"), domain_table[source_column].name(count_column_name), ) - augmented = events.join( - lookup, events.event_id == lookup._corr_join_key, how="left" - ) + augmented = events.join(lookup, events.event_id == lookup._corr_join_key, how="left") base_columns = events.columns projection = [augmented[name] for name in base_columns if name in augmented.columns] projection.append(augmented[count_column_name]) diff --git a/circe/execution/builders/measurement.py b/circe/execution/builders/measurement.py index 6c20e15e..2659b519 100644 --- a/circe/execution/builders/measurement.py +++ b/circe/execution/builders/measurement.py @@ -26,16 +26,10 @@ def build_measurement(criteria: Measurement, ctx: BuildContext): concept_column = criteria.get_concept_id_column() table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) if criteria.first: - table = apply_first_event( - table, criteria.get_start_date_column(), criteria.get_primary_key_column() - ) + table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - table = apply_date_range( - table, criteria.get_start_date_column(), criteria.occurrence_start_date - ) - table = apply_date_range( - table, criteria.get_end_date_column(), criteria.occurrence_end_date - ) + table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) + table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) table = apply_concept_criteria( table, @@ -63,9 +57,7 @@ def build_measurement(criteria: Measurement, ctx: BuildContext): selection=None, ctx=ctx, ) - table, value_column = _maybe_normalize_units( - table, criteria.unit, criteria.value_as_number - ) + table, value_column = _maybe_normalize_units(table, criteria.unit, criteria.value_as_number) table = apply_concept_criteria( table, column="unit_concept_id", @@ -94,9 +86,7 @@ def build_measurement(criteria: Measurement, ctx: BuildContext): denom = ibis.ifelse(table.range_high == 0, ibis.null(), table.range_high) ratio = (table.value_as_number / denom).name("_range_high_ratio") table = table.mutate(_range_high_ratio=ratio) - table = apply_numeric_range( - table, "_range_high_ratio", criteria.range_high_ratio - ) + table = apply_numeric_range(table, "_range_high_ratio", criteria.range_high_ratio) if getattr(criteria, "abnormal", None): abnormal_predicate = ( @@ -107,9 +97,7 @@ def build_measurement(criteria: Measurement, ctx: BuildContext): table = table.filter(abnormal_predicate) if criteria.age: - table = apply_age_filter( - table, criteria.age, ctx, criteria.get_start_date_column() - ) + table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) table = apply_provider_specialty_filter( table, @@ -118,9 +106,7 @@ def build_measurement(criteria: Measurement, ctx: BuildContext): ctx, provider_column="provider_id", ) - table = apply_visit_concept_filters( - table, criteria.visit_type, criteria.visit_type_cs, ctx - ) + table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) if criteria.measurement_source_concept is not None: table = apply_codeset_filter( table, @@ -150,9 +136,7 @@ def _maybe_normalize_units(table, units, value_range): - For cell counts, only normalize when the numeric range appears to be in the canonical 10^9/L scale. Heuristic: upper bound <= 100. """ - unit_ids = [ - concept.concept_id for concept in units if concept.concept_id is not None - ] + unit_ids = [concept.concept_id for concept in units if concept.concept_id is not None] if not unit_ids: return table, "value_as_number" if not all(unit_id in _UNIT_NORMALIZATION for unit_id in unit_ids): diff --git a/circe/execution/builders/observation.py b/circe/execution/builders/observation.py index 9b100c26..93dff5bc 100644 --- a/circe/execution/builders/observation.py +++ b/circe/execution/builders/observation.py @@ -22,16 +22,10 @@ @register("Observation") def build_observation(criteria: Observation, ctx: BuildContext): table = ctx.table("observation") - table = apply_codeset_filter( - table, criteria.get_concept_id_column(), criteria.codeset_id, ctx - ) + table = apply_codeset_filter(table, criteria.get_concept_id_column(), criteria.codeset_id, ctx) - table = apply_date_range( - table, criteria.get_start_date_column(), criteria.occurrence_start_date - ) - table = apply_date_range( - table, criteria.get_end_date_column(), criteria.occurrence_end_date - ) + table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) + table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) table = apply_concept_criteria( table, @@ -70,9 +64,7 @@ def build_observation(criteria: Observation, ctx: BuildContext): table = apply_text_filter(table, "value_as_string", criteria.value_as_string) if criteria.age: - table = apply_age_filter( - table, criteria.age, ctx, criteria.get_start_date_column() - ) + table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) table = apply_provider_specialty_filter( table, @@ -81,9 +73,7 @@ def build_observation(criteria: Observation, ctx: BuildContext): ctx, provider_column="provider_id", ) - table = apply_visit_concept_filters( - table, criteria.visit_type, criteria.visit_type_cs, ctx - ) + table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) if criteria.observation_source_concept is not None: table = apply_codeset_filter( table, @@ -93,9 +83,7 @@ def build_observation(criteria: Observation, ctx: BuildContext): ) if criteria.first: - table = apply_first_event( - table, criteria.get_start_date_column(), criteria.get_primary_key_column() - ) + table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) events = standardize_output( table, diff --git a/circe/execution/builders/observation_period.py b/circe/execution/builders/observation_period.py index 33e73f64..e5e396f2 100644 --- a/circe/execution/builders/observation_period.py +++ b/circe/execution/builders/observation_period.py @@ -19,12 +19,8 @@ def build_observation_period(criteria: ObservationPeriod, ctx: BuildContext): table = ctx.table("observation_period") - table = apply_date_range( - table, "observation_period_start_date", criteria.period_start_date - ) - table = apply_date_range( - table, "observation_period_end_date", criteria.period_end_date - ) + table = apply_date_range(table, "observation_period_start_date", criteria.period_start_date) + table = apply_date_range(table, "observation_period_end_date", criteria.period_end_date) table = apply_concept_criteria( table, @@ -42,13 +38,9 @@ def build_observation_period(criteria: ObservationPeriod, ctx: BuildContext): ) if criteria.age_at_start: - table = apply_age_filter( - table, criteria.age_at_start, ctx, "observation_period_start_date" - ) + table = apply_age_filter(table, criteria.age_at_start, ctx, "observation_period_start_date") if criteria.age_at_end: - table = apply_age_filter( - table, criteria.age_at_end, ctx, "observation_period_end_date" - ) + table = apply_age_filter(table, criteria.age_at_end, ctx, "observation_period_end_date") table, start_column, end_column = apply_user_defined_period( table, diff --git a/circe/execution/builders/payer_plan_period.py b/circe/execution/builders/payer_plan_period.py index c4b3a063..cd85a860 100644 --- a/circe/execution/builders/payer_plan_period.py +++ b/circe/execution/builders/payer_plan_period.py @@ -20,12 +20,8 @@ def build_payer_plan_period(criteria: PayerPlanPeriod, ctx: BuildContext): table = ctx.table("payer_plan_period") - table = apply_date_range( - table, "payer_plan_period_start_date", criteria.period_start_date - ) - table = apply_date_range( - table, "payer_plan_period_end_date", criteria.period_end_date - ) + table = apply_date_range(table, "payer_plan_period_start_date", criteria.period_start_date) + table = apply_date_range(table, "payer_plan_period_end_date", criteria.period_end_date) table = apply_interval_range( table, @@ -35,36 +31,20 @@ def build_payer_plan_period(criteria: PayerPlanPeriod, ctx: BuildContext): ) if criteria.age_at_start: - table = apply_age_filter( - table, criteria.age_at_start, ctx, "payer_plan_period_start_date" - ) + table = apply_age_filter(table, criteria.age_at_start, ctx, "payer_plan_period_start_date") if criteria.age_at_end: - table = apply_age_filter( - table, criteria.age_at_end, ctx, "payer_plan_period_end_date" - ) + table = apply_age_filter(table, criteria.age_at_end, ctx, "payer_plan_period_end_date") table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) table = apply_codeset_filter(table, "payer_concept_id", criteria.payer_concept, ctx) table = apply_codeset_filter(table, "plan_concept_id", criteria.plan_concept, ctx) - table = apply_codeset_filter( - table, "sponsor_concept_id", criteria.sponsor_concept, ctx - ) - table = apply_codeset_filter( - table, "stop_reason_concept_id", criteria.stop_reason_concept, ctx - ) - table = apply_codeset_filter( - table, "payer_source_concept_id", criteria.payer_source_concept, ctx - ) - table = apply_codeset_filter( - table, "plan_source_concept_id", criteria.plan_source_concept, ctx - ) - table = apply_codeset_filter( - table, "sponsor_source_concept_id", criteria.sponsor_source_concept, ctx - ) - table = apply_codeset_filter( - table, "stop_reason_source_concept_id", criteria.stop_reason_source_concept, ctx - ) + table = apply_codeset_filter(table, "sponsor_concept_id", criteria.sponsor_concept, ctx) + table = apply_codeset_filter(table, "stop_reason_concept_id", criteria.stop_reason_concept, ctx) + table = apply_codeset_filter(table, "payer_source_concept_id", criteria.payer_source_concept, ctx) + table = apply_codeset_filter(table, "plan_source_concept_id", criteria.plan_source_concept, ctx) + table = apply_codeset_filter(table, "sponsor_source_concept_id", criteria.sponsor_source_concept, ctx) + table = apply_codeset_filter(table, "stop_reason_source_concept_id", criteria.stop_reason_source_concept, ctx) table, start_column, end_column = apply_user_defined_period( table, diff --git a/circe/execution/builders/pipeline.py b/circe/execution/builders/pipeline.py index cddf4c96..14a86d7f 100644 --- a/circe/execution/builders/pipeline.py +++ b/circe/execution/builders/pipeline.py @@ -44,9 +44,7 @@ def _maybe_materialize(table: ir.Table, label: str) -> ir.Table: if ctx.should_materialize_stages(): materialized: list[ir.Table] = [] for idx, table in enumerate(event_tables, start=1): - materialized.append( - ctx.maybe_materialize(table, label=f"primary_src_{idx}", analyze=True) - ) + materialized.append(ctx.maybe_materialize(table, label=f"primary_src_{idx}", analyze=True)) event_tables = materialized events = event_tables[0] for table in event_tables[1:]: @@ -71,9 +69,7 @@ def _maybe_materialize(table: ir.Table, label: str) -> ir.Table: events = apply_criteria_group(events, expression.additional_criteria, ctx) if expression.additional_criteria: - events = ctx.maybe_materialize( - events, label="additional_criteria", analyze=True - ) + events = ctx.maybe_materialize(events, label="additional_criteria", analyze=True) events = apply_inclusion_rules(events, expression.inclusion_rules, ctx) if expression.inclusion_rules: @@ -97,9 +93,7 @@ def _maybe_materialize(table: ir.Table, label: str) -> ir.Table: return events -def build_primary_events_polars( - expression: CohortExpression, ctx: BuildContext -) -> pl.DataFrame: +def build_primary_events_polars(expression: CohortExpression, ctx: BuildContext) -> pl.DataFrame: events = build_primary_events(expression, ctx) if events is None: return pl.DataFrame(schema=OUTPUT_SCHEMA) @@ -118,11 +112,7 @@ def _assign_primary_event_ids(events): event_id=(person_rank + 1), _person_ordinal=(person_rank + 1), ) - supplemental = [ - events[column] - for column in ("observation_period_start_date", "observation_period_end_date") - if column in events.columns - ] + supplemental = [events[column] for column in ("observation_period_start_date", "observation_period_end_date") if column in events.columns] return events.select( events.person_id, events.event_id, diff --git a/circe/execution/builders/post_processing.py b/circe/execution/builders/post_processing.py index 5523ec96..8dd3b292 100644 --- a/circe/execution/builders/post_processing.py +++ b/circe/execution/builders/post_processing.py @@ -13,9 +13,7 @@ def apply_additional_criteria(events: ir.Table, group, ctx: BuildContext) -> ir. return apply_criteria_group(events, group, ctx) -def apply_inclusion_rules( - events: ir.Table, rules: list[InclusionRule], ctx: BuildContext -) -> ir.Table: +def apply_inclusion_rules(events: ir.Table, rules: list[InclusionRule], ctx: BuildContext) -> ir.Table: if not rules: return events @@ -55,19 +53,13 @@ def apply_inclusion_rules( mask = mask.filter((mask._rule_mask & target_literal) == target_literal) filtered_ids = base_events.inner_join(mask, ["person_id", "event_id"]) - return events.inner_join(filtered_ids, ["person_id", "event_id"]).select( - events.columns - ) + return events.inner_join(filtered_ids, ["person_id", "event_id"]).select(events.columns) -def apply_censoring( - events: ir.Table, criteria_list: list[Criteria], ctx: BuildContext -) -> ir.Table: +def apply_censoring(events: ir.Table, criteria_list: list[Criteria], ctx: BuildContext) -> ir.Table: if not criteria_list: return events - censor_tables = [ - build_events(criteria, ctx) for criteria in criteria_list if criteria - ] + censor_tables = [build_events(criteria, ctx) for criteria in criteria_list if criteria] if not censor_tables: return events censor_events = censor_tables[0] @@ -80,18 +72,14 @@ def apply_censoring( ) joined = events.join( censor_events, - (events.person_id == censor_events.person_id) - & (censor_events.censor_start >= events.start_date), + (events.person_id == censor_events.person_id) & (censor_events.censor_start >= events.start_date), how="left", ) - min_censor = joined.group_by(joined.person_id, joined.event_id).aggregate( - censor_date=joined.censor_start.min() - ) + min_censor = joined.group_by(joined.person_id, joined.event_id).aggregate(censor_date=joined.censor_start.min()) event_columns = events.columns events = events.left_join( min_censor, - (events.person_id == min_censor.person_id) - & (events.event_id == min_censor.event_id), + (events.person_id == min_censor.person_id) & (events.event_id == min_censor.event_id), ) events = events.select(*event_columns, min_censor.censor_date) events = events.mutate( diff --git a/circe/execution/builders/procedure_occurrence.py b/circe/execution/builders/procedure_occurrence.py index a7425ff0..20584e5f 100644 --- a/circe/execution/builders/procedure_occurrence.py +++ b/circe/execution/builders/procedure_occurrence.py @@ -25,16 +25,10 @@ def build_procedure_occurrence(criteria: ProcedureOccurrence, ctx: BuildContext) concept_column = criteria.get_concept_id_column() table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) if criteria.first: - table = apply_first_event( - table, criteria.get_start_date_column(), criteria.get_primary_key_column() - ) + table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - table = apply_date_range( - table, criteria.get_start_date_column(), criteria.occurrence_start_date - ) - table = apply_date_range( - table, criteria.get_end_date_column(), criteria.occurrence_end_date - ) + table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) + table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) table = apply_concept_criteria( table, @@ -56,9 +50,7 @@ def build_procedure_occurrence(criteria: ProcedureOccurrence, ctx: BuildContext) table = apply_numeric_range(table, "quantity", criteria.quantity) if criteria.age: - table = apply_age_filter( - table, criteria.age, ctx, criteria.get_start_date_column() - ) + table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) table = apply_provider_specialty_filter( table, @@ -67,14 +59,10 @@ def build_procedure_occurrence(criteria: ProcedureOccurrence, ctx: BuildContext) ctx, provider_column="provider_id", ) - table = apply_visit_concept_filters( - table, criteria.visit_type, criteria.visit_type_cs, ctx - ) + table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) if criteria.procedure_source_concept is not None: - table = apply_codeset_filter( - table, "procedure_source_concept_id", criteria.procedure_source_concept, ctx - ) + table = apply_codeset_filter(table, "procedure_source_concept_id", criteria.procedure_source_concept, ctx) events = standardize_output( table, diff --git a/circe/execution/builders/visit_detail.py b/circe/execution/builders/visit_detail.py index 5f6fac59..58dff8cf 100644 --- a/circe/execution/builders/visit_detail.py +++ b/circe/execution/builders/visit_detail.py @@ -24,20 +24,12 @@ def build_visit_detail(criteria: VisitDetail, ctx: BuildContext): table = ctx.table("visit_detail") - table = apply_codeset_filter( - table, "visit_detail_concept_id", criteria.codeset_id, ctx - ) + table = apply_codeset_filter(table, "visit_detail_concept_id", criteria.codeset_id, ctx) if criteria.first: table = apply_first_event(table, "visit_detail_start_date", "visit_detail_id") - table = apply_date_range( - table, "visit_detail_start_date", criteria.visit_detail_start_date - ) - table = apply_date_range( - table, "visit_detail_end_date", criteria.visit_detail_end_date - ) - table = apply_concept_set_selection( - table, "visit_detail_type_concept_id", criteria.visit_detail_type_cs, ctx - ) + table = apply_date_range(table, "visit_detail_start_date", criteria.visit_detail_start_date) + table = apply_date_range(table, "visit_detail_end_date", criteria.visit_detail_end_date) + table = apply_concept_set_selection(table, "visit_detail_type_concept_id", criteria.visit_detail_type_cs, ctx) if criteria.visit_detail_source_concept is not None: table = apply_codeset_filter( table, diff --git a/circe/execution/builders/visit_occurrence.py b/circe/execution/builders/visit_occurrence.py index d9e873ee..1f2e8eff 100644 --- a/circe/execution/builders/visit_occurrence.py +++ b/circe/execution/builders/visit_occurrence.py @@ -25,12 +25,8 @@ def build_visit_occurrence(criteria: VisitOccurrence, ctx: BuildContext): concept_column = criteria.get_concept_id_column() table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) - table = apply_date_range( - table, criteria.get_start_date_column(), criteria.occurrence_start_date - ) - table = apply_date_range( - table, criteria.get_end_date_column(), criteria.occurrence_end_date - ) + table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) + table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) table = apply_concept_criteria( table, @@ -58,20 +54,14 @@ def build_visit_occurrence(criteria: VisitOccurrence, ctx: BuildContext): table = apply_numeric_range(table, "visit_length", criteria.visit_length) if criteria.age: - table = apply_age_filter( - table, criteria.age, ctx, criteria.get_start_date_column() - ) + table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) if criteria.visit_source_concept is not None: - table = apply_codeset_filter( - table, "visit_source_concept_id", criteria.visit_source_concept, ctx - ) + table = apply_codeset_filter(table, "visit_source_concept_id", criteria.visit_source_concept, ctx) if criteria.first: - table = apply_first_event( - table, criteria.get_start_date_column(), criteria.get_primary_key_column() - ) + table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) table = project_event_columns( table, diff --git a/circe/execution/criteria_compat.py b/circe/execution/criteria_compat.py index fb52f2b6..a63dfa91 100644 --- a/circe/execution/criteria_compat.py +++ b/circe/execution/criteria_compat.py @@ -152,9 +152,7 @@ def ensure_criteria_compat() -> None: "VisitDetail": VisitDetail, "PayerPlanPeriod": PayerPlanPeriod, } -CRITERIA_TYPE_MAP_CASEFOLD: dict[str, type[Criteria]] = { - name.casefold(): model for name, model in CRITERIA_TYPE_MAP.items() -} +CRITERIA_TYPE_MAP_CASEFOLD: dict[str, type[Criteria]] = {name.casefold(): model for name, model in CRITERIA_TYPE_MAP.items()} def parse_single_criteria(criteria_dict: Any) -> Criteria: diff --git a/circe/execution/ibis.py b/circe/execution/ibis.py index b526730a..d06821ba 100644 --- a/circe/execution/ibis.py +++ b/circe/execution/ibis.py @@ -45,18 +45,14 @@ def to_polars(self, expression: ExpressionInput) -> pl.DataFrame: """Execute cohort expression and collect to Polars.""" table = self.build(expression) if not hasattr(table, "to_polars"): - raise RuntimeError( - "The returned ibis table does not support to_polars() on this backend." - ) + raise RuntimeError("The returned ibis table does not support to_polars() on this backend.") return table.to_polars() def to_pandas(self, expression: ExpressionInput) -> pd.DataFrame: """Execute cohort expression and collect to pandas.""" table = self.build(expression) if not hasattr(table, "to_pandas"): - raise RuntimeError( - "The returned ibis table does not support to_pandas() on this backend." - ) + raise RuntimeError("The returned ibis table does not support to_pandas() on this backend.") return table.to_pandas() def write( @@ -71,20 +67,15 @@ def write( ) -> Any: """Persist cohort rows to a cohort table and return a backend table handle.""" if append and overwrite: - raise ValueError( - "`append=True` and `overwrite=True` cannot be used together." - ) + raise ValueError("`append=True` and `overwrite=True` cannot be used together.") cohort_expression = load_expression(expression) self.close() - events, ctx = self._build_with_context_native( - cohort_expression, cohort_id_override=cohort_id - ) + events, ctx = self._build_with_context_native(cohort_expression, cohort_id_override=cohort_id) self._open_contexts.append(ctx) return ctx.write_cohort_table( events, table_name=table, - database=schema_to_str(schema) - or schema_to_str(self._options.result_schema), + database=schema_to_str(schema) or schema_to_str(self._options.result_schema), overwrite=overwrite, append=append, ) @@ -117,9 +108,7 @@ def _build_native(self, cohort_expression: Any) -> Any: self._open_contexts.append(ctx) return events - def _build_with_context_native( - self, cohort_expression: Any, cohort_id_override: int | None = None - ) -> Any: + def _build_with_context_native(self, cohort_expression: Any, cohort_id_override: int | None = None) -> Any: try: from .build_context import ( BuildContext, @@ -139,11 +128,7 @@ def _build_with_context_native( cdm_schema=schema_to_str(self._options.cdm_schema), vocabulary_schema=schema_to_str(self._options.vocabulary_schema), result_schema=schema_to_str(self._options.result_schema), - cohort_id=( - cohort_id_override - if cohort_id_override is not None - else self._options.cohort_id - ), + cohort_id=(cohort_id_override if cohort_id_override is not None else self._options.cohort_id), materialize_stages=self._options.materialize_stages, materialize_codesets=self._options.materialize_codesets, temp_emulation_schema=schema_to_str(self._options.temp_emulation_schema), @@ -151,15 +136,11 @@ def _build_with_context_native( capture_sql=self._options.capture_sql, backend=backend, ) - resource = compile_codesets( - self._conn, cohort_expression.concept_sets or [], options - ) + resource = compile_codesets(self._conn, cohort_expression.concept_sets or [], options) ctx = BuildContext(self._conn, options, resource) events = build_primary_events(cohort_expression, ctx) if events is None: - raise RuntimeError( - "No primary events were generated for the supplied cohort expression." - ) + raise RuntimeError("No primary events were generated for the supplied cohort expression.") return events, ctx @staticmethod diff --git a/circe/execution/ibis_compat.py b/circe/execution/ibis_compat.py index d5f215f9..f6a885fd 100644 --- a/circe/execution/ibis_compat.py +++ b/circe/execution/ibis_compat.py @@ -22,9 +22,7 @@ def table_from_literal_list( """ values_list = list(values) if not values_list: - dummy = ops.DummyTable( - values=FrozenOrderedDict({column_name: ibis.null().cast(element_type).op()}) - ).to_expr() + dummy = ops.DummyTable(values=FrozenOrderedDict({column_name: ibis.null().cast(element_type).op()})).to_expr() return dummy.select(dummy[column_name]).filter(ibis.literal(False)) array_type = f"array<{element_type}>" diff --git a/circe/helper/cohort_modifiers.py b/circe/helper/cohort_modifiers.py index 55d36500..76bf8a70 100644 --- a/circe/helper/cohort_modifiers.py +++ b/circe/helper/cohort_modifiers.py @@ -348,9 +348,7 @@ def set_age_criteria( groups=[], ) else: - cohort_expression.additional_criteria.demographic_criteria_list.append( - demographic - ) + cohort_expression.additional_criteria.demographic_criteria_list.append(demographic) return cohort_expression @@ -423,9 +421,7 @@ def set_gender_criteria( groups=[], ) else: - cohort_expression.additional_criteria.demographic_criteria_list.append( - demographic - ) + cohort_expression.additional_criteria.demographic_criteria_list.append(demographic) return cohort_expression @@ -497,10 +493,7 @@ def set_end_date_strategy( ) else: - raise ValueError( - f"Unknown strategy '{strategy}'. " - "Expected 'fixed_duration', 'end_of_observation', or 'custom_era'." - ) + raise ValueError(f"Unknown strategy '{strategy}'. Expected 'fixed_duration', 'end_of_observation', or 'custom_era'.") return cohort_expression @@ -618,10 +611,7 @@ def set_clean_window( pc = cohort_expression.primary_criteria if pc is None or not pc.criteria_list: - raise ValueError( - "Cannot set a clean window without primary criteria. " - "Add at least one primary criterion first." - ) + raise ValueError("Cannot set a clean window without primary criteria. Add at least one primary criterion first.") # Remove any existing clean-window rule before adding a new one reset_clean_window(cohort_expression) @@ -661,10 +651,7 @@ def set_clean_window( rule = InclusionRule( name=_CLEAN_WINDOW_RULE_NAME, - description=( - f"Exclude events within {days} days of a prior qualifying event " - f"(criteria_mode={mode})" - ), + description=(f"Exclude events within {days} days of a prior qualifying event (criteria_mode={mode})"), expression=CriteriaGroup( type=group_type, criteria_list=correlated_list, @@ -691,11 +678,7 @@ def reset_clean_window( The modified *cohort_expression*. """ if cohort_expression.inclusion_rules: - cohort_expression.inclusion_rules = [ - r - for r in cohort_expression.inclusion_rules - if getattr(r, "name", None) != _CLEAN_WINDOW_RULE_NAME - ] + cohort_expression.inclusion_rules = [r for r in cohort_expression.inclusion_rules if getattr(r, "name", None) != _CLEAN_WINDOW_RULE_NAME] return cohort_expression @@ -823,9 +806,7 @@ def reset_age_criteria( """ if cohort_expression.additional_criteria is not None: cohort_expression.additional_criteria.demographic_criteria_list = [ - dc - for dc in cohort_expression.additional_criteria.demographic_criteria_list - if dc.age is None + dc for dc in cohort_expression.additional_criteria.demographic_criteria_list if dc.age is None ] return cohort_expression @@ -843,9 +824,7 @@ def reset_gender_criteria( """ if cohort_expression.additional_criteria is not None: cohort_expression.additional_criteria.demographic_criteria_list = [ - dc - for dc in cohort_expression.additional_criteria.demographic_criteria_list - if dc.gender is None + dc for dc in cohort_expression.additional_criteria.demographic_criteria_list if dc.gender is None ] return cohort_expression diff --git a/circe/io.py b/circe/io.py index 8e75ae7e..5a0a1bf5 100644 --- a/circe/io.py +++ b/circe/io.py @@ -52,11 +52,7 @@ def load_expression(value: ExpressionInput) -> CohortExpression: try: parsed = json.loads(stripped) except json.JSONDecodeError as exc: - raise ValueError( - "Expected JSON string or path to a JSON file for cohort expression input." - ) from exc + raise ValueError("Expected JSON string or path to a JSON file for cohort expression input.") from exc return CohortExpression.model_validate(parsed) - raise TypeError( - "Unsupported expression input type. Expected CohortExpression, mapping, JSON string, or Path." - ) + raise TypeError("Unsupported expression input type. Expected CohortExpression, mapping, JSON string, or Path.") diff --git a/circe/vocabulary/concept.py b/circe/vocabulary/concept.py index 6b44781e..797abbad 100644 --- a/circe/vocabulary/concept.py +++ b/circe/vocabulary/concept.py @@ -23,9 +23,7 @@ class Concept(BaseModel): concept_id: Optional[int] = Field( default=None, - validation_alias=AliasChoices( - "ConceptId", "CONCEPT_ID", "conceptId", "ConceptID" - ), + validation_alias=AliasChoices("ConceptId", "CONCEPT_ID", "conceptId", "ConceptID"), serialization_alias="CONCEPT_ID", ) concept_name: Optional[str] = Field( @@ -40,23 +38,17 @@ class Concept(BaseModel): ) concept_class_id: Optional[str] = Field( default=None, - validation_alias=AliasChoices( - "ConceptClassId", "CONCEPT_CLASS_ID", "conceptClassId" - ), + validation_alias=AliasChoices("ConceptClassId", "CONCEPT_CLASS_ID", "conceptClassId"), serialization_alias="CONCEPT_CLASS_ID", ) standard_concept: Optional[str] = Field( default=None, - validation_alias=AliasChoices( - "StandardConcept", "STANDARD_CONCEPT", "standardConcept" - ), + validation_alias=AliasChoices("StandardConcept", "STANDARD_CONCEPT", "standardConcept"), serialization_alias="STANDARD_CONCEPT", ) invalid_reason: Optional[str] = Field( default=None, - validation_alias=AliasChoices( - "InvalidReason", "INVALID_REASON", "invalidReason" - ), + validation_alias=AliasChoices("InvalidReason", "INVALID_REASON", "invalidReason"), serialization_alias="INVALID_REASON", ) domain_id: Optional[str] = Field( diff --git a/circe/vocabulary/concept_set_expression_query_builder.py b/circe/vocabulary/concept_set_expression_query_builder.py index 612a866e..e8c13cd4 100644 --- a/circe/vocabulary/concept_set_expression_query_builder.py +++ b/circe/vocabulary/concept_set_expression_query_builder.py @@ -19,9 +19,7 @@ class ConceptSetExpressionQueryBuilder: """ # SQL templates - equivalent to Java ResourceHelper.GetResourceAsString - CONCEPT_SET_QUERY_TEMPLATE = ( - "select concept_id from @vocabulary_database_schema.CONCEPT where @conceptIdIn" - ) + CONCEPT_SET_QUERY_TEMPLATE = "select concept_id from @vocabulary_database_schema.CONCEPT where @conceptIdIn" CONCEPT_SET_DESCENDANTS_TEMPLATE = """ select c.concept_id from @vocabulary_database_schema.CONCEPT c @@ -54,13 +52,9 @@ def get_concept_ids(self, concepts: list[Concept]) -> list[int]: Java equivalent: getConceptIds() """ - return [ - concept.concept_id for concept in concepts if concept.concept_id is not None - ] + return [concept.concept_id for concept in concepts if concept.concept_id is not None] - def build_concept_set_sub_query( - self, concepts: list[Concept], descendant_concepts: list[Concept] - ) -> str: + def build_concept_set_sub_query(self, concepts: list[Concept], descendant_concepts: list[Concept]) -> str: """Build concept set sub-query. Java equivalent: buildConceptSetSubQuery() @@ -69,39 +63,25 @@ def build_concept_set_sub_query( if concepts: concept_ids = self.get_concept_ids(concepts) - concept_id_in = BuilderUtils.split_in_clause( - "concept_id", concept_ids, self.MAX_IN_LENGTH - ) - query = self.CONCEPT_SET_QUERY_TEMPLATE.replace( - "@conceptIdIn", concept_id_in - ) + concept_id_in = BuilderUtils.split_in_clause("concept_id", concept_ids, self.MAX_IN_LENGTH) + query = self.CONCEPT_SET_QUERY_TEMPLATE.replace("@conceptIdIn", concept_id_in) queries.append(query) if descendant_concepts: descendant_ids = self.get_concept_ids(descendant_concepts) - concept_id_in = BuilderUtils.split_in_clause( - "ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH - ) - query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace( - "@conceptIdIn", concept_id_in - ) + concept_id_in = BuilderUtils.split_in_clause("ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH) + query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace("@conceptIdIn", concept_id_in) queries.append(query) return " UNION ".join(queries) - def build_concept_set_mapped_query( - self, mapped_concepts: list[Concept], mapped_descendant_concepts: list[Concept] - ) -> str: + def build_concept_set_mapped_query(self, mapped_concepts: list[Concept], mapped_descendant_concepts: list[Concept]) -> str: """Build concept set mapped query. Java equivalent: buildConceptSetMappedQuery() """ - concept_set_query = self.build_concept_set_sub_query( - mapped_concepts, mapped_descendant_concepts - ) - return self.CONCEPT_SET_MAPPED_TEMPLATE.replace( - "@conceptsetQuery", concept_set_query - ) + concept_set_query = self.build_concept_set_sub_query(mapped_concepts, mapped_descendant_concepts) + return self.CONCEPT_SET_MAPPED_TEMPLATE.replace("@conceptsetQuery", concept_set_query) def build_concept_set_query( self, @@ -115,18 +95,12 @@ def build_concept_set_query( Java equivalent: buildConceptSetQuery() """ if not concepts: - return ( - "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1" - ) + return "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1" - concept_set_query = self.build_concept_set_sub_query( - concepts, descendant_concepts - ) + concept_set_query = self.build_concept_set_sub_query(concepts, descendant_concepts) if mapped_concepts or mapped_descendant_concepts: - mapped_query = self.build_concept_set_mapped_query( - mapped_concepts, mapped_descendant_concepts - ) + mapped_query = self.build_concept_set_mapped_query(mapped_concepts, mapped_descendant_concepts) concept_set_query += " UNION " + mapped_query return concept_set_query diff --git a/cohort_definition.py b/cohort_definition.py index 6f63018f..3ec216cc 100644 --- a/cohort_definition.py +++ b/cohort_definition.py @@ -7,9 +7,7 @@ cohort = ( CohortBuilder("Fournier's Gangrene Cohort") .with_concept_sets({"id": 1, "name": "Fournier's Gangrene"}) - .with_condition( - 1 - ) # Entry event: Diagnosis of Fournier's Gangrene (Concept Set ID 1) + .with_condition(1) # Entry event: Diagnosis of Fournier's Gangrene (Concept Set ID 1) .build() ) diff --git a/debug_app/app.py b/debug_app/app.py index a0a44baf..2e4dfa77 100644 --- a/debug_app/app.py +++ b/debug_app/app.py @@ -101,9 +101,7 @@ def cohort_view(filename): # Handle R errors if ref_result.get("error"): # If R fails, append to existing error or set it - combined_error = ( - f"{result['error'] or ''}\n\nR Error: {ref_result['error']}".strip() - ) + combined_error = f"{result['error'] or ''}\n\nR Error: {ref_result['error']}".strip() result["error"] = combined_error ref_result["sql"] diff --git a/debug_app/sandbox.py b/debug_app/sandbox.py index 95df3bdb..6afc8838 100644 --- a/debug_app/sandbox.py +++ b/debug_app/sandbox.py @@ -121,9 +121,7 @@ def execute_cohort_code(code: str) -> dict[str, Any]: python_code = to_python_code(cohort_expression) # Serialize to JSON - json_output = json.dumps( - cohort_expression.model_dump(exclude_none=True, by_alias=True), indent=2 - ) + json_output = json.dumps(cohort_expression.model_dump(exclude_none=True, by_alias=True), indent=2) return { "cohort_expression": cohort_expression, @@ -135,20 +133,11 @@ def execute_cohort_code(code: str) -> dict[str, Any]: } except SyntaxError as e: - return { - "error": f"Syntax Error: {e.msg} at line {e.lineno}\n\n" - f"Check your Python syntax and try again." - } + return {"error": f"Syntax Error: {e.msg} at line {e.lineno}\n\nCheck your Python syntax and try again."} except ImportError as e: - return { - "error": f"Import Error: {str(e)}\n\n" - f"Only imports from 'circe.cohort_builder' and 'circe.vocabulary' are allowed." - } + return {"error": f"Import Error: {str(e)}\n\nOnly imports from 'circe.cohort_builder' and 'circe.vocabulary' are allowed."} except AttributeError as e: - return { - "error": f"Attribute Error: {str(e)}\n\n" - f"Check the fluent API documentation for correct method names." - } + return {"error": f"Attribute Error: {str(e)}\n\nCheck the fluent API documentation for correct method names."} except Exception as e: import traceback diff --git a/debug_app/utils.py b/debug_app/utils.py index 18f7cd51..94342901 100644 --- a/debug_app/utils.py +++ b/debug_app/utils.py @@ -210,9 +210,7 @@ def generate_reference_with_r(json_content: str) -> dict: import subprocess import tempfile - r_script_path = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "circe_sql.R") - ) + r_script_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "circe_sql.R")) if not os.path.exists(r_script_path): return { @@ -273,9 +271,7 @@ def generate_reference_with_r(json_content: str) -> dict: } -def get_ai_explanation( - ref_content: str, gen_content: str, type_label: str = "SQL" -) -> dict: +def get_ai_explanation(ref_content: str, gen_content: str, type_label: str = "SQL") -> dict: """ Uses Google GenAI to explain the differences between reference and generated content. """ @@ -325,42 +321,32 @@ def get_ai_explanation( try: from google import genai except ImportError: - return { - "error": "google-genai library not installed. Please pip install google-genai." - } + return {"error": "google-genai library not installed. Please pip install google-genai."} try: from dotenv import load_dotenv - env_path = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", ".env") - ) + env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env")) load_dotenv(env_path, override=True) except ImportError: pass api_key = os.environ.get("GOOGLE_API_KEY") if not api_key: - return { - "error": "GOOGLE_API_KEY environment variable not set. Please set it in a .env file or in your terminal." - } + return {"error": "GOOGLE_API_KEY environment variable not set. Please set it in a .env file or in your terminal."} # 4. Call API try: client = genai.Client(api_key=api_key) - response = client.models.generate_content( - model="gemini-2.5-flash-lite", contents=prompt - ) + response = client.models.generate_content(model="gemini-2.5-flash-lite", contents=prompt) explanation = response.text # 5. Save to Cache try: with open(cache_file, "w") as f: - json.dump( - {"explanation": explanation, "model": "gemini-2.5-flash-lite"}, f - ) + json.dump({"explanation": explanation, "model": "gemini-2.5-flash-lite"}, f) except Exception as e: print(f"Failed to save cache: {e}") diff --git a/examples/complex_cohort.py b/examples/complex_cohort.py index 974e74cb..e958ee7e 100644 --- a/examples/complex_cohort.py +++ b/examples/complex_cohort.py @@ -51,9 +51,7 @@ def create_complex_cohort(): expression=ConceptSetExpression( items=[ ConceptSetItem( - concept=Concept( - concept_id=201826, concept_name="Type 2 diabetes mellitus" - ), + concept=Concept(concept_id=201826, concept_name="Type 2 diabetes mellitus"), include_descendants=True, ) ] @@ -112,9 +110,7 @@ def create_complex_cohort(): expression=ConceptSetExpression( items=[ ConceptSetItem( - concept=Concept( - concept_id=46271022, concept_name="End stage renal disease" - ), + concept=Concept(concept_id=46271022, concept_name="End stage renal disease"), include_descendants=True, ) ] @@ -287,9 +283,7 @@ def create_complex_cohort(): for cs in cohort.concept_sets: print(f" - {cs.name}") - print( - f"\nAdditional Criteria: {len(cohort.additional_criteria.criteria_list)} conditions" - ) + print(f"\nAdditional Criteria: {len(cohort.additional_criteria.criteria_list)} conditions") print(f"Inclusion Rules: {len(cohort.inclusion_rules)} rules") for rule in cohort.inclusion_rules: print(f" - {rule.name}") diff --git a/examples/generate_sql.py b/examples/generate_sql.py index abc79c83..1e11a655 100644 --- a/examples/generate_sql.py +++ b/examples/generate_sql.py @@ -100,9 +100,7 @@ def main(): { "ConceptSets": [], "PrimaryCriteria": { - "CriteriaList": [ - {"ConditionOccurrence": {"CodesetId": 1, "First": True}} - ], + "CriteriaList": [{"ConditionOccurrence": {"CodesetId": 1, "First": True}}], "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, "PrimaryLimit": {"Type": "All"}, }, diff --git a/examples/type2_diabetes_cohort.ipynb b/examples/type2_diabetes_cohort.ipynb index 5500db39..80e51e5a 100644 --- a/examples/type2_diabetes_cohort.ipynb +++ b/examples/type2_diabetes_cohort.ipynb @@ -363,9 +363,7 @@ "\n", "options = BuildExpressionQueryOptions()\n", "options.cdm_schema = \"my_cdm_schema\" # Replace with your CDM schema name\n", - "options.vocabulary_schema = (\n", - " \"my_vocab_schema\" # Replace with your vocabulary schema name\n", - ")\n", + "options.vocabulary_schema = \"my_vocab_schema\" # Replace with your vocabulary schema name\n", "options.target_table = \"cohort\"\n", "options.cohort_id = 1 # Cohort ID for the results table\n", "\n", diff --git a/examples/validate_cohort.py b/examples/validate_cohort.py index fe91acc5..b51bdaad 100644 --- a/examples/validate_cohort.py +++ b/examples/validate_cohort.py @@ -89,9 +89,7 @@ def create_valid_cohort_json(): } ], "PrimaryCriteria": { - "CriteriaList": [ - {"ConditionOccurrence": {"CodesetId": 1, "First": True}} - ], + "CriteriaList": [{"ConditionOccurrence": {"CodesetId": 1, "First": True}}], "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, "PrimaryLimit": {"Type": "All"}, }, @@ -188,9 +186,7 @@ def main(): print(f"\n✗ {file_path} has validation issues!") else: print("No example cohort JSON files found.") - print( - "Run basic_cohort.py or complex_cohort.py first to generate example files." - ) + print("Run basic_cohort.py or complex_cohort.py first to generate example files.") print("\n" + "=" * 50) print("Validation examples completed!") diff --git a/scripts/generate_skill_backup.py b/scripts/generate_skill_backup.py index aa21ff7b..5144bf59 100644 --- a/scripts/generate_skill_backup.py +++ b/scripts/generate_skill_backup.py @@ -69,12 +69,8 @@ def extract_method_info(self, cls, method_name: str) -> MethodInfo: continue param_info = { "name": param_name, - "type": str(param.annotation) - if param.annotation != inspect.Parameter.empty - else "Any", - "default": param.default - if param.default != inspect.Parameter.empty - else None, + "type": str(param.annotation) if param.annotation != inspect.Parameter.empty else "Any", + "default": param.default if param.default != inspect.Parameter.empty else None, "required": param.default == inspect.Parameter.empty, } params.append(param_info) @@ -92,9 +88,7 @@ def extract_method_info(self, cls, method_name: str) -> MethodInfo: docstring = inspect.getdoc(method) or "" # Determine if method finalizes (returns parent) or chains (returns self) - finalizes = ( - "CohortWithCriteria" in return_type or "CohortWithEntry" in return_type - ) + finalizes = "CohortWithCriteria" in return_type or "CohortWithEntry" in return_type is_chainable = return_type != "None" and not finalizes return MethodInfo( @@ -111,20 +105,14 @@ def discover_methods(self): """Discover all public methods from the builder classes.""" # CohortBuilder entry methods - for name, _method in inspect.getmembers( - CohortBuilder, predicate=inspect.isfunction - ): + for name, _method in inspect.getmembers(CohortBuilder, predicate=inspect.isfunction): if name.startswith("_") or name == "with_concept_sets": continue if name.startswith("with_"): - self.builder_methods.append( - self.extract_method_info(CohortBuilder, name) - ) + self.builder_methods.append(self.extract_method_info(CohortBuilder, name)) # CohortWithEntry methods - for name, _method in inspect.getmembers( - CohortWithEntry, predicate=inspect.isfunction - ): + for name, _method in inspect.getmembers(CohortWithEntry, predicate=inspect.isfunction): if name.startswith("_"): continue if name in [ @@ -141,14 +129,10 @@ def discover_methods(self): "all_of", "at_least_of", ]: - self.entry_methods.append( - self.extract_method_info(CohortWithEntry, name) - ) + self.entry_methods.append(self.extract_method_info(CohortWithEntry, name)) # CohortWithCriteria methods - for name, _method in inspect.getmembers( - CohortWithCriteria, predicate=inspect.isfunction - ): + for name, _method in inspect.getmembers(CohortWithCriteria, predicate=inspect.isfunction): if name.startswith("_"): continue if ( @@ -167,14 +151,10 @@ def discover_methods(self): "exclude_any_of", ] ): - self.criteria_methods.append( - self.extract_method_info(CohortWithCriteria, name) - ) + self.criteria_methods.append(self.extract_method_info(CohortWithCriteria, name)) # BaseQuery time windows - for name, _method in inspect.getmembers( - BaseQuery, predicate=inspect.isfunction - ): + for name, _method in inspect.getmembers(BaseQuery, predicate=inspect.isfunction): if name in [ "within_days_before", "within_days_after", @@ -223,9 +203,7 @@ def discover_methods(self): self.query_modifiers[cls_name] = [] for method_name in methods: if hasattr(cls, method_name): - self.query_modifiers[cls_name].append( - self.extract_method_info(cls, method_name) - ) + self.query_modifiers[cls_name].append(self.extract_method_info(cls, method_name)) def generate_markdown(self) -> str: """Generate the SKILL.md content.""" @@ -233,28 +211,20 @@ def generate_markdown(self) -> str: # Header md.append("---") - md.append( - "description: Build OHDSI cohort definitions using the fluent Python API" - ) + md.append("description: Build OHDSI cohort definitions using the fluent Python API") md.append("---") md.append("") md.append("# Cohort Builder Skill") md.append("") - md.append( - "Build OHDSI cohort definitions step-by-step using the fluent `cohort_builder` API." - ) + md.append("Build OHDSI cohort definitions step-by-step using the fluent `cohort_builder` API.") md.append("") - md.append( - "**⚠️ AUTO-GENERATED**: This file is generated from the codebase. Do not edit manually." - ) + md.append("**⚠️ AUTO-GENERATED**: This file is generated from the codebase. Do not edit manually.") md.append("") # Entry Events md.append("## Entry Event Methods") md.append("") - md.append( - "Start building a cohort with one of these methods on `CohortBuilder`:" - ) + md.append("Start building a cohort with one of these methods on `CohortBuilder`:") md.append("") md.append("```python") for method in sorted(self.builder_methods, key=lambda m: m.name): @@ -286,9 +256,7 @@ def generate_markdown(self) -> str: md.append("") for method in sorted(self.entry_methods, key=lambda m: m.name): if method.name.startswith("require_"): - md.append( - f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}" - ) + md.append(f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}") md.append("") # CRITICAL CHAINING RULE @@ -296,9 +264,7 @@ def generate_markdown(self) -> str: md.append("") md.append("**Modifiers MUST be called BEFORE time windows!**") md.append("") - md.append( - "Time window methods finalize the criteria and return to the parent builder." - ) + md.append("Time window methods finalize the criteria and return to the parent builder.") md.append("Once a time window is called, you cannot chain further modifiers.") md.append("") md.append("✅ **CORRECT**:") @@ -318,9 +284,7 @@ def generate_markdown(self) -> str: md.append("These methods finalize the criteria:") md.append("") for method in sorted(self.time_windows, key=lambda m: m.name): - md.append( - f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}" - ) + md.append(f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}") md.append("") # Modifiers @@ -410,13 +374,7 @@ def update_system_prompt(self, skill_content: str, prompt_path: str): new_skill_section = "\n".join(skill_body).strip() - new_prompt = ( - prompt_content[: start_idx + len(start_marker)] - + "\n\n" - + new_skill_section - + "\n\n" - + prompt_content[end_idx:] - ) + new_prompt = prompt_content[: start_idx + len(start_marker)] + "\n\n" + new_skill_section + "\n\n" + prompt_content[end_idx:] # Write updated prompt with open(prompt_path, "w") as f: From b45a2efe3ef893637703c360ddf0044a9aff26ab Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Mon, 16 Mar 2026 15:02:49 -0700 Subject: [PATCH 20/62] Cdm extension registration (#2) * Initial implementation of extensions with waveform extension as an example * Tests an examples for the use of extensions * Moved waveform extension and created optional install patten --- README.md | 23 +- circe/cohortdefinition/builders/__init__.py | 8 + .../cohort_expression_query_builder.py | 105 ++++--- circe/cohortdefinition/criteria.py | 47 ++- .../printfriendly/markdown_render.py | 44 ++- .../printfriendly/templates/criteria_types.j2 | 7 +- circe/extensions/__init__.py | 214 +++++++++++++ circe/extensions/waveform/__init__.py | 14 + .../extensions/waveform/builders/__init__.py | 2 + .../builders/waveform_channel_metadata.py | 110 +++++++ .../waveform/builders/waveform_feature.py | 138 +++++++++ .../waveform/builders/waveform_occurrence.py | 109 +++++++ .../waveform/builders/waveform_registry.py | 98 ++++++ circe/extensions/waveform/criteria.py | 290 ++++++++++++++++++ .../templates/waveform_channel_metadata.j2 | 32 ++ .../waveform/templates/waveform_feature.j2 | 42 +++ .../waveform/templates/waveform_occurrence.j2 | 39 +++ .../waveform/templates/waveform_registry.j2 | 27 ++ docs/developer/extensions.rst | 155 ++++++++++ docs/index.rst | 1 + docs/waveform_extension.md | 104 +++++++ examples/waveform_extension.py | 209 +++++++++++++ pyproject.toml | 4 + tests/test_extension_system.py | 179 +++++++++++ tests/test_waveform_extension.py | 116 +++++++ 25 files changed, 2054 insertions(+), 63 deletions(-) create mode 100644 circe/extensions/__init__.py create mode 100644 circe/extensions/waveform/__init__.py create mode 100644 circe/extensions/waveform/builders/__init__.py create mode 100644 circe/extensions/waveform/builders/waveform_channel_metadata.py create mode 100644 circe/extensions/waveform/builders/waveform_feature.py create mode 100644 circe/extensions/waveform/builders/waveform_occurrence.py create mode 100644 circe/extensions/waveform/builders/waveform_registry.py create mode 100644 circe/extensions/waveform/criteria.py create mode 100644 circe/extensions/waveform/templates/waveform_channel_metadata.j2 create mode 100644 circe/extensions/waveform/templates/waveform_feature.j2 create mode 100644 circe/extensions/waveform/templates/waveform_occurrence.j2 create mode 100644 circe/extensions/waveform/templates/waveform_registry.j2 create mode 100644 docs/developer/extensions.rst create mode 100644 docs/waveform_extension.md create mode 100644 examples/waveform_extension.py create mode 100644 tests/test_extension_system.py create mode 100644 tests/test_waveform_extension.py diff --git a/README.md b/README.md index ff131791..c95d95c4 100644 --- a/README.md +++ b/README.md @@ -161,12 +161,23 @@ This package provides a complete Python implementation of CIRCE-BE with: - Measurement, Observation - Visit Occurrence/Detail - Device Exposure, Specimen - - Death, Location Region - - Observation Period, Payer Plan Period - - And more... -- **Full cohort expression validation** with comprehensive error checking -- **Markdown rendering** for human-readable cohort descriptions -- **Complete CLI interface** with 4 commands (validate, generate-sql, render-markdown, process) + - Specimen, Death + - Payer Plan Period, Location Region +- **Full Cohort Expression Validation** with 40+ checker implementations +- **Markdown Rendering** for human-readable descriptions +- **Complete CLI Interface** for validation, SQL, and rendering +- **Extension System** to support custom CDM domains + +## Extensions + +`circe_py` includes a powerful extension system that allows adding support for custom CDM domains. + +Included Extensions: + +- **OHDSI Waveform Extension**: Support for the OHDSI Waveform Extension specification (waveform_occurrence, waveform_registry, waveform_channel_metadata, waveform_feature). Install with `pip install "ohdsi-circe-python-alpha[waveform]"`. See [docs/waveform_extension.md](docs/waveform_extension.md). + +For information on how to implement your own extension, see the [Developer Guide for Extensions](docs/developer/extensions.rst). + - **Java interoperability** - supports both camelCase and snake_case field names for seamless Java CIRCE-BE compatibility ## ⚠️ Java Fidelity Requirement diff --git a/circe/cohortdefinition/builders/__init__.py b/circe/cohortdefinition/builders/__init__.py index f907b0de..da575d2b 100644 --- a/circe/cohortdefinition/builders/__init__.py +++ b/circe/cohortdefinition/builders/__init__.py @@ -10,6 +10,7 @@ """ from .base import CriteriaSqlBuilder +from circe.extensions import get_registry from .condition_era import ConditionEraSqlBuilder from .condition_occurrence import ConditionOccurrenceSqlBuilder from .death import DeathSqlBuilder @@ -28,6 +29,12 @@ from .visit_detail import VisitDetailSqlBuilder from .visit_occurrence import VisitOccurrenceSqlBuilder +# Extension support +def get_builder_for_criteria(criteria): + """Get a SQL builder for a criteria instance, checking extensions first.""" + registry = get_registry() + return registry.get_builder(criteria) + __all__ = [ # Utility classes "BuilderUtils", @@ -52,4 +59,5 @@ "PayerPlanPeriodSqlBuilder", "VisitDetailSqlBuilder", "LocationRegionSqlBuilder", + "get_builder_for_criteria" ] diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index 52ff132d..e4e3d683 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -30,6 +30,12 @@ VisitOccurrenceSqlBuilder, ) from .builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn +from .builders import ( + ConditionOccurrenceSqlBuilder, DeathSqlBuilder, DeviceExposureSqlBuilder, + MeasurementSqlBuilder, ObservationSqlBuilder, SpecimenSqlBuilder, + VisitOccurrenceSqlBuilder, DrugExposureSqlBuilder, ProcedureOccurrenceSqlBuilder, + ConditionEraSqlBuilder, DrugEraSqlBuilder, DoseEraSqlBuilder, ObservationPeriodSqlBuilder, PayerPlanPeriodSqlBuilder, + VisitDetailSqlBuilder, LocationRegionSqlBuilder, get_builder_for_criteria) from .cohort import CohortExpression from .concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder from .core import CustomEraStrategy, DateOffsetStrategy, Period @@ -57,6 +63,7 @@ VisitDetail, VisitOccurrence, ) +from circe.extensions import get_registry from .interfaces import IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher @@ -1605,49 +1612,67 @@ def get_criteria_sql( "DoseEra": DoE, } - if criteria_type in criteria_class_map: - try: - # Make a mutable copy to add defaults - criteria_data = dict(criteria_data) if criteria_data else {} - # Set default values for required fields that might be missing - if ( - criteria_type == "Measurement" - and "measurementTypeExclude" not in criteria_data - ): - criteria_data["measurementTypeExclude"] = False - if ( - criteria_type == "Observation" - and "observationTypeExclude" not in criteria_data - ): - criteria_data["observationTypeExclude"] = False - if ( - criteria_type == "ProcedureOccurrence" - and "procedureTypeExclude" not in criteria_data - ): - criteria_data["procedureTypeExclude"] = False - if ( - criteria_type == "DrugExposure" - and "drugTypeExclude" not in criteria_data - ): - criteria_data["drugTypeExclude"] = False - # Most criteria types require 'first' field - if ( - "first" not in criteria_data - or criteria_data.get("first") is None - ): - criteria_data["first"] = False - criteria = criteria_class_map[criteria_type].model_validate( - criteria_data, strict=False - ) - except Exception as e: - raise ValueError( - f"Failed to deserialize criteria from dict: {criteria_type} - {e}" - ) - else: - raise ValueError(f"Unknown criteria type in dict: {criteria_type}") + # Check if it's a registered extension criteria + registry = get_registry() + if criteria_type and criteria_type in registry._criteria_classes: + try: + criteria_data = dict(criteria_data) if criteria_data else {} + # Add defaults if needed + if 'first' not in criteria_data or criteria_data.get('first') is None: + criteria_data['first'] = False + + criteria = registry._criteria_classes[criteria_type].model_validate(criteria_data, strict=False) + except Exception as e: + raise ValueError(f"Failed to deserialize extension criteria: {criteria_type} - {e}") + elif criteria_type in criteria_class_map: + try: + # Make a mutable copy to add defaults + criteria_data = dict(criteria_data) if criteria_data else {} + # Set default values for required fields that might be missing + if ( + criteria_type == "Measurement" + and "measurementTypeExclude" not in criteria_data + ): + criteria_data["measurementTypeExclude"] = False + if ( + criteria_type == "Observation" + and "observationTypeExclude" not in criteria_data + ): + criteria_data["observationTypeExclude"] = False + if ( + criteria_type == "ProcedureOccurrence" + and "procedureTypeExclude" not in criteria_data + ): + criteria_data["procedureTypeExclude"] = False + if ( + criteria_type == "DrugExposure" + and "drugTypeExclude" not in criteria_data + ): + criteria_data["drugTypeExclude"] = False + # Most criteria types require 'first' field + if ( + "first" not in criteria_data + or criteria_data.get("first") is None + ): + criteria_data["first"] = False + criteria = criteria_class_map[criteria_type].model_validate( + criteria_data, strict=False + ) + except Exception as e: + raise ValueError( + f"Failed to deserialize criteria from dict: {criteria_type} - {e}" + ) else: + raise ValueError(f"Unknown criteria type in dict: {criteria_type}") + else: + if isinstance(criteria, dict): raise ValueError(f"Invalid criteria dict structure: {criteria}") + # Check for extension builder first + extension_builder = get_builder_for_criteria(criteria) + if extension_builder: + return self._get_criteria_sql_from_builder(extension_builder, criteria, options) + # Import here to avoid circular dependency - use the already imported names if isinstance(criteria, ConditionOccurrence): return self._get_criteria_sql_from_builder( diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index 5e1591df..b954809b 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -8,8 +8,9 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from pydantic import BaseModel, Field, ConfigDict, model_serializer, AliasChoices, field_validator, BeforeValidator from enum import Enum -from typing import TYPE_CHECKING, Any, ClassVar, List, Optional, Union +from typing import Annotated, TYPE_CHECKING, Any, ClassVar, List, Optional, Union from pydantic import ( AliasChoices, @@ -257,6 +258,27 @@ class Criteria(CirceBaseModel): @model_serializer(mode="wrap") def _serialize_polymorphic(self, serializer, info): """Serialize with polymorphic type wrapper for Java compatibility.""" + if self.__class__.__name__ == 'Criteria': + return serializer(self) + + # For subclasses (extensions), we want to ensure all fields are included + # even if serialized via a base class Union link. + # We manually build the dict to avoid infinite recursion with model_dump() + data = {} + for field_name, field_info in self.model_fields.items(): + value = getattr(self, field_name) + if value is not None: + # Use serialization_alias if it exists, otherwise use field name + # Note: alias_generator (PascalCase) is handled via serialization_alias + # effectively if we use the right property. + # In Pydantic V2, serialization_alias is often the PascalCase version if configured. + alias = field_info.serialization_alias or field_name + # If it's a generic field without explicit alias, it might need PascalCase + # but most CIRCE fields have explicit aliases. + data[alias] = value + + return {self.__class__.__name__: data} + # Get the serialized data using default serialization data = serializer(self) # Wrap in class name for polymorphic deserialization in Java @@ -1534,8 +1556,10 @@ def normalize_window(window_dict: dict) -> dict: return deserialized -# Define CriteriaType Union for strict typing -CriteriaType = Union[ +# Define CriteriaType Union for strict typing. +# Criteria is last so known subtypes are tried first; it also acts as +# a catch-all that accepts any registered extension subclass. +_CriteriaTypeUnion = Union[ ConditionOccurrence, DrugExposure, ProcedureOccurrence, @@ -1552,8 +1576,25 @@ def normalize_window(window_dict: dict) -> dict: ConditionEra, DrugEra, DoseEra, + Criteria, # catch-all for extension subclasses ] +def _validate_criteria_extension(v: Any) -> Any: + """Deserialize extension criteria from a single-key dict via the extensions registry.""" + if isinstance(v, dict) and len(v) == 1: + key = next(iter(v)) + try: + from circe.extensions import get_registry + registry = get_registry() + cls = registry.get_criteria_class(key) + if cls: + return cls.model_validate(v[key]) + except ImportError: + pass + return v + +CriteriaType = Annotated[_CriteriaTypeUnion, BeforeValidator(_validate_criteria_extension)] + # Map for dynamic lookup NAMES_TO_CLASSES = { "ConditionOccurrence": ConditionOccurrence, diff --git a/circe/cohortdefinition/printfriendly/markdown_render.py b/circe/cohortdefinition/printfriendly/markdown_render.py index 380defb3..a865cf14 100644 --- a/circe/cohortdefinition/printfriendly/markdown_render.py +++ b/circe/cohortdefinition/printfriendly/markdown_render.py @@ -31,34 +31,52 @@ class MarkdownRender: FreeMarker template implementation. Templates are located in the templates/ subdirectory and mirror the structure of Java's .ftl files. """ - - def __init__( - self, - concept_sets: Optional[List[ConceptSet]] = None, - include_concept_sets: bool = False, - ): + + def __init__(self, concept_sets: Optional[List[ConceptSet]] = None, include_concept_sets: bool = False, template_paths: Optional[List[Path]] = None): """Initialize the markdown renderer. Args: concept_sets: Optional list of concept sets for resolving codeset IDs to names include_concept_sets: Whether to include concept set tables in the output (default: False) + template_paths: Optional list of additional template directories to search """ self._concept_sets = concept_sets or [] self._include_concept_sets = include_concept_sets - - # Initialize Jinja2 environment - template_dir = Path(__file__).parent / "templates" + + # Initialize Jinja2 environment with multiple loaders + built_in_template_dir = Path(__file__).parent / 'templates' + + # Start with built-in templates + loaders = [jinja2.FileSystemLoader(str(built_in_template_dir))] + + # Add user provided paths + if template_paths: + for path in template_paths: + loaders.append(jinja2.FileSystemLoader(str(path))) + + # Add registry paths + from circe.extensions import get_registry + registry = get_registry() + for path in registry.template_paths: + loaders.append(jinja2.FileSystemLoader(str(path))) + self._env = jinja2.Environment( - loader=jinja2.FileSystemLoader(str(template_dir)), + loader=jinja2.ChoiceLoader(loaders), trim_blocks=True, lstrip_blocks=True, autoescape=False, # We're generating markdown, not HTML ) # Register custom filters (matching Java utils.ftl) - self._env.filters["format_date"] = self._format_date - self._env.filters["format_number"] = self._format_number - + self._env.filters['format_date'] = self._format_date + self._env.filters['format_number'] = self._format_number + + # Add extension helper to look up template name for a criteria instance + def get_template_for_criteria(criteria): + return registry.get_template(criteria) + + self._env.globals['get_template_for_criteria'] = get_template_for_criteria + # Register global functions self._env.globals["codeset_name"] = self._codeset_name self._env.globals["format_date"] = self._format_date diff --git a/circe/cohortdefinition/printfriendly/templates/criteria_types.j2 b/circe/cohortdefinition/printfriendly/templates/criteria_types.j2 index 2e7c9ce6..927cf6d2 100644 --- a/circe/cohortdefinition/printfriendly/templates/criteria_types.j2 +++ b/circe/cohortdefinition/printfriendly/templates/criteria_types.j2 @@ -11,7 +11,12 @@ ============================================ #} {%- macro Criteria(c, level=0, isPlural=true, countCriteria={}, indexLabel="cohort entry") -%} {%- set type_name = c.__class__.__name__ -%} - {%- if type_name == "ConditionEra" -%}{{ ConditionEra(c, level, isPlural, countCriteria, indexLabel) }} + {%- set custom_template = get_template_for_criteria(c) -%} + {%- if custom_template -%} + {%- with criteria=c, level=level, isPlural=isPlural, countCriteria=countCriteria, indexLabel=indexLabel -%} + {%- include custom_template -%} + {%- endwith -%} + {%- elif type_name == "ConditionEra" -%}{{ ConditionEra(c, level, isPlural, countCriteria, indexLabel) }} {%- elif type_name == "ConditionOccurrence" -%}{{ ConditionOccurrence(c, level, isPlural, countCriteria, indexLabel) }} {%- elif type_name == "Death" -%}{{ Death(c, level, isPlural, countCriteria, indexLabel) }} {%- elif type_name == "DeviceExposure" -%}{{ DeviceExposure(c, level, isPlural, countCriteria, indexLabel) }} diff --git a/circe/extensions/__init__.py b/circe/extensions/__init__.py new file mode 100644 index 00000000..114f886b --- /dev/null +++ b/circe/extensions/__init__.py @@ -0,0 +1,214 @@ +""" +Extension Registry for OMOP CDM. + +This module provides the central registry for managing extensions to circe-py, +allowing external projects to register custom criteria classes, SQL builders, +and markdown renderers. + +Decorator Usage +--------------- +Extension authors can use the provided decorator functions to register their +classes automatically, rather than calling the registry methods directly:: + + from circe.extensions import criteria_class, sql_builder, markdown_template + + @criteria_class("WaveformOccurrence") + class WaveformOccurrence(Criteria): + ... + + @sql_builder(WaveformOccurrence) + class WaveformOccurrenceSqlBuilder(CriteriaSqlBuilder): + ... + + @markdown_template(WaveformOccurrence, "waveform_occurrence.j2") + class WaveformOccurrenceMarkdownRenderer: + ... +""" +from typing import Callable, Dict, List, Optional, Type, Union +from pathlib import Path + +# Forward references to avoid circular imports +# Actual imports happen inside methods or with TYPE_CHECKING +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .cohortdefinition.criteria import Criteria + from .cohortdefinition.builders.base import CriteriaSqlBuilder + +class ExtensionRegistry: + """Central registry for OMOP CDM extensions.""" + + def __init__(self): + # Maps criteria names to criteria classes (for JSON deserialization) + self._criteria_classes: Dict[str, Type['Criteria']] = {} + + # Maps criteria types to SQL builder classes + self._sql_builders: Dict[Type['Criteria'], Type['CriteriaSqlBuilder']] = {} + + # Maps criteria types to markdown template names + self._markdown_templates: Dict[Type['Criteria'], str] = {} + + # List of paths to search for Jinja2 templates + self._template_paths: List[Path] = [] + + def register_criteria_class(self, name: str, cls: Type['Criteria']) -> None: + """Register a new criteria class for JSON deserialization. + + Args: + name: The name of the criteria type (e.g. "WaveformOccurrence") + cls: The Criteria subclass + """ + self._criteria_classes[name] = cls + + def register_sql_builder(self, criteria_cls: Type['Criteria'], builder_cls: Type['CriteriaSqlBuilder']) -> None: + """Register a SQL builder for a criteria type. + + Args: + criteria_cls: The Criteria subclass + builder_cls: The CriteriaSqlBuilder subclass + """ + self._sql_builders[criteria_cls] = builder_cls + + def register_markdown_template(self, criteria_cls: Type['Criteria'], template_name: str) -> None: + """Register a Jinja2 template for markdown rendering. + + Args: + criteria_cls: The Criteria subclass + template_name: The name of the template file (e.g. "waveform_occurrence.j2") + """ + self._markdown_templates[criteria_cls] = template_name + + def add_template_path(self, path: Path) -> None: + """Add a path to search for Jinja2 templates. + + Args: + path: Path to a directory containing Jinja2 templates + """ + if path not in self._template_paths: + self._template_paths.append(path) + + def get_builder(self, criteria: 'Criteria') -> Optional['CriteriaSqlBuilder']: + """Get the SQL builder for a criteria instance. + + Args: + criteria: The criteria instance + + Returns: + An instance of the registered SQL builder, or None if not found + """ + builder_cls = self._sql_builders.get(type(criteria)) + return builder_cls() if builder_cls else None + + def get_template(self, criteria: 'Criteria') -> Optional[str]: + """Get the markdown template name for a criteria instance. + + Args: + criteria: The criteria instance + + Returns: + The template name, or None if not found + """ + return self._markdown_templates.get(type(criteria)) + + def get_criteria_class(self, name: str) -> Optional[Type['Criteria']]: + """Get a registered criteria class by name. + + Args: + name: The name of the criteria type + + Returns: + The Criteria subclass, or None if not found + """ + return self._criteria_classes.get(name) + + @property + def template_paths(self) -> List[Path]: + """Get all registered template paths.""" + return list(self._template_paths) + +# Global registry instance +_registry = ExtensionRegistry() + + +def get_registry() -> ExtensionRegistry: + """Get the global extension registry instance.""" + return _registry + + +# --------------------------------------------------------------------------- +# Decorator helpers +# --------------------------------------------------------------------------- + +def criteria_class(name: str) -> "Callable[[Type['Criteria']], Type['Criteria']]": + """Class decorator that registers a Criteria subclass for JSON deserialization. + + Args: + name: The criteria type name used as the JSON key + (e.g. ``"WaveformOccurrence"``). + + Example:: + + @criteria_class("WaveformOccurrence") + class WaveformOccurrence(Criteria): + ... + """ + def decorator(cls: "Type['Criteria']") -> "Type['Criteria']": + _registry.register_criteria_class(name, cls) # type: ignore[arg-type] + return cls + return decorator # type: ignore[return-value] + + +def sql_builder(criteria_cls: "Type['Criteria']") -> "Callable[[Type['CriteriaSqlBuilder']], Type['CriteriaSqlBuilder']]": + """Class decorator that registers a SQL builder for a given Criteria type. + + Args: + criteria_cls: The Criteria subclass this builder handles. + + Example:: + + @sql_builder(WaveformOccurrence) + class WaveformOccurrenceSqlBuilder(CriteriaSqlBuilder): + ... + """ + def decorator(builder_cls: "Type['CriteriaSqlBuilder']") -> "Type['CriteriaSqlBuilder']": + _registry.register_sql_builder(criteria_cls, builder_cls) # type: ignore[arg-type] + return builder_cls + return decorator # type: ignore[return-value] + + +def markdown_template(criteria_cls: "Type['Criteria']", template_name: str) -> "Callable[[Type], Type]": + """Class decorator that registers a Jinja2 markdown template for a Criteria type. + + Args: + criteria_cls: The Criteria subclass this template renders. + template_name: Filename of the Jinja2 template + (e.g. ``"waveform_occurrence.j2"``). + + Example:: + + @markdown_template(WaveformOccurrence, "waveform_occurrence.j2") + class WaveformOccurrenceMarkdownRenderer: + ... + """ + def decorator(cls: Type) -> Type: + _registry.register_markdown_template(criteria_cls, template_name) # type: ignore[arg-type] + return cls + return decorator + + +def template_path(path: Union[str, Path]) -> None: + """Register a directory as a template search path. + + This is a convenience function (not a decorator) that adds *path* to the + global registry so that Jinja2 can locate extension templates. + + Args: + path: Path to a directory containing Jinja2 templates. + + Example:: + + template_path(Path(__file__).parent / "templates") + """ + _registry.add_template_path(Path(path)) + + diff --git a/circe/extensions/waveform/__init__.py b/circe/extensions/waveform/__init__.py new file mode 100644 index 00000000..1d280c7d --- /dev/null +++ b/circe/extensions/waveform/__init__.py @@ -0,0 +1,14 @@ +from pathlib import Path +from circe.extensions import template_path + +# Importing criteria triggers @criteria_class decorators +from .criteria import WaveformOccurrence, WaveformRegistry, WaveformChannelMetadata, WaveformFeature + +# Importing builders triggers @sql_builder and @markdown_template decorators +from .builders.waveform_occurrence import WaveformOccurrenceSqlBuilder +from .builders.waveform_registry import WaveformRegistrySqlBuilder +from .builders.waveform_channel_metadata import WaveformChannelMetadataSqlBuilder +from .builders.waveform_feature import WaveformFeatureSqlBuilder + +# Register the templates directory so Jinja2 can locate extension templates +template_path(Path(__file__).parent / "templates") \ No newline at end of file diff --git a/circe/extensions/waveform/builders/__init__.py b/circe/extensions/waveform/builders/__init__.py new file mode 100644 index 00000000..a9fadbaa --- /dev/null +++ b/circe/extensions/waveform/builders/__init__.py @@ -0,0 +1,2 @@ +"""builders sub-package for the waveform extension.""" + diff --git a/circe/extensions/waveform/builders/waveform_channel_metadata.py b/circe/extensions/waveform/builders/waveform_channel_metadata.py new file mode 100644 index 00000000..e14d2c73 --- /dev/null +++ b/circe/extensions/waveform/builders/waveform_channel_metadata.py @@ -0,0 +1,110 @@ +from typing import Set + +from circe.cohortdefinition.builders.base import CriteriaSqlBuilder +from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderUtils, BuilderOptions +from circe.extensions import sql_builder, markdown_template +from ..criteria import WaveformChannelMetadata + + +@sql_builder(WaveformChannelMetadata) +@markdown_template(WaveformChannelMetadata, "waveform_channel_metadata.j2") +class WaveformChannelMetadataSqlBuilder(CriteriaSqlBuilder[WaveformChannelMetadata]): + """ + SQL Builder for Waveform Channel Metadata criteria. + + Maps to the waveform_channel_metadata table in the OHDSI Waveform Extension. + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html + """ + + def get_query_template(self) -> str: + return """ +SELECT C.person_id, C.waveform_channel_metadata_id as event_id, + NULL as start_date, NULL as end_date, + NULL as visit_occurrence_id, + NULL as sort_date +FROM @cdm_database_schema.waveform_channel_metadata C +LEFT JOIN @cdm_database_schema.waveform_registry WR ON C.waveform_registry_id = WR.waveform_registry_id +@codesetClause +@joinClause +WHERE @whereClause +""" + + def get_default_columns(self) -> Set[CriteriaColumn]: + return set() # Metadata doesn't have standard event columns + + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: + # Channel metadata doesn't map to standard event columns + raise ValueError(f"Invalid CriteriaColumn for Waveform Channel Metadata: {column}") + + def get_criteria_sql_with_options(self, criteria: WaveformChannelMetadata, options: BuilderOptions) -> str: + query = self.get_query_template() + + where_clauses = [] + join_clauses = [] + codeset_clause = "" + + # Link to registry file + if criteria.waveform_registry_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.waveform_registry_id", criteria.waveform_registry_id) + ) + + # Channel identification + if criteria.channel_concept_id: + ids = [str(c.concept_id) for c in criteria.channel_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.channel_concept_id IN ({','.join(ids)})") + if criteria.waveform_channel_source_value: + where_clauses.append( + BuilderUtils.build_text_filter_clause("C.waveform_channel_source_value", criteria.waveform_channel_source_value) + ) + + # Metadata type + if criteria.metadata_concept_id: + ids = [str(c.concept_id) for c in criteria.metadata_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.metadata_concept_id IN ({','.join(ids)})") + if criteria.metadata_source_value: + where_clauses.append( + BuilderUtils.build_text_filter_clause("C.metadata_source_value", criteria.metadata_source_value) + ) + + # Metadata values + if criteria.value_as_number: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number) + ) + if criteria.value_as_concept_id: + ids = [str(c.concept_id) for c in criteria.value_as_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.value_as_concept_id IN ({','.join(ids)})") + + # Units + if criteria.unit_concept_id: + ids = [str(c.concept_id) for c in criteria.unit_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.unit_concept_id IN ({','.join(ids)})") + + # Device/procedure linkage + if criteria.device_exposure_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.device_exposure_id", criteria.device_exposure_id) + ) + if criteria.procedure_occurrence_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.procedure_occurrence_id", criteria.procedure_occurrence_id) + ) + + # Get person_id from registry since it's not in channel_metadata + where_clauses.append("WR.person_id IS NOT NULL") + + # Apply replacements + query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") + query = query.replace("@codesetClause", codeset_clause) + query = query.replace("@joinClause", "\n".join(join_clauses)) + query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") + + # Fix person_id in SELECT - need to pull from registry + query = query.replace("C.person_id", "WR.person_id") + + return query diff --git a/circe/extensions/waveform/builders/waveform_feature.py b/circe/extensions/waveform/builders/waveform_feature.py new file mode 100644 index 00000000..5e115863 --- /dev/null +++ b/circe/extensions/waveform/builders/waveform_feature.py @@ -0,0 +1,138 @@ +from typing import Set + +from circe.cohortdefinition.builders.base import CriteriaSqlBuilder +from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderUtils, BuilderOptions +from circe.extensions import sql_builder, markdown_template +from ..criteria import WaveformFeature + + +@sql_builder(WaveformFeature) +@markdown_template(WaveformFeature, "waveform_feature.j2") +class WaveformFeatureSqlBuilder(CriteriaSqlBuilder[WaveformFeature]): + """ + SQL Builder for Waveform Feature criteria. + + Maps to the waveform_feature table in the OHDSI Waveform Extension. + This is the most clinically valuable table for cohort selection, containing + derived measurements like heart rate, SpO2, arrhythmia detections, etc. + + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html + """ + + def get_query_template(self) -> str: + return """ +SELECT C.person_id, C.waveform_feature_id as event_id, + C.waveform_feature_start_timestamp as start_date, + C.waveform_feature_end_timestamp as end_date, + WO.visit_occurrence_id, + C.waveform_feature_start_timestamp as sort_date +FROM @cdm_database_schema.waveform_feature C +LEFT JOIN @cdm_database_schema.waveform_occurrence WO ON C.waveform_occurrence_id = WO.waveform_occurrence_id +@codesetClause +@joinClause +WHERE @whereClause +""" + + def get_default_columns(self) -> Set[CriteriaColumn]: + return { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_ID + } + + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: + if column == CriteriaColumn.START_DATE: + return "C.waveform_feature_start_timestamp" + elif column == CriteriaColumn.END_DATE: + return "C.waveform_feature_end_timestamp" + elif column == CriteriaColumn.VISIT_ID: + return "WO.visit_occurrence_id" + else: + raise ValueError(f"Invalid CriteriaColumn for Waveform Feature: {column}") + + def get_criteria_sql_with_options(self, criteria: WaveformFeature, options: BuilderOptions) -> str: + query = self.get_query_template() + + where_clauses = [] + join_clauses = [] + codeset_clause = "" + + # Parent links + if criteria.waveform_occurrence_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.waveform_occurrence_id", criteria.waveform_occurrence_id) + ) + if criteria.waveform_registry_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.waveform_registry_id", criteria.waveform_registry_id) + ) + if criteria.waveform_channel_metadata_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.waveform_channel_metadata_id", criteria.waveform_channel_metadata_id) + ) + + # Feature type (e.g., heart rate, SpO2) + if criteria.feature_concept_id: + ids = [str(c.concept_id) for c in criteria.feature_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.feature_concept_id IN ({','.join(ids)})") + + # Algorithm used + if criteria.algorithm_concept_id: + ids = [str(c.concept_id) for c in criteria.algorithm_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.algorithm_concept_id IN ({','.join(ids)})") + if criteria.algorithm_source_value: + where_clauses.append( + BuilderUtils.build_text_filter_clause("C.algorithm_source_value", criteria.algorithm_source_value) + ) + + # Temporal window + if criteria.feature_start_timestamp: + where_clauses.append( + BuilderUtils.build_date_range_clause("C.waveform_feature_start_timestamp", criteria.feature_start_timestamp) + ) + if criteria.feature_end_timestamp: + where_clauses.append( + BuilderUtils.build_date_range_clause("C.waveform_feature_end_timestamp", criteria.feature_end_timestamp) + ) + + # Feature values + if criteria.value_as_number: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number) + ) + if criteria.value_as_concept_id: + ids = [str(c.concept_id) for c in criteria.value_as_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.value_as_concept_id IN ({','.join(ids)})") + + # Units + if criteria.unit_concept_id: + ids = [str(c.concept_id) for c in criteria.unit_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.unit_concept_id IN ({','.join(ids)})") + + # Links to standard OMOP tables + if criteria.measurement_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.measurement_id", criteria.measurement_id) + ) + if criteria.observation_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.observation_id", criteria.observation_id) + ) + + # Get person_id from occurrence + where_clauses.append("WO.person_id IS NOT NULL") + + # Apply replacements + query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") + query = query.replace("@codesetClause", codeset_clause) + query = query.replace("@joinClause", "\n".join(join_clauses)) + query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") + + # Fix person_id in SELECT - need to pull from occurrence + query = query.replace("C.person_id", "WO.person_id") + + return query diff --git a/circe/extensions/waveform/builders/waveform_occurrence.py b/circe/extensions/waveform/builders/waveform_occurrence.py new file mode 100644 index 00000000..0f0ba26d --- /dev/null +++ b/circe/extensions/waveform/builders/waveform_occurrence.py @@ -0,0 +1,109 @@ +from typing import Set + +from circe.cohortdefinition.builders.base import CriteriaSqlBuilder +from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderUtils, BuilderOptions +from circe.extensions import sql_builder, markdown_template +from ..criteria import WaveformOccurrence + + +@sql_builder(WaveformOccurrence) +@markdown_template(WaveformOccurrence, "waveform_occurrence.j2") +class WaveformOccurrenceSqlBuilder(CriteriaSqlBuilder[WaveformOccurrence]): + """ + SQL Builder for Waveform Occurrence criteria. + + Maps to the waveform_occurrence table in the OHDSI Waveform Extension. + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html + """ + + def get_query_template(self) -> str: + return """ +SELECT C.person_id, C.waveform_occurrence_id as event_id, + C.waveform_occurrence_start_datetime as start_date, + C.waveform_occurrence_end_datetime as end_date, + C.visit_occurrence_id, + C.waveform_occurrence_start_datetime as sort_date +FROM @cdm_database_schema.waveform_occurrence C +@codesetClause +@joinClause +WHERE @whereClause +""" + + def get_default_columns(self) -> Set[CriteriaColumn]: + return { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_ID, + CriteriaColumn.DOMAIN_CONCEPT + } + + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: + if column == CriteriaColumn.START_DATE: + return "C.waveform_occurrence_start_datetime" + elif column == CriteriaColumn.END_DATE: + return "C.waveform_occurrence_end_datetime" + elif column == CriteriaColumn.VISIT_ID: + return "C.visit_occurrence_id" + elif column == CriteriaColumn.DOMAIN_CONCEPT: + return "C.waveform_occurrence_concept_id" + else: + raise ValueError(f"Invalid CriteriaColumn for Waveform Occurrence: {column}") + + def get_criteria_sql_with_options(self, criteria: WaveformOccurrence, options: BuilderOptions) -> str: + query = self.get_query_template() + + where_clauses = [] + join_clauses = [] + codeset_clause = "" + + # Filter by waveform occurrence concept + if criteria.waveform_occurrence_concept_id: + ids = [str(c.concept_id) for c in criteria.waveform_occurrence_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.waveform_occurrence_concept_id IN ({','.join(ids)})") + + # Date filters + if criteria.occurrence_start_datetime: + where_clauses.append( + BuilderUtils.build_date_range_clause("C.waveform_occurrence_start_datetime", criteria.occurrence_start_datetime) + ) + if criteria.occurrence_end_datetime: + where_clauses.append( + BuilderUtils.build_date_range_clause("C.waveform_occurrence_end_datetime", criteria.occurrence_end_datetime) + ) + + # Visit context + if criteria.visit_occurrence_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.visit_occurrence_id", criteria.visit_occurrence_id) + ) + if criteria.visit_detail_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.visit_detail_id", criteria.visit_detail_id) + ) + + # File metadata + if criteria.num_of_files: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.num_of_files", criteria.num_of_files) + ) + + # Source value text filter + if criteria.waveform_occurrence_source_value: + where_clauses.append( + BuilderUtils.build_text_filter_clause("C.waveform_occurrence_source_value", criteria.waveform_occurrence_source_value) + ) + + # Sequence/chain filtering + if criteria.preceding_waveform_occurrence_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.preceding_waveform_occurrence_id", criteria.preceding_waveform_occurrence_id) + ) + + # Apply replacements + query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") + query = query.replace("@codesetClause", codeset_clause) + query = query.replace("@joinClause", "\n".join(join_clauses)) + query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") + + return query diff --git a/circe/extensions/waveform/builders/waveform_registry.py b/circe/extensions/waveform/builders/waveform_registry.py new file mode 100644 index 00000000..ff3c1908 --- /dev/null +++ b/circe/extensions/waveform/builders/waveform_registry.py @@ -0,0 +1,98 @@ +from typing import Set + +from circe.cohortdefinition.builders.base import CriteriaSqlBuilder +from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderUtils, BuilderOptions +from circe.extensions import sql_builder, markdown_template +from ..criteria import WaveformRegistry + + +@sql_builder(WaveformRegistry) +@markdown_template(WaveformRegistry, "waveform_registry.j2") +class WaveformRegistrySqlBuilder(CriteriaSqlBuilder[WaveformRegistry]): + """ + SQL Builder for Waveform Registry criteria. + + Maps to the waveform_registry table in the OHDSI Waveform Extension. + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html + """ + + def get_query_template(self) -> str: + return """ +SELECT C.person_id, C.waveform_registry_id as event_id, + C.waveform_file_start_datetime as start_date, + C.waveform_file_end_datetime as end_date, + C.visit_occurrence_id, + C.waveform_file_start_datetime as sort_date +FROM @cdm_database_schema.waveform_registry C +@codesetClause +@joinClause +WHERE @whereClause +""" + + def get_default_columns(self) -> Set[CriteriaColumn]: + return { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_ID + } + + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: + if column == CriteriaColumn.START_DATE: + return "C.waveform_file_start_datetime" + elif column == CriteriaColumn.END_DATE: + return "C.waveform_file_end_datetime" + elif column == CriteriaColumn.VISIT_ID: + return "C.visit_occurrence_id" + else: + raise ValueError(f"Invalid CriteriaColumn for Waveform Registry: {column}") + + def get_criteria_sql_with_options(self, criteria: WaveformRegistry, options: BuilderOptions) -> str: + query = self.get_query_template() + + where_clauses = [] + join_clauses = [] + codeset_clause = "" + + # Link to parent occurrence + if criteria.waveform_occurrence_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.waveform_occurrence_id", criteria.waveform_occurrence_id) + ) + + # File temporal bounds + if criteria.file_start_datetime: + where_clauses.append( + BuilderUtils.build_date_range_clause("C.waveform_file_start_datetime", criteria.file_start_datetime) + ) + if criteria.file_end_datetime: + where_clauses.append( + BuilderUtils.build_date_range_clause("C.waveform_file_end_datetime", criteria.file_end_datetime) + ) + + # File format + if criteria.file_extension_concept_id: + ids = [str(c.concept_id) for c in criteria.file_extension_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.file_extension_concept_id IN ({','.join(ids)})") + if criteria.file_extension_source_value: + where_clauses.append( + BuilderUtils.build_text_filter_clause("C.file_extension_source_value", criteria.file_extension_source_value) + ) + + # Visit context + if criteria.visit_occurrence_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.visit_occurrence_id", criteria.visit_occurrence_id) + ) + if criteria.visit_detail_id: + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.visit_detail_id", criteria.visit_detail_id) + ) + + # Apply replacements + query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") + query = query.replace("@codesetClause", codeset_clause) + query = query.replace("@joinClause", "\n".join(join_clauses)) + query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") + + return query diff --git a/circe/extensions/waveform/criteria.py b/circe/extensions/waveform/criteria.py new file mode 100644 index 00000000..131123ef --- /dev/null +++ b/circe/extensions/waveform/criteria.py @@ -0,0 +1,290 @@ +from typing import Optional, List +from pydantic import Field, AliasChoices + +from circe.cohortdefinition.criteria import Criteria, CriteriaGroup +from circe.cohortdefinition.core import NumericRange, DateRange, TextFilter +from circe.extensions import criteria_class +from circe.vocabulary.concept import Concept + + +@criteria_class("WaveformOccurrence") +class WaveformOccurrence(Criteria): + """ + Criteria for Waveform Occurrence. + + Represents the clinical and temporal context for a waveform recording session. + Maps to the waveform_occurrence table in the OHDSI Waveform Extension. + + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html + """ + # Core concept - type of waveform recording + waveform_occurrence_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("WaveformOccurrenceConceptId", "waveformOccurrenceConceptId"), + serialization_alias="WaveformOccurrenceConceptId" + ) + + # Temporal bounds + occurrence_start_datetime: Optional[DateRange] = Field( + default=None, + validation_alias=AliasChoices("OccurrenceStartDatetime", "occurrenceStartDatetime"), + serialization_alias="OccurrenceStartDatetime" + ) + occurrence_end_datetime: Optional[DateRange] = Field( + default=None, + validation_alias=AliasChoices("OccurrenceEndDatetime", "occurrenceEndDatetime"), + serialization_alias="OccurrenceEndDatetime" + ) + + # Visit context + visit_occurrence_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("VisitOccurrenceId", "visitOccurrenceId"), + serialization_alias="VisitOccurrenceId" + ) + visit_detail_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("VisitDetailId", "visitDetailId"), + serialization_alias="VisitDetailId" + ) + + # File metadata + num_of_files: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("NumOfFiles", "numOfFiles"), + serialization_alias="NumOfFiles" + ) + + # Source identifiers + waveform_occurrence_source_value: Optional[TextFilter] = Field( + default=None, + validation_alias=AliasChoices("WaveformOccurrenceSourceValue", "waveformOccurrenceSourceValue"), + serialization_alias="WaveformOccurrenceSourceValue" + ) + + # Sequence/chain filtering + preceding_waveform_occurrence_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("PrecedingWaveformOccurrenceId", "precedingWaveformOccurrenceId"), + serialization_alias="PrecedingWaveformOccurrenceId" + ) + +@criteria_class("WaveformRegistry") +class WaveformRegistry(Criteria): + """ + Criteria for Waveform Registry. + + Registers individual waveform files with their storage locations, formats, and temporal boundaries. + Maps to the waveform_registry table in the OHDSI Waveform Extension. + + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html + """ + # Link to parent occurrence + waveform_occurrence_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("WaveformOccurrenceId", "waveformOccurrenceId"), + serialization_alias="WaveformOccurrenceId" + ) + + # File temporal bounds + file_start_datetime: Optional[DateRange] = Field( + default=None, + validation_alias=AliasChoices("FileStartDatetime", "fileStartDatetime"), + serialization_alias="FileStartDatetime" + ) + file_end_datetime: Optional[DateRange] = Field( + default=None, + validation_alias=AliasChoices("FileEndDatetime", "fileEndDatetime"), + serialization_alias="FileEndDatetime" + ) + + # File format + file_extension_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("FileExtensionConceptId", "fileExtensionConceptId"), + serialization_alias="FileExtensionConceptId" + ) + file_extension_source_value: Optional[TextFilter] = Field( + default=None, + validation_alias=AliasChoices("FileExtensionSourceValue", "fileExtensionSourceValue"), + serialization_alias="FileExtensionSourceValue" + ) + + # Visit context (denormalized for easier querying) + visit_occurrence_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("VisitOccurrenceId", "visitOccurrenceId"), + serialization_alias="VisitOccurrenceId" + ) + visit_detail_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("VisitDetailId", "visitDetailId"), + serialization_alias="VisitDetailId" + ) + + +@criteria_class("WaveformChannelMetadata") +class WaveformChannelMetadata(Criteria): + """ + Criteria for Waveform Channel Metadata. + + Describes per-signal-channel metadata including sampling rates, gains, calibration factors, + and signal quality indicators. + Maps to the waveform_channel_metadata table in the OHDSI Waveform Extension. + + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html + """ + # Link to registry file + waveform_registry_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("WaveformRegistryId", "waveformRegistryId"), + serialization_alias="WaveformRegistryId" + ) + + # Channel identification + channel_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("ChannelConceptId", "channelConceptId"), + serialization_alias="ChannelConceptId" + ) + waveform_channel_source_value: Optional[TextFilter] = Field( + default=None, + validation_alias=AliasChoices("WaveformChannelSourceValue", "waveformChannelSourceValue"), + serialization_alias="WaveformChannelSourceValue" + ) + + # Metadata type (e.g., sampling rate, gain, offset) + metadata_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("MetadataConceptId", "metadataConceptId"), + serialization_alias="MetadataConceptId" + ) + metadata_source_value: Optional[TextFilter] = Field( + default=None, + validation_alias=AliasChoices("MetadataSourceValue", "metadataSourceValue"), + serialization_alias="MetadataSourceValue" + ) + + # Metadata values (at least one must be populated) + value_as_number: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), + serialization_alias="ValueAsNumber" + ) + value_as_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("ValueAsConceptId", "valueAsConceptId"), + serialization_alias="ValueAsConceptId" + ) + + # Units for numeric values + unit_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("UnitConceptId", "unitConceptId"), + serialization_alias="UnitConceptId" + ) + + # Device/procedure linkage + device_exposure_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("DeviceExposureId", "deviceExposureId"), + serialization_alias="DeviceExposureId" + ) + procedure_occurrence_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("ProcedureOccurrenceId", "procedureOccurrenceId"), + serialization_alias="ProcedureOccurrenceId" + ) + + +@criteria_class("WaveformFeature") +class WaveformFeature(Criteria): + """ + Criteria for Waveform Feature. + + Stores measurements and features derived from waveform signals. + Supports both traditional signal processing features and AI-derived embeddings. + Maps to the waveform_feature table in the OHDSI Waveform Extension. + + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html + """ + # Parent links + waveform_occurrence_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("WaveformOccurrenceId", "waveformOccurrenceId"), + serialization_alias="WaveformOccurrenceId" + ) + waveform_registry_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("WaveformRegistryId", "waveformRegistryId"), + serialization_alias="WaveformRegistryId" + ) + waveform_channel_metadata_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("WaveformChannelMetadataId", "waveformChannelMetadataId"), + serialization_alias="WaveformChannelMetadataId" + ) + + # Feature type (e.g., heart rate, SpO2, QRS detection) + feature_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("FeatureConceptId", "featureConceptId"), + serialization_alias="FeatureConceptId" + ) + + # Algorithm used to derive feature + algorithm_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("AlgorithmConceptId", "algorithmConceptId"), + serialization_alias="AlgorithmConceptId" + ) + algorithm_source_value: Optional[TextFilter] = Field( + default=None, + validation_alias=AliasChoices("AlgorithmSourceValue", "algorithmSourceValue"), + serialization_alias="AlgorithmSourceValue" + ) + + # Temporal window for feature + feature_start_timestamp: Optional[DateRange] = Field( + default=None, + validation_alias=AliasChoices("FeatureStartTimestamp", "featureStartTimestamp"), + serialization_alias="FeatureStartTimestamp" + ) + feature_end_timestamp: Optional[DateRange] = Field( + default=None, + validation_alias=AliasChoices("FeatureEndTimestamp", "featureEndTimestamp"), + serialization_alias="FeatureEndTimestamp" + ) + + # Feature values (at least one must be populated) + value_as_number: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), + serialization_alias="ValueAsNumber" + ) + value_as_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("ValueAsConceptId", "valueAsConceptId"), + serialization_alias="ValueAsConceptId" + ) + + # Units for numeric values + unit_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("UnitConceptId", "unitConceptId"), + serialization_alias="UnitConceptId" + ) + + # Links to standard OMOP tables + measurement_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("MeasurementId", "measurementId"), + serialization_alias="MeasurementId" + ) + observation_id: Optional[NumericRange] = Field( + default=None, + validation_alias=AliasChoices("ObservationId", "observationId"), + serialization_alias="ObservationId" + ) + +# Rebuild models to resolve forward references diff --git a/circe/extensions/waveform/templates/waveform_channel_metadata.j2 b/circe/extensions/waveform/templates/waveform_channel_metadata.j2 new file mode 100644 index 00000000..de54b86c --- /dev/null +++ b/circe/extensions/waveform/templates/waveform_channel_metadata.j2 @@ -0,0 +1,32 @@ +{%- import 'input_types.j2' as inputTypes -%} + +{%- macro WaveformChannelMetadata(c, level, isPlural=true, countCriteria={}, indexLabel="cohort entry") -%} + {%- set attrs = [] -%} + + {%- if c.channel_concept_id -%} + {%- set temp -%}channel type: {{ inputTypes.ConceptList(c.channel_concept_id) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.metadata_concept_id -%} + {%- set temp -%}metadata type: {{ inputTypes.ConceptList(c.metadata_concept_id) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.value_as_number -%} + {%- set temp -%}value {{ inputTypes.NumericRange(c.value_as_number) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.unit_concept_id -%} + {%- set temp -%}units: {{ inputTypes.ConceptList(c.unit_concept_id) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + waveform channel metadata record{% if isPlural and not c.first %}s{% endif %} + {%- if c.first %} for the first time in the person's history{% endif -%} + {%- if attrs|length > 0 -%}, {{ attrs|join("; ") }}{%- endif -%} + . +{%- endmacro -%} + +{{ WaveformChannelMetadata(criteria, level, isPlural, countCriteria, indexLabel) }} diff --git a/circe/extensions/waveform/templates/waveform_feature.j2 b/circe/extensions/waveform/templates/waveform_feature.j2 new file mode 100644 index 00000000..efecb768 --- /dev/null +++ b/circe/extensions/waveform/templates/waveform_feature.j2 @@ -0,0 +1,42 @@ +{%- import 'input_types.j2' as inputTypes -%} + +{%- macro WaveformFeature(c, level, isPlural=true, countCriteria={}, indexLabel="cohort entry") -%} + {%- set attrs = [] -%} + + {%- if c.feature_concept_id -%} + {%- set temp -%}feature type: {{ inputTypes.ConceptList(c.feature_concept_id) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.algorithm_concept_id -%} + {%- set temp -%}detected by {{ inputTypes.ConceptList(c.algorithm_concept_id) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.value_as_number -%} + {%- set temp -%}value {{ inputTypes.NumericRange(c.value_as_number) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.unit_concept_id -%} + {%- set temp -%}units: {{ inputTypes.ConceptList(c.unit_concept_id) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.feature_start_timestamp -%} + {%- set temp -%}starting {{ inputTypes.DateRange(c.feature_start_timestamp) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.feature_end_timestamp -%} + {%- set temp -%}ending {{ inputTypes.DateRange(c.feature_end_timestamp) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + waveform-derived feature{% if isPlural and not c.first %}s{% endif %} + {%- if c.first %} for the first time in the person's history{% endif -%} + {%- if attrs|length > 0 -%}, {{ attrs|join("; ") }}{%- endif -%} + . +{%- endmacro -%} + +{{ WaveformFeature(criteria, level, isPlural, countCriteria, indexLabel) }} diff --git a/circe/extensions/waveform/templates/waveform_occurrence.j2 b/circe/extensions/waveform/templates/waveform_occurrence.j2 new file mode 100644 index 00000000..db06653b --- /dev/null +++ b/circe/extensions/waveform/templates/waveform_occurrence.j2 @@ -0,0 +1,39 @@ +{%- import 'input_types.j2' as inputTypes -%} + +{%- macro WaveformOccurrence(c, level, isPlural=true, countCriteria={}, indexLabel="cohort entry") -%} + {%- set attrs = [] -%} + + {# Reuse core WindowCriteria logic if possible, or reimplement #} + {%- if countCriteria and countCriteria.occurrence and countCriteria.occurrence.count_window -%} + {# Simplifying for example #} + {%- set temp -%}occurring relative to {{ indexLabel }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.occurrence_start_datetime -%} + {%- set temp -%}starting {{ inputTypes.DateRange(c.occurrence_start_datetime) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.occurrence_end_datetime -%} + {%- set temp -%}ending {{ inputTypes.DateRange(c.occurrence_end_datetime) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.waveform_occurrence_concept_id -%} + {%- set temp -%}waveform type: {{ inputTypes.ConceptList(c.waveform_occurrence_concept_id) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.num_of_files -%} + {%- set temp -%}with {{ inputTypes.NumericRange(c.num_of_files) }} files{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + waveform occurrence{% if isPlural and not c.first %}s{% endif %} + {%- if c.first %} for the first time in the person's history{% endif -%} + {%- if attrs|length > 0 -%}, {{ attrs|join("; ") }}{%- endif -%} + . +{%- endmacro -%} + +{{ WaveformOccurrence(criteria, level, isPlural, countCriteria, indexLabel) }} diff --git a/circe/extensions/waveform/templates/waveform_registry.j2 b/circe/extensions/waveform/templates/waveform_registry.j2 new file mode 100644 index 00000000..9e92956d --- /dev/null +++ b/circe/extensions/waveform/templates/waveform_registry.j2 @@ -0,0 +1,27 @@ +{%- import 'input_types.j2' as inputTypes -%} + +{%- macro WaveformRegistry(c, level, isPlural=true, countCriteria={}, indexLabel="cohort entry") -%} + {%- set attrs = [] -%} + + {%- if c.file_start_datetime -%} + {%- set temp -%}file starting {{ inputTypes.DateRange(c.file_start_datetime) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.file_end_datetime -%} + {%- set temp -%}file ending {{ inputTypes.DateRange(c.file_end_datetime) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + {%- if c.file_extension_concept_id -%} + {%- set temp -%}file format: {{ inputTypes.ConceptList(c.file_extension_concept_id) }}{%- endset -%} + {%- set _ = attrs.append(temp) -%} + {%- endif -%} + + waveform file{% if isPlural and not c.first %}s{% endif %} + {%- if c.first %} for the first time in the person's history{% endif -%} + {%- if attrs|length > 0 -%}, {{ attrs|join("; ") }}{%- endif -%} + . +{%- endmacro -%} + +{{ WaveformRegistry(criteria, level, isPlural, countCriteria, indexLabel) }} diff --git a/docs/developer/extensions.rst b/docs/developer/extensions.rst new file mode 100644 index 00000000..69904f52 --- /dev/null +++ b/docs/developer/extensions.rst @@ -0,0 +1,155 @@ +Extending circe_py +=================== + +This guide explains how to extend `circe_py` with custom criteria types. This is useful when you have data in your CDM that isn't part of the standard OMOP domains (e.g., weather data, genomic features, or specialized clinical registries). + +Architecture Overview +--------------------- + +The extension system consists of three main components: + +1. **Criteria Class**: A Pydantic model that defines the fields available in your new criteria. +2. **SQL Builder**: A class that translates your criteria into SQL. +3. **Markdown Template**: A Jinja2 template that generates a human-readable description. + +Registration is handled by the `ExtensionRegistry`. + +Example: Weather Conditions +--------------------------- + +Imagine you want to create a cohort based on weather conditions (e.g., "Patients diagnosed with asthma during extreme cold"). + +Step 1: Define the Criteria Class +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Your class must inherit from `circe.cohortdefinition.criteria.Criteria`. Use Pydantic's `Field` and `AliasChoices` to maintain compatibility with both Pythonic (`snake_case`) and Java-style (`PascalCase`) field names. + +.. code-block:: python + + from typing import Optional, List + from pydantic import Field, AliasChoices + from circe.cohortdefinition.criteria import Criteria, CriteriaGroup + from circe.vocabulary.concept import Concept + + class WeatherCondition(Criteria): + """Criteria for weather data linked to persons.""" + weather_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("WeatherConceptId", "weatherConceptId"), + serialization_alias="WeatherConceptId" + ) + temperature_celsius: Optional[float] = Field( + default=None, + validation_alias=AliasChoices("TemperatureCelsius", "temperatureCelsius"), + serialization_alias="TemperatureCelsius" + ) + + # Resolve forward references (required for complex criteria types) + WeatherCondition.model_rebuild() + +Step 2: Implement the SQL Builder +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The SQL builder must inherit from `circe.cohortdefinition.builders.base.CriteriaSqlBuilder`. + +.. code-block:: python + + from typing import Set + from circe.cohortdefinition.builders.base import CriteriaSqlBuilder + from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderOptions + + class WeatherConditionSqlBuilder(CriteriaSqlBuilder[WeatherCondition]): + def get_query_template(self) -> str: + return """ + SELECT C.person_id, C.weather_id as event_id, C.observation_date as start_date, C.observation_date as end_date, + NULL as visit_occurrence_id, C.observation_date as sort_date + FROM @cdm_database_schema.weather_data C + WHERE @whereClause + """ + + def get_default_columns(self) -> Set[CriteriaColumn]: + return {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE} + + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: + if column == CriteriaColumn.START_DATE: + return "C.observation_date" + elif column == CriteriaColumn.END_DATE: + return "C.observation_date" + raise ValueError(f"Unsupported column: {column}") + + def get_criteria_sql_with_options(self, criteria: WeatherCondition, options: BuilderOptions) -> str: + query = self.get_query_template() + where_clauses = ["1=1"] + + if criteria.weather_concept_id: + ids = [str(c.concept_id) for c in criteria.weather_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.weather_concept_id IN ({','.join(ids)})") + + if criteria.temperature_celsius is not None: + where_clauses.append(f"C.temp_c >= {criteria.temperature_celsius}") + + query = query.replace("@cdm_database_schema", options.cdm_database_schema) + query = query.replace("@whereClause", " AND ".join(where_clauses)) + return query + +Step 3: Register the Extension +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use the extension registry to link your classes and templates. + +.. code-block:: python + + from circe.extensions import get_registry + from pathlib import Path + + def register_weather_extension(): + registry = get_registry() + + # 1. Register the Criteria Class + registry.register_criteria_class("WeatherCondition", WeatherCondition) + + # 2. Register the SQL Builder + registry.register_sql_builder(WeatherCondition, WeatherConditionSqlBuilder) + + # 3. Register Markdown Template + # Ensure templates/weather_condition.j2 exists + template_path = Path(__file__).parent / "templates" + registry.add_template_path(template_path) + registry.register_markdown_template(WeatherCondition, "weather_condition.j2") + +Step 4: Create a Markdown Template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Create a file named `weather_condition.j2`: + +.. code-block:: jinja + + Weather condition: {{ criteria.weather_concept_id[0].concept_name if criteria.weather_concept_id else 'Any' }} + {% if criteria.temperature_celsius %} with temperature >= {{ criteria.temperature_celsius }}°C{% endif %}. + +Full End-to-End Usage +--------------------- + +Once registered, you can use your custom criteria just like any built-in type. + +.. code-block:: python + + from circe.cohortdefinition import CohortExpression, PrimaryCriteria + from circe.vocabulary.concept import Concept + + # Setup + register_weather_extension() + + # Define cohort + weather_criteria = WeatherCondition( + weather_concept_id=[Concept(concept_id=123, concept_name="Snowing")], + temperature_celsius=-5.0 + ) + + cohort = CohortExpression( + primary_criteria=PrimaryCriteria(criteria_list=[weather_criteria]) + ) + + # generate SQL or Markdown as usual + # ... diff --git a/docs/index.rst b/docs/index.rst index 8a6e6afa..5740564b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -51,6 +51,7 @@ A Python implementation of the OHDSI CIRCE-BE (Cohort Inclusion and Restriction developer/contributing developer/architecture + developer/extensions developer/testing developer/release diff --git a/docs/waveform_extension.md b/docs/waveform_extension.md new file mode 100644 index 00000000..cb6c0e07 --- /dev/null +++ b/docs/waveform_extension.md @@ -0,0 +1,104 @@ +# OHDSI Waveform Extension for circe_py + +This extension implements the full [OHDSI Waveform Extension specification](https://ohdsi.github.io/WaveformWG/waveform-tables.html) for cohort definition and SQL generation in circe_py. + +## Tables Implemented + +The extension provides criteria classes and SQL builders for all 4 waveform tables: + +1. **waveform_occurrence** - Clinical and temporal context for recording sessions +2. **waveform_registry** - File metadata (format, storage, temporal bounds) +3. **waveform_channel_metadata** - Signal parameters (sampling rates, gains, calibration) +4. **waveform_feature** - Derived measurements (heart rate, SpO2, arrhythmias, AI features) + +## Installation + +Install the waveform extension as an optional extra: + +```bash +pip install "ohdsi-circe-python-alpha[waveform]" +``` + +Then import the package — registration is automatic: + +```python +import circe.extensions.waveform +``` + + +## Usage Examples + +### Example 1: ICU Monitoring Sessions with Multiple Files + +```python +from circe.extensions.waveform.criteria import WaveformOccurrence +from circe.cohortdefinition.core import NumericRange, DateRange + +criteria = WaveformOccurrence( + waveform_occurrence_concept_id=[create_concept(2000000001, "ICU Continuous Monitoring")], + occurrence_start_datetime=DateRange(value="2025-01-01", op="gte"), + num_of_files=NumericRange(value=10, op="gte") +) +``` + +**Generated SQL**: Queries `waveform_occurrence` table for ICU monitoring sessions with ≥10 files starting after 2025-01-01. + +### Example 2: High-Quality ECG Channels + +```python +from circe.extensions.waveform.criteria import WaveformChannelMetadata + +criteria = WaveformChannelMetadata( + channel_concept_id=[create_concept(2000000020, "ECG Lead II")], + metadata_concept_id=[create_concept(2000000030, "Sampling Rate")], + value_as_number=NumericRange(value=500, op="gte"), # ≥500 Hz + unit_concept_id=[create_concept(8504, "Hz")] +) +``` + +**Use Case**: Ensure high-quality signals for QRS detection. + +### Example 3: Derived Heart Rate (Most Clinically Valuable) + +```python +from circe.extensions.waveform.criteria import WaveformFeature + +criteria = WaveformFeature( + feature_concept_id=[create_concept(3027018, "Heart Rate")], + algorithm_concept_id=[create_concept(2000000040, "Pan-Tompkins QRS Detection")], + value_as_number=NumericRange(value=60, op="gte", extent=100), # 60-100 bpm + unit_concept_id=[create_concept(8541, "beats/min")] +) +``` + +**Use Case**: Identify patients with normal cardiac rhythm derived from waveform data. + +### Example 4: EDF File Format Filter + +```python +from circe.extensions.waveform.criteria import WaveformRegistry + +criteria = WaveformRegistry( + file_extension_concept_id=[create_concept(2000000010, "EDF")] +) +``` + +**Use Case**: Filter cohorts to only include patients with EDF waveform files. + +## Architecture + +The extension demonstrates the full circe_py extension capabilities: + +- **Criteria Classes** (`criteria.py`): 4 Pydantic models matching OHDSI spec — decorated with `@criteria_class` +- **SQL Builders** (`builders/*.py`): 4 builders decorated with `@sql_builder` and `@markdown_template` +- **Markdown Templates** (`templates/*.j2`): 4 Jinja2 templates for human-readable output +- **Registration** (`__init__.py`): Fully automatic via decorators on import + +## Running the Examples + +```bash +cd /path/to/circe_py +export PYTHONPATH=. +python3 examples/waveform_extension.py +``` + diff --git a/examples/waveform_extension.py b/examples/waveform_extension.py new file mode 100644 index 00000000..2fd45f38 --- /dev/null +++ b/examples/waveform_extension.py @@ -0,0 +1,209 @@ +""" +Comprehensive example demonstrating the full OHDSI Waveform Extension. + +This example showcases all 4 waveform tables: +1. waveform_occurrence - Clinical context for recording sessions +2. waveform_registry - File metadata +3. waveform_channel_metadata - Signal parameters (sampling rates, etc.) +4. waveform_feature - Derived measurements (heart rate, SpO2, etc.) + +Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html +""" + +import json +from circe.cohortdefinition import CohortExpression, PrimaryCriteria +from circe.cohortdefinition.cohort_expression_query_builder import CohortExpressionQueryBuilder, BuildExpressionQueryOptions +from circe.cohortdefinition.printfriendly.markdown_render import MarkdownRender +from circe.vocabulary.concept import Concept +from circe.cohortdefinition.core import NumericRange, DateRange + +# Import the extension — registration is automatic via decorators +import circe.extensions.waveform + +# Import criteria classes +from circe.extensions.waveform.criteria import ( + WaveformOccurrence, WaveformRegistry, + WaveformChannelMetadata, WaveformFeature +) + +def create_concept(concept_id, name): + """Helper to create a concept.""" + return Concept( + concept_id=concept_id, + concept_name=name, + invalid_reason="", + domain_id="Waveform", + vocabulary_id="Custom", + concept_class_id="Waveform", + standard_concept="S", + concept_code=str(concept_id) + ) + +# ============================================================================= +# Example 1: ICU monitoring session with multiple files +# ============================================================================= +print("=" * 80) +print("Example 1: ICU Telemetry Session with ≥10 Files") +print("=" * 80) + +waveform_occ_example = WaveformOccurrence( + waveform_occurrence_concept_id=[create_concept(2000000001, "ICU Continuous Monitoring")], + occurrence_start_datetime=DateRange(value="2025-01-01", op="gte"), + num_of_files=NumericRange(value=10, op="gte") +) + +expression1 = CohortExpression( + primary_criteria=PrimaryCriteria( + criteria_list=[waveform_occ_example], + observation_window={"priorDays": 0, "postDays": 0}, + primary_limit={"type": "First"} + ), + concept_sets=[], + inclusion_rules=[], + qualified_limit={"type": "First"}, + expression_limit={"type": "First"} +) + +builder = CohortExpressionQueryBuilder() +options = BuildExpressionQueryOptions() +options.cdm_schema = "cdm" +options.result_schema = "results" +options.cohort_id = 1 + +sql1 = builder.build_expression_query(expression1, options) +print("\n--- SQL Snippet ---") +print(sql1[sql1.find("FROM"):sql1.find("FROM")+200] + "...") +print("\n✓ Table: waveform_occurrence") +print("✓ Filters: ICU monitoring, ≥10 files, starting after 2025-01-01") + +md1 = MarkdownRender().render_cohort_expression(expression1) +print("\n--- Markdown ---") +print(md1.split("\n")[4:7]) # Print relevant lines + +# ============================================================================= +# Example 2: EDF files from emergency department +# ============================================================================= +print("\n" + "=" * 80) +print("Example 2: EDF Waveform Files") +print("=" * 80) + +waveform_reg_example = WaveformRegistry( + file_extension_concept_id=[create_concept(2000000010, "EDF")] +) + +expression2 = CohortExpression( + primary_criteria=PrimaryCriteria( + criteria_list=[waveform_reg_example], + observation_window={"priorDays": 0, "postDays": 0}, + primary_limit={"type": "First"} + ), + concept_sets=[], + inclusion_rules=[], + qualified_limit={"type": "First"} +) + +sql2 = builder.build_expression_query(expression2, options) +print("\n--- SQL Snippet ---") +print(sql2[sql2.find("FROM"):sql2.find("FROM")+200] + "...") +print("\n✓ Table: waveform_registry") +print("✓ Filters: EDF file format only") + +# ============================================================================= +# Example 3: High-quality ECG Lead II at ≥500Hz +# ============================================================================= +print("\n" + "=" * 80) +print("Example 3: High-Quality ECG Lead II (≥500 Hz)") +print("=" * 80) + +waveform_chan_example = WaveformChannelMetadata( + channel_concept_id=[create_concept(2000000020, "ECG Lead II")], + metadata_concept_id=[create_concept(2000000030, "Sampling Rate")], + value_as_number=NumericRange(value=500, op="gte"), # ≥500 Hz + unit_concept_id=[create_concept(8504, "Hz")] +) + +expression3 = CohortExpression( + primary_criteria=PrimaryCriteria( + criteria_list=[waveform_chan_example], + observation_window={"priorDays": 0, "postDays": 0}, + primary_limit={"type": "First"} + ), + concept_sets=[], + inclusion_rules=[], + qualified_limit={"type": "First"} +) + +sql3 = builder.build_expression_query(expression3, options) +print("\n--- SQL Snippet ---") +print(sql3[sql3.find("FROM"):sql3.find("FROM")+250] + "...") +print("\n✓ Table: waveform_channel_metadata") +print("✓ Filters: ECG Lead II, sampling rate ≥500 Hz") +print("✓ Use Case: Ensure high-quality signals for QRS detection") + +# ============================================================================= +# Example 4: Derived Heart Rate 60-100 bpm (MOST CLINICALLY VALUABLE) +# ============================================================================= +print("\n" + "=" * 80) +print("Example 4: Derived Heart Rate 60-100 bpm (Normal Range)") +print("=" * 80) + +waveform_feat_example = WaveformFeature( + feature_concept_id=[create_concept(3027018, "Heart Rate")], + algorithm_concept_id=[create_concept(2000000040, "Pan-Tompkins QRS Detection")], + value_as_number=NumericRange(value=60, op="gte", extent=100), # 60-100 bpm + unit_concept_id=[create_concept(8541, "beats/min")] +) + +expression4 = CohortExpression( + primary_criteria=PrimaryCriteria( + criteria_list=[waveform_feat_example], + observation_window={"priorDays": 0, "postDays": 0}, + primary_limit={"type": "First"} + ), + concept_sets=[], + inclusion_rules=[], + qualified_limit={"type": "First"} +) + +sql4 = builder.build_expression_query(expression4, options) +print("\n--- SQL Snippet ---") +print(sql4[sql4.find("FROM"):sql4.find("FROM")+250] + "...") +print("\n✓ Table: waveform_feature") +print("✓ Filters: Heart Rate 60-100 bpm derived by Pan-Tompkins algorithm") +print("✓ Use Case: Identify patients with normal cardiac rhythm") + +md4 = MarkdownRender().render_cohort_expression(expression4) +print("\n--- Markdown ---") +print(md4.split("\n")[4:7]) # Print relevant lines + +# ============================================================================= +# Verification Summary +# ============================================================================= +print("\n" + "=" * 80) +print("VERIFICATION SUMMARY") +print("=" * 80) + +checks = [ + ("waveform_occurrence table used", "waveform_occurrence" in sql1), + ("waveform_registry table used", "waveform_registry" in sql2), + ("waveform_channel_metadata table used", "waveform_channel_metadata" in sql3), + ("waveform_feature table used", "waveform_feature" in sql4), + ("Correct column: waveform_occurrence_concept_id", "waveform_occurrence_concept_id" in sql1), + ("Correct column: waveform_occurrence_start_datetime", "waveform_occurrence_start_datetime" in sql1), + ("Correct column: file_extension_concept_id", "file_extension_concept_id" in sql2), + ("Correct column: channel_concept_id", "channel_concept_id" in sql3), + ("Correct column: feature_concept_id", "feature_concept_id" in sql4), + ("Markdown rendering works", "waveform-derived feature" in md4.lower()) +] + +for check_name, result in checks: + status = "✓" if result else "✗" + print(f"{status} {check_name}") + +all_passed = all(r for _, r in checks) +print("\n" + ("="*80)) +if all_passed: + print("SUCCESS: All 4 OHDSI Waveform Extension tables implemented correctly!") +else: + print("FAILURE: Some checks failed") +print("=" * 80) diff --git a/pyproject.toml b/pyproject.toml index f1b1ca2b..754b3c48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,9 @@ ibis-postgres = [ ibis-databricks = [ "ibis-framework[databricks]>=11.0.0; python_version >= '3.9'", ] +waveform = [ + "pydantic>=2.0.0", +] [project.urls] Homepage = "https://github.com/OHDSI/Circepy" @@ -87,6 +90,7 @@ exclude = ["circe.tests*"] [tool.setuptools.package-data] circe = ["py.typed"] +"circe.extensions.waveform" = ["templates/*.j2"] [tool.black] line-length = 88 diff --git a/tests/test_extension_system.py b/tests/test_extension_system.py new file mode 100644 index 00000000..7d5a1ef6 --- /dev/null +++ b/tests/test_extension_system.py @@ -0,0 +1,179 @@ +import pytest +import json +from typing import Optional, List, Set +from pydantic import Field, AliasChoices + +from circe.cohortdefinition import CohortExpression, PrimaryCriteria, CriteriaGroup +from circe.cohortdefinition.criteria import Criteria +from circe.cohortdefinition.builders.base import CriteriaSqlBuilder +from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderUtils, BuilderOptions +from circe.cohortdefinition.cohort_expression_query_builder import CohortExpressionQueryBuilder, BuildExpressionQueryOptions +from circe.cohortdefinition.printfriendly.markdown_render import MarkdownRender +from circe.vocabulary.concept import Concept +from circe.extensions import get_registry + +# ----------------------------------------------------------------------------- +# 1. Define the Extension Components +# ----------------------------------------------------------------------------- + +class WeatherCondition(Criteria): + """ + Example extension criteria for 'Weather Conditions'. + Imagine a CDM extension where weather data is linked to persons. + """ + weather_concept_id: Optional[List[Concept]] = Field( + default=None, + validation_alias=AliasChoices("WeatherConceptId", "weatherConceptId"), + serialization_alias="WeatherConceptId" + ) + temperature_celsius: Optional[float] = Field( + default=None, + validation_alias=AliasChoices("TemperatureCelsius", "temperatureCelsius"), + serialization_alias="TemperatureCelsius" + ) + +# Important: Rebuild models to resolve forward references inherited from Criteria +WeatherCondition.model_rebuild() + +class WeatherConditionSqlBuilder(CriteriaSqlBuilder[WeatherCondition]): + """ + SQL Builder for WeatherCondition. + """ + def get_query_template(self) -> str: + return """ +SELECT C.person_id, C.weather_id as event_id, C.observation_date as start_date, C.observation_date as end_date, + NULL as visit_occurrence_id, C.observation_date as sort_date +FROM @cdm_database_schema.weather_data C +WHERE @whereClause +""" + + def get_default_columns(self) -> Set[CriteriaColumn]: + return {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE} + + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: + if column == CriteriaColumn.START_DATE: + return "C.observation_date" + elif column == CriteriaColumn.END_DATE: + return "C.observation_date" + else: + raise ValueError(f"Unsupported column: {column}") + + def get_criteria_sql_with_options(self, criteria: WeatherCondition, options: BuilderOptions) -> str: + query = self.get_query_template() + where_clauses = ["1=1"] + + if criteria.weather_concept_id: + ids = [str(c.concept_id) for c in criteria.weather_concept_id if c.concept_id] + if ids: + where_clauses.append(f"C.weather_concept_id IN ({','.join(ids)})") + + if criteria.temperature_celsius is not None: + where_clauses.append(f"C.temp_c >= {criteria.temperature_celsius}") + + query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") + query = query.replace("@whereClause", " AND ".join(where_clauses)) + return query + +# ----------------------------------------------------------------------------- +# 2. Test Cases +# ----------------------------------------------------------------------------- + +def test_simple_extension_integration(tmp_path): + """ + Full end-to-end test of the extension system. + """ + registry = get_registry() + + # Register the extension + registry.register_criteria_class("WeatherCondition", WeatherCondition) + registry.register_sql_builder(WeatherCondition, WeatherConditionSqlBuilder) + + # Create a dummy template file + template_dir = tmp_path / "templates" + template_dir.mkdir() + template_file = template_dir / "weather_condition.j2" + template_file.write_text(""" +Weather condition: {{ criteria.weather_concept_id[0].concept_name if criteria.weather_concept_id else 'Any' }} +{% if criteria.temperature_celsius %} with temperature >= {{ criteria.temperature_celsius }}°C{% endif %}. +""") + + registry.add_template_path(template_dir) + registry.register_markdown_template(WeatherCondition, "weather_condition.j2") + + # Construct a cohort using the extension + weather_concept = Concept(concept_id=123, concept_name="Snowing", standard_concept="S", concept_code="SNOW") + weather_criteria = WeatherCondition( + weather_concept_id=[weather_concept], + temperature_celsius=-5.0 + ) + + expression = CohortExpression( + primary_criteria=PrimaryCriteria( + criteria_list=[weather_criteria], + observation_window={"priorDays": 0, "postDays": 0}, + primary_limit={"type": "First"} + ), + concept_sets=[], + inclusion_rules=[], + qualified_limit={"type": "First"} + ) + + # 1. Test SQL Generation + builder = CohortExpressionQueryBuilder() + sql_options = BuildExpressionQueryOptions() + sql_options.cdm_schema = "my_cdm" + sql = builder.build_expression_query(expression, sql_options) + + assert "weather_data" in sql + assert "weather_concept_id IN (123)" in sql + assert "temp_c >= -5.0" in sql + + # 2. Test Markdown Rendering + renderer = MarkdownRender() + markdown = renderer.render_cohort_expression(expression) + + assert "Weather condition: Snowing" in markdown + assert "temperature >= -5.0°C" in markdown + + # 3. Test JSON Serialization/Deserialization (Round-trip) + # This verifies that Pydantic uses the registry to find the class + json_str = expression.model_dump_json(by_alias=True) + loaded_expression = CohortExpression.model_validate_json(json_str) + + # Check that it loaded as a WeatherCondition object, not a generic Criteria or dict + loaded_criteria = loaded_expression.primary_criteria.criteria_list[0] + assert isinstance(loaded_criteria, WeatherCondition) + assert loaded_criteria.temperature_celsius == -5.0 + assert loaded_criteria.weather_concept_id[0].concept_name == "Snowing" + +def test_unregistered_extension_fails(): + """ + Verifies that using an unregistered extension key in JSON doesn't result + in a custom extension object. It will instead fall back to a standard + Criteria type (like ConditionOccurrence) because all of them have optional + fields and ignore extra fields. + """ + # Using a key that is NOT registered + bad_json_str = json.dumps({ + "PrimaryCriteria": { + "CriteriaList": [ + { + "UnregisteredKey": { + "SomeSpecificField": "Value" + } + } + ], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryLimit": {"Type": "First"} + } + }) + + loaded = CohortExpression.model_validate_json(bad_json_str) + item = loaded.primary_criteria.criteria_list[0] + + # It should NOT be a WeatherCondition (because it's not registered) + assert not isinstance(item, WeatherCondition) + + # It will likely be a ConditionOccurrence because it's first in the Union + # and all fields are optional with extra='ignore'. + assert not hasattr(item, "SomeSpecificField") diff --git a/tests/test_waveform_extension.py b/tests/test_waveform_extension.py new file mode 100644 index 00000000..373e4450 --- /dev/null +++ b/tests/test_waveform_extension.py @@ -0,0 +1,116 @@ +""" +Tests for the waveform extension's decorator-based auto-registration. + +Verifies that importing extensions.waveform is sufficient to register all four +criteria classes, SQL builders, and markdown templates with the global registry. +""" +import pytest +from pathlib import Path + +from circe.extensions import get_registry + + +# --------------------------------------------------------------------------- +# Import the extension — this is the only step an extension author needs. +# All registrations happen via decorators at import time. +# --------------------------------------------------------------------------- +import circe.extensions.waveform # noqa: F401 triggers all decorators + +from circe.extensions.waveform.criteria import ( + WaveformOccurrence, + WaveformRegistry, + WaveformChannelMetadata, + WaveformFeature, +) +from circe.extensions.waveform.builders.waveform_occurrence import WaveformOccurrenceSqlBuilder +from circe.extensions.waveform.builders.waveform_registry import WaveformRegistrySqlBuilder +from circe.extensions.waveform.builders.waveform_channel_metadata import WaveformChannelMetadataSqlBuilder +from circe.extensions.waveform.builders.waveform_feature import WaveformFeatureSqlBuilder + + +# --------------------------------------------------------------------------- +# Criteria class registration +# --------------------------------------------------------------------------- + +class TestCriteriaClassRegistration: + """@criteria_class decorator registers each class by its JSON key.""" + + @pytest.mark.parametrize("name, expected_cls", [ + ("WaveformOccurrence", WaveformOccurrence), + ("WaveformRegistry", WaveformRegistry), + ("WaveformChannelMetadata", WaveformChannelMetadata), + ("WaveformFeature", WaveformFeature), + ]) + def test_criteria_class_registered(self, name, expected_cls): + reg = get_registry() + assert reg.get_criteria_class(name) is expected_cls + + def test_unregistered_name_returns_none(self): + reg = get_registry() + assert reg.get_criteria_class("NonExistentCriteria") is None + + +# --------------------------------------------------------------------------- +# SQL builder registration +# --------------------------------------------------------------------------- + +class TestSqlBuilderRegistration: + """@sql_builder decorator maps each criteria type to the right builder.""" + + @pytest.mark.parametrize("criteria_cls, expected_builder_cls", [ + (WaveformOccurrence, WaveformOccurrenceSqlBuilder), + (WaveformRegistry, WaveformRegistrySqlBuilder), + (WaveformChannelMetadata, WaveformChannelMetadataSqlBuilder), + (WaveformFeature, WaveformFeatureSqlBuilder), + ]) + def test_builder_returned_for_criteria_instance(self, criteria_cls, expected_builder_cls): + reg = get_registry() + instance = criteria_cls() + builder = reg.get_builder(instance) + assert builder is not None + assert isinstance(builder, expected_builder_cls) + + +# --------------------------------------------------------------------------- +# Markdown template registration +# --------------------------------------------------------------------------- + +class TestMarkdownTemplateRegistration: + """@markdown_template decorator maps each criteria type to its .j2 file.""" + + @pytest.mark.parametrize("criteria_cls, expected_template", [ + (WaveformOccurrence, "waveform_occurrence.j2"), + (WaveformRegistry, "waveform_registry.j2"), + (WaveformChannelMetadata, "waveform_channel_metadata.j2"), + (WaveformFeature, "waveform_feature.j2"), + ]) + def test_template_registered(self, criteria_cls, expected_template): + reg = get_registry() + instance = criteria_cls() + assert reg.get_template(instance) == expected_template + + +# --------------------------------------------------------------------------- +# Template path registration +# --------------------------------------------------------------------------- + +class TestTemplatePathRegistration: + """template_path() call in __init__.py adds the templates directory.""" + + def test_template_directory_registered(self): + reg = get_registry() + expected = Path(circe.extensions.waveform.__file__).parent / "templates" + assert expected in reg.template_paths + + def test_template_files_exist(self): + expected = Path(circe.extensions.waveform.__file__).parent / "templates" + for name in [ + "waveform_occurrence.j2", + "waveform_registry.j2", + "waveform_channel_metadata.j2", + "waveform_feature.j2", + ]: + assert (expected / name).exists(), f"Missing template: {name}" + + + From 99fe3596be937735286823cfb7168c9f4797ee11 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Tue, 17 Mar 2026 07:17:35 -0700 Subject: [PATCH 21/62] ruff check and format --- circe/cohortdefinition/builders/__init__.py | 7 +- .../cohort_expression_query_builder.py | 16 +- circe/cohortdefinition/criteria.py | 9 +- .../printfriendly/markdown_render.py | 14 +- circe/extensions/__init__.py | 95 ++++---- circe/extensions/waveform/__init__.py | 23 +- .../extensions/waveform/builders/__init__.py | 1 - .../builders/waveform_channel_metadata.py | 63 ++--- .../waveform/builders/waveform_feature.py | 85 +++---- .../waveform/builders/waveform_occurrence.py | 58 ++--- .../waveform/builders/waveform_registry.py | 61 ++--- circe/extensions/waveform/criteria.py | 227 +++++++----------- examples/waveform_extension.py | 64 ++--- 13 files changed, 302 insertions(+), 421 deletions(-) diff --git a/circe/cohortdefinition/builders/__init__.py b/circe/cohortdefinition/builders/__init__.py index da575d2b..80a0d235 100644 --- a/circe/cohortdefinition/builders/__init__.py +++ b/circe/cohortdefinition/builders/__init__.py @@ -9,8 +9,9 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from .base import CriteriaSqlBuilder from circe.extensions import get_registry + +from .base import CriteriaSqlBuilder from .condition_era import ConditionEraSqlBuilder from .condition_occurrence import ConditionOccurrenceSqlBuilder from .death import DeathSqlBuilder @@ -29,12 +30,14 @@ from .visit_detail import VisitDetailSqlBuilder from .visit_occurrence import VisitOccurrenceSqlBuilder + # Extension support def get_builder_for_criteria(criteria): """Get a SQL builder for a criteria instance, checking extensions first.""" registry = get_registry() return registry.get_builder(criteria) + __all__ = [ # Utility classes "BuilderUtils", @@ -59,5 +62,5 @@ def get_builder_for_criteria(criteria): "PayerPlanPeriodSqlBuilder", "VisitDetailSqlBuilder", "LocationRegionSqlBuilder", - "get_builder_for_criteria" + "get_builder_for_criteria", ] diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index 45d1f55c..918f53b0 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -11,6 +11,8 @@ import json from typing import Any, Optional, Union +from circe.extensions import get_registry + from .builders import ( ConditionEraSqlBuilder, ConditionOccurrenceSqlBuilder, @@ -28,14 +30,9 @@ SpecimenSqlBuilder, VisitDetailSqlBuilder, VisitOccurrenceSqlBuilder, + get_builder_for_criteria, ) from .builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn -from .builders import ( - ConditionOccurrenceSqlBuilder, DeathSqlBuilder, DeviceExposureSqlBuilder, - MeasurementSqlBuilder, ObservationSqlBuilder, SpecimenSqlBuilder, - VisitOccurrenceSqlBuilder, DrugExposureSqlBuilder, ProcedureOccurrenceSqlBuilder, - ConditionEraSqlBuilder, DrugEraSqlBuilder, DoseEraSqlBuilder, ObservationPeriodSqlBuilder, PayerPlanPeriodSqlBuilder, - VisitDetailSqlBuilder, LocationRegionSqlBuilder, get_builder_for_criteria) from .cohort import CohortExpression from .concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder from .core import CustomEraStrategy, DateOffsetStrategy, Period @@ -62,7 +59,6 @@ VisitDetail, VisitOccurrence, ) -from circe.extensions import get_registry from .interfaces import IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher @@ -1434,12 +1430,12 @@ def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] try: criteria_data = dict(criteria_data) if criteria_data else {} # Add defaults if needed - if 'first' not in criteria_data or criteria_data.get('first') is None: - criteria_data['first'] = False + if "first" not in criteria_data or criteria_data.get("first") is None: + criteria_data["first"] = False criteria = registry._criteria_classes[criteria_type].model_validate(criteria_data, strict=False) except Exception as e: - raise ValueError(f"Failed to deserialize extension criteria: {criteria_type} - {e}") + raise ValueError(f"Failed to deserialize extension criteria: {criteria_type} - {e}") from e elif criteria_type in criteria_class_map: try: diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index efc1e3b1..49df9fa1 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -8,13 +8,13 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from pydantic import BaseModel, Field, ConfigDict, model_serializer, AliasChoices, field_validator, BeforeValidator from enum import Enum -from typing import Any, Optional, Union +from typing import Annotated, Any, Optional, Union from pydantic import ( AliasChoices, BaseModel, + BeforeValidator, ConfigDict, Field, field_validator, @@ -250,7 +250,7 @@ class Criteria(CirceBaseModel): @model_serializer(mode="wrap") def _serialize_polymorphic(self, serializer, info): """Serialize with polymorphic type wrapper for Java compatibility.""" - if self.__class__.__name__ == 'Criteria': + if self.__class__.__name__ == "Criteria": return serializer(self) # For subclasses (extensions), we want to ensure all fields are included @@ -1362,12 +1362,14 @@ def normalize_window(window_dict: dict) -> dict: Criteria, # catch-all for extension subclasses ] + def _validate_criteria_extension(v: Any) -> Any: """Deserialize extension criteria from a single-key dict via the extensions registry.""" if isinstance(v, dict) and len(v) == 1: key = next(iter(v)) try: from circe.extensions import get_registry + registry = get_registry() cls = registry.get_criteria_class(key) if cls: @@ -1376,6 +1378,7 @@ def _validate_criteria_extension(v: Any) -> Any: pass return v + CriteriaType = Annotated[_CriteriaTypeUnion, BeforeValidator(_validate_criteria_extension)] # Map for dynamic lookup diff --git a/circe/cohortdefinition/printfriendly/markdown_render.py b/circe/cohortdefinition/printfriendly/markdown_render.py index 6af963df..b6971a3f 100644 --- a/circe/cohortdefinition/printfriendly/markdown_render.py +++ b/circe/cohortdefinition/printfriendly/markdown_render.py @@ -33,10 +33,7 @@ class MarkdownRender: """ def __init__( - self, - concept_sets: Optional[list[ConceptSet]] = None, - include_concept_sets: bool = False, - template_paths: Optional[list[Path]] = None + self, concept_sets: Optional[list[ConceptSet]] = None, include_concept_sets: bool = False, template_paths: Optional[list[Path]] = None ): """Initialize the markdown renderer. @@ -49,7 +46,7 @@ def __init__( self._include_concept_sets = include_concept_sets # Initialize Jinja2 environment with multiple loaders - built_in_template_dir = Path(__file__).parent / 'templates' + built_in_template_dir = Path(__file__).parent / "templates" # Start with built-in templates loaders = [jinja2.FileSystemLoader(str(built_in_template_dir))] @@ -61,6 +58,7 @@ def __init__( # Add registry paths from circe.extensions import get_registry + registry = get_registry() for path in registry.template_paths: loaders.append(jinja2.FileSystemLoader(str(path))) @@ -73,14 +71,14 @@ def __init__( ) # Register custom filters (matching Java utils.ftl) - self._env.filters['format_date'] = self._format_date - self._env.filters['format_number'] = self._format_number + self._env.filters["format_date"] = self._format_date + self._env.filters["format_number"] = self._format_number # Add extension helper to look up template name for a criteria instance def get_template_for_criteria(criteria): return registry.get_template(criteria) - self._env.globals['get_template_for_criteria'] = get_template_for_criteria + self._env.globals["get_template_for_criteria"] = get_template_for_criteria # Register global functions self._env.globals["codeset_name"] = self._codeset_name diff --git a/circe/extensions/__init__.py b/circe/extensions/__init__.py index 114f886b..c98f5ab1 100644 --- a/circe/extensions/__init__.py +++ b/circe/extensions/__init__.py @@ -24,108 +24,110 @@ class WaveformOccurrenceSqlBuilder(CriteriaSqlBuilder): class WaveformOccurrenceMarkdownRenderer: ... """ -from typing import Callable, Dict, List, Optional, Type, Union + from pathlib import Path # Forward references to avoid circular imports # Actual imports happen inside methods or with TYPE_CHECKING -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable, Optional, Union if TYPE_CHECKING: - from .cohortdefinition.criteria import Criteria from .cohortdefinition.builders.base import CriteriaSqlBuilder + from .cohortdefinition.criteria import Criteria + class ExtensionRegistry: """Central registry for OMOP CDM extensions.""" - + def __init__(self): # Maps criteria names to criteria classes (for JSON deserialization) - self._criteria_classes: Dict[str, Type['Criteria']] = {} - + self._criteria_classes: dict[str, type[Criteria]] = {} + # Maps criteria types to SQL builder classes - self._sql_builders: Dict[Type['Criteria'], Type['CriteriaSqlBuilder']] = {} - + self._sql_builders: dict[type[Criteria], type[CriteriaSqlBuilder]] = {} + # Maps criteria types to markdown template names - self._markdown_templates: Dict[Type['Criteria'], str] = {} - + self._markdown_templates: dict[type[Criteria], str] = {} + # List of paths to search for Jinja2 templates - self._template_paths: List[Path] = [] - - def register_criteria_class(self, name: str, cls: Type['Criteria']) -> None: + self._template_paths: list[Path] = [] + + def register_criteria_class(self, name: str, cls: type["Criteria"]) -> None: """Register a new criteria class for JSON deserialization. - + Args: name: The name of the criteria type (e.g. "WaveformOccurrence") cls: The Criteria subclass """ self._criteria_classes[name] = cls - - def register_sql_builder(self, criteria_cls: Type['Criteria'], builder_cls: Type['CriteriaSqlBuilder']) -> None: + + def register_sql_builder(self, criteria_cls: type["Criteria"], builder_cls: type["CriteriaSqlBuilder"]) -> None: """Register a SQL builder for a criteria type. - + Args: criteria_cls: The Criteria subclass builder_cls: The CriteriaSqlBuilder subclass """ self._sql_builders[criteria_cls] = builder_cls - - def register_markdown_template(self, criteria_cls: Type['Criteria'], template_name: str) -> None: + + def register_markdown_template(self, criteria_cls: type["Criteria"], template_name: str) -> None: """Register a Jinja2 template for markdown rendering. - + Args: criteria_cls: The Criteria subclass template_name: The name of the template file (e.g. "waveform_occurrence.j2") """ self._markdown_templates[criteria_cls] = template_name - + def add_template_path(self, path: Path) -> None: """Add a path to search for Jinja2 templates. - + Args: path: Path to a directory containing Jinja2 templates """ if path not in self._template_paths: self._template_paths.append(path) - - def get_builder(self, criteria: 'Criteria') -> Optional['CriteriaSqlBuilder']: + + def get_builder(self, criteria: "Criteria") -> Optional["CriteriaSqlBuilder"]: """Get the SQL builder for a criteria instance. - + Args: criteria: The criteria instance - + Returns: An instance of the registered SQL builder, or None if not found """ builder_cls = self._sql_builders.get(type(criteria)) return builder_cls() if builder_cls else None - - def get_template(self, criteria: 'Criteria') -> Optional[str]: + + def get_template(self, criteria: "Criteria") -> Optional[str]: """Get the markdown template name for a criteria instance. - + Args: criteria: The criteria instance - + Returns: The template name, or None if not found """ return self._markdown_templates.get(type(criteria)) - - def get_criteria_class(self, name: str) -> Optional[Type['Criteria']]: + + def get_criteria_class(self, name: str) -> Optional[type["Criteria"]]: """Get a registered criteria class by name. - + Args: name: The name of the criteria type - + Returns: The Criteria subclass, or None if not found """ return self._criteria_classes.get(name) - + @property - def template_paths(self) -> List[Path]: + def template_paths(self) -> list[Path]: """Get all registered template paths.""" return list(self._template_paths) + # Global registry instance _registry = ExtensionRegistry() @@ -139,7 +141,8 @@ def get_registry() -> ExtensionRegistry: # Decorator helpers # --------------------------------------------------------------------------- -def criteria_class(name: str) -> "Callable[[Type['Criteria']], Type['Criteria']]": + +def criteria_class(name: str) -> "Callable[[type['Criteria']], type['Criteria']]": """Class decorator that registers a Criteria subclass for JSON deserialization. Args: @@ -152,13 +155,15 @@ def criteria_class(name: str) -> "Callable[[Type['Criteria']], Type['Criteria']] class WaveformOccurrence(Criteria): ... """ - def decorator(cls: "Type['Criteria']") -> "Type['Criteria']": + + def decorator(cls: "type['Criteria']") -> "type['Criteria']": _registry.register_criteria_class(name, cls) # type: ignore[arg-type] return cls + return decorator # type: ignore[return-value] -def sql_builder(criteria_cls: "Type['Criteria']") -> "Callable[[Type['CriteriaSqlBuilder']], Type['CriteriaSqlBuilder']]": +def sql_builder(criteria_cls: "type['Criteria']") -> "Callable[[type['CriteriaSqlBuilder']], type['CriteriaSqlBuilder']]": """Class decorator that registers a SQL builder for a given Criteria type. Args: @@ -170,13 +175,15 @@ def sql_builder(criteria_cls: "Type['Criteria']") -> "Callable[[Type['CriteriaSq class WaveformOccurrenceSqlBuilder(CriteriaSqlBuilder): ... """ - def decorator(builder_cls: "Type['CriteriaSqlBuilder']") -> "Type['CriteriaSqlBuilder']": + + def decorator(builder_cls: "type['CriteriaSqlBuilder']") -> "type['CriteriaSqlBuilder']": _registry.register_sql_builder(criteria_cls, builder_cls) # type: ignore[arg-type] return builder_cls + return decorator # type: ignore[return-value] -def markdown_template(criteria_cls: "Type['Criteria']", template_name: str) -> "Callable[[Type], Type]": +def markdown_template(criteria_cls: "type['Criteria']", template_name: str) -> "Callable[[type], type]": """Class decorator that registers a Jinja2 markdown template for a Criteria type. Args: @@ -190,9 +197,11 @@ def markdown_template(criteria_cls: "Type['Criteria']", template_name: str) -> " class WaveformOccurrenceMarkdownRenderer: ... """ - def decorator(cls: Type) -> Type: + + def decorator(cls: type) -> type: _registry.register_markdown_template(criteria_cls, template_name) # type: ignore[arg-type] return cls + return decorator @@ -210,5 +219,3 @@ def template_path(path: Union[str, Path]) -> None: template_path(Path(__file__).parent / "templates") """ _registry.add_template_path(Path(path)) - - diff --git a/circe/extensions/waveform/__init__.py b/circe/extensions/waveform/__init__.py index 1d280c7d..bd410196 100644 --- a/circe/extensions/waveform/__init__.py +++ b/circe/extensions/waveform/__init__.py @@ -1,14 +1,27 @@ from pathlib import Path + from circe.extensions import template_path -# Importing criteria triggers @criteria_class decorators -from .criteria import WaveformOccurrence, WaveformRegistry, WaveformChannelMetadata, WaveformFeature +from .builders.waveform_channel_metadata import WaveformChannelMetadataSqlBuilder +from .builders.waveform_feature import WaveformFeatureSqlBuilder # Importing builders triggers @sql_builder and @markdown_template decorators from .builders.waveform_occurrence import WaveformOccurrenceSqlBuilder from .builders.waveform_registry import WaveformRegistrySqlBuilder -from .builders.waveform_channel_metadata import WaveformChannelMetadataSqlBuilder -from .builders.waveform_feature import WaveformFeatureSqlBuilder + +# Importing criteria triggers @criteria_class decorators +from .criteria import WaveformChannelMetadata, WaveformFeature, WaveformOccurrence, WaveformRegistry # Register the templates directory so Jinja2 can locate extension templates -template_path(Path(__file__).parent / "templates") \ No newline at end of file +template_path(Path(__file__).parent / "templates") + +__all__ = [ + "WaveformChannelMetadata", + "WaveformFeature", + "WaveformOccurrence", + "WaveformRegistry", + "WaveformChannelMetadataSqlBuilder", + "WaveformFeatureSqlBuilder", + "WaveformOccurrenceSqlBuilder", + "WaveformRegistrySqlBuilder", +] diff --git a/circe/extensions/waveform/builders/__init__.py b/circe/extensions/waveform/builders/__init__.py index a9fadbaa..622b5a40 100644 --- a/circe/extensions/waveform/builders/__init__.py +++ b/circe/extensions/waveform/builders/__init__.py @@ -1,2 +1 @@ """builders sub-package for the waveform extension.""" - diff --git a/circe/extensions/waveform/builders/waveform_channel_metadata.py b/circe/extensions/waveform/builders/waveform_channel_metadata.py index e14d2c73..37a5fde8 100644 --- a/circe/extensions/waveform/builders/waveform_channel_metadata.py +++ b/circe/extensions/waveform/builders/waveform_channel_metadata.py @@ -1,8 +1,7 @@ -from typing import Set - from circe.cohortdefinition.builders.base import CriteriaSqlBuilder -from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderUtils, BuilderOptions -from circe.extensions import sql_builder, markdown_template +from circe.cohortdefinition.builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn +from circe.extensions import markdown_template, sql_builder + from ..criteria import WaveformChannelMetadata @@ -11,11 +10,11 @@ class WaveformChannelMetadataSqlBuilder(CriteriaSqlBuilder[WaveformChannelMetadata]): """ SQL Builder for Waveform Channel Metadata criteria. - + Maps to the waveform_channel_metadata table in the OHDSI Waveform Extension. Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html """ - + def get_query_template(self) -> str: return """ SELECT C.person_id, C.waveform_channel_metadata_id as event_id, @@ -28,83 +27,71 @@ def get_query_template(self) -> str: @joinClause WHERE @whereClause """ - - def get_default_columns(self) -> Set[CriteriaColumn]: + + def get_default_columns(self) -> set[CriteriaColumn]: return set() # Metadata doesn't have standard event columns - + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: # Channel metadata doesn't map to standard event columns raise ValueError(f"Invalid CriteriaColumn for Waveform Channel Metadata: {column}") def get_criteria_sql_with_options(self, criteria: WaveformChannelMetadata, options: BuilderOptions) -> str: query = self.get_query_template() - + where_clauses = [] join_clauses = [] codeset_clause = "" - + # Link to registry file if criteria.waveform_registry_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.waveform_registry_id", criteria.waveform_registry_id) - ) - + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.waveform_registry_id", criteria.waveform_registry_id)) + # Channel identification if criteria.channel_concept_id: ids = [str(c.concept_id) for c in criteria.channel_concept_id if c.concept_id] if ids: where_clauses.append(f"C.channel_concept_id IN ({','.join(ids)})") if criteria.waveform_channel_source_value: - where_clauses.append( - BuilderUtils.build_text_filter_clause("C.waveform_channel_source_value", criteria.waveform_channel_source_value) - ) - + where_clauses.append(BuilderUtils.build_text_filter_clause("C.waveform_channel_source_value", criteria.waveform_channel_source_value)) + # Metadata type if criteria.metadata_concept_id: ids = [str(c.concept_id) for c in criteria.metadata_concept_id if c.concept_id] if ids: where_clauses.append(f"C.metadata_concept_id IN ({','.join(ids)})") if criteria.metadata_source_value: - where_clauses.append( - BuilderUtils.build_text_filter_clause("C.metadata_source_value", criteria.metadata_source_value) - ) - + where_clauses.append(BuilderUtils.build_text_filter_clause("C.metadata_source_value", criteria.metadata_source_value)) + # Metadata values if criteria.value_as_number: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number)) if criteria.value_as_concept_id: ids = [str(c.concept_id) for c in criteria.value_as_concept_id if c.concept_id] if ids: where_clauses.append(f"C.value_as_concept_id IN ({','.join(ids)})") - + # Units if criteria.unit_concept_id: ids = [str(c.concept_id) for c in criteria.unit_concept_id if c.concept_id] if ids: where_clauses.append(f"C.unit_concept_id IN ({','.join(ids)})") - + # Device/procedure linkage if criteria.device_exposure_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.device_exposure_id", criteria.device_exposure_id) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.device_exposure_id", criteria.device_exposure_id)) if criteria.procedure_occurrence_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.procedure_occurrence_id", criteria.procedure_occurrence_id) - ) - + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.procedure_occurrence_id", criteria.procedure_occurrence_id)) + # Get person_id from registry since it's not in channel_metadata where_clauses.append("WR.person_id IS NOT NULL") - + # Apply replacements query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") query = query.replace("@codesetClause", codeset_clause) query = query.replace("@joinClause", "\n".join(join_clauses)) query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") - + # Fix person_id in SELECT - need to pull from registry query = query.replace("C.person_id", "WR.person_id") - + return query diff --git a/circe/extensions/waveform/builders/waveform_feature.py b/circe/extensions/waveform/builders/waveform_feature.py index 5e115863..84c8a53e 100644 --- a/circe/extensions/waveform/builders/waveform_feature.py +++ b/circe/extensions/waveform/builders/waveform_feature.py @@ -1,8 +1,7 @@ -from typing import Set - from circe.cohortdefinition.builders.base import CriteriaSqlBuilder -from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderUtils, BuilderOptions -from circe.extensions import sql_builder, markdown_template +from circe.cohortdefinition.builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn +from circe.extensions import markdown_template, sql_builder + from ..criteria import WaveformFeature @@ -11,14 +10,14 @@ class WaveformFeatureSqlBuilder(CriteriaSqlBuilder[WaveformFeature]): """ SQL Builder for Waveform Feature criteria. - + Maps to the waveform_feature table in the OHDSI Waveform Extension. This is the most clinically valuable table for cohort selection, containing derived measurements like heart rate, SpO2, arrhythmia detections, etc. - + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html """ - + def get_query_template(self) -> str: return """ SELECT C.person_id, C.waveform_feature_id as event_id, @@ -32,14 +31,10 @@ def get_query_template(self) -> str: @joinClause WHERE @whereClause """ - - def get_default_columns(self) -> Set[CriteriaColumn]: - return { - CriteriaColumn.START_DATE, - CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID - } - + + def get_default_columns(self) -> set[CriteriaColumn]: + return {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_ID} + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: if column == CriteriaColumn.START_DATE: return "C.waveform_feature_start_timestamp" @@ -52,87 +47,69 @@ def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: def get_criteria_sql_with_options(self, criteria: WaveformFeature, options: BuilderOptions) -> str: query = self.get_query_template() - + where_clauses = [] join_clauses = [] codeset_clause = "" - + # Parent links if criteria.waveform_occurrence_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.waveform_occurrence_id", criteria.waveform_occurrence_id) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.waveform_occurrence_id", criteria.waveform_occurrence_id)) if criteria.waveform_registry_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.waveform_registry_id", criteria.waveform_registry_id) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.waveform_registry_id", criteria.waveform_registry_id)) if criteria.waveform_channel_metadata_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.waveform_channel_metadata_id", criteria.waveform_channel_metadata_id) - ) - + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.waveform_channel_metadata_id", criteria.waveform_channel_metadata_id)) + # Feature type (e.g., heart rate, SpO2) if criteria.feature_concept_id: ids = [str(c.concept_id) for c in criteria.feature_concept_id if c.concept_id] if ids: where_clauses.append(f"C.feature_concept_id IN ({','.join(ids)})") - + # Algorithm used if criteria.algorithm_concept_id: ids = [str(c.concept_id) for c in criteria.algorithm_concept_id if c.concept_id] if ids: where_clauses.append(f"C.algorithm_concept_id IN ({','.join(ids)})") if criteria.algorithm_source_value: - where_clauses.append( - BuilderUtils.build_text_filter_clause("C.algorithm_source_value", criteria.algorithm_source_value) - ) - + where_clauses.append(BuilderUtils.build_text_filter_clause("C.algorithm_source_value", criteria.algorithm_source_value)) + # Temporal window if criteria.feature_start_timestamp: - where_clauses.append( - BuilderUtils.build_date_range_clause("C.waveform_feature_start_timestamp", criteria.feature_start_timestamp) - ) + where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_feature_start_timestamp", criteria.feature_start_timestamp)) if criteria.feature_end_timestamp: - where_clauses.append( - BuilderUtils.build_date_range_clause("C.waveform_feature_end_timestamp", criteria.feature_end_timestamp) - ) - + where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_feature_end_timestamp", criteria.feature_end_timestamp)) + # Feature values if criteria.value_as_number: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number)) if criteria.value_as_concept_id: ids = [str(c.concept_id) for c in criteria.value_as_concept_id if c.concept_id] if ids: where_clauses.append(f"C.value_as_concept_id IN ({','.join(ids)})") - + # Units if criteria.unit_concept_id: ids = [str(c.concept_id) for c in criteria.unit_concept_id if c.concept_id] if ids: where_clauses.append(f"C.unit_concept_id IN ({','.join(ids)})") - + # Links to standard OMOP tables if criteria.measurement_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.measurement_id", criteria.measurement_id) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.measurement_id", criteria.measurement_id)) if criteria.observation_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.observation_id", criteria.observation_id) - ) - + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.observation_id", criteria.observation_id)) + # Get person_id from occurrence where_clauses.append("WO.person_id IS NOT NULL") - + # Apply replacements query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") query = query.replace("@codesetClause", codeset_clause) query = query.replace("@joinClause", "\n".join(join_clauses)) query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") - + # Fix person_id in SELECT - need to pull from occurrence query = query.replace("C.person_id", "WO.person_id") - + return query diff --git a/circe/extensions/waveform/builders/waveform_occurrence.py b/circe/extensions/waveform/builders/waveform_occurrence.py index 0f0ba26d..57cba99a 100644 --- a/circe/extensions/waveform/builders/waveform_occurrence.py +++ b/circe/extensions/waveform/builders/waveform_occurrence.py @@ -1,8 +1,7 @@ -from typing import Set - from circe.cohortdefinition.builders.base import CriteriaSqlBuilder -from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderUtils, BuilderOptions -from circe.extensions import sql_builder, markdown_template +from circe.cohortdefinition.builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn +from circe.extensions import markdown_template, sql_builder + from ..criteria import WaveformOccurrence @@ -11,11 +10,11 @@ class WaveformOccurrenceSqlBuilder(CriteriaSqlBuilder[WaveformOccurrence]): """ SQL Builder for Waveform Occurrence criteria. - + Maps to the waveform_occurrence table in the OHDSI Waveform Extension. Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html """ - + def get_query_template(self) -> str: return """ SELECT C.person_id, C.waveform_occurrence_id as event_id, @@ -28,15 +27,10 @@ def get_query_template(self) -> str: @joinClause WHERE @whereClause """ - - def get_default_columns(self) -> Set[CriteriaColumn]: - return { - CriteriaColumn.START_DATE, - CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID, - CriteriaColumn.DOMAIN_CONCEPT - } - + + def get_default_columns(self) -> set[CriteriaColumn]: + return {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_ID, CriteriaColumn.DOMAIN_CONCEPT} + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: if column == CriteriaColumn.START_DATE: return "C.waveform_occurrence_start_datetime" @@ -51,11 +45,11 @@ def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: def get_criteria_sql_with_options(self, criteria: WaveformOccurrence, options: BuilderOptions) -> str: query = self.get_query_template() - + where_clauses = [] join_clauses = [] codeset_clause = "" - + # Filter by waveform occurrence concept if criteria.waveform_occurrence_concept_id: ids = [str(c.concept_id) for c in criteria.waveform_occurrence_concept_id if c.concept_id] @@ -64,36 +58,26 @@ def get_criteria_sql_with_options(self, criteria: WaveformOccurrence, options: B # Date filters if criteria.occurrence_start_datetime: - where_clauses.append( - BuilderUtils.build_date_range_clause("C.waveform_occurrence_start_datetime", criteria.occurrence_start_datetime) - ) + where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_occurrence_start_datetime", criteria.occurrence_start_datetime)) if criteria.occurrence_end_datetime: - where_clauses.append( - BuilderUtils.build_date_range_clause("C.waveform_occurrence_end_datetime", criteria.occurrence_end_datetime) - ) - + where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_occurrence_end_datetime", criteria.occurrence_end_datetime)) + # Visit context if criteria.visit_occurrence_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.visit_occurrence_id", criteria.visit_occurrence_id) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.visit_occurrence_id", criteria.visit_occurrence_id)) if criteria.visit_detail_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.visit_detail_id", criteria.visit_detail_id) - ) - + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.visit_detail_id", criteria.visit_detail_id)) + # File metadata if criteria.num_of_files: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.num_of_files", criteria.num_of_files) - ) - + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.num_of_files", criteria.num_of_files)) + # Source value text filter if criteria.waveform_occurrence_source_value: where_clauses.append( BuilderUtils.build_text_filter_clause("C.waveform_occurrence_source_value", criteria.waveform_occurrence_source_value) ) - + # Sequence/chain filtering if criteria.preceding_waveform_occurrence_id: where_clauses.append( @@ -105,5 +89,5 @@ def get_criteria_sql_with_options(self, criteria: WaveformOccurrence, options: B query = query.replace("@codesetClause", codeset_clause) query = query.replace("@joinClause", "\n".join(join_clauses)) query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") - + return query diff --git a/circe/extensions/waveform/builders/waveform_registry.py b/circe/extensions/waveform/builders/waveform_registry.py index ff3c1908..6a826ea9 100644 --- a/circe/extensions/waveform/builders/waveform_registry.py +++ b/circe/extensions/waveform/builders/waveform_registry.py @@ -1,8 +1,7 @@ -from typing import Set - from circe.cohortdefinition.builders.base import CriteriaSqlBuilder -from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderUtils, BuilderOptions -from circe.extensions import sql_builder, markdown_template +from circe.cohortdefinition.builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn +from circe.extensions import markdown_template, sql_builder + from ..criteria import WaveformRegistry @@ -11,11 +10,11 @@ class WaveformRegistrySqlBuilder(CriteriaSqlBuilder[WaveformRegistry]): """ SQL Builder for Waveform Registry criteria. - + Maps to the waveform_registry table in the OHDSI Waveform Extension. Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html """ - + def get_query_template(self) -> str: return """ SELECT C.person_id, C.waveform_registry_id as event_id, @@ -28,14 +27,10 @@ def get_query_template(self) -> str: @joinClause WHERE @whereClause """ - - def get_default_columns(self) -> Set[CriteriaColumn]: - return { - CriteriaColumn.START_DATE, - CriteriaColumn.END_DATE, - CriteriaColumn.VISIT_ID - } - + + def get_default_columns(self) -> set[CriteriaColumn]: + return {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_ID} + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: if column == CriteriaColumn.START_DATE: return "C.waveform_file_start_datetime" @@ -48,51 +43,39 @@ def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: def get_criteria_sql_with_options(self, criteria: WaveformRegistry, options: BuilderOptions) -> str: query = self.get_query_template() - + where_clauses = [] join_clauses = [] codeset_clause = "" - + # Link to parent occurrence if criteria.waveform_occurrence_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.waveform_occurrence_id", criteria.waveform_occurrence_id) - ) - + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.waveform_occurrence_id", criteria.waveform_occurrence_id)) + # File temporal bounds if criteria.file_start_datetime: - where_clauses.append( - BuilderUtils.build_date_range_clause("C.waveform_file_start_datetime", criteria.file_start_datetime) - ) + where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_file_start_datetime", criteria.file_start_datetime)) if criteria.file_end_datetime: - where_clauses.append( - BuilderUtils.build_date_range_clause("C.waveform_file_end_datetime", criteria.file_end_datetime) - ) - + where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_file_end_datetime", criteria.file_end_datetime)) + # File format if criteria.file_extension_concept_id: ids = [str(c.concept_id) for c in criteria.file_extension_concept_id if c.concept_id] if ids: where_clauses.append(f"C.file_extension_concept_id IN ({','.join(ids)})") if criteria.file_extension_source_value: - where_clauses.append( - BuilderUtils.build_text_filter_clause("C.file_extension_source_value", criteria.file_extension_source_value) - ) - + where_clauses.append(BuilderUtils.build_text_filter_clause("C.file_extension_source_value", criteria.file_extension_source_value)) + # Visit context if criteria.visit_occurrence_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.visit_occurrence_id", criteria.visit_occurrence_id) - ) + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.visit_occurrence_id", criteria.visit_occurrence_id)) if criteria.visit_detail_id: - where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.visit_detail_id", criteria.visit_detail_id) - ) - + where_clauses.append(BuilderUtils.build_numeric_range_clause("C.visit_detail_id", criteria.visit_detail_id)) + # Apply replacements query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") query = query.replace("@codesetClause", codeset_clause) query = query.replace("@joinClause", "\n".join(join_clauses)) query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") - + return query diff --git a/circe/extensions/waveform/criteria.py b/circe/extensions/waveform/criteria.py index 131123ef..7b7583f9 100644 --- a/circe/extensions/waveform/criteria.py +++ b/circe/extensions/waveform/criteria.py @@ -1,8 +1,9 @@ -from typing import Optional, List -from pydantic import Field, AliasChoices +from typing import Optional -from circe.cohortdefinition.criteria import Criteria, CriteriaGroup -from circe.cohortdefinition.core import NumericRange, DateRange, TextFilter +from pydantic import AliasChoices, Field + +from circe.cohortdefinition.core import DateRange, NumericRange, TextFilter +from circe.cohortdefinition.criteria import Criteria from circe.extensions import criteria_class from circe.vocabulary.concept import Concept @@ -11,115 +12,98 @@ class WaveformOccurrence(Criteria): """ Criteria for Waveform Occurrence. - + Represents the clinical and temporal context for a waveform recording session. Maps to the waveform_occurrence table in the OHDSI Waveform Extension. - + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html """ + # Core concept - type of waveform recording - waveform_occurrence_concept_id: Optional[List[Concept]] = Field( + waveform_occurrence_concept_id: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("WaveformOccurrenceConceptId", "waveformOccurrenceConceptId"), - serialization_alias="WaveformOccurrenceConceptId" + serialization_alias="WaveformOccurrenceConceptId", ) - + # Temporal bounds occurrence_start_datetime: Optional[DateRange] = Field( default=None, validation_alias=AliasChoices("OccurrenceStartDatetime", "occurrenceStartDatetime"), - serialization_alias="OccurrenceStartDatetime" + serialization_alias="OccurrenceStartDatetime", ) occurrence_end_datetime: Optional[DateRange] = Field( - default=None, - validation_alias=AliasChoices("OccurrenceEndDatetime", "occurrenceEndDatetime"), - serialization_alias="OccurrenceEndDatetime" + default=None, validation_alias=AliasChoices("OccurrenceEndDatetime", "occurrenceEndDatetime"), serialization_alias="OccurrenceEndDatetime" ) - + # Visit context visit_occurrence_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("VisitOccurrenceId", "visitOccurrenceId"), - serialization_alias="VisitOccurrenceId" + default=None, validation_alias=AliasChoices("VisitOccurrenceId", "visitOccurrenceId"), serialization_alias="VisitOccurrenceId" ) visit_detail_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("VisitDetailId", "visitDetailId"), - serialization_alias="VisitDetailId" + default=None, validation_alias=AliasChoices("VisitDetailId", "visitDetailId"), serialization_alias="VisitDetailId" ) - + # File metadata num_of_files: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("NumOfFiles", "numOfFiles"), - serialization_alias="NumOfFiles" + default=None, validation_alias=AliasChoices("NumOfFiles", "numOfFiles"), serialization_alias="NumOfFiles" ) - + # Source identifiers waveform_occurrence_source_value: Optional[TextFilter] = Field( default=None, validation_alias=AliasChoices("WaveformOccurrenceSourceValue", "waveformOccurrenceSourceValue"), - serialization_alias="WaveformOccurrenceSourceValue" + serialization_alias="WaveformOccurrenceSourceValue", ) - + # Sequence/chain filtering preceding_waveform_occurrence_id: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("PrecedingWaveformOccurrenceId", "precedingWaveformOccurrenceId"), - serialization_alias="PrecedingWaveformOccurrenceId" + serialization_alias="PrecedingWaveformOccurrenceId", ) + @criteria_class("WaveformRegistry") class WaveformRegistry(Criteria): """ Criteria for Waveform Registry. - + Registers individual waveform files with their storage locations, formats, and temporal boundaries. Maps to the waveform_registry table in the OHDSI Waveform Extension. - + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html """ + # Link to parent occurrence waveform_occurrence_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("WaveformOccurrenceId", "waveformOccurrenceId"), - serialization_alias="WaveformOccurrenceId" + default=None, validation_alias=AliasChoices("WaveformOccurrenceId", "waveformOccurrenceId"), serialization_alias="WaveformOccurrenceId" ) - + # File temporal bounds file_start_datetime: Optional[DateRange] = Field( - default=None, - validation_alias=AliasChoices("FileStartDatetime", "fileStartDatetime"), - serialization_alias="FileStartDatetime" + default=None, validation_alias=AliasChoices("FileStartDatetime", "fileStartDatetime"), serialization_alias="FileStartDatetime" ) file_end_datetime: Optional[DateRange] = Field( - default=None, - validation_alias=AliasChoices("FileEndDatetime", "fileEndDatetime"), - serialization_alias="FileEndDatetime" + default=None, validation_alias=AliasChoices("FileEndDatetime", "fileEndDatetime"), serialization_alias="FileEndDatetime" ) - + # File format - file_extension_concept_id: Optional[List[Concept]] = Field( - default=None, - validation_alias=AliasChoices("FileExtensionConceptId", "fileExtensionConceptId"), - serialization_alias="FileExtensionConceptId" + file_extension_concept_id: Optional[list[Concept]] = Field( + default=None, validation_alias=AliasChoices("FileExtensionConceptId", "fileExtensionConceptId"), serialization_alias="FileExtensionConceptId" ) file_extension_source_value: Optional[TextFilter] = Field( default=None, validation_alias=AliasChoices("FileExtensionSourceValue", "fileExtensionSourceValue"), - serialization_alias="FileExtensionSourceValue" + serialization_alias="FileExtensionSourceValue", ) - + # Visit context (denormalized for easier querying) visit_occurrence_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("VisitOccurrenceId", "visitOccurrenceId"), - serialization_alias="VisitOccurrenceId" + default=None, validation_alias=AliasChoices("VisitOccurrenceId", "visitOccurrenceId"), serialization_alias="VisitOccurrenceId" ) visit_detail_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("VisitDetailId", "visitDetailId"), - serialization_alias="VisitDetailId" + default=None, validation_alias=AliasChoices("VisitDetailId", "visitDetailId"), serialization_alias="VisitDetailId" ) @@ -127,73 +111,56 @@ class WaveformRegistry(Criteria): class WaveformChannelMetadata(Criteria): """ Criteria for Waveform Channel Metadata. - + Describes per-signal-channel metadata including sampling rates, gains, calibration factors, and signal quality indicators. Maps to the waveform_channel_metadata table in the OHDSI Waveform Extension. - + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html """ + # Link to registry file waveform_registry_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("WaveformRegistryId", "waveformRegistryId"), - serialization_alias="WaveformRegistryId" + default=None, validation_alias=AliasChoices("WaveformRegistryId", "waveformRegistryId"), serialization_alias="WaveformRegistryId" ) - + # Channel identification - channel_concept_id: Optional[List[Concept]] = Field( - default=None, - validation_alias=AliasChoices("ChannelConceptId", "channelConceptId"), - serialization_alias="ChannelConceptId" + channel_concept_id: Optional[list[Concept]] = Field( + default=None, validation_alias=AliasChoices("ChannelConceptId", "channelConceptId"), serialization_alias="ChannelConceptId" ) waveform_channel_source_value: Optional[TextFilter] = Field( default=None, validation_alias=AliasChoices("WaveformChannelSourceValue", "waveformChannelSourceValue"), - serialization_alias="WaveformChannelSourceValue" + serialization_alias="WaveformChannelSourceValue", ) - + # Metadata type (e.g., sampling rate, gain, offset) - metadata_concept_id: Optional[List[Concept]] = Field( - default=None, - validation_alias=AliasChoices("MetadataConceptId", "metadataConceptId"), - serialization_alias="MetadataConceptId" + metadata_concept_id: Optional[list[Concept]] = Field( + default=None, validation_alias=AliasChoices("MetadataConceptId", "metadataConceptId"), serialization_alias="MetadataConceptId" ) metadata_source_value: Optional[TextFilter] = Field( - default=None, - validation_alias=AliasChoices("MetadataSourceValue", "metadataSourceValue"), - serialization_alias="MetadataSourceValue" + default=None, validation_alias=AliasChoices("MetadataSourceValue", "metadataSourceValue"), serialization_alias="MetadataSourceValue" ) - + # Metadata values (at least one must be populated) value_as_number: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), - serialization_alias="ValueAsNumber" + default=None, validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), serialization_alias="ValueAsNumber" ) - value_as_concept_id: Optional[List[Concept]] = Field( - default=None, - validation_alias=AliasChoices("ValueAsConceptId", "valueAsConceptId"), - serialization_alias="ValueAsConceptId" + value_as_concept_id: Optional[list[Concept]] = Field( + default=None, validation_alias=AliasChoices("ValueAsConceptId", "valueAsConceptId"), serialization_alias="ValueAsConceptId" ) - + # Units for numeric values - unit_concept_id: Optional[List[Concept]] = Field( - default=None, - validation_alias=AliasChoices("UnitConceptId", "unitConceptId"), - serialization_alias="UnitConceptId" + unit_concept_id: Optional[list[Concept]] = Field( + default=None, validation_alias=AliasChoices("UnitConceptId", "unitConceptId"), serialization_alias="UnitConceptId" ) - + # Device/procedure linkage device_exposure_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("DeviceExposureId", "deviceExposureId"), - serialization_alias="DeviceExposureId" + default=None, validation_alias=AliasChoices("DeviceExposureId", "deviceExposureId"), serialization_alias="DeviceExposureId" ) procedure_occurrence_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("ProcedureOccurrenceId", "procedureOccurrenceId"), - serialization_alias="ProcedureOccurrenceId" + default=None, validation_alias=AliasChoices("ProcedureOccurrenceId", "procedureOccurrenceId"), serialization_alias="ProcedureOccurrenceId" ) @@ -201,90 +168,68 @@ class WaveformChannelMetadata(Criteria): class WaveformFeature(Criteria): """ Criteria for Waveform Feature. - + Stores measurements and features derived from waveform signals. Supports both traditional signal processing features and AI-derived embeddings. Maps to the waveform_feature table in the OHDSI Waveform Extension. - + Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html """ + # Parent links waveform_occurrence_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("WaveformOccurrenceId", "waveformOccurrenceId"), - serialization_alias="WaveformOccurrenceId" + default=None, validation_alias=AliasChoices("WaveformOccurrenceId", "waveformOccurrenceId"), serialization_alias="WaveformOccurrenceId" ) waveform_registry_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("WaveformRegistryId", "waveformRegistryId"), - serialization_alias="WaveformRegistryId" + default=None, validation_alias=AliasChoices("WaveformRegistryId", "waveformRegistryId"), serialization_alias="WaveformRegistryId" ) waveform_channel_metadata_id: Optional[NumericRange] = Field( default=None, validation_alias=AliasChoices("WaveformChannelMetadataId", "waveformChannelMetadataId"), - serialization_alias="WaveformChannelMetadataId" + serialization_alias="WaveformChannelMetadataId", ) - + # Feature type (e.g., heart rate, SpO2, QRS detection) - feature_concept_id: Optional[List[Concept]] = Field( - default=None, - validation_alias=AliasChoices("FeatureConceptId", "featureConceptId"), - serialization_alias="FeatureConceptId" + feature_concept_id: Optional[list[Concept]] = Field( + default=None, validation_alias=AliasChoices("FeatureConceptId", "featureConceptId"), serialization_alias="FeatureConceptId" ) - + # Algorithm used to derive feature - algorithm_concept_id: Optional[List[Concept]] = Field( - default=None, - validation_alias=AliasChoices("AlgorithmConceptId", "algorithmConceptId"), - serialization_alias="AlgorithmConceptId" + algorithm_concept_id: Optional[list[Concept]] = Field( + default=None, validation_alias=AliasChoices("AlgorithmConceptId", "algorithmConceptId"), serialization_alias="AlgorithmConceptId" ) algorithm_source_value: Optional[TextFilter] = Field( - default=None, - validation_alias=AliasChoices("AlgorithmSourceValue", "algorithmSourceValue"), - serialization_alias="AlgorithmSourceValue" + default=None, validation_alias=AliasChoices("AlgorithmSourceValue", "algorithmSourceValue"), serialization_alias="AlgorithmSourceValue" ) - + # Temporal window for feature feature_start_timestamp: Optional[DateRange] = Field( - default=None, - validation_alias=AliasChoices("FeatureStartTimestamp", "featureStartTimestamp"), - serialization_alias="FeatureStartTimestamp" + default=None, validation_alias=AliasChoices("FeatureStartTimestamp", "featureStartTimestamp"), serialization_alias="FeatureStartTimestamp" ) feature_end_timestamp: Optional[DateRange] = Field( - default=None, - validation_alias=AliasChoices("FeatureEndTimestamp", "featureEndTimestamp"), - serialization_alias="FeatureEndTimestamp" + default=None, validation_alias=AliasChoices("FeatureEndTimestamp", "featureEndTimestamp"), serialization_alias="FeatureEndTimestamp" ) - + # Feature values (at least one must be populated) value_as_number: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), - serialization_alias="ValueAsNumber" + default=None, validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), serialization_alias="ValueAsNumber" ) - value_as_concept_id: Optional[List[Concept]] = Field( - default=None, - validation_alias=AliasChoices("ValueAsConceptId", "valueAsConceptId"), - serialization_alias="ValueAsConceptId" + value_as_concept_id: Optional[list[Concept]] = Field( + default=None, validation_alias=AliasChoices("ValueAsConceptId", "valueAsConceptId"), serialization_alias="ValueAsConceptId" ) - + # Units for numeric values - unit_concept_id: Optional[List[Concept]] = Field( - default=None, - validation_alias=AliasChoices("UnitConceptId", "unitConceptId"), - serialization_alias="UnitConceptId" + unit_concept_id: Optional[list[Concept]] = Field( + default=None, validation_alias=AliasChoices("UnitConceptId", "unitConceptId"), serialization_alias="UnitConceptId" ) - + # Links to standard OMOP tables measurement_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("MeasurementId", "measurementId"), - serialization_alias="MeasurementId" + default=None, validation_alias=AliasChoices("MeasurementId", "measurementId"), serialization_alias="MeasurementId" ) observation_id: Optional[NumericRange] = Field( - default=None, - validation_alias=AliasChoices("ObservationId", "observationId"), - serialization_alias="ObservationId" + default=None, validation_alias=AliasChoices("ObservationId", "observationId"), serialization_alias="ObservationId" ) + # Rebuild models to resolve forward references diff --git a/examples/waveform_extension.py b/examples/waveform_extension.py index 2fd45f38..425cdda9 100644 --- a/examples/waveform_extension.py +++ b/examples/waveform_extension.py @@ -10,21 +10,16 @@ Reference: https://ohdsi.github.io/WaveformWG/waveform-tables.html """ -import json from circe.cohortdefinition import CohortExpression, PrimaryCriteria -from circe.cohortdefinition.cohort_expression_query_builder import CohortExpressionQueryBuilder, BuildExpressionQueryOptions +from circe.cohortdefinition.cohort_expression_query_builder import BuildExpressionQueryOptions, CohortExpressionQueryBuilder +from circe.cohortdefinition.core import DateRange, NumericRange from circe.cohortdefinition.printfriendly.markdown_render import MarkdownRender -from circe.vocabulary.concept import Concept -from circe.cohortdefinition.core import NumericRange, DateRange # Import the extension — registration is automatic via decorators -import circe.extensions.waveform - # Import criteria classes -from circe.extensions.waveform.criteria import ( - WaveformOccurrence, WaveformRegistry, - WaveformChannelMetadata, WaveformFeature -) +from circe.extensions.waveform.criteria import WaveformChannelMetadata, WaveformFeature, WaveformOccurrence, WaveformRegistry +from circe.vocabulary.concept import Concept + def create_concept(concept_id, name): """Helper to create a concept.""" @@ -36,9 +31,10 @@ def create_concept(concept_id, name): vocabulary_id="Custom", concept_class_id="Waveform", standard_concept="S", - concept_code=str(concept_id) + concept_code=str(concept_id), ) + # ============================================================================= # Example 1: ICU monitoring session with multiple files # ============================================================================= @@ -49,19 +45,17 @@ def create_concept(concept_id, name): waveform_occ_example = WaveformOccurrence( waveform_occurrence_concept_id=[create_concept(2000000001, "ICU Continuous Monitoring")], occurrence_start_datetime=DateRange(value="2025-01-01", op="gte"), - num_of_files=NumericRange(value=10, op="gte") + num_of_files=NumericRange(value=10, op="gte"), ) expression1 = CohortExpression( primary_criteria=PrimaryCriteria( - criteria_list=[waveform_occ_example], - observation_window={"priorDays": 0, "postDays": 0}, - primary_limit={"type": "First"} + criteria_list=[waveform_occ_example], observation_window={"priorDays": 0, "postDays": 0}, primary_limit={"type": "First"} ), concept_sets=[], inclusion_rules=[], qualified_limit={"type": "First"}, - expression_limit={"type": "First"} + expression_limit={"type": "First"}, ) builder = CohortExpressionQueryBuilder() @@ -72,7 +66,7 @@ def create_concept(concept_id, name): sql1 = builder.build_expression_query(expression1, options) print("\n--- SQL Snippet ---") -print(sql1[sql1.find("FROM"):sql1.find("FROM")+200] + "...") +print(sql1[sql1.find("FROM") : sql1.find("FROM") + 200] + "...") print("\n✓ Table: waveform_occurrence") print("✓ Filters: ICU monitoring, ≥10 files, starting after 2025-01-01") @@ -87,24 +81,20 @@ def create_concept(concept_id, name): print("Example 2: EDF Waveform Files") print("=" * 80) -waveform_reg_example = WaveformRegistry( - file_extension_concept_id=[create_concept(2000000010, "EDF")] -) +waveform_reg_example = WaveformRegistry(file_extension_concept_id=[create_concept(2000000010, "EDF")]) expression2 = CohortExpression( primary_criteria=PrimaryCriteria( - criteria_list=[waveform_reg_example], - observation_window={"priorDays": 0, "postDays": 0}, - primary_limit={"type": "First"} + criteria_list=[waveform_reg_example], observation_window={"priorDays": 0, "postDays": 0}, primary_limit={"type": "First"} ), concept_sets=[], inclusion_rules=[], - qualified_limit={"type": "First"} + qualified_limit={"type": "First"}, ) sql2 = builder.build_expression_query(expression2, options) print("\n--- SQL Snippet ---") -print(sql2[sql2.find("FROM"):sql2.find("FROM")+200] + "...") +print(sql2[sql2.find("FROM") : sql2.find("FROM") + 200] + "...") print("\n✓ Table: waveform_registry") print("✓ Filters: EDF file format only") @@ -119,23 +109,21 @@ def create_concept(concept_id, name): channel_concept_id=[create_concept(2000000020, "ECG Lead II")], metadata_concept_id=[create_concept(2000000030, "Sampling Rate")], value_as_number=NumericRange(value=500, op="gte"), # ≥500 Hz - unit_concept_id=[create_concept(8504, "Hz")] + unit_concept_id=[create_concept(8504, "Hz")], ) expression3 = CohortExpression( primary_criteria=PrimaryCriteria( - criteria_list=[waveform_chan_example], - observation_window={"priorDays": 0, "postDays": 0}, - primary_limit={"type": "First"} + criteria_list=[waveform_chan_example], observation_window={"priorDays": 0, "postDays": 0}, primary_limit={"type": "First"} ), concept_sets=[], inclusion_rules=[], - qualified_limit={"type": "First"} + qualified_limit={"type": "First"}, ) sql3 = builder.build_expression_query(expression3, options) print("\n--- SQL Snippet ---") -print(sql3[sql3.find("FROM"):sql3.find("FROM")+250] + "...") +print(sql3[sql3.find("FROM") : sql3.find("FROM") + 250] + "...") print("\n✓ Table: waveform_channel_metadata") print("✓ Filters: ECG Lead II, sampling rate ≥500 Hz") print("✓ Use Case: Ensure high-quality signals for QRS detection") @@ -151,23 +139,21 @@ def create_concept(concept_id, name): feature_concept_id=[create_concept(3027018, "Heart Rate")], algorithm_concept_id=[create_concept(2000000040, "Pan-Tompkins QRS Detection")], value_as_number=NumericRange(value=60, op="gte", extent=100), # 60-100 bpm - unit_concept_id=[create_concept(8541, "beats/min")] + unit_concept_id=[create_concept(8541, "beats/min")], ) expression4 = CohortExpression( primary_criteria=PrimaryCriteria( - criteria_list=[waveform_feat_example], - observation_window={"priorDays": 0, "postDays": 0}, - primary_limit={"type": "First"} + criteria_list=[waveform_feat_example], observation_window={"priorDays": 0, "postDays": 0}, primary_limit={"type": "First"} ), concept_sets=[], inclusion_rules=[], - qualified_limit={"type": "First"} + qualified_limit={"type": "First"}, ) sql4 = builder.build_expression_query(expression4, options) print("\n--- SQL Snippet ---") -print(sql4[sql4.find("FROM"):sql4.find("FROM")+250] + "...") +print(sql4[sql4.find("FROM") : sql4.find("FROM") + 250] + "...") print("\n✓ Table: waveform_feature") print("✓ Filters: Heart Rate 60-100 bpm derived by Pan-Tompkins algorithm") print("✓ Use Case: Identify patients with normal cardiac rhythm") @@ -193,7 +179,7 @@ def create_concept(concept_id, name): ("Correct column: file_extension_concept_id", "file_extension_concept_id" in sql2), ("Correct column: channel_concept_id", "channel_concept_id" in sql3), ("Correct column: feature_concept_id", "feature_concept_id" in sql4), - ("Markdown rendering works", "waveform-derived feature" in md4.lower()) + ("Markdown rendering works", "waveform-derived feature" in md4.lower()), ] for check_name, result in checks: @@ -201,7 +187,7 @@ def create_concept(concept_id, name): print(f"{status} {check_name}") all_passed = all(r for _, r in checks) -print("\n" + ("="*80)) +print("\n" + ("=" * 80)) if all_passed: print("SUCCESS: All 4 OHDSI Waveform Extension tables implemented correctly!") else: From 00d2f66b7756678f42d77473e807463b9fd58519 Mon Sep 17 00:00:00 2001 From: egillax Date: Tue, 17 Mar 2026 18:12:29 +0100 Subject: [PATCH 22/62] chore: line lenghts to 110 --- circe/api.py | 11 +- circe/chat.py | 6 +- .../checkers/attribute_checker_factory.py | 5 +- circe/check/checkers/base_checker_factory.py | 5 +- .../checkers/base_corelated_criteria_check.py | 7 +- circe/check/checkers/base_value_check.py | 22 +- circe/check/checkers/comparisons.py | 11 +- .../check/checkers/concept_checker_factory.py | 11 +- .../checkers/concept_set_criteria_check.py | 104 +++++-- .../concept_set_selection_checker_factory.py | 11 +- .../checkers/criteria_checker_factory.py | 13 +- .../checkers/criteria_contradictions_check.py | 13 +- .../check/checkers/death_time_window_check.py | 15 +- circe/check/checkers/domain_type_check.py | 86 +++++- circe/check/checkers/drug_domain_check.py | 5 +- circe/check/checkers/drug_era_check.py | 7 +- .../checkers/duplicates_criteria_check.py | 10 +- .../check/checkers/empty_concept_set_check.py | 6 +- .../checkers/events_progression_check.py | 6 +- circe/check/checkers/exit_criteria_check.py | 6 +- circe/check/checkers/incomplete_rule_check.py | 5 +- circe/check/checkers/initial_event_check.py | 6 +- .../check/checkers/no_exit_criteria_check.py | 9 +- circe/check/checkers/ocurrence_check.py | 11 +- circe/check/checkers/range_check.py | 16 +- circe/check/checkers/range_checker_factory.py | 31 +- circe/check/checkers/text_checker_factory.py | 11 +- circe/check/checkers/time_pattern_check.py | 13 +- circe/check/checkers/time_window_check.py | 7 +- circe/check/checkers/unused_concepts_check.py | 44 ++- circe/cli.py | 4 +- circe/cohortdefinition/builders/base.py | 8 +- .../builders/condition_era.py | 54 +++- .../builders/condition_occurrence.py | 67 ++++- circe/cohortdefinition/builders/death.py | 24 +- .../builders/device_exposure.py | 50 +++- circe/cohortdefinition/builders/dose_era.py | 40 ++- circe/cohortdefinition/builders/drug_era.py | 40 ++- .../builders/drug_exposure.py | 84 +++++- .../builders/location_region.py | 18 +- .../cohortdefinition/builders/measurement.py | 104 +++++-- .../cohortdefinition/builders/observation.py | 82 +++-- .../builders/observation_period.py | 84 ++++-- .../builders/payer_plan_period.py | 94 ++++-- .../builders/procedure_occurrence.py | 120 ++++++-- circe/cohortdefinition/builders/specimen.py | 56 +++- circe/cohortdefinition/builders/utils.py | 6 +- .../cohortdefinition/builders/visit_detail.py | 68 ++++- .../builders/visit_occurrence.py | 77 ++++- circe/cohortdefinition/cohort.py | 22 +- .../cohort_expression_query_builder.py | 215 +++++++++++--- .../concept_set_expression_query_builder.py | 14 +- circe/cohortdefinition/criteria.py | 58 +++- .../printfriendly/markdown_render.py | 15 +- circe/execution/build_context.py | 4 +- circe/execution/builders/common.py | 48 ++- circe/execution/builders/condition_era.py | 4 +- circe/execution/builders/groups.py | 26 +- circe/execution/builders/payer_plan_period.py | 4 +- circe/execution/builders/pipeline.py | 6 +- circe/execution/builders/post_processing.py | 4 +- .../builders/procedure_occurrence.py | 4 +- circe/execution/builders/visit_detail.py | 4 +- circe/execution/criteria_compat.py | 4 +- circe/execution/ibis.py | 6 +- circe/execution/ibis_compat.py | 4 +- circe/extensions/__init__.py | 10 +- .../builders/waveform_channel_metadata.py | 42 ++- .../waveform/builders/waveform_feature.py | 52 +++- .../waveform/builders/waveform_occurrence.py | 43 ++- .../waveform/builders/waveform_registry.py | 36 ++- circe/extensions/waveform/criteria.py | 124 ++++++-- circe/helper/cohort_modifiers.py | 14 +- circe/io.py | 8 +- .../concept_set_expression_query_builder.py | 14 +- debug_app/sandbox.py | 12 +- debug_app/utils.py | 4 +- examples/generate_sql.py | 6 +- examples/waveform_extension.py | 28 +- pyproject.toml | 27 +- scripts/generate_skill_backup.py | 22 +- tests/test_builder_utils_coverage.py | 8 +- tests/test_builders.py | 76 ++--- tests/test_builders_sql.py | 10 +- tests/test_checkers.py | 149 +++------- tests/test_cli.py | 8 +- tests/test_code_generator.py | 4 +- tests/test_cohort_expression.py | 36 +-- ...ohort_expression_query_builder_coverage.py | 4 +- ...ohort_expression_query_builder_extended.py | 48 +-- tests/test_cohort_modifiers.py | 94 ++---- tests/test_comparisons_coverage.py | 88 ++---- ...st_concept_set_expression_query_builder.py | 8 +- .../test_condition_occurrence_sql_builder.py | 116 ++------ tests/test_criteria_classes.py | 4 +- tests/test_date_adjustment_parity.py | 23 +- tests/test_device_exposure_sql.py | 17 +- tests/test_documentation.py | 21 +- tests/test_drug_era_sql_builder.py | 96 ++---- tests/test_drug_exposure_builder.py | 82 ++--- tests/test_execution_api.py | 8 +- tests/test_extension_system.py | 104 ++++--- tests/test_hashing.py | 36 +-- tests/test_java_interoperability.py | 10 +- tests/test_kitchen_sink_cohort.py | 72 ++--- tests/test_markdown_render_coverage.py | 4 +- tests/test_print_friendly_parity.py | 4 +- tests/test_query_builders.py | 62 +--- tests/test_range_checker_factory_coverage.py | 12 +- tests/test_real_example_cohorts.py | 43 +-- tests/test_schema_compatibility.py | 4 +- tests/test_simple_sql_builders.py | 3 +- tests/test_sql_builders.py | 279 ++++-------------- tests/test_sql_rendering_parity.py | 231 ++++----------- tests/test_supporting_classes.py | 24 +- tests/test_utils_db.py | 4 +- tests/test_visit_occurrence_parity.py | 24 +- tests/test_waveform_extension.py | 72 +++-- 118 files changed, 2464 insertions(+), 1901 deletions(-) diff --git a/circe/api.py b/circe/api.py index 12b05206..06b52370 100644 --- a/circe/api.py +++ b/circe/api.py @@ -52,7 +52,11 @@ def cohort_expression_from_json(json_str: str) -> CohortExpression: # Ensure ConceptSetExpression objects have required fields if "conceptSets" in data and data["conceptSets"]: for concept_set in data["conceptSets"]: - if isinstance(concept_set, dict) and "expression" in concept_set and concept_set["expression"] is not None: + if ( + isinstance(concept_set, dict) + and "expression" in concept_set + and concept_set["expression"] is not None + ): expr = concept_set["expression"] if isinstance(expr, dict): if "isExcluded" not in expr: @@ -68,7 +72,10 @@ def cohort_expression_from_json(json_str: str) -> CohortExpression: raise ValueError(f"Invalid cohort expression JSON: {str(e)}") from e -def build_cohort_query(expression: CohortExpression, options: Optional[BuildExpressionQueryOptions] = None) -> str: +def build_cohort_query( + expression: CohortExpression, + options: Optional[BuildExpressionQueryOptions] = None, +) -> str: """Generate SQL query from a cohort expression. This is equivalent to R CirceR's `buildCohortQuery()` function. diff --git a/circe/chat.py b/circe/chat.py index 46fd5fc4..fd58d8d1 100644 --- a/circe/chat.py +++ b/circe/chat.py @@ -231,7 +231,11 @@ def _process_response_content(content: str, output_base: Optional[str]): json_output = None if hasattr(cohort_obj, "json"): # Pydantic v1/v2 - json_output = cohort_obj.model_dump_json(indent=2) if hasattr(cohort_obj, "model_dump_json") else cohort_obj.json(indent=2) + json_output = ( + cohort_obj.model_dump_json(indent=2) + if hasattr(cohort_obj, "model_dump_json") + else cohort_obj.json(indent=2) + ) elif hasattr(cohort_obj, "to_json"): json_output = cohort_obj.to_json() else: diff --git a/circe/check/checkers/attribute_checker_factory.py b/circe/check/checkers/attribute_checker_factory.py index 714f08ab..2d7681de 100644 --- a/circe/check/checkers/attribute_checker_factory.py +++ b/circe/check/checkers/attribute_checker_factory.py @@ -65,7 +65,10 @@ def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], No """ return lambda c: None # Non-demographic criteria don't need attribute checks - def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic( + self, + criteria: "DemographicCriteria", + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. Args: diff --git a/circe/check/checkers/base_checker_factory.py b/circe/check/checkers/base_checker_factory.py index b29487dc..10b3eb05 100644 --- a/circe/check/checkers/base_checker_factory.py +++ b/circe/check/checkers/base_checker_factory.py @@ -63,7 +63,10 @@ def _get_check_criteria(self, criteria: "Criteria") -> Callable[["Criteria"], No """ raise NotImplementedError("Subclasses must implement _get_check_criteria") - def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic( + self, + criteria: "DemographicCriteria", + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for a demographic criteria (to be implemented by subclasses). Args: diff --git a/circe/check/checkers/base_corelated_criteria_check.py b/circe/check/checkers/base_corelated_criteria_check.py index 580cd773..42b82960 100644 --- a/circe/check/checkers/base_corelated_criteria_check.py +++ b/circe/check/checkers/base_corelated_criteria_check.py @@ -84,7 +84,12 @@ def _check_criteria_group(self, criteria: "Criteria", group_name: str, reporter: if hasattr(corelated_criteria, "criteria") and corelated_criteria.criteria: self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) - def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: + def _check_criteria( + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, + ) -> None: """Check a single corelated criteria (to be implemented by subclasses). Args: diff --git a/circe/check/checkers/base_value_check.py b/circe/check/checkers/base_value_check.py index f6e87c34..79950c17 100644 --- a/circe/check/checkers/base_value_check.py +++ b/circe/check/checkers/base_value_check.py @@ -59,7 +59,11 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N self._check_inclusion_rules(expression, reporter) self._check_censoring_criteria(expression, reporter) - def _check_primary_criteria(self, primary_criteria: Optional["PrimaryCriteria"], reporter: WarningReporter) -> None: + def _check_primary_criteria( + self, + primary_criteria: Optional["PrimaryCriteria"], + reporter: WarningReporter, + ) -> None: """Check primary criteria. Args: @@ -70,7 +74,11 @@ def _check_primary_criteria(self, primary_criteria: Optional["PrimaryCriteria"], for criteria in primary_criteria.criteria_list: self._check_criteria(criteria, reporter, self.PRIMARY_CRITERIA) - def _check_additional_criteria(self, criteria_group: Optional["CriteriaGroup"], reporter: WarningReporter) -> None: + def _check_additional_criteria( + self, + criteria_group: Optional["CriteriaGroup"], + reporter: WarningReporter, + ) -> None: """Check additional criteria. Args: @@ -81,7 +89,10 @@ def _check_additional_criteria(self, criteria_group: Optional["CriteriaGroup"], if hasattr(criteria_group, "criteria_list") and criteria_group.criteria_list: for criteria in criteria_group.criteria_list: self._check_criteria(criteria, reporter, self.ADDITIONAL_CRITERIA) - if hasattr(criteria_group, "demographic_criteria_list") and criteria_group.demographic_criteria_list: + if ( + hasattr(criteria_group, "demographic_criteria_list") + and criteria_group.demographic_criteria_list + ): for criteria in criteria_group.demographic_criteria_list: self._check_criteria(criteria, reporter, self.ADDITIONAL_CRITERIA) if hasattr(criteria_group, "groups") and criteria_group.groups: @@ -113,7 +124,10 @@ def _check_inclusion_rules(self, expression: "CohortExpression", reporter: Warni if hasattr(rule.expression, "criteria_list") and rule.expression.criteria_list: for criteria in rule.expression.criteria_list: self._check_criteria(criteria, reporter, rule_name) - if hasattr(rule.expression, "demographic_criteria_list") and rule.expression.demographic_criteria_list: + if ( + hasattr(rule.expression, "demographic_criteria_list") + and rule.expression.demographic_criteria_list + ): for criteria in rule.expression.demographic_criteria_list: self._check_criteria(criteria, reporter, rule_name) diff --git a/circe/check/checkers/comparisons.py b/circe/check/checkers/comparisons.py index 05e95cb6..b8c44eb8 100644 --- a/circe/check/checkers/comparisons.py +++ b/circe/check/checkers/comparisons.py @@ -189,10 +189,17 @@ def compare_concept_set(source: "ConceptSet"): def compare_func(concept_set: "ConceptSet") -> bool: if concept_set.expression == source.expression: return True - if concept_set.expression and source.expression and len(concept_set.expression.items) == len(source.expression.items): + if ( + concept_set.expression + and source.expression + and len(concept_set.expression.items) == len(source.expression.items) + ): source_concepts = [item.concept for item in source.expression.items] return all( - any(Comparisons.compare_concept(concept)(source_concept) for source_concept in source_concepts) + any( + Comparisons.compare_concept(concept)(source_concept) + for source_concept in source_concepts + ) for concept in [item.concept for item in concept_set.expression.items] ) return False diff --git a/circe/check/checkers/concept_checker_factory.py b/circe/check/checkers/concept_checker_factory.py index 533c62d6..118058d5 100644 --- a/circe/check/checkers/concept_checker_factory.py +++ b/circe/check/checkers/concept_checker_factory.py @@ -399,7 +399,10 @@ def default_check(c: "Criteria") -> None: else: return default_check - def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic( + self, + criteria: "DemographicCriteria", + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. Args: @@ -436,4 +439,8 @@ def _check_concept(self, concepts: Optional[list["Concept"]], criteria_name: str def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - Operations.match(concepts).when(lambda c: c is not None and len(c) == 0).then(lambda c: warning(self.WARNING_EMPTY_VALUE)) + ( + Operations.match(concepts) + .when(lambda c: c is not None and len(c) == 0) + .then(lambda c: warning(self.WARNING_EMPTY_VALUE)) + ) diff --git a/circe/check/checkers/concept_set_criteria_check.py b/circe/check/checkers/concept_set_criteria_check.py index dc568b65..22cc3680 100644 --- a/circe/check/checkers/concept_set_criteria_check.py +++ b/circe/check/checkers/concept_set_criteria_check.py @@ -70,26 +70,86 @@ def _check_criteria(self, criteria: "Criteria", group_name: str, reporter: Warni VisitOccurrence, ) - Operations.match(criteria).is_a(ConditionEra).then( - lambda c: Operations.match(c).when(lambda ce: ce.codeset_id is None).then(add_warning) - ).is_a(ConditionOccurrence).then( - lambda c: Operations.match(c).when(lambda co: co.codeset_id is None and co.condition_source_concept is None).then(add_warning) - ).is_a(Death).then(lambda c: Operations.match(c).when(lambda d: d.codeset_id is None).then(add_warning)).is_a(DeviceExposure).then( - lambda c: Operations.match(c).when(lambda de: de.codeset_id is None and de.device_source_concept is None).then(add_warning) - ).is_a(DoseEra).then(lambda c: Operations.match(c).when(lambda de: de.codeset_id is None).then(add_warning)).is_a(DrugEra).then( - lambda c: Operations.match(c).when(lambda de: de.codeset_id is None).then(add_warning) - ).is_a(DrugExposure).then( - lambda c: Operations.match(c).when(lambda de: de.codeset_id is None and de.drug_source_concept is None).then(add_warning) - ).is_a(Measurement).then( - lambda c: Operations.match(c).when(lambda m: m.codeset_id is None and m.measurement_source_concept is None).then(add_warning) - ).is_a(Observation).then( - lambda c: Operations.match(c).when(lambda o: o.codeset_id is None and o.observation_source_concept is None).then(add_warning) - ).is_a(ProcedureOccurrence).then( - lambda c: Operations.match(c).when(lambda po: po.codeset_id is None and po.procedure_source_concept is None).then(add_warning) - ).is_a(Specimen).then( - lambda c: Operations.match(c).when(lambda s: s.codeset_id is None and s.specimen_source_concept is None).then(add_warning) - ).is_a(VisitOccurrence).then( - lambda c: Operations.match(c).when(lambda vo: vo.codeset_id is None and vo.visit_source_concept is None).then(add_warning) - ).is_a(VisitDetail).then( - lambda c: Operations.match(c).when(lambda vd: vd.codeset_id is None and vd.visit_detail_source_concept is None).then(add_warning) + ( + Operations.match(criteria) + .is_a(ConditionEra) + .then(lambda c: Operations.match(c).when(lambda ce: ce.codeset_id is None).then(add_warning)) + .is_a(ConditionOccurrence) + .then( + lambda c: ( + Operations.match(c) + .when(lambda co: co.codeset_id is None and co.condition_source_concept is None) + .then(add_warning) + ) + ) + .is_a(Death) + .then(lambda c: Operations.match(c).when(lambda d: d.codeset_id is None).then(add_warning)) + .is_a(DeviceExposure) + .then( + lambda c: ( + Operations.match(c) + .when(lambda de: de.codeset_id is None and de.device_source_concept is None) + .then(add_warning) + ) + ) + .is_a(DoseEra) + .then(lambda c: Operations.match(c).when(lambda de: de.codeset_id is None).then(add_warning)) + .is_a(DrugEra) + .then(lambda c: Operations.match(c).when(lambda de: de.codeset_id is None).then(add_warning)) + .is_a(DrugExposure) + .then( + lambda c: ( + Operations.match(c) + .when(lambda de: de.codeset_id is None and de.drug_source_concept is None) + .then(add_warning) + ) + ) + .is_a(Measurement) + .then( + lambda c: ( + Operations.match(c) + .when(lambda m: m.codeset_id is None and m.measurement_source_concept is None) + .then(add_warning) + ) + ) + .is_a(Observation) + .then( + lambda c: ( + Operations.match(c) + .when(lambda o: o.codeset_id is None and o.observation_source_concept is None) + .then(add_warning) + ) + ) + .is_a(ProcedureOccurrence) + .then( + lambda c: ( + Operations.match(c) + .when(lambda po: po.codeset_id is None and po.procedure_source_concept is None) + .then(add_warning) + ) + ) + .is_a(Specimen) + .then( + lambda c: ( + Operations.match(c) + .when(lambda s: s.codeset_id is None and s.specimen_source_concept is None) + .then(add_warning) + ) + ) + .is_a(VisitOccurrence) + .then( + lambda c: ( + Operations.match(c) + .when(lambda vo: vo.codeset_id is None and vo.visit_source_concept is None) + .then(add_warning) + ) + ) + .is_a(VisitDetail) + .then( + lambda c: ( + Operations.match(c) + .when(lambda vd: vd.codeset_id is None and vd.visit_detail_source_concept is None) + .then(add_warning) + ) + ) ) diff --git a/circe/check/checkers/concept_set_selection_checker_factory.py b/circe/check/checkers/concept_set_selection_checker_factory.py index 4d529f91..3967a9ff 100644 --- a/circe/check/checkers/concept_set_selection_checker_factory.py +++ b/circe/check/checkers/concept_set_selection_checker_factory.py @@ -101,7 +101,10 @@ def check(c: "VisitDetail") -> None: else: return lambda c: None # No ConceptSetSelection checks for other criteria types - def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic( + self, + criteria: "DemographicCriteria", + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. Args: @@ -129,6 +132,8 @@ def _check_concept_set_selection( def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - Operations.match(concept_set_selection).when(lambda css: css is not None and css.codeset_id is None).then( - lambda css: warning(self.WARNING_EMPTY_VALUE) + ( + Operations.match(concept_set_selection) + .when(lambda css: css is not None and css.codeset_id is None) + .then(lambda css: warning(self.WARNING_EMPTY_VALUE)) ) diff --git a/circe/check/checkers/criteria_checker_factory.py b/circe/check/checkers/criteria_checker_factory.py index abc4276d..47f19fe0 100644 --- a/circe/check/checkers/criteria_checker_factory.py +++ b/circe/check/checkers/criteria_checker_factory.py @@ -134,10 +134,14 @@ def check_drug_exposure(c: "DrugExposure") -> bool: return c.codeset_id == self._concept_set.id or c.drug_source_concept == self._concept_set.id def check_measurement(c: "Measurement") -> bool: - return c.codeset_id == self._concept_set.id or c.measurement_source_concept == self._concept_set.id + return ( + c.codeset_id == self._concept_set.id or c.measurement_source_concept == self._concept_set.id + ) def check_observation(c: "Observation") -> bool: - return c.codeset_id == self._concept_set.id or c.observation_source_concept == self._concept_set.id + return ( + c.codeset_id == self._concept_set.id or c.observation_source_concept == self._concept_set.id + ) def check_procedure_occurrence(c: "ProcedureOccurrence") -> bool: return c.codeset_id == self._concept_set.id or c.procedure_source_concept == self._concept_set.id @@ -197,7 +201,10 @@ def default_check(c: "Criteria") -> bool: else: return default_check - def _get_concept_set_selection_suppliers(self, criteria: "VisitDetail") -> list[Callable[[], Optional["ConceptSetSelection"]]]: + def _get_concept_set_selection_suppliers( + self, + criteria: "VisitDetail", + ) -> list[Callable[[], Optional["ConceptSetSelection"]]]: """Get suppliers for ConceptSetSelection fields in VisitDetail. Args: diff --git a/circe/check/checkers/criteria_contradictions_check.py b/circe/check/checkers/criteria_contradictions_check.py index fa757893..f9982088 100644 --- a/circe/check/checkers/criteria_contradictions_check.py +++ b/circe/check/checkers/criteria_contradictions_check.py @@ -76,7 +76,12 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.WARNING - def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: + def _check_criteria( + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, + ) -> None: """Collect criteria information. Args: @@ -99,9 +104,9 @@ def _after_check(self, reporter: WarningReporter, expression: "CohortExpression" for i in range(size - 1): info = self._criteria_list[i] for other_info in self._criteria_list[i + 1 :]: - if Comparisons.compare_criteria(info.criteria.criteria, other_info.criteria.criteria) and self._check_contradiction( - info.criteria.occurrence, other_info.criteria.occurrence - ): + if Comparisons.compare_criteria( + info.criteria.criteria, other_info.criteria.criteria + ) and self._check_contradiction(info.criteria.occurrence, other_info.criteria.occurrence): reporter(self.WARNING, info.name, other_info.name) def _check_contradiction(self, o1: Optional["Occurrence"], o2: Optional["Occurrence"]) -> bool: diff --git a/circe/check/checkers/death_time_window_check.py b/circe/check/checkers/death_time_window_check.py index 62f3da36..41f7d18b 100644 --- a/circe/check/checkers/death_time_window_check.py +++ b/circe/check/checkers/death_time_window_check.py @@ -33,7 +33,9 @@ class DeathTimeWindowCheck(BaseCorelatedCriteriaCheck): Java equivalent: org.ohdsi.circe.check.checkers.DeathTimeWindowCheck """ - MESSAGE = "%s attempts to identify death event prior to index event. Events post-death may not be available" + MESSAGE = ( + "%s attempts to identify death event prior to index event. Events post-death may not be available" + ) def _define_severity(self) -> WarningSeverity: """Define the severity level for this check. @@ -105,7 +107,12 @@ def _check_criteria_group(self, criteria: "Criteria", group_name: str, reporter: for corelated_criteria in group.criteria_list: self._check_criteria(corelated_criteria, group_name, reporter) - def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: + def _check_criteria( + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, + ) -> None: """Check a corelated criteria for death time window issues. Args: @@ -119,6 +126,8 @@ def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, report match_result.is_a(Death) match_result.then( lambda death: ( - Operations.match(criteria).when(lambda c: Comparisons.is_before(c.start_window)).then(lambda c: reporter(self.MESSAGE, name)) + Operations.match(criteria) + .when(lambda c: Comparisons.is_before(c.start_window)) + .then(lambda c: reporter(self.MESSAGE, name)) ) ) diff --git a/circe/check/checkers/domain_type_check.py b/circe/check/checkers/domain_type_check.py index 71a6c00e..6acf8925 100644 --- a/circe/check/checkers/domain_type_check.py +++ b/circe/check/checkers/domain_type_check.py @@ -74,20 +74,78 @@ def add_warning() -> None: VisitOccurrence, ) - Operations.match(criteria).is_a(ConditionOccurrence).then( - lambda c: Operations.match(c).when(lambda co: co.condition_type is None).then(lambda co: add_warning()) - ).is_a(Death).then(lambda c: Operations.match(c).when(lambda d: d.death_type is None).then(lambda d: add_warning())).is_a( - DeviceExposure - ).then(lambda c: Operations.match(c).when(lambda de: de.device_type is None).then(lambda de: add_warning())).is_a(DrugExposure).then( - lambda c: Operations.match(c).when(lambda de: de.drug_type is None).then(lambda de: add_warning()) - ).is_a(Measurement).then(lambda c: Operations.match(c).when(lambda m: m.measurement_type is None).then(lambda m: add_warning())).is_a( - Observation - ).then(lambda c: Operations.match(c).when(lambda o: o.observation_type is None).then(lambda o: add_warning())).is_a(ProcedureOccurrence).then( - lambda c: Operations.match(c).when(lambda po: po.procedure_type is None).then(lambda po: add_warning()) - ).is_a(Specimen).then(lambda c: Operations.match(c).when(lambda s: s.specimen_type is None).then(lambda s: add_warning())).is_a( - VisitOccurrence - ).then(lambda c: Operations.match(c).when(lambda vo: vo.visit_type is None).then(lambda vo: add_warning())).is_a(VisitDetail).then( - lambda c: Operations.match(c).when(lambda vd: vd.visit_detail_type_cs is None).then(lambda vd: add_warning()) + ( + Operations.match(criteria) + .is_a(ConditionOccurrence) + .then( + lambda c: ( + Operations.match(c) + .when(lambda co: co.condition_type is None) + .then(lambda co: add_warning()) + ) + ) + .is_a(Death) + .then( + lambda c: ( + Operations.match(c).when(lambda d: d.death_type is None).then(lambda d: add_warning()) + ) + ) + .is_a(DeviceExposure) + .then( + lambda c: ( + Operations.match(c).when(lambda de: de.device_type is None).then(lambda de: add_warning()) + ) + ) + .is_a(DrugExposure) + .then( + lambda c: ( + Operations.match(c).when(lambda de: de.drug_type is None).then(lambda de: add_warning()) + ) + ) + .is_a(Measurement) + .then( + lambda c: ( + Operations.match(c) + .when(lambda m: m.measurement_type is None) + .then(lambda m: add_warning()) + ) + ) + .is_a(Observation) + .then( + lambda c: ( + Operations.match(c) + .when(lambda o: o.observation_type is None) + .then(lambda o: add_warning()) + ) + ) + .is_a(ProcedureOccurrence) + .then( + lambda c: ( + Operations.match(c) + .when(lambda po: po.procedure_type is None) + .then(lambda po: add_warning()) + ) + ) + .is_a(Specimen) + .then( + lambda c: ( + Operations.match(c).when(lambda s: s.specimen_type is None).then(lambda s: add_warning()) + ) + ) + .is_a(VisitOccurrence) + .then( + lambda c: ( + Operations.match(c).when(lambda vo: vo.visit_type is None).then(lambda vo: add_warning()) + ) + ) + .is_a(VisitDetail) + .then( + lambda c: ( + Operations.match(c) + .when(lambda vd: vd.visit_detail_type_cs is None) + .then(lambda vd: add_warning()) + ) + ) ) def _after_check(self, reporter: WarningReporter, expression: "CohortExpression") -> None: diff --git a/circe/check/checkers/drug_domain_check.py b/circe/check/checkers/drug_domain_check.py index 087db6f6..c251a726 100644 --- a/circe/check/checkers/drug_domain_check.py +++ b/circe/check/checkers/drug_domain_check.py @@ -152,7 +152,10 @@ def _is_concept_in_drug_domain(self, expression: "CohortExpression", codeset_id: if not concept_set or not concept_set.expression or not concept_set.expression.items: return False - return any(item.concept and item.concept.domain_id and item.concept.domain_id.upper() == "DRUG" for item in concept_set.expression.items) + return any( + item.concept and item.concept.domain_id and item.concept.domain_id.upper() == "DRUG" + for item in concept_set.expression.items + ) def _map_concept_set(self, expression: "CohortExpression", codeset_id: int) -> Optional["ConceptSet"]: """Map a codeset ID to a concept set. diff --git a/circe/check/checkers/drug_era_check.py b/circe/check/checkers/drug_era_check.py index c7645ccf..2d78f8d6 100644 --- a/circe/check/checkers/drug_era_check.py +++ b/circe/check/checkers/drug_era_check.py @@ -39,7 +39,12 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.INFO - def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: + def _check_criteria( + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, + ) -> None: """Check drug era criteria for missing days supply information. Args: diff --git a/circe/check/checkers/duplicates_criteria_check.py b/circe/check/checkers/duplicates_criteria_check.py index 1d37b793..bc8c0f0b 100644 --- a/circe/check/checkers/duplicates_criteria_check.py +++ b/circe/check/checkers/duplicates_criteria_check.py @@ -48,7 +48,11 @@ def _after_check(self, reporter: WarningReporter, expression: "CohortExpression" if len(self._criteria_list) > 1: for i in range(len(self._criteria_list) - 1): criteria, criteria_obj = self._criteria_list[i] - duplicates = [(name, obj) for name, obj in self._criteria_list[i + 1 :] if self._compare_criteria(criteria_obj, obj)] + duplicates = [ + (name, obj) + for name, obj in self._criteria_list[i + 1 :] + if self._compare_criteria(criteria_obj, obj) + ] if duplicates: names = ", ".join(name for name, _ in duplicates) reporter(self.DUPLICATE_WARNING, criteria, names) @@ -96,7 +100,9 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: if isinstance(c1, ConditionEra): return c1.codeset_id == c2.codeset_id elif isinstance(c1, ConditionOccurrence): - return c1.codeset_id == c2.codeset_id and c1.condition_source_concept == c2.condition_source_concept + return ( + c1.codeset_id == c2.codeset_id and c1.condition_source_concept == c2.condition_source_concept + ) elif isinstance( c1, ( diff --git a/circe/check/checkers/empty_concept_set_check.py b/circe/check/checkers/empty_concept_set_check.py index 7139a233..56a28de6 100644 --- a/circe/check/checkers/empty_concept_set_check.py +++ b/circe/check/checkers/empty_concept_set_check.py @@ -38,5 +38,9 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N """ if expression.concept_sets: for concept_set in expression.concept_sets: - if not concept_set.expression or not concept_set.expression.items or len(concept_set.expression.items) == 0: + if ( + not concept_set.expression + or not concept_set.expression.items + or len(concept_set.expression.items) == 0 + ): reporter(self.EMPTY_ERROR, concept_set.name) diff --git a/circe/check/checkers/events_progression_check.py b/circe/check/checkers/events_progression_check.py index f515266b..5a46211d 100644 --- a/circe/check/checkers/events_progression_check.py +++ b/circe/check/checkers/events_progression_check.py @@ -114,7 +114,11 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N cohort_initial_weight = self._get_weight(expression.qualified_limit) # Qualifying limit is ignored when no additionalCriteria specified - qualifying_weight = self._get_weight(expression.expression_limit) if expression.additional_criteria is not None else LimitType.NONE.weight + qualifying_weight = ( + self._get_weight(expression.expression_limit) + if expression.additional_criteria is not None + else LimitType.NONE.weight + ) if initial_weight - cohort_initial_weight < 0: reporter(self.WARNING, "Cohort of initial events") diff --git a/circe/check/checkers/exit_criteria_check.py b/circe/check/checkers/exit_criteria_check.py index 2eb24ed7..5073d063 100644 --- a/circe/check/checkers/exit_criteria_check.py +++ b/circe/check/checkers/exit_criteria_check.py @@ -42,5 +42,9 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N match_result = Operations.match(expression.end_strategy) match_result.is_a(CustomEraStrategy) match_result.then( - lambda s: Operations.match(s).when(lambda ces: ces.drug_codeset_id is None).then(lambda ces: reporter(self.DRUG_CONCEPT_EMPTY_ERROR)) + lambda s: ( + Operations.match(s) + .when(lambda ces: ces.drug_codeset_id is None) + .then(lambda ces: reporter(self.DRUG_CONCEPT_EMPTY_ERROR)) + ) ) diff --git a/circe/check/checkers/incomplete_rule_check.py b/circe/check/checkers/incomplete_rule_check.py index f32b7019..89f23273 100644 --- a/circe/check/checkers/incomplete_rule_check.py +++ b/circe/check/checkers/incomplete_rule_check.py @@ -69,7 +69,10 @@ def _check_inclusion_rule(self, rule: "InclusionRule", reporter: WarningReporter # Check if expression is empty if not rule.expression or ( (not hasattr(rule.expression, "criteria_list") or not rule.expression.criteria_list) - and (not hasattr(rule.expression, "demographic_criteria_list") or not rule.expression.demographic_criteria_list) + and ( + not hasattr(rule.expression, "demographic_criteria_list") + or not rule.expression.demographic_criteria_list + ) and (not hasattr(rule.expression, "groups") or not rule.expression.groups) ): reporter(rule.name) diff --git a/circe/check/checkers/initial_event_check.py b/circe/check/checkers/initial_event_check.py index e56543dd..68fabcd4 100644 --- a/circe/check/checkers/initial_event_check.py +++ b/circe/check/checkers/initial_event_check.py @@ -39,6 +39,10 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N """ match_result = Operations.match(expression) match_result.when( - lambda e: e.primary_criteria is None or e.primary_criteria.criteria_list is None or len(e.primary_criteria.criteria_list) == 0 + lambda e: ( + e.primary_criteria is None + or e.primary_criteria.criteria_list is None + or len(e.primary_criteria.criteria_list) == 0 + ) ) match_result.then(lambda e: reporter(self.NO_INITIAL_EVENT_ERROR)) diff --git a/circe/check/checkers/no_exit_criteria_check.py b/circe/check/checkers/no_exit_criteria_check.py index f809212d..0f88c38d 100644 --- a/circe/check/checkers/no_exit_criteria_check.py +++ b/circe/check/checkers/no_exit_criteria_check.py @@ -57,7 +57,14 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N and e.expression_limit and e.expression_limit.type and e.expression_limit.type.upper() == "ALL" - and (e.additional_criteria is None or (e.qualified_limit and e.qualified_limit.type and e.qualified_limit.type.upper() == "ALL")) + and ( + e.additional_criteria is None + or ( + e.qualified_limit + and e.qualified_limit.type + and e.qualified_limit.type.upper() == "ALL" + ) + ) ) ) match_result.then(lambda e: reporter(self.NO_EXIT_CRITERIA_WARNING)) diff --git a/circe/check/checkers/ocurrence_check.py b/circe/check/checkers/ocurrence_check.py index 67367861..81e43b17 100644 --- a/circe/check/checkers/ocurrence_check.py +++ b/circe/check/checkers/ocurrence_check.py @@ -25,7 +25,9 @@ class OcurrenceCheck(BaseCorelatedCriteriaCheck): Java equivalent: org.ohdsi.circe.check.checkers.OcurrenceCheck """ - AT_LEAST_0_WARNING = "'at least 0' occurrence is not a real constraint, probably meant 'exactly 0' or 'at least 1'" + AT_LEAST_0_WARNING = ( + "'at least 0' occurrence is not a real constraint, probably meant 'exactly 0' or 'at least 1'" + ) AT_LEAST = 2 def _define_severity(self) -> WarningSeverity: @@ -36,7 +38,12 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.WARNING - def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: + def _check_criteria( + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, + ) -> None: """Check occurrence for invalid values. Args: diff --git a/circe/check/checkers/range_check.py b/circe/check/checkers/range_check.py index a67058cb..30082b38 100644 --- a/circe/check/checkers/range_check.py +++ b/circe/check/checkers/range_check.py @@ -51,7 +51,9 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N "observation window", ) - RangeCheckerFactory.get_factory(reporter, self.PRIMARY_CRITERIA).check_range(expression.censor_window, "cohort", "censor window") + RangeCheckerFactory.get_factory(reporter, self.PRIMARY_CRITERIA).check_range( + expression.censor_window, "cohort", "censor window" + ) def _check_inclusion_rules(self, expression: "CohortExpression", reporter: WarningReporter) -> None: """Check inclusion rules for window issues. @@ -71,8 +73,12 @@ def _check_inclusion_rules(self, expression: "CohortExpression", reporter: Warni start_window = criteria.get("startWindow") or criteria.get("start_window") end_window = criteria.get("endWindow") or criteria.get("end_window") else: - start_window = getattr(criteria, "start_window", None) or getattr(criteria, "startWindow", None) - end_window = getattr(criteria, "end_window", None) or getattr(criteria, "endWindow", None) + start_window = getattr(criteria, "start_window", None) or getattr( + criteria, "startWindow", None + ) + end_window = getattr(criteria, "end_window", None) or getattr( + criteria, "endWindow", None + ) self._check_window(start_window, reporter, rule.name) self._check_window(end_window, reporter, rule.name) @@ -91,7 +97,9 @@ def _check_window(self, window, reporter: WarningReporter, name: str) -> None: end = window.get("end") or window.get("End") if start: - start_days = start.get("days") if isinstance(start, dict) else getattr(start, "days", None) + start_days = ( + start.get("days") if isinstance(start, dict) else getattr(start, "days", None) + ) if start_days is not None and start_days < 0: reporter(self.NEGATIVE_VALUE_ERROR, name, start_days, "start") diff --git a/circe/check/checkers/range_checker_factory.py b/circe/check/checkers/range_checker_factory.py index 8786825f..0d4702fb 100644 --- a/circe/check/checkers/range_checker_factory.py +++ b/circe/check/checkers/range_checker_factory.py @@ -566,7 +566,10 @@ def default_check(c) -> None: return default_check - def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic( + self, + criteria: "DemographicCriteria", + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. Args: @@ -628,7 +631,11 @@ def warning(template: str) -> None: ) ) match_result.or_else( - lambda r: Operations.match(r).when(lambda x: x.value is None).then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + lambda r: ( + Operations.match(r) + .when(lambda x: x.value is None) + .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + ) ) elif isinstance(range_val, NumericRange): # Numeric range checks @@ -645,7 +652,11 @@ def warning(template: str) -> None: ) ) match_result.or_else( - lambda r: Operations.match(r).when(lambda x: x.value is None).then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + lambda r: ( + Operations.match(r) + .when(lambda x: x.value is None) + .then(lambda x: warning(self.WARNING_EMPTY_START_VALUE)) + ) ) def check_range(self, period: Optional["Period"], criteria_name: str, attribute: str) -> None: @@ -663,13 +674,15 @@ def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) match_result = Operations.match(period) - match_result.when(lambda x: x.start_date is not None and not Comparisons.is_date_valid(x.start_date)).then( - lambda x: warning(self.WARNING_DATE_IS_INVALID) - ) - match_result.when(lambda x: x.end_date is not None and not Comparisons.is_date_valid(x.end_date)).then( - lambda x: warning(self.WARNING_DATE_IS_INVALID) + match_result.when( + lambda x: x.start_date is not None and not Comparisons.is_date_valid(x.start_date) + ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) + match_result.when( + lambda x: x.end_date is not None and not Comparisons.is_date_valid(x.end_date) + ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) + match_result.when(Comparisons.start_is_greater_than_end).then( + lambda x: warning(self.WARNING_START_GREATER_THAN_END) ) - match_result.when(Comparisons.start_is_greater_than_end).then(lambda x: warning(self.WARNING_START_GREATER_THAN_END)) def check(self, expression_or_criteria) -> None: """Check the cohort expression's censor window or individual criteria. diff --git a/circe/check/checkers/text_checker_factory.py b/circe/check/checkers/text_checker_factory.py index 40a3c0a1..d002f2da 100644 --- a/circe/check/checkers/text_checker_factory.py +++ b/circe/check/checkers/text_checker_factory.py @@ -149,7 +149,10 @@ def check(c: "Specimen") -> None: else: return lambda c: None # No text checks for other criteria types - def _get_check_demographic(self, criteria: "DemographicCriteria") -> Callable[["DemographicCriteria"], None]: + def _get_check_demographic( + self, + criteria: "DemographicCriteria", + ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. Args: @@ -172,4 +175,8 @@ def _check_text(self, text_filter: Optional["TextFilter"], criteria_name: str, a def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - Operations.match(text_filter).when(lambda tf: tf is not None and tf.text is None).then(lambda tf: warning(self.WARNING_EMPTY_VALUE)) + ( + Operations.match(text_filter) + .when(lambda tf: tf is not None and tf.text is None) + .then(lambda tf: warning(self.WARNING_EMPTY_VALUE)) + ) diff --git a/circe/check/checkers/time_pattern_check.py b/circe/check/checkers/time_pattern_check.py index 75b33cdc..7a636248 100644 --- a/circe/check/checkers/time_pattern_check.py +++ b/circe/check/checkers/time_pattern_check.py @@ -83,7 +83,12 @@ def _define_severity(self) -> WarningSeverity: """ return WarningSeverity.INFO - def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: + def _check_criteria( + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, + ) -> None: """Collect time window information. Args: @@ -115,7 +120,11 @@ def _after_check(self, reporter: WarningReporter, expression: "CohortExpression" # Find the most common pattern most_common_value = max(freq, key=freq.get) most_common_info = next( - (info for info in self._time_window_info_list if self._start_days(info.start) == most_common_value), + ( + info + for info in self._time_window_info_list + if self._start_days(info.start) == most_common_value + ), None, ) diff --git a/circe/check/checkers/time_window_check.py b/circe/check/checkers/time_window_check.py index 8734135f..8e2a222e 100644 --- a/circe/check/checkers/time_window_check.py +++ b/circe/check/checkers/time_window_check.py @@ -62,7 +62,12 @@ def _before_check(self, reporter: WarningReporter, expression: "CohortExpression if expression.primary_criteria: self._observation_filter = expression.primary_criteria.observation_window - def _check_criteria(self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter) -> None: + def _check_criteria( + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, + ) -> None: """Check criteria for time window issues. Args: diff --git a/circe/check/checkers/unused_concepts_check.py b/circe/check/checkers/unused_concepts_check.py index 866277dd..3cf065a5 100644 --- a/circe/check/checkers/unused_concepts_check.py +++ b/circe/check/checkers/unused_concepts_check.py @@ -94,7 +94,9 @@ def _get_additional_criteria(self, expression: "CohortExpression") -> list["Crit if expression.additional_criteria: additional_criteria.extend(self._to_criteria_list(expression.additional_criteria.criteria_list)) if expression.additional_criteria.groups: - additional_criteria.extend(self._to_criteria_list_from_groups(expression.additional_criteria.groups)) + additional_criteria.extend( + self._to_criteria_list_from_groups(expression.additional_criteria.groups) + ) return additional_criteria def _is_used( @@ -132,16 +134,30 @@ def _is_used( # Convert rule expression to criteria list rule_criteria_list = [] if hasattr(rule.expression, "criteria_list") and rule.expression.criteria_list: - rule_criteria_list.extend([c.criteria for c in rule.expression.criteria_list if hasattr(c, "criteria") and c.criteria]) - if rule_criteria_list and self._is_concept_set_used_in_list(concept_set, rule_criteria_list): + rule_criteria_list.extend( + [ + c.criteria + for c in rule.expression.criteria_list + if hasattr(c, "criteria") and c.criteria + ] + ) + if rule_criteria_list and self._is_concept_set_used_in_list( + concept_set, rule_criteria_list + ): return True # Check end strategy (CustomEraStrategy) - if isinstance(expression.end_strategy, CustomEraStrategy) and expression.end_strategy.drug_codeset_id == concept_set.id: + if ( + isinstance(expression.end_strategy, CustomEraStrategy) + and expression.end_strategy.drug_codeset_id == concept_set.id + ): return True # Check censoring criteria - return bool(expression.censoring_criteria and self._is_concept_set_used(concept_set, expression.censoring_criteria)) + return bool( + expression.censoring_criteria + and self._is_concept_set_used(concept_set, expression.censoring_criteria) + ) def _is_concept_set_used(self, concept_set: "ConceptSet", target) -> bool: """Check if a concept set is used (supports both List[Criteria] and CriteriaGroup). @@ -171,7 +187,11 @@ def _is_concept_set_used(self, concept_set: "ConceptSet", target) -> bool: else: return False - def _is_concept_set_used_in_list(self, concept_set: "ConceptSet", criteria_list: list["Criteria"]) -> bool: + def _is_concept_set_used_in_list( + self, + concept_set: "ConceptSet", + criteria_list: list["Criteria"], + ) -> bool: """Check if a concept set is used in a criteria list. Args: @@ -208,11 +228,19 @@ def _correlated_criteria_to_list(self, correlated_criteria) -> list["Criteria"]: """ criteria_list: list[Criteria] = [] if hasattr(correlated_criteria, "criteria_list") and correlated_criteria.criteria_list: - criteria_list.extend([c.criteria for c in correlated_criteria.criteria_list if hasattr(c, "criteria") and c.criteria]) + criteria_list.extend( + [ + c.criteria + for c in correlated_criteria.criteria_list + if hasattr(c, "criteria") and c.criteria + ] + ) if hasattr(correlated_criteria, "groups") and correlated_criteria.groups: for group in correlated_criteria.groups: if hasattr(group, "criteria_list") and group.criteria_list: - criteria_list.extend([c.criteria for c in group.criteria_list if hasattr(c, "criteria") and c.criteria]) + criteria_list.extend( + [c.criteria for c in group.criteria_list if hasattr(c, "criteria") and c.criteria] + ) return criteria_list def _to_criteria_list(self, criteria_list: Optional[list["CorelatedCriteria"]]) -> list["Criteria"]: diff --git a/circe/cli.py b/circe/cli.py index bf76280e..592cbc13 100644 --- a/circe/cli.py +++ b/circe/cli.py @@ -53,7 +53,9 @@ def main(): md_parser.add_argument("--title", "-t", type=str, help="Title to add to markdown document") # Generate source code command - source_parser = subparsers.add_parser("generate-source", help="Generate Python source code from cohort definition") + source_parser = subparsers.add_parser( + "generate-source", help="Generate Python source code from cohort definition" + ) source_parser.add_argument("input", help="Input JSON file") source_parser.add_argument("--output", "-o", help="Output Python file (default: stdout)") diff --git a/circe/cohortdefinition/builders/base.py b/circe/cohortdefinition/builders/base.py index a13bfdaf..d936ab96 100644 --- a/circe/cohortdefinition/builders/base.py +++ b/circe/cohortdefinition/builders/base.py @@ -54,7 +54,9 @@ def get_criteria_sql_with_options(self, criteria: T, options: Optional[BuilderOp query = self.embed_where_clauses(query, where_clauses) if options is not None: - filtered_columns = [column for column in options.additional_columns if column not in self.get_default_columns()] + filtered_columns = [ + column for column in options.additional_columns if column not in self.get_default_columns() + ] if filtered_columns: query = query.replace( "@additionalColumns", @@ -163,4 +165,6 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: CriteriaSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) + return ", ".join( + [f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns] + ) diff --git a/circe/cohortdefinition/builders/condition_era.py b/circe/cohortdefinition/builders/condition_era.py index 0ba49707..180480cb 100644 --- a/circe/cohortdefinition/builders/condition_era.py +++ b/circe/cohortdefinition/builders/condition_era.py @@ -87,21 +87,41 @@ def embed_ordinal_expression(self, query: str, criteria: ConditionEra, where_cla query = query.replace("@ordinalExpression", "") return query - def resolve_select_clauses(self, criteria: ConditionEra, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: ConditionEra, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for condition era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "ce.condition_era_start_date" if criteria.date_adjustment.start_with == "start_date" else "ce.condition_era_end_date" - end_column = "ce.condition_era_start_date" if criteria.date_adjustment.end_with == "start_date" else "ce.condition_era_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "ce.condition_era_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "ce.condition_era_end_date" + ) + end_column = ( + "ce.condition_era_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "ce.condition_era_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: - select_cols.append("ce.condition_era_start_date as start_date, ce.condition_era_end_date as end_date") + select_cols.append( + "ce.condition_era_start_date as start_date, ce.condition_era_end_date as end_date" + ) return select_cols - def resolve_join_clauses(self, criteria: ConditionEra, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_join_clauses( + self, criteria: ConditionEra, options: Optional[BuilderOptions] = None + ) -> list[str]: """Resolve join clauses for condition era criteria.""" join_clauses = [] @@ -116,7 +136,11 @@ def resolve_join_clauses(self, criteria: ConditionEra, options: Optional[Builder return join_clauses - def resolve_where_clauses(self, criteria: ConditionEra, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: ConditionEra, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for condition era criteria.""" where_clauses = [] @@ -134,25 +158,33 @@ def resolve_where_clauses(self, criteria: ConditionEra, options: Optional[Builde # occurrenceCount if criteria.occurrence_count is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("C.condition_occurrence_count", criteria.occurrence_count) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "C.condition_occurrence_count", criteria.occurrence_count + ) if numeric_clause: where_clauses.append(numeric_clause) # eraLength if criteria.era_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length + ) if numeric_clause: where_clauses.append(numeric_clause) # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start + ) if numeric_clause: where_clauses.append(numeric_clause) # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end + ) if numeric_clause: where_clauses.append(numeric_clause) diff --git a/circe/cohortdefinition/builders/condition_occurrence.py b/circe/cohortdefinition/builders/condition_occurrence.py index 915cf039..d9173fd7 100644 --- a/circe/cohortdefinition/builders/condition_occurrence.py +++ b/circe/cohortdefinition/builders/condition_occurrence.py @@ -83,7 +83,12 @@ def embed_codeset_clause(self, query: str, criteria: ConditionOccurrence) -> str ), ) - def embed_ordinal_expression(self, query: str, criteria: ConditionOccurrence, where_clauses: list[str]) -> str: + def embed_ordinal_expression( + self, + query: str, + criteria: ConditionOccurrence, + where_clauses: list[str], + ) -> str: """Embed ordinal expression in query.""" # first if criteria.first is not None and criteria.first: @@ -96,12 +101,18 @@ def embed_ordinal_expression(self, query: str, criteria: ConditionOccurrence, wh query = query.replace("@ordinalExpression", "") return query - def resolve_select_clauses(self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: ConditionOccurrence, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for condition occurrence criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) # Condition Type - if (criteria.condition_type is not None and len(criteria.condition_type) > 0) or criteria.condition_type_cs is not None: + if ( + criteria.condition_type is not None and len(criteria.condition_type) > 0 + ) or criteria.condition_type_cs is not None: select_cols.append("co.condition_type_concept_id") # Stop Reason @@ -109,11 +120,15 @@ def resolve_select_clauses(self, criteria: ConditionOccurrence, options: Optiona select_cols.append("co.stop_reason") # providerSpecialty - if (criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs is not None: + if ( + criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs is not None: select_cols.append("co.provider_id") # conditionStatus - if (criteria.condition_status is not None and len(criteria.condition_status) > 0) or criteria.condition_status_cs is not None: + if ( + criteria.condition_status is not None and len(criteria.condition_status) > 0 + ) or criteria.condition_status_cs is not None: select_cols.append("co.condition_status_concept_id") # dateAdjustment or default start/end dates @@ -128,7 +143,11 @@ def resolve_select_clauses(self, criteria: ConditionOccurrence, options: Optiona if criteria.date_adjustment.end_with == "start_date" else "COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date))" ) - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: select_cols.append( "co.condition_start_date as start_date, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)) as end_date" @@ -136,27 +155,43 @@ def resolve_select_clauses(self, criteria: ConditionOccurrence, options: Optiona return select_cols - def resolve_join_clauses(self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_join_clauses( + self, + criteria: ConditionOccurrence, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for condition occurrence criteria.""" join_clauses = [] # join to PERSON - if criteria.age is not None or (criteria.gender is not None and len(criteria.gender) > 0) or criteria.gender_cs is not None: + if ( + criteria.age is not None + or (criteria.gender is not None and len(criteria.gender) > 0) + or criteria.gender_cs is not None + ): join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") # join to VISIT_OCCURRENCE - if (criteria.visit_type is not None and len(criteria.visit_type) > 0) or criteria.visit_type_cs is not None: + if ( + criteria.visit_type is not None and len(criteria.visit_type) > 0 + ) or criteria.visit_type_cs is not None: join_clauses.append( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" ) # join to PROVIDER - if (criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs is not None: - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") + if ( + criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs is not None: + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) return join_clauses - def resolve_where_clauses(self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None + ) -> list[str]: """Resolve where clauses for condition occurrence criteria.""" where_clauses = [] @@ -177,7 +212,9 @@ def resolve_where_clauses(self, criteria: ConditionOccurrence, options: Optional concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.condition_type) if concept_ids: exclude_clause = "not" if criteria.condition_type_exclude else "" - where_clauses.append(f"C.condition_type_concept_id {exclude_clause} in ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"C.condition_type_concept_id {exclude_clause} in ({','.join(map(str, concept_ids))})" + ) # conditionTypeCS if criteria.condition_type_cs is not None: @@ -197,7 +234,9 @@ def resolve_where_clauses(self, criteria: ConditionOccurrence, options: Optional # age if criteria.age is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age + ) if numeric_clause: where_clauses.append(numeric_clause) diff --git a/circe/cohortdefinition/builders/death.py b/circe/cohortdefinition/builders/death.py index 0f37ca1f..2ae267cc 100644 --- a/circe/cohortdefinition/builders/death.py +++ b/circe/cohortdefinition/builders/death.py @@ -86,7 +86,9 @@ def resolve_select_clauses(self, criteria: Death, options: Optional[BuilderOptio select_cols = ["d.person_id", "d.cause_concept_id"] # deathType - if (criteria.death_type and len(criteria.death_type) > 0) or (criteria.death_type_cs and criteria.death_type_cs.codeset_id): + if (criteria.death_type and len(criteria.death_type) > 0) or ( + criteria.death_type_cs and criteria.death_type_cs.codeset_id + ): select_cols.append("d.death_type_concept_id") # dateAdjustment or default start/end dates @@ -109,7 +111,11 @@ def resolve_join_clauses(self, criteria: Death, options: Optional[BuilderOptions joins = [] # join to PERSON - if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): joins.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") return joins @@ -132,11 +138,17 @@ def resolve_where_clauses(self, criteria: Death, options: Optional[BuilderOption # deathTypeCS if criteria.death_type_cs and criteria.death_type_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.death_type_cs.codeset_id, "C.death_type_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.death_type_cs.codeset_id, "C.death_type_concept_id" + ) + ) # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age) + ) # gender if criteria.gender and len(criteria.gender) > 0: @@ -145,6 +157,8 @@ def resolve_where_clauses(self, criteria: Death, options: Optional[BuilderOption # genderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id") + ) return where_clauses diff --git a/circe/cohortdefinition/builders/device_exposure.py b/circe/cohortdefinition/builders/device_exposure.py index 91998326..c2b35181 100644 --- a/circe/cohortdefinition/builders/device_exposure.py +++ b/circe/cohortdefinition/builders/device_exposure.py @@ -65,7 +65,9 @@ def resolve_select_clauses(self, criteria: DeviceExposure, options: BuilderOptio ] # Device Type - if (criteria.device_type and len(criteria.device_type) > 0) or (criteria.device_type_cs and criteria.device_type_cs.codeset_id): + if (criteria.device_type and len(criteria.device_type) > 0) or ( + criteria.device_type_cs and criteria.device_type_cs.codeset_id + ): select_cols.append("de.device_type_concept_id") # unique_device_id @@ -108,11 +110,17 @@ def resolve_join_clauses(self, criteria: DeviceExposure, options: BuilderOptions joins = [] # Join to PERSON - if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): joins.append("JOIN @cdm_database_schema.PERSON P ON C.person_id = P.person_id") # Join to VISIT_OCCURRENCE - if (criteria.visit_type and len(criteria.visit_type) > 0) or (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): + if (criteria.visit_type and len(criteria.visit_type) > 0) or ( + criteria.visit_type_cs and criteria.visit_type_cs.codeset_id + ): joins.append( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V ON C.visit_occurrence_id = V.visit_occurrence_id AND C.person_id = V.person_id" ) @@ -160,11 +168,17 @@ def resolve_where_clauses(self, criteria: DeviceExposure, options: BuilderOption # deviceTypeCS if criteria.device_type_cs and criteria.device_type_cs.codeset_id: - conditions.append(BuilderUtils.get_codeset_in_expression(criteria.device_type_cs.codeset_id, "C.device_type_concept_id")) + conditions.append( + BuilderUtils.get_codeset_in_expression( + criteria.device_type_cs.codeset_id, "C.device_type_concept_id" + ) + ) # Add unique device ID condition if criteria.unique_device_id: - device_id_clause = BuilderUtils.build_text_filter_clause(criteria.unique_device_id, "C.unique_device_id") + device_id_clause = BuilderUtils.build_text_filter_clause( + criteria.unique_device_id, "C.unique_device_id" + ) if device_id_clause: conditions.append(device_id_clause) @@ -176,7 +190,9 @@ def resolve_where_clauses(self, criteria: DeviceExposure, options: BuilderOption # Age if criteria.age: - conditions.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) + conditions.append( + BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age) + ) # Gender if criteria.gender and len(criteria.gender) > 0: @@ -185,7 +201,9 @@ def resolve_where_clauses(self, criteria: DeviceExposure, options: BuilderOption # GenderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: - conditions.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) + conditions.append( + BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id") + ) # Provider Specialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: @@ -194,7 +212,11 @@ def resolve_where_clauses(self, criteria: DeviceExposure, options: BuilderOption # Provider Specialty CS if criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id: - conditions.append(BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id")) + conditions.append( + BuilderUtils.get_codeset_in_expression( + criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id" + ) + ) # Visit Type if criteria.visit_type and len(criteria.visit_type) > 0: @@ -203,7 +225,11 @@ def resolve_where_clauses(self, criteria: DeviceExposure, options: BuilderOption # Visit Type CS if criteria.visit_type_cs and criteria.visit_type_cs.codeset_id: - conditions.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) + conditions.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, "V.visit_concept_id" + ) + ) return conditions @@ -213,7 +239,11 @@ def resolve_ordinal_expression(self, criteria: DeviceExposure, options: BuilderO return ", row_number() over (PARTITION BY de.person_id ORDER BY de.device_exposure_start_date, de.device_exposure_id) as ordinal" return "" - def get_ordinal_expression_where_clause(self, criteria: DeviceExposure, options: BuilderOptions) -> list[str]: + def get_ordinal_expression_where_clause( + self, + criteria: DeviceExposure, + options: BuilderOptions, + ) -> list[str]: if criteria.first: return ["C.ordinal = 1"] return [] diff --git a/circe/cohortdefinition/builders/dose_era.py b/circe/cohortdefinition/builders/dose_era.py index 99829870..f0b9c591 100644 --- a/circe/cohortdefinition/builders/dose_era.py +++ b/circe/cohortdefinition/builders/dose_era.py @@ -94,15 +94,31 @@ def embed_ordinal_expression(self, query: str, criteria: DoseEra, where_clauses: query = query.replace("@ordinalExpression", "") return query - def resolve_select_clauses(self, criteria: DoseEra, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: DoseEra, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for dose era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "de.dose_era_start_date" if criteria.date_adjustment.start_with == "start_date" else "de.dose_era_end_date" - end_column = "de.dose_era_start_date" if criteria.date_adjustment.end_with == "start_date" else "de.dose_era_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "de.dose_era_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "de.dose_era_end_date" + ) + end_column = ( + "de.dose_era_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "de.dose_era_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: select_cols.append("de.dose_era_start_date as start_date, de.dose_era_end_date as end_date") @@ -157,25 +173,33 @@ def resolve_where_clauses(self, criteria: DoseEra, options: Optional[BuilderOpti # doseValue if criteria.dose_value is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("C.dose_value", criteria.dose_value, ".4f") + numeric_clause = BuilderUtils.build_numeric_range_clause( + "C.dose_value", criteria.dose_value, ".4f" + ) if numeric_clause: where_clauses.append(numeric_clause) # eraLength if criteria.era_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length + ) if numeric_clause: where_clauses.append(numeric_clause) # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start + ) if numeric_clause: where_clauses.append(numeric_clause) # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end + ) if numeric_clause: where_clauses.append(numeric_clause) diff --git a/circe/cohortdefinition/builders/drug_era.py b/circe/cohortdefinition/builders/drug_era.py index 98c86086..a44c9b4c 100644 --- a/circe/cohortdefinition/builders/drug_era.py +++ b/circe/cohortdefinition/builders/drug_era.py @@ -98,7 +98,11 @@ def embed_ordinal_expression(self, query: str, criteria: DrugEra, where_clauses: query = query.replace("@ordinalExpression", "") return query - def resolve_select_clauses(self, criteria: DrugEra, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: DrugEra, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for drug era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -106,9 +110,21 @@ def resolve_select_clauses(self, criteria: DrugEra, options: Optional[BuilderOpt # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "de.drug_era_start_date" if criteria.date_adjustment.start_with == "start_date" else "de.drug_era_end_date" - end_column = "de.drug_era_start_date" if criteria.date_adjustment.end_with == "start_date" else "de.drug_era_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "de.drug_era_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "de.drug_era_end_date" + ) + end_column = ( + "de.drug_era_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "de.drug_era_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: select_cols.append("de.drug_era_start_date as start_date, de.drug_era_end_date as end_date") @@ -147,13 +163,17 @@ def resolve_where_clauses(self, criteria: DrugEra, options: Optional[BuilderOpti # occurrenceCount if criteria.occurrence_count is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("C.drug_exposure_count", criteria.occurrence_count) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "C.drug_exposure_count", criteria.occurrence_count + ) if numeric_clause: where_clauses.append(numeric_clause) # eraLength if criteria.era_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.era_length + ) if numeric_clause: where_clauses.append(numeric_clause) @@ -165,13 +185,17 @@ def resolve_where_clauses(self, criteria: DrugEra, options: Optional[BuilderOpti # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start + ) if numeric_clause: where_clauses.append(numeric_clause) # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end + ) if numeric_clause: where_clauses.append(numeric_clause) diff --git a/circe/cohortdefinition/builders/drug_exposure.py b/circe/cohortdefinition/builders/drug_exposure.py index 0032332d..6ae1994c 100644 --- a/circe/cohortdefinition/builders/drug_exposure.py +++ b/circe/cohortdefinition/builders/drug_exposure.py @@ -119,7 +119,11 @@ def embed_ordinal_expression(self, query: str, criteria: DrugExposure, where_cla return query - def resolve_select_clauses(self, criteria: DrugExposure, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: DrugExposure, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveSelectClauses() @@ -148,7 +152,9 @@ def resolve_select_clauses(self, criteria: DrugExposure, options: Optional[Build select_cols.append("de.route_concept_id") # providerSpecialty - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: + if ( + criteria.provider_specialty and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs: select_cols.append("de.provider_id") # doseUnit @@ -164,8 +170,16 @@ def resolve_select_clauses(self, criteria: DrugExposure, options: Optional[Build select_cols.append( BuilderUtils.get_date_adjustment_expression( criteria.date_adjustment, - ("de.drug_exposure_start_date" if criteria.date_adjustment.start_with == "start_date" else "de.drug_exposure_end_date"), - ("de.drug_exposure_start_date" if criteria.date_adjustment.end_with == "start_date" else "de.drug_exposure_end_date"), + ( + "de.drug_exposure_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "de.drug_exposure_end_date" + ), + ( + "de.drug_exposure_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "de.drug_exposure_end_date" + ), ) ) else: @@ -175,7 +189,11 @@ def resolve_select_clauses(self, criteria: DrugExposure, options: Optional[Build return select_cols - def resolve_join_clauses(self, criteria: DrugExposure, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_join_clauses( + self, + criteria: DrugExposure, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveJoinClauses() @@ -193,12 +211,20 @@ def resolve_join_clauses(self, criteria: DrugExposure, options: Optional[Builder ) # Join to PROVIDER if provider specialty conditions are present - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") + if ( + criteria.provider_specialty and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs: + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) return join_clauses - def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: DrugExposure, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveWhereClauses() @@ -226,7 +252,11 @@ def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[Builde # drugTypeCS if criteria.drug_type_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.drug_type_cs.codeset_id, "C.drug_type_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.drug_type_cs.codeset_id, "C.drug_type_concept_id" + ) + ) # stopReason if criteria.stop_reason: @@ -239,7 +269,11 @@ def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[Builde # routeConceptCS if criteria.route_concept_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.route_concept_cs.codeset_id, "C.route_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.route_concept_cs.codeset_id, "C.route_concept_id" + ) + ) # doseUnit if criteria.dose_unit and len(criteria.dose_unit) > 0: @@ -248,7 +282,11 @@ def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[Builde # doseUnitCS if criteria.dose_unit_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.dose_unit_cs.codeset_id, "C.dose_unit_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.dose_unit_cs.codeset_id, "C.dose_unit_concept_id" + ) + ) # LotNumber if criteria.lot_number: @@ -264,11 +302,15 @@ def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[Builde # daysSupply if criteria.days_supply: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.days_supply", criteria.days_supply)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.days_supply", criteria.days_supply) + ) # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age) + ) # gender if criteria.gender and len(criteria.gender) > 0: @@ -277,7 +319,9 @@ def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[Builde # genderCS if criteria.gender_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id") + ) # providerSpecialty if criteria.provider_specialty and len(criteria.provider_specialty) > 0: @@ -286,7 +330,11 @@ def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[Builde # providerSpecialtyCS if criteria.provider_specialty_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id" + ) + ) # visitType if criteria.visit_type and len(criteria.visit_type) > 0: @@ -295,6 +343,10 @@ def resolve_where_clauses(self, criteria: DrugExposure, options: Optional[Builde # visitTypeCS if criteria.visit_type_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, "V.visit_concept_id" + ) + ) return [c for c in where_clauses if c] # Filter out None values diff --git a/circe/cohortdefinition/builders/location_region.py b/circe/cohortdefinition/builders/location_region.py index 4680d33d..e305408c 100644 --- a/circe/cohortdefinition/builders/location_region.py +++ b/circe/cohortdefinition/builders/location_region.py @@ -77,7 +77,11 @@ def embed_ordinal_expression(self, query: str, criteria: LocationRegion, where_c """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") - def resolve_select_clauses(self, criteria: LocationRegion, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: LocationRegion, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for location region criteria.""" # Default select columns that are always returned select_cols = ["C.person_id", "C.location_id", "C.region_concept_id"] @@ -94,10 +98,18 @@ def resolve_select_clauses(self, criteria: LocationRegion, options: Optional[Bui return select_cols - def resolve_join_clauses(self, criteria: LocationRegion, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_join_clauses( + self, + criteria: LocationRegion, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for location region criteria.""" return [] - def resolve_where_clauses(self, criteria: LocationRegion, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: LocationRegion, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for location region criteria.""" return [] diff --git a/circe/cohortdefinition/builders/measurement.py b/circe/cohortdefinition/builders/measurement.py index 1a6d891f..f00f9b2c 100644 --- a/circe/cohortdefinition/builders/measurement.py +++ b/circe/cohortdefinition/builders/measurement.py @@ -86,7 +86,11 @@ def embed_codeset_clause(self, query: str, criteria: Measurement) -> str: ), ) - def resolve_select_clauses(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: Measurement, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveSelectClauses() @@ -119,7 +123,9 @@ def resolve_select_clauses(self, criteria: Measurement, options: Optional[Builde select_cols.append("m.unit_concept_id") # providerSpecialty - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: + if ( + criteria.provider_specialty and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs: select_cols.append("m.provider_id") # dateAdjustment or default start/end dates @@ -127,16 +133,30 @@ def resolve_select_clauses(self, criteria: Measurement, options: Optional[Builde select_cols.append( BuilderUtils.get_date_adjustment_expression( criteria.date_adjustment, - ("m.measurement_date" if criteria.date_adjustment.start_with == "start_date" else "DATEADD(day,1,m.measurement_date)"), - ("m.measurement_date" if criteria.date_adjustment.end_with == "start_date" else "DATEADD(day,1,m.measurement_date)"), + ( + "m.measurement_date" + if criteria.date_adjustment.start_with == "start_date" + else "DATEADD(day,1,m.measurement_date)" + ), + ( + "m.measurement_date" + if criteria.date_adjustment.end_with == "start_date" + else "DATEADD(day,1,m.measurement_date)" + ), ) ) else: - select_cols.append("m.measurement_date as start_date, DATEADD(day,1,m.measurement_date) as end_date") + select_cols.append( + "m.measurement_date as start_date, DATEADD(day,1,m.measurement_date) as end_date" + ) return select_cols - def resolve_join_clauses(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_join_clauses( + self, + criteria: Measurement, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveJoinClauses() @@ -144,11 +164,17 @@ def resolve_join_clauses(self, criteria: Measurement, options: Optional[BuilderO join_clauses = [] # Join to PERSON if age or gender conditions are present - if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") # Join to VISIT_OCCURRENCE - if (criteria.visit_type and len(criteria.visit_type) > 0) or (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): + if (criteria.visit_type and len(criteria.visit_type) > 0) or ( + criteria.visit_type_cs and criteria.visit_type_cs.codeset_id + ): join_clauses.append( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" ) @@ -158,17 +184,27 @@ def resolve_join_clauses(self, criteria: Measurement, options: Optional[BuilderO if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id ): - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) return join_clauses - def resolve_ordinal_expression(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> str: + def resolve_ordinal_expression( + self, + criteria: Measurement, + options: Optional[BuilderOptions] = None, + ) -> str: """Resolve ordinal expression for measurement criteria.""" if criteria.first: return "ORDER BY m.measurement_date, m.measurement_id ASC" return "" - def resolve_where_clauses(self, criteria: Measurement, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: Measurement, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveWhereClauses() @@ -187,7 +223,9 @@ def resolve_where_clauses(self, criteria: Measurement, options: Optional[Builder if criteria.measurement_type and len(criteria.measurement_type) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.measurement_type) operator = "not in" if criteria.measurement_type_exclude else "in" - where_clauses.append(f"C.measurement_type_concept_id {operator} ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"C.measurement_type_concept_id {operator} ({','.join(map(str, concept_ids))})" + ) # measurementTypeCS if criteria.measurement_type_cs: @@ -205,12 +243,18 @@ def resolve_where_clauses(self, criteria: Measurement, options: Optional[Builder # operatorCS if criteria.operator_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.operator_cs.codeset_id, "C.operator_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.operator_cs.codeset_id, "C.operator_concept_id" + ) + ) # valueAsNumber if criteria.value_as_number: # Java uses .4f - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number, ".4f")) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number, ".4f") + ) # valueAsConcept if criteria.value_as_concept and len(criteria.value_as_concept) > 0: @@ -219,7 +263,11 @@ def resolve_where_clauses(self, criteria: Measurement, options: Optional[Builder # valueAsConceptCS if criteria.value_as_concept_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id" + ) + ) # unit if criteria.unit and len(criteria.unit) > 0: @@ -228,15 +276,21 @@ def resolve_where_clauses(self, criteria: Measurement, options: Optional[Builder # unitCS if criteria.unit_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id") + ) # rangeLow if criteria.range_low: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.range_low", criteria.range_low, ".4f")) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.range_low", criteria.range_low, ".4f") + ) # rangeHigh if criteria.range_high: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.range_high", criteria.range_high, ".4f")) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.range_high", criteria.range_high, ".4f") + ) # rangeLowRatio if criteria.range_low_ratio: @@ -266,7 +320,9 @@ def resolve_where_clauses(self, criteria: Measurement, options: Optional[Builder # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age) + ) # gender if criteria.gender and len(criteria.gender) > 0: @@ -304,7 +360,11 @@ def resolve_where_clauses(self, criteria: Measurement, options: Optional[Builder # visitTypeCS if criteria.visit_type_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, "V.visit_concept_id" + ) + ) return where_clauses @@ -313,4 +373,6 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: MeasurementSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) + return ", ".join( + [f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns] + ) diff --git a/circe/cohortdefinition/builders/observation.py b/circe/cohortdefinition/builders/observation.py index 7f6ef83f..8efdcb76 100644 --- a/circe/cohortdefinition/builders/observation.py +++ b/circe/cohortdefinition/builders/observation.py @@ -69,7 +69,11 @@ def embed_codeset_clause(self, query: str, criteria: Observation) -> str: ), ) - def resolve_select_clauses(self, criteria: Observation, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: Observation, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for observation criteria. Java equivalent: ObservationSqlBuilder.resolveSelectClauses() @@ -95,7 +99,9 @@ def resolve_select_clauses(self, criteria: Observation, options: Optional[Builde select_cols.append("o.qualifier_concept_id") # providerSpecialty - if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or criteria.provider_specialty_cs: + if ( + criteria.provider_specialty and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs: select_cols.append("o.provider_id") # Add date columns (start_date and end_date) @@ -104,7 +110,9 @@ def resolve_select_clauses(self, criteria: Observation, options: Optional[Builde return select_cols - def resolve_join_clauses(self, criteria: Observation, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_join_clauses( + self, criteria: Observation, options: Optional[BuilderOptions] = None + ) -> list[str]: """Resolve join clauses for observation criteria. Java equivalent: ObservationSqlBuilder.resolveJoinClauses() @@ -112,7 +120,11 @@ def resolve_join_clauses(self, criteria: Observation, options: Optional[BuilderO join_clauses = [] # Join to PERSON if age or gender conditions are present - if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") # Join to PROVIDER if provider specialty conditions are present @@ -120,17 +132,25 @@ def resolve_join_clauses(self, criteria: Observation, options: Optional[BuilderO if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id ): - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) # Join to VISIT_OCCURRENCE if visit type conditions are present - if (criteria.visit_type and len(criteria.visit_type) > 0) or (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): + if (criteria.visit_type and len(criteria.visit_type) > 0) or ( + criteria.visit_type_cs and criteria.visit_type_cs.codeset_id + ): join_clauses.append( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id" ) return join_clauses - def resolve_where_clauses(self, criteria: Observation, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: Observation, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for observation criteria.""" where_clauses = super().resolve_where_clauses(criteria) @@ -149,7 +169,9 @@ def resolve_where_clauses(self, criteria: Observation, options: Optional[Builder if criteria.observation_type and len(criteria.observation_type) > 0: concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.observation_type) operator = "not in" if criteria.observation_type_exclude else "in" - where_clauses.append(f"C.observation_type_concept_id {operator} ({','.join(map(str, concept_ids))})") + where_clauses.append( + f"C.observation_type_concept_id {operator} ({','.join(map(str, concept_ids))})" + ) # observationTypeCS if criteria.observation_type_cs: @@ -162,20 +184,32 @@ def resolve_where_clauses(self, criteria: Observation, options: Optional[Builder # valueAsNumber if hasattr(criteria, "value_as_number") and criteria.value_as_number: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number, ".4f")) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number, ".4f") + ) # valueAsString if criteria.value_as_string: - where_clauses.append(BuilderUtils.build_text_filter_clause(criteria.value_as_string, "C.value_as_string")) + where_clauses.append( + BuilderUtils.build_text_filter_clause(criteria.value_as_string, "C.value_as_string") + ) # valueAsConcept - if hasattr(criteria, "value_as_concept") and criteria.value_as_concept and len(criteria.value_as_concept) > 0: + if ( + hasattr(criteria, "value_as_concept") + and criteria.value_as_concept + and len(criteria.value_as_concept) > 0 + ): concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.value_as_concept) where_clauses.append(f"C.value_as_concept_id in ({','.join(map(str, concept_ids))})") # valueAsConceptCS if hasattr(criteria, "value_as_concept_cs") and criteria.value_as_concept_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.value_as_concept_cs.codeset_id, "C.value_as_concept_id" + ) + ) # unit if hasattr(criteria, "unit") and criteria.unit and len(criteria.unit) > 0: @@ -184,7 +218,9 @@ def resolve_where_clauses(self, criteria: Observation, options: Optional[Builder # unitCS if hasattr(criteria, "unit_cs") and criteria.unit_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id") + ) # qualifier if hasattr(criteria, "qualifier") and criteria.qualifier and len(criteria.qualifier) > 0: @@ -193,11 +229,17 @@ def resolve_where_clauses(self, criteria: Observation, options: Optional[Builder # qualifierCS if hasattr(criteria, "qualifier_cs") and criteria.qualifier_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.qualifier_cs.codeset_id, "C.qualifier_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.qualifier_cs.codeset_id, "C.qualifier_concept_id" + ) + ) # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age) + ) # gender if criteria.gender and len(criteria.gender) > 0: @@ -235,7 +277,11 @@ def resolve_where_clauses(self, criteria: Observation, options: Optional[Builder # visitTypeCS if criteria.visit_type_cs: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, "V.visit_concept_id" + ) + ) return where_clauses @@ -244,7 +290,9 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: ObservationSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) + return ", ".join( + [f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns] + ) def embed_ordinal_expression(self, query: str, criteria: Observation, where_clauses: list[str]) -> str: """Embed ordinal expression in query.""" diff --git a/circe/cohortdefinition/builders/observation_period.py b/circe/cohortdefinition/builders/observation_period.py index f06d8eda..2ece7b56 100644 --- a/circe/cohortdefinition/builders/observation_period.py +++ b/circe/cohortdefinition/builders/observation_period.py @@ -70,14 +70,19 @@ def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) } return column_mapping.get(criteria_column, "NULL") - def get_criteria_sql_with_options(self, criteria: ObservationPeriod, options: Optional[BuilderOptions]) -> str: + def get_criteria_sql_with_options( + self, + criteria: ObservationPeriod, + options: Optional[BuilderOptions], + ) -> str: """Get SQL query for criteria with builder options.""" query = super().get_criteria_sql_with_options(criteria, options) # Override user defined dates in select start_date_expression = ( BuilderUtils.date_string_to_sql(criteria.user_defined_period.start_date) - if criteria.user_defined_period is not None and criteria.user_defined_period.start_date is not None + if criteria.user_defined_period is not None + and criteria.user_defined_period.start_date is not None else "C.start_date" ) query = query.replace("@startDateExpression", start_date_expression) @@ -95,11 +100,20 @@ def embed_codeset_clause(self, query: str, criteria: ObservationPeriod) -> str: """Embed codeset clause in query.""" return query.replace("@codesetClause", "") - def embed_ordinal_expression(self, query: str, criteria: ObservationPeriod, where_clauses: list[str]) -> str: + def embed_ordinal_expression( + self, + query: str, + criteria: ObservationPeriod, + where_clauses: list[str], + ) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") - def resolve_select_clauses(self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: ObservationPeriod, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for observation period criteria. Note: The outer SELECT in the template handles event_id, start_date, end_date, visit_occurrence_id, sort_date. @@ -110,16 +124,32 @@ def resolve_select_clauses(self, criteria: ObservationPeriod, options: Optional[ # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: start_column = ( - "op.observation_period_start_date" if criteria.date_adjustment.start_with == "start_date" else "op.observation_period_end_date" + "op.observation_period_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "op.observation_period_end_date" + ) + end_column = ( + "op.observation_period_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "op.observation_period_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) ) - end_column = "op.observation_period_start_date" if criteria.date_adjustment.end_with == "start_date" else "op.observation_period_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) else: - select_cols.append("op.observation_period_start_date as start_date, op.observation_period_end_date as end_date") + select_cols.append( + "op.observation_period_start_date as start_date, op.observation_period_end_date as end_date" + ) return select_cols - def resolve_join_clauses(self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_join_clauses( + self, + criteria: ObservationPeriod, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for observation period criteria.""" join_clauses = [] @@ -129,7 +159,11 @@ def resolve_join_clauses(self, criteria: ObservationPeriod, options: Optional[Bu return join_clauses - def resolve_where_clauses(self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: ObservationPeriod, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for observation period criteria.""" where_clauses = [] @@ -142,11 +176,15 @@ def resolve_where_clauses(self, criteria: ObservationPeriod, options: Optional[B if user_defined_period.start_date is not None: start_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.start_date) - where_clauses.append(f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}") + where_clauses.append( + f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}" + ) if user_defined_period.end_date is not None: end_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.end_date) - where_clauses.append(f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}") + where_clauses.append( + f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}" + ) # periodStartDate if criteria.period_start_date is not None: @@ -161,7 +199,11 @@ def resolve_where_clauses(self, criteria: ObservationPeriod, options: Optional[B where_clauses.append(date_clause) # periodType - if criteria.period_type is not None and hasattr(criteria.period_type, "__len__") and len(criteria.period_type) > 0: + if ( + criteria.period_type is not None + and hasattr(criteria.period_type, "__len__") + and len(criteria.period_type) > 0 + ): concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.period_type) if concept_ids: where_clauses.append(f"C.period_type_concept_id in ({','.join(map(str, concept_ids))})") @@ -178,19 +220,25 @@ def resolve_where_clauses(self, criteria: ObservationPeriod, options: Optional[B # periodLength if criteria.period_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length + ) if numeric_clause: where_clauses.append(numeric_clause) # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start + ) if numeric_clause: where_clauses.append(numeric_clause) # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end + ) if numeric_clause: where_clauses.append(numeric_clause) @@ -201,4 +249,6 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: ObservationPeriodSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) + return ", ".join( + [f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns] + ) diff --git a/circe/cohortdefinition/builders/payer_plan_period.py b/circe/cohortdefinition/builders/payer_plan_period.py index 58996390..27466f75 100644 --- a/circe/cohortdefinition/builders/payer_plan_period.py +++ b/circe/cohortdefinition/builders/payer_plan_period.py @@ -74,13 +74,18 @@ def get_table_column_for_criteria_column(self, criteria_column: CriteriaColumn) } return column_mapping.get(criteria_column, "NULL") - def get_criteria_sql_with_options(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions]) -> str: + def get_criteria_sql_with_options( + self, + criteria: PayerPlanPeriod, + options: Optional[BuilderOptions], + ) -> str: """Get SQL query for criteria with builder options.""" query = super().get_criteria_sql_with_options(criteria, options) start_date_expression = ( BuilderUtils.date_string_to_sql(criteria.user_defined_period.start_date) - if criteria.user_defined_period is not None and criteria.user_defined_period.start_date is not None + if criteria.user_defined_period is not None + and criteria.user_defined_period.start_date is not None else "C.start_date" ) query = query.replace("@startDateExpression", start_date_expression) @@ -98,11 +103,20 @@ def embed_codeset_clause(self, query: str, criteria: PayerPlanPeriod) -> str: """Embed codeset clause in query.""" return query.replace("@codesetClause", "") - def embed_ordinal_expression(self, query: str, criteria: PayerPlanPeriod, where_clauses: list[str]) -> str: + def embed_ordinal_expression( + self, + query: str, + criteria: PayerPlanPeriod, + where_clauses: list[str], + ) -> str: """Embed ordinal expression in query.""" return query.replace("@ordinalExpression", "") - def resolve_select_clauses(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: PayerPlanPeriod, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for payer plan period criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -141,10 +155,20 @@ def resolve_select_clauses(self, criteria: PayerPlanPeriod, options: Optional[Bu # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: start_column = ( - "ppp.payer_plan_period_start_date" if criteria.date_adjustment.start_with == "start_date" else "ppp.payer_plan_period_end_date" + "ppp.payer_plan_period_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "ppp.payer_plan_period_end_date" + ) + end_column = ( + "ppp.payer_plan_period_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "ppp.payer_plan_period_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) ) - end_column = "ppp.payer_plan_period_start_date" if criteria.date_adjustment.end_with == "start_date" else "ppp.payer_plan_period_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) else: select_cols.append("ppp.payer_plan_period_start_date as start_date") select_cols.append("ppp.payer_plan_period_end_date as end_date") @@ -157,7 +181,11 @@ def resolve_select_clauses(self, criteria: PayerPlanPeriod, options: Optional[Bu return select_cols - def resolve_join_clauses(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_join_clauses( + self, + criteria: PayerPlanPeriod, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for payer plan period criteria.""" join_clauses = [] @@ -171,7 +199,11 @@ def resolve_join_clauses(self, criteria: PayerPlanPeriod, options: Optional[Buil return join_clauses - def resolve_where_clauses(self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: PayerPlanPeriod, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for payer plan period criteria.""" where_clauses = [] @@ -185,11 +217,15 @@ def resolve_where_clauses(self, criteria: PayerPlanPeriod, options: Optional[Bui if user_defined_period.start_date is not None: start_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.start_date) - where_clauses.append(f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}") + where_clauses.append( + f"C.start_date <= {start_date_expression} and C.end_date >= {start_date_expression}" + ) if user_defined_period.end_date is not None: end_date_expression = BuilderUtils.date_string_to_sql(user_defined_period.end_date) - where_clauses.append(f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}") + where_clauses.append( + f"C.start_date <= {end_date_expression} and C.end_date >= {end_date_expression}" + ) # periodStartDate if criteria.period_start_date is not None: @@ -205,19 +241,25 @@ def resolve_where_clauses(self, criteria: PayerPlanPeriod, options: Optional[Bui # periodLength if criteria.period_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.period_length + ) if numeric_clause: where_clauses.append(numeric_clause) # ageAtStart if criteria.age_at_start is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.start_date) - P.year_of_birth", criteria.age_at_start + ) if numeric_clause: where_clauses.append(numeric_clause) # ageAtEnd if criteria.age_at_end is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age_at_end + ) if numeric_clause: where_clauses.append(numeric_clause) @@ -239,19 +281,27 @@ def resolve_where_clauses(self, criteria: PayerPlanPeriod, options: Optional[Bui # payer concept if criteria.payer_concept is not None: - where_clauses.append(f"C.payer_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.payer_concept})") + where_clauses.append( + f"C.payer_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.payer_concept})" + ) # plan concept if criteria.plan_concept is not None: - where_clauses.append(f"C.plan_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_concept})") + where_clauses.append( + f"C.plan_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_concept})" + ) # sponsor concept if criteria.sponsor_concept is not None: - where_clauses.append(f"C.sponsor_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.sponsor_concept})") + where_clauses.append( + f"C.sponsor_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.sponsor_concept})" + ) # stop reason concept if criteria.stop_reason_concept is not None: - where_clauses.append(f"C.stop_reason_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.stop_reason_concept})") + where_clauses.append( + f"C.stop_reason_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.stop_reason_concept})" + ) # payer SourceConcept if criteria.payer_source_concept is not None: @@ -261,7 +311,9 @@ def resolve_where_clauses(self, criteria: PayerPlanPeriod, options: Optional[Bui # plan SourceConcept if criteria.plan_source_concept is not None: - where_clauses.append(f"C.plan_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_source_concept})") + where_clauses.append( + f"C.plan_source_concept_id in (SELECT concept_id from #Codesets where codeset_id = {criteria.plan_source_concept})" + ) # sponsor SourceConcept if criteria.sponsor_source_concept is not None: @@ -282,4 +334,6 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: PayerPlanPeriodSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) + return ", ".join( + [f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns] + ) diff --git a/circe/cohortdefinition/builders/procedure_occurrence.py b/circe/cohortdefinition/builders/procedure_occurrence.py index fd29dd8b..de1d7bc4 100644 --- a/circe/cohortdefinition/builders/procedure_occurrence.py +++ b/circe/cohortdefinition/builders/procedure_occurrence.py @@ -117,12 +117,20 @@ def embed_codeset_clause(self, query: str, criteria: Criteria) -> str: BuilderUtils.get_codeset_join_expression( criteria.codeset_id if hasattr(criteria, "codeset_id") else None, "po.procedure_concept_id", - (criteria.procedure_source_concept if hasattr(criteria, "procedure_source_concept") else None), + ( + criteria.procedure_source_concept + if hasattr(criteria, "procedure_source_concept") + else None + ), "po.procedure_source_concept_id", ), ) - def resolve_select_clauses(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: Criteria, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for criteria. Java equivalent: ProcedureOccurrenceSqlBuilder.resolveSelectClauses() @@ -130,9 +138,11 @@ def resolve_select_clauses(self, criteria: Criteria, options: Optional[BuilderOp select_cols = list(self.DEFAULT_SELECT_COLUMNS) # procedureType - if (hasattr(criteria, "procedure_type") and criteria.procedure_type and len(criteria.procedure_type) > 0) or ( - hasattr(criteria, "procedure_type_cs") and criteria.procedure_type_cs is not None - ): + if ( + hasattr(criteria, "procedure_type") + and criteria.procedure_type + and len(criteria.procedure_type) > 0 + ) or (hasattr(criteria, "procedure_type_cs") and criteria.procedure_type_cs is not None): select_cols.append("po.procedure_type_concept_id") # modifier @@ -142,9 +152,11 @@ def resolve_select_clauses(self, criteria: Criteria, options: Optional[BuilderOp select_cols.append("po.modifier_concept_id") # providerSpecialty - if (hasattr(criteria, "provider_specialty") and criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( - hasattr(criteria, "provider_specialty_cs") and criteria.provider_specialty_cs is not None - ): + if ( + hasattr(criteria, "provider_specialty") + and criteria.provider_specialty + and len(criteria.provider_specialty) > 0 + ) or (hasattr(criteria, "provider_specialty_cs") and criteria.provider_specialty_cs is not None): select_cols.append("po.provider_id") # dateAdjustment or default start/end dates @@ -152,12 +164,22 @@ def resolve_select_clauses(self, criteria: Criteria, options: Optional[BuilderOp select_cols.append( BuilderUtils.get_date_adjustment_expression( criteria.date_adjustment, - ("po.procedure_date" if criteria.date_adjustment.start_with == "start_date" else "DATEADD(day,1,po.procedure_date)"), - ("po.procedure_date" if criteria.date_adjustment.end_with == "start_date" else "DATEADD(day,1,po.procedure_date)"), + ( + "po.procedure_date" + if criteria.date_adjustment.start_with == "start_date" + else "DATEADD(day,1,po.procedure_date)" + ), + ( + "po.procedure_date" + if criteria.date_adjustment.end_with == "start_date" + else "DATEADD(day,1,po.procedure_date)" + ), ) ) else: - select_cols.append("po.procedure_date as start_date, DATEADD(day,1,po.procedure_date) as end_date") + select_cols.append( + "po.procedure_date as start_date, DATEADD(day,1,po.procedure_date) as end_date" + ) return select_cols @@ -185,14 +207,22 @@ def resolve_join_clauses(self, criteria: Criteria, options: Optional[BuilderOpti ) # providerSpecialty - if (hasattr(criteria, "provider_specialty") and criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( - hasattr(criteria, "provider_specialty_cs") and criteria.provider_specialty_cs is not None - ): - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") + if ( + hasattr(criteria, "provider_specialty") + and criteria.provider_specialty + and len(criteria.provider_specialty) > 0 + ) or (hasattr(criteria, "provider_specialty_cs") and criteria.provider_specialty_cs is not None): + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) return join_clauses - def resolve_where_clauses(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: Criteria, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for criteria. Java equivalent: ProcedureOccurrenceSqlBuilder.resolveWhereClauses() @@ -201,17 +231,33 @@ def resolve_where_clauses(self, criteria: Criteria, options: Optional[BuilderOpt # occurrenceStartDate if hasattr(criteria, "occurrence_start_date") and criteria.occurrence_start_date: - where_clauses.append(BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date)) + where_clauses.append( + BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date) + ) # procedureType - if hasattr(criteria, "procedure_type") and criteria.procedure_type and len(criteria.procedure_type) > 0: + if ( + hasattr(criteria, "procedure_type") + and criteria.procedure_type + and len(criteria.procedure_type) > 0 + ): concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.procedure_type) - exclude = "not " if hasattr(criteria, "procedure_type_exclude") and criteria.procedure_type_exclude else "" - where_clauses.append(f"C.procedure_type_concept_id {exclude}in ({','.join(map(str, concept_ids))})") + exclude = ( + "not " + if hasattr(criteria, "procedure_type_exclude") and criteria.procedure_type_exclude + else "" + ) + where_clauses.append( + f"C.procedure_type_concept_id {exclude}in ({','.join(map(str, concept_ids))})" + ) # procedureTypeCS if hasattr(criteria, "procedure_type_cs") and criteria.procedure_type_cs is not None: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.procedure_type_cs.codeset_id, "C.procedure_type_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.procedure_type_cs.codeset_id, "C.procedure_type_concept_id" + ) + ) # modifier if hasattr(criteria, "modifier") and criteria.modifier and len(criteria.modifier) > 0: @@ -220,7 +266,11 @@ def resolve_where_clauses(self, criteria: Criteria, options: Optional[BuilderOpt # modifierCS if hasattr(criteria, "modifier_cs") and criteria.modifier_cs is not None: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.modifier_cs.codeset_id, "C.modifier_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.modifier_cs.codeset_id, "C.modifier_concept_id" + ) + ) # quantity if hasattr(criteria, "quantity") and criteria.quantity: @@ -228,7 +278,9 @@ def resolve_where_clauses(self, criteria: Criteria, options: Optional[BuilderOpt # age if hasattr(criteria, "age") and criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age) + ) # gender if hasattr(criteria, "gender") and criteria.gender and len(criteria.gender) > 0: @@ -237,16 +289,26 @@ def resolve_where_clauses(self, criteria: Criteria, options: Optional[BuilderOpt # genderCS if hasattr(criteria, "gender_cs") and criteria.gender_cs is not None: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id") + ) # providerSpecialty - if hasattr(criteria, "provider_specialty") and criteria.provider_specialty and len(criteria.provider_specialty) > 0: + if ( + hasattr(criteria, "provider_specialty") + and criteria.provider_specialty + and len(criteria.provider_specialty) > 0 + ): concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) where_clauses.append(f"PR.specialty_concept_id in ({','.join(map(str, concept_ids))})") # providerSpecialtyCS if hasattr(criteria, "provider_specialty_cs") and criteria.provider_specialty_cs is not None: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.provider_specialty_cs.codeset_id, "PR.specialty_concept_id" + ) + ) # visitType if hasattr(criteria, "visit_type") and criteria.visit_type and len(criteria.visit_type) > 0: @@ -255,6 +317,10 @@ def resolve_where_clauses(self, criteria: Criteria, options: Optional[BuilderOpt # visitTypeCS if hasattr(criteria, "visit_type_cs") and criteria.visit_type_cs is not None: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.visit_type_cs.codeset_id, "V.visit_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.visit_type_cs.codeset_id, "V.visit_concept_id" + ) + ) return where_clauses diff --git a/circe/cohortdefinition/builders/specimen.py b/circe/cohortdefinition/builders/specimen.py index 6b27bc27..7aaea3d5 100644 --- a/circe/cohortdefinition/builders/specimen.py +++ b/circe/cohortdefinition/builders/specimen.py @@ -87,18 +87,28 @@ def resolve_join_clauses(self, criteria: Specimen, options: Optional[BuilderOpti joins = [] # join to PERSON - if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): joins.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") return joins - def resolve_where_clauses(self, criteria: Specimen, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: Specimen, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for specimen criteria.""" where_clauses = [] # occurrenceStartDate if criteria.occurrence_start_date: - date_clause = BuilderUtils.build_date_range_clause("C.specimen_date", criteria.occurrence_start_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.specimen_date", criteria.occurrence_start_date + ) if date_clause: where_clauses.append(date_clause) @@ -110,11 +120,17 @@ def resolve_where_clauses(self, criteria: Specimen, options: Optional[BuilderOpt # specimenTypeCS if criteria.specimen_type_cs and criteria.specimen_type_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.specimen_type_cs.codeset_id, "C.specimen_type_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.specimen_type_cs.codeset_id, "C.specimen_type_concept_id" + ) + ) # quantity if criteria.quantity: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity, ".4f")) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.quantity", criteria.quantity, ".4f") + ) # unit if criteria.unit and len(criteria.unit) > 0: @@ -123,7 +139,9 @@ def resolve_where_clauses(self, criteria: Specimen, options: Optional[BuilderOpt # unitCS if criteria.unit_cs and criteria.unit_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression(criteria.unit_cs.codeset_id, "C.unit_concept_id") + ) # anatomicSite if criteria.anatomic_site and len(criteria.anatomic_site) > 0: @@ -132,7 +150,11 @@ def resolve_where_clauses(self, criteria: Specimen, options: Optional[BuilderOpt # anatomicSiteCS if criteria.anatomic_site_cs and criteria.anatomic_site_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.anatomic_site_cs.codeset_id, "C.anatomic_site_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.anatomic_site_cs.codeset_id, "C.anatomic_site_concept_id" + ) + ) # diseaseStatus if criteria.disease_status and len(criteria.disease_status) > 0: @@ -141,15 +163,25 @@ def resolve_where_clauses(self, criteria: Specimen, options: Optional[BuilderOpt # diseaseStatusCS if criteria.disease_status_cs and criteria.disease_status_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.disease_status_cs.codeset_id, "C.disease_status_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression( + criteria.disease_status_cs.codeset_id, "C.disease_status_concept_id" + ) + ) # sourceId if criteria.source_id: - where_clauses.append(BuilderUtils.build_text_filter_clause(criteria.source_id, "C.specimen_source_id")) + where_clauses.append( + BuilderUtils.build_text_filter_clause(criteria.source_id, "C.specimen_source_id") + ) # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.specimen_date) - P.year_of_birth", criteria.age)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "YEAR(C.specimen_date) - P.year_of_birth", criteria.age + ) + ) # gender if criteria.gender and len(criteria.gender) > 0: @@ -158,6 +190,8 @@ def resolve_where_clauses(self, criteria: Specimen, options: Optional[BuilderOpt # genderCS if criteria.gender_cs and criteria.gender_cs.codeset_id: - where_clauses.append(BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id")) + where_clauses.append( + BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id") + ) return where_clauses diff --git a/circe/cohortdefinition/builders/utils.py b/circe/cohortdefinition/builders/utils.py index f29c2641..5ef033fd 100644 --- a/circe/cohortdefinition/builders/utils.py +++ b/circe/cohortdefinition/builders/utils.py @@ -44,7 +44,11 @@ class BuilderUtils: NON_STANDARD_ALIAS = "cns" @staticmethod - def get_date_adjustment_expression(date_adjustment: DateAdjustment, start_column: str, end_column: str) -> str: + def get_date_adjustment_expression( + date_adjustment: DateAdjustment, + start_column: str, + end_column: str, + ) -> str: """Get date adjustment expression for SQL. Java equivalent: BuilderUtils.getDateAdjustmentExpression() diff --git a/circe/cohortdefinition/builders/visit_detail.py b/circe/cohortdefinition/builders/visit_detail.py index f7740593..8eb93569 100644 --- a/circe/cohortdefinition/builders/visit_detail.py +++ b/circe/cohortdefinition/builders/visit_detail.py @@ -99,7 +99,11 @@ def embed_ordinal_expression(self, query: str, criteria: VisitDetail, where_clau query = query.replace("@ordinalExpression", "") return query - def resolve_select_clauses(self, criteria: VisitDetail, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: VisitDetail, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for visit detail criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -117,9 +121,21 @@ def resolve_select_clauses(self, criteria: VisitDetail, options: Optional[Builde # dateAdjustment or default start/end dates if criteria.date_adjustment is not None: - start_column = "vd.visit_detail_start_date" if criteria.date_adjustment.start_with == "start_date" else "vd.visit_detail_end_date" - end_column = "vd.visit_detail_start_date" if criteria.date_adjustment.end_with == "start_date" else "vd.visit_detail_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_column, end_column)) + start_column = ( + "vd.visit_detail_start_date" + if criteria.date_adjustment.start_with == "start_date" + else "vd.visit_detail_end_date" + ) + end_column = ( + "vd.visit_detail_start_date" + if criteria.date_adjustment.end_with == "start_date" + else "vd.visit_detail_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression( + criteria.date_adjustment, start_column, end_column + ) + ) else: select_cols.append("vd.visit_detail_start_date as start_date") select_cols.append("vd.visit_detail_end_date as end_date") @@ -132,31 +148,45 @@ def resolve_select_clauses(self, criteria: VisitDetail, options: Optional[Builde return select_cols - def resolve_join_clauses(self, criteria: VisitDetail, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_join_clauses( + self, + criteria: VisitDetail, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for visit detail criteria.""" join_clauses = [] - if criteria.age is not None or criteria.gender_cs is not None or criteria.gender is not None: # join to PERSON + if ( + criteria.age is not None or criteria.gender_cs is not None or criteria.gender is not None + ): # join to PERSON join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") if criteria.place_of_service_cs is not None or criteria.place_of_service_location is not None: join_clauses.append("JOIN @cdm_database_schema.CARE_SITE CS on C.care_site_id = CS.care_site_id") if criteria.provider_specialty_cs is not None: - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) if criteria.place_of_service_location is not None: self.add_filtering_by_care_site_location_region(join_clauses, criteria.place_of_service_location) return join_clauses - def resolve_where_clauses(self, criteria: VisitDetail, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: VisitDetail, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for visit detail criteria.""" where_clauses = [] # occurrenceStartDate if criteria.visit_detail_start_date is not None: - date_clause = BuilderUtils.build_date_range_clause("C.start_date", criteria.visit_detail_start_date) + date_clause = BuilderUtils.build_date_range_clause( + "C.start_date", criteria.visit_detail_start_date + ) if date_clause: where_clauses.append(date_clause) @@ -177,13 +207,17 @@ def resolve_where_clauses(self, criteria: VisitDetail, options: Optional[Builder # visitLength if criteria.visit_detail_length is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_detail_length) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_detail_length + ) if numeric_clause: where_clauses.append(numeric_clause) # age if criteria.age is not None: - numeric_clause = BuilderUtils.build_numeric_range_clause("YEAR(C.end_date) - P.year_of_birth", criteria.age) + numeric_clause = BuilderUtils.build_numeric_range_clause( + "YEAR(C.end_date) - P.year_of_birth", criteria.age + ) if numeric_clause: where_clauses.append(numeric_clause) @@ -215,7 +249,9 @@ def get_additional_columns(self, columns: list[CriteriaColumn]) -> str: Java equivalent: VisitDetailSqlBuilder.getAdditionalColumns() """ - return ", ".join([f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns]) + return ", ".join( + [f"{self.get_table_column_for_criteria_column(col)} as {col.value}" for col in columns] + ) def add_filtering_by_care_site_location_region(self, join_clauses: list[str], codeset_id: int): """Add filtering by care site location region.""" @@ -232,13 +268,17 @@ def add_where_clause( ): """Add where clause for concept set selection.""" is_exclusion = exclude if exclude is not None else concept_set_selection.is_exclusion - codeset_clause = BuilderUtils.get_codeset_in_expression(concept_set_selection.codeset_id, concept_column, is_exclusion) + codeset_clause = BuilderUtils.get_codeset_in_expression( + concept_set_selection.codeset_id, concept_column, is_exclusion + ) if codeset_clause: where_clauses.append(codeset_clause) def add_filtering(self, join_clauses: list[str], codeset_id: int, standard_concept_column: str): """Add filtering join clause.""" - join_clauses.append(BuilderUtils.get_codeset_join_expression(codeset_id, standard_concept_column, None, None)) + join_clauses.append( + BuilderUtils.get_codeset_join_expression(codeset_id, standard_concept_column, None, None) + ) def get_location_history_join(self, alias: str, domain: str, entity_id_field: str) -> str: """Get location history join clause.""" diff --git a/circe/cohortdefinition/builders/visit_occurrence.py b/circe/cohortdefinition/builders/visit_occurrence.py index 06386ec7..53263734 100644 --- a/circe/cohortdefinition/builders/visit_occurrence.py +++ b/circe/cohortdefinition/builders/visit_occurrence.py @@ -71,13 +71,19 @@ def embed_codeset_clause(self, query: str, criteria: VisitOccurrence) -> str: ) return query.replace("@codesetClause", codeset_clause) - def resolve_select_clauses(self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_select_clauses( + self, + criteria: VisitOccurrence, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for visit occurrence criteria.""" # Default select columns that are always returned select_cols = ["vo.person_id", "vo.visit_occurrence_id", "vo.visit_concept_id"] # visitType - if (criteria.visit_type and len(criteria.visit_type) > 0) or (criteria.visit_type_cs and criteria.visit_type_cs.codeset_id): + if (criteria.visit_type and len(criteria.visit_type) > 0) or ( + criteria.visit_type_cs and criteria.visit_type_cs.codeset_id + ): select_cols.append("vo.visit_type_concept_id") # providerSpecialty @@ -98,20 +104,38 @@ def resolve_select_clauses(self, criteria: VisitOccurrence, options: Optional[Bu # BuilderUtils.getDateAdjustmentExpression(criteria.dateAdjustment, # criteria.dateAdjustment.startWith == DateAdjustment.DateType.START_DATE ? "vo.visit_start_date" : "vo.visit_end_date", # criteria.dateAdjustment.endWith == DateAdjustment.DateType.START_DATE ? "vo.visit_start_date" : "vo.visit_end_date") - start_col = "vo.visit_start_date" if criteria.date_adjustment.start_with == "START_DATE" else "vo.visit_end_date" - end_col = "vo.visit_start_date" if criteria.date_adjustment.end_with == "START_DATE" else "vo.visit_end_date" - select_cols.append(BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_col, end_col)) + start_col = ( + "vo.visit_start_date" + if criteria.date_adjustment.start_with == "START_DATE" + else "vo.visit_end_date" + ) + end_col = ( + "vo.visit_start_date" + if criteria.date_adjustment.end_with == "START_DATE" + else "vo.visit_end_date" + ) + select_cols.append( + BuilderUtils.get_date_adjustment_expression(criteria.date_adjustment, start_col, end_col) + ) else: select_cols.append("vo.visit_start_date as start_date, vo.visit_end_date as end_date") return select_cols - def resolve_join_clauses(self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_join_clauses( + self, + criteria: VisitOccurrence, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for visit occurrence criteria.""" join_clauses = [] # Join to PERSON if age or gender conditions are present - if criteria.age or (criteria.gender and len(criteria.gender) > 0) or (criteria.gender_cs and criteria.gender_cs.codeset_id): + if ( + criteria.age + or (criteria.gender and len(criteria.gender) > 0) + or (criteria.gender_cs and criteria.gender_cs.codeset_id) + ): join_clauses.append("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id") # Join to CARE_SITE if place of service conditions are present @@ -126,24 +150,34 @@ def resolve_join_clauses(self, criteria: VisitOccurrence, options: Optional[Buil if (criteria.provider_specialty and len(criteria.provider_specialty) > 0) or ( criteria.provider_specialty_cs and criteria.provider_specialty_cs.codeset_id ): - join_clauses.append("LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id") + join_clauses.append( + "LEFT JOIN @cdm_database_schema.PROVIDER PR on C.provider_id = PR.provider_id" + ) if criteria.place_of_service_location is not None: self._add_filtering_by_care_site_location_region(join_clauses, criteria.place_of_service_location) return join_clauses - def resolve_where_clauses(self, criteria: VisitOccurrence, options: Optional[BuilderOptions] = None) -> list[str]: + def resolve_where_clauses( + self, + criteria: VisitOccurrence, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for visit occurrence criteria.""" where_clauses = super().resolve_where_clauses(criteria, options) # occurrenceStartDate if criteria.occurrence_start_date: - where_clauses.append(BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date)) + where_clauses.append( + BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date) + ) # occurrenceEndDate if criteria.occurrence_end_date: - where_clauses.append(BuilderUtils.build_date_range_clause("C.end_date", criteria.occurrence_end_date)) + where_clauses.append( + BuilderUtils.build_date_range_clause("C.end_date", criteria.occurrence_end_date) + ) # visitType if criteria.visit_type and len(criteria.visit_type) > 0: @@ -163,11 +197,17 @@ def resolve_where_clauses(self, criteria: VisitOccurrence, options: Optional[Bui # visitLength if criteria.visit_length: - where_clauses.append(BuilderUtils.build_numeric_range_clause("DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_length)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "DATEDIFF(d,C.start_date, C.end_date)", criteria.visit_length + ) + ) # age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("YEAR(C.start_date) - P.year_of_birth", criteria.age) + ) # gender if criteria.gender and len(criteria.gender) > 0: @@ -218,7 +258,12 @@ def resolve_where_clauses(self, criteria: VisitOccurrence, options: Optional[Bui return where_clauses - def embed_ordinal_expression(self, query: str, criteria: VisitOccurrence, where_clauses: list[str]) -> str: + def embed_ordinal_expression( + self, + query: str, + criteria: VisitOccurrence, + where_clauses: list[str], + ) -> str: """Embed ordinal expression for visit occurrence criteria.""" if criteria.first is not None and criteria.first: where_clauses.append("C.ordinal = 1") @@ -231,7 +276,9 @@ def _add_filtering_by_care_site_location_region(self, join_clauses: list[str], c """Add joins for filtering by care site location region.""" join_clauses.append(self._get_location_history_join("LH", "CARE_SITE", "C.care_site_id")) join_clauses.append("JOIN @cdm_database_schema.LOCATION LOC on LOC.location_id = LH.location_id") - join_clauses.append(BuilderUtils.get_codeset_join_expression(codeset_id, "LOC.region_concept_id", None, None)) + join_clauses.append( + BuilderUtils.get_codeset_join_expression(codeset_id, "LOC.region_concept_id", None, None) + ) def _get_location_history_join(self, alias: str, domain: str, entity_id_field: str) -> str: """Get location history join expression.""" diff --git a/circe/cohortdefinition/cohort.py b/circe/cohortdefinition/cohort.py index 97e289cd..6071dc81 100644 --- a/circe/cohortdefinition/cohort.py +++ b/circe/cohortdefinition/cohort.py @@ -228,11 +228,23 @@ def deserialize_censoring_criteria(cls, v: Any) -> Any: data_copy = dict(criteria_data) if "First" not in data_copy and "first" not in data_copy: data_copy["First"] = False - if criteria_type == "Measurement" and "MeasurementTypeExclude" not in data_copy and "measurementTypeExclude" not in data_copy: + if ( + criteria_type == "Measurement" + and "MeasurementTypeExclude" not in data_copy + and "measurementTypeExclude" not in data_copy + ): data_copy["MeasurementTypeExclude"] = False - if criteria_type == "Observation" and "ObservationTypeExclude" not in data_copy and "observationTypeExclude" not in data_copy: + if ( + criteria_type == "Observation" + and "ObservationTypeExclude" not in data_copy + and "observationTypeExclude" not in data_copy + ): data_copy["ObservationTypeExclude"] = False - if criteria_type == "ConditionOccurrence" and "ConditionTypeExclude" not in data_copy and "conditionTypeExclude" not in data_copy: + if ( + criteria_type == "ConditionOccurrence" + and "ConditionTypeExclude" not in data_copy + and "conditionTypeExclude" not in data_copy + ): data_copy["ConditionTypeExclude"] = False criteria_obj = criteria_class_map[criteria_type].model_validate(data_copy, strict=False) @@ -298,7 +310,9 @@ def remove_censoring_criteria_by_type(self, criteria_type: str) -> None: Removes a censoring criteria by its type """ if self.censoring_criteria: - self.censoring_criteria = [c for c in self.censoring_criteria if c.__class__.__name__ != criteria_type] + self.censoring_criteria = [ + c for c in self.censoring_criteria if c.__class__.__name__ != criteria_type + ] def validate_expression(self) -> bool: """Validate the cohort expression.""" diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index 918f53b0..d56f8beb 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -463,9 +463,7 @@ class CohortExpressionQueryBuilder(IGetCriteriaSqlDispatcher, IGetEndStrategySql DROP TABLE #drugTarget; """ - DEFAULT_DRUG_EXPOSURE_END_DATE_EXPRESSION = ( - "COALESCE(DRUG_EXPOSURE_END_DATE, DATEADD(day,DAYS_SUPPLY,DRUG_EXPOSURE_START_DATE), DATEADD(day,1,DRUG_EXPOSURE_START_DATE))" - ) + DEFAULT_DRUG_EXPOSURE_END_DATE_EXPRESSION = "COALESCE(DRUG_EXPOSURE_END_DATE, DATEADD(day,DAYS_SUPPLY,DRUG_EXPOSURE_START_DATE), DATEADD(day,1,DRUG_EXPOSURE_START_DATE))" DEFAULT_COHORT_ID_FIELD_NAME = "cohort_definition_id" def __init__(self): @@ -591,7 +589,11 @@ def get_censoring_events_query(self, censoring_criteria: list[Criteria]) -> str: return " UNION ALL ".join(criteria_queries) - def get_primary_events_query(self, primary_criteria: PrimaryCriteria, subquery: Optional[str] = None) -> str: + def get_primary_events_query( + self, + primary_criteria: PrimaryCriteria, + subquery: Optional[str] = None, + ) -> str: """Get primary events query. Java equivalent: getPrimaryEventsQuery() @@ -627,7 +629,11 @@ def _get_primary_events_subquery(self, primary_criteria: PrimaryCriteria) -> str # Event sort event_sort = ( "DESC" - if (primary_criteria.primary_limit and primary_criteria.primary_limit.type and str(primary_criteria.primary_limit.type).upper() == "LAST") + if ( + primary_criteria.primary_limit + and primary_criteria.primary_limit.type + and str(primary_criteria.primary_limit.type).upper() == "LAST" + ) else "ASC" ) query = query.replace("@EventSort", event_sort) @@ -635,7 +641,11 @@ def _get_primary_events_subquery(self, primary_criteria: PrimaryCriteria) -> str # Primary event limit - this filters P.ordinal primary_event_limit = ( "" - if (primary_criteria.primary_limit and primary_criteria.primary_limit.type and str(primary_criteria.primary_limit.type).upper() == "ALL") + if ( + primary_criteria.primary_limit + and primary_criteria.primary_limit.type + and str(primary_criteria.primary_limit.type).upper() == "ALL" + ) else "WHERE P.ordinal = 1" ) query = query.replace("@primaryEventLimit", primary_event_limit) @@ -655,7 +665,9 @@ def get_final_cohort_query(self, censor_window: Optional[Period]) -> str: if censor_window and (censor_window.start_date or censor_window.end_date): if censor_window.start_date: censor_start_date = BuilderUtils.date_string_to_sql(censor_window.start_date) - start_date = f"CASE WHEN start_date > {censor_start_date} THEN start_date ELSE {censor_start_date} END" + start_date = ( + f"CASE WHEN start_date > {censor_start_date} THEN start_date ELSE {censor_start_date} END" + ) if censor_window.end_date: censor_end_date = BuilderUtils.date_string_to_sql(censor_window.end_date) end_date = f"CASE WHEN end_date < {censor_end_date} THEN end_date ELSE {censor_end_date} END" @@ -766,7 +778,11 @@ def _build_inclusion_analysis_section(self, expression: CohortExpression) -> str {cleanup}}} """ - def build_expression_query(self, expression: Union[str, CohortExpression], options: BuildExpressionQueryOptions) -> str: + def build_expression_query( + self, + expression: Union[str, CohortExpression], + options: BuildExpressionQueryOptions, + ) -> str: """Build expression query from CohortExpression object or JSON string. Java equivalent: buildExpressionQuery(String, BuildExpressionQueryOptions) @@ -785,7 +801,9 @@ def build_expression_query(self, expression: Union[str, CohortExpression], optio primary_events_subquery = self._get_primary_events_subquery(expression.primary_criteria) # Primary events query (full wrapper) - primary_events_query = self.get_primary_events_query(expression.primary_criteria, primary_events_subquery) + primary_events_query = self.get_primary_events_query( + expression.primary_criteria, primary_events_subquery + ) result_sql = result_sql.replace("@primaryEventsQuery", primary_events_query) # Additional criteria query - this filters primary events based on additional conditions @@ -793,7 +811,9 @@ def build_expression_query(self, expression: Union[str, CohortExpression], optio # Generate criteria group query that joins with the pe (primary events) subquery # The pe subquery is defined in PRIMARY_EVENTS_TEMPLATE and has columns: # event_id, person_id, start_date, end_date, op_start_date, op_end_date, visit_occurrence_id - additional_criteria_group_query = self.get_criteria_group_query(expression.additional_criteria, f"({primary_events_subquery})") + additional_criteria_group_query = self.get_criteria_group_query( + expression.additional_criteria, f"({primary_events_subquery})" + ) # Create a JOIN clause that filters pe events based on the additional criteria additional_criteria_sql = f"\nJOIN (\n{additional_criteria_group_query}) AC ON AC.person_id = pe.person_id AND AC.event_id = pe.event_id" @@ -806,7 +826,11 @@ def build_expression_query(self, expression: Union[str, CohortExpression], optio # Qualified event sort qualified_event_sort = ( "DESC" - if (expression.qualified_limit and expression.qualified_limit.type and str(expression.qualified_limit.type).upper() == "LAST") + if ( + expression.qualified_limit + and expression.qualified_limit.type + and str(expression.qualified_limit.type).upper() == "LAST" + ) else "ASC" ) result_sql = result_sql.replace("@QualifiedEventSort", qualified_event_sort) @@ -835,12 +859,19 @@ def build_expression_query(self, expression: Union[str, CohortExpression], optio inclusion_rule_temp_tables.append(f"#Inclusion_{i}") ir_temp_union = "\nUNION ALL\n".join( - [f"select inclusion_rule_id, person_id, event_id from {table}" for table in inclusion_rule_temp_tables] + [ + f"select inclusion_rule_id, person_id, event_id from {table}" + for table in inclusion_rule_temp_tables + ] ) - inclusion_rule_inserts.append(f"SELECT inclusion_rule_id, person_id, event_id\nINTO #inclusion_events\nFROM ({ir_temp_union}) I;") + inclusion_rule_inserts.append( + f"SELECT inclusion_rule_id, person_id, event_id\nINTO #inclusion_events\nFROM ({ir_temp_union}) I;" + ) - inclusion_rule_inserts.extend([f"TRUNCATE TABLE {table};\nDROP TABLE {table};\n" for table in inclusion_rule_temp_tables]) + inclusion_rule_inserts.extend( + [f"TRUNCATE TABLE {table};\nDROP TABLE {table};\n" for table in inclusion_rule_temp_tables] + ) result_sql = result_sql.replace("@inclusionCohortInserts", "\n".join(inclusion_rule_inserts)) else: @@ -860,13 +891,21 @@ def build_expression_query(self, expression: Union[str, CohortExpression], optio # Included event sort - determine sort order based on expression limit included_event_sort = ( "DESC" - if (expression.expression_limit and expression.expression_limit.type and str(expression.expression_limit.type).upper() == "LAST") + if ( + expression.expression_limit + and expression.expression_limit.type + and str(expression.expression_limit.type).upper() == "LAST" + ) else "ASC" ) included_events_query = included_events_query.replace("@IncludedEventSort", included_event_sort) # Result limit filter - if expression.expression_limit and expression.expression_limit.type and str(expression.expression_limit.type).upper() != "ALL": + if ( + expression.expression_limit + and expression.expression_limit.type + and str(expression.expression_limit.type).upper() != "ALL" + ): result_limit_filter = "WHERE Results.ordinal = 1" else: result_limit_filter = "" @@ -882,7 +921,9 @@ def build_expression_query(self, expression: Union[str, CohortExpression], optio ) else: inclusion_rule_mask_filter = "" - included_events_query = included_events_query.replace("@InclusionRuleMaskFilter", inclusion_rule_mask_filter) + included_events_query = included_events_query.replace( + "@InclusionRuleMaskFilter", inclusion_rule_mask_filter + ) result_sql = result_sql.replace("@includedEventsQuery", included_events_query) @@ -918,7 +959,9 @@ def build_expression_query(self, expression: Union[str, CohortExpression], optio result_sql = result_sql.replace("@strategy_ends_cleanup", "") if expression.censoring_criteria: - end_date_selects.append(f"-- Censor Events\n{self.get_censoring_events_query(expression.censoring_criteria)}") + end_date_selects.append( + f"-- Censor Events\n{self.get_censoring_events_query(expression.censoring_criteria)}" + ) final_cohort_query = self.get_final_cohort_query(expression.censor_window) result_sql = result_sql.replace("@finalCohortQuery", final_cohort_query) @@ -948,7 +991,9 @@ def build_expression_query(self, expression: Union[str, CohortExpression], optio if options.cdm_schema: result_sql = result_sql.replace("@cdm_database_schema", options.cdm_schema) if options.target_table: - result_sql = result_sql.replace("@target_database_schema.@target_cohort_table", options.target_table) + result_sql = result_sql.replace( + "@target_database_schema.@target_cohort_table", options.target_table + ) if options.result_schema: result_sql = result_sql.replace("@results_database_schema", options.result_schema) if options.vocabulary_schema: @@ -1032,7 +1077,9 @@ def get_inclusion_rule_query(self, inclusion_rule: CriteriaGroup) -> str: result_sql = self.INCLUSION_RULE_QUERY_TEMPLATE criteria_group_sql = self.get_criteria_group_query(inclusion_rule, "#qualified_events") criteria_group_sql = criteria_group_sql.replace("@indexId", "0") - additional_criteria_query = f"\nJOIN (\n{criteria_group_sql}) AC on AC.person_id = pe.person_id AND AC.event_id = pe.event_id" + additional_criteria_query = ( + f"\nJOIN (\n{criteria_group_sql}) AC on AC.person_id = pe.person_id AND AC.event_id = pe.event_id" + ) result_sql = result_sql.replace("@additionalCriteriaQuery", additional_criteria_query) result_sql = result_sql.replace("@eventTable", "#qualified_events") return result_sql @@ -1049,7 +1096,9 @@ def get_demographic_criteria_query(self, criteria: DemographicCriteria, event_ta # Age if criteria.age: - where_clauses.append(BuilderUtils.build_numeric_range_clause("YEAR(E.start_date) - P.year_of_birth", criteria.age)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("YEAR(E.start_date) - P.year_of_birth", criteria.age) + ) # Gender if criteria.gender: @@ -1098,13 +1147,21 @@ def get_demographic_criteria_query(self, criteria: DemographicCriteria, event_ta # OccurrenceStartDate if criteria.occurrence_start_date: - where_clauses.append(BuilderUtils.build_date_range_clause("E.start_date", criteria.occurrence_start_date)) + where_clauses.append( + BuilderUtils.build_date_range_clause("E.start_date", criteria.occurrence_start_date) + ) # OccurrenceEndDate if criteria.occurrence_end_date: - where_clauses.append(BuilderUtils.build_date_range_clause("E.end_date", criteria.occurrence_end_date)) + where_clauses.append( + BuilderUtils.build_date_range_clause("E.end_date", criteria.occurrence_end_date) + ) - query = query.replace("@whereClause", "WHERE " + " AND ".join(where_clauses)) if where_clauses else query.replace("@whereClause", "") + query = ( + query.replace("@whereClause", "WHERE " + " AND ".join(where_clauses)) + if where_clauses + else query.replace("@whereClause", "") + ) return query @@ -1165,18 +1222,25 @@ def _get_windowed_criteria_query_internal( criteria_data["measurementTypeExclude"] = False if criteria_type == "Observation" and "observationTypeExclude" not in criteria_data: criteria_data["observationTypeExclude"] = False - if criteria_type == "ProcedureOccurrence" and "procedureTypeExclude" not in criteria_data: + if ( + criteria_type == "ProcedureOccurrence" + and "procedureTypeExclude" not in criteria_data + ): criteria_data["procedureTypeExclude"] = False if criteria_type == "DrugExposure" and "drugTypeExclude" not in criteria_data: criteria_data["drugTypeExclude"] = False # Most criteria types require 'first' field if "first" not in criteria_data or criteria_data.get("first") is None: criteria_data["first"] = False - inner_criteria = criteria_class_map[criteria_type].model_validate(criteria_data, strict=False) + inner_criteria = criteria_class_map[criteria_type].model_validate( + criteria_data, strict=False + ) # Update the criteria object criteria.criteria = inner_criteria except Exception as e: - raise ValueError(f"Failed to deserialize criteria from dict: {criteria_type} - {e}") from e + raise ValueError( + f"Failed to deserialize criteria from dict: {criteria_type} - {e}" + ) from e else: raise ValueError(f"Unknown criteria type in dict: {criteria_type}") else: @@ -1203,9 +1267,17 @@ def _get_windowed_criteria_query_internal( start_window = criteria.start_window if start_window: # Java: (useIndexEnd != null && useIndexEnd) - true only if not null AND true - start_index_date_expression = "P.END_DATE" if (start_window.use_index_end is not None and start_window.use_index_end) else "P.START_DATE" + start_index_date_expression = ( + "P.END_DATE" + if (start_window.use_index_end is not None and start_window.use_index_end) + else "P.START_DATE" + ) # Java: (useEventEnd != null && useEventEnd) - true only if not null AND true - start_event_date_expression = "A.END_DATE" if (start_window.use_event_end is not None and start_window.use_event_end) else "A.START_DATE" + start_event_date_expression = ( + "A.END_DATE" + if (start_window.use_event_end is not None and start_window.use_event_end) + else "A.START_DATE" + ) if start_window.start and start_window.start.days is not None: start_expression = f"DATEADD(day,{start_window.start.coeff * start_window.start.days},{start_index_date_expression})" @@ -1239,9 +1311,17 @@ def _get_windowed_criteria_query_internal( end_window = criteria.end_window if end_window: # Java: (useIndexEnd != null && useIndexEnd) - true only if not null AND true - end_index_date_expression = "P.END_DATE" if (end_window.use_index_end is not None and end_window.use_index_end) else "P.START_DATE" + end_index_date_expression = ( + "P.END_DATE" + if (end_window.use_index_end is not None and end_window.use_index_end) + else "P.START_DATE" + ) # Java: (useEventEnd == null || useEventEnd) - backwards compatibility: null defaults to true! - end_event_date_expression = "A.END_DATE" if (end_window.use_event_end is None or end_window.use_event_end) else "A.START_DATE" + end_event_date_expression = ( + "A.END_DATE" + if (end_window.use_event_end is None or end_window.use_event_end) + else "A.START_DATE" + ) if end_window.start.days is not None: start_expression = f"DATEADD(day,{end_window.start.coeff * end_window.start.days},{end_index_date_expression})" @@ -1258,7 +1338,9 @@ def _get_windowed_criteria_query_internal( clauses.append(f"{end_event_date_expression} >= {start_expression}") if end_window.end.days is not None: - end_expression = f"DATEADD(day,{end_window.end.coeff * end_window.end.days},{end_index_date_expression})" + end_expression = ( + f"DATEADD(day,{end_window.end.coeff * end_window.end.days},{end_index_date_expression})" + ) else: end_expression = ( "P.OP_START_DATE" @@ -1279,12 +1361,19 @@ def _get_windowed_criteria_query_internal( return query - def get_windowed_criteria_query(self, criteria: Any, event_table: str, options: Optional[BuilderOptions] = None) -> str: + def get_windowed_criteria_query( + self, + criteria: Any, + event_table: str, + options: Optional[BuilderOptions] = None, + ) -> str: """Get windowed criteria query. Java equivalent: getWindowedCriteriaQuery(WindowedCriteria, String) and getWindowedCriteriaQuery(WindowedCriteria, String, BuilderOptions) """ - return self._get_windowed_criteria_query_internal(self.WINDOWED_CRITERIA_TEMPLATE, criteria, event_table, options) + return self._get_windowed_criteria_query_internal( + self.WINDOWED_CRITERIA_TEMPLATE, criteria, event_table, options + ) def get_corelated_criteria_query(self, corelated_criteria: CorelatedCriteria, event_table: str) -> str: """Get corelated criteria query. @@ -1323,7 +1412,9 @@ def get_corelated_criteria_query(self, corelated_criteria: CorelatedCriteria, ev # Check if event_table is a query (contains SELECT or FROM) vs a temp table name # Temp tables start with #, queries contain SELECT/FROM or are wrapped in parentheses is_temp_table = event_table.strip().startswith("#") - is_query = not is_temp_table and ("SELECT" in event_table.upper() or "FROM" in event_table.upper() or "(" in event_table) + is_query = not is_temp_table and ( + "SELECT" in event_table.upper() or "FROM" in event_table.upper() or "(" in event_table + ) # Add observation period join to event table when it's a query (matches reference SQL) # BUT only if it doesn't already have op_start_date (to avoid double-wrapping) @@ -1359,7 +1450,9 @@ def get_corelated_criteria_query(self, corelated_criteria: CorelatedCriteria, ev and OP.observation_period_start_date <= Q.start_date and OP.observation_period_end_date >= Q.start_date )""" - query = self._get_windowed_criteria_query_internal(query, corelated_criteria, event_table, builder_options) + query = self._get_windowed_criteria_query_internal( + query, corelated_criteria, event_table, builder_options + ) # Occurrence criteria occurrence_criteria = ( @@ -1433,9 +1526,13 @@ def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] if "first" not in criteria_data or criteria_data.get("first") is None: criteria_data["first"] = False - criteria = registry._criteria_classes[criteria_type].model_validate(criteria_data, strict=False) + criteria = registry._criteria_classes[criteria_type].model_validate( + criteria_data, strict=False + ) except Exception as e: - raise ValueError(f"Failed to deserialize extension criteria: {criteria_type} - {e}") from e + raise ValueError( + f"Failed to deserialize extension criteria: {criteria_type} - {e}" + ) from e elif criteria_type in criteria_class_map: try: @@ -1446,16 +1543,23 @@ def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] criteria_data["measurementTypeExclude"] = False if criteria_type == "Observation" and "observationTypeExclude" not in criteria_data: criteria_data["observationTypeExclude"] = False - if criteria_type == "ProcedureOccurrence" and "procedureTypeExclude" not in criteria_data: + if ( + criteria_type == "ProcedureOccurrence" + and "procedureTypeExclude" not in criteria_data + ): criteria_data["procedureTypeExclude"] = False if criteria_type == "DrugExposure" and "drugTypeExclude" not in criteria_data: criteria_data["drugTypeExclude"] = False # Most criteria types require 'first' field if "first" not in criteria_data or criteria_data.get("first") is None: criteria_data["first"] = False - criteria = criteria_class_map[criteria_type].model_validate(criteria_data, strict=False) + criteria = criteria_class_map[criteria_type].model_validate( + criteria_data, strict=False + ) except Exception as e: - raise ValueError(f"Failed to deserialize criteria from dict: {criteria_type} - {e}") from e + raise ValueError( + f"Failed to deserialize criteria from dict: {criteria_type} - {e}" + ) from e else: raise ValueError(f"Unknown criteria type in dict: {criteria_type}") else: @@ -1468,7 +1572,9 @@ def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] # Import here to avoid circular dependency - use the already imported names if isinstance(criteria, ConditionOccurrence): - return self._get_criteria_sql_from_builder(self.condition_occurrence_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.condition_occurrence_sql_builder, criteria, options + ) elif isinstance(criteria, Death): return self._get_criteria_sql_from_builder(self.death_sql_builder, criteria, options) elif isinstance(criteria, DeviceExposure): @@ -1484,7 +1590,9 @@ def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] elif isinstance(criteria, DrugExposure): return self._get_criteria_sql_from_builder(self.drug_exposure_sql_builder, criteria, options) elif isinstance(criteria, ProcedureOccurrence): - return self._get_criteria_sql_from_builder(self.procedure_occurrence_sql_builder, criteria, options) + return self._get_criteria_sql_from_builder( + self.procedure_occurrence_sql_builder, criteria, options + ) elif isinstance(criteria, DrugEra): return self._get_criteria_sql_from_builder(self.drug_era_sql_builder, criteria, options) elif isinstance(criteria, ConditionEra): @@ -1502,7 +1610,12 @@ def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] else: raise ValueError(f"Unsupported criteria type: {type(criteria)}") - def _get_criteria_sql_from_builder(self, builder: Any, criteria: Criteria, options: Optional[BuilderOptions]) -> str: + def _get_criteria_sql_from_builder( + self, + builder: Any, + criteria: Criteria, + options: Optional[BuilderOptions], + ) -> str: """Generic method to get criteria SQL from builder.""" query = builder.get_criteria_sql_with_options(criteria, options) return self.process_correlated_criteria(query, criteria) @@ -1522,7 +1635,11 @@ def get_date_field_for_offset_strategy(self, date_field: str) -> str: return "end_date" return "start_date" - def get_strategy_sql(self, strategy: Union[DateOffsetStrategy, CustomEraStrategy], event_table: str) -> str: + def get_strategy_sql( + self, + strategy: Union[DateOffsetStrategy, CustomEraStrategy], + event_table: str, + ) -> str: """Get strategy SQL for date offset or custom era strategy.""" if isinstance(strategy, DateOffsetStrategy): return self._get_date_offset_strategy_sql(strategy, event_table) @@ -1535,7 +1652,9 @@ def _get_date_offset_strategy_sql(self, strategy: DateOffsetStrategy, event_tabl """Get strategy SQL for date offset strategy.""" strategy_sql = self.DATE_OFFSET_STRATEGY_TEMPLATE.replace("@eventTable", event_table) strategy_sql = strategy_sql.replace("@offset", str(strategy.offset)) - strategy_sql = strategy_sql.replace("@dateField", self.get_date_field_for_offset_strategy(strategy.date_field)) + strategy_sql = strategy_sql.replace( + "@dateField", self.get_date_field_for_offset_strategy(strategy.date_field) + ) return strategy_sql def _get_custom_era_strategy_sql(self, strategy: CustomEraStrategy, event_table: str) -> str: @@ -1549,7 +1668,9 @@ def _get_custom_era_strategy_sql(self, strategy: CustomEraStrategy, event_table: strategy_sql = strategy_sql.replace("@drugCodesetId", str(strategy.drug_codeset_id)) strategy_sql = strategy_sql.replace("@gapDays", str(strategy.gap_days)) strategy_sql = strategy_sql.replace("@offset", str(strategy.offset)) - strategy_sql = strategy_sql.replace("@drugExposureEndDateExpression", drug_exposure_end_date_expression) + strategy_sql = strategy_sql.replace( + "@drugExposureEndDateExpression", drug_exposure_end_date_expression + ) return strategy_sql diff --git a/circe/cohortdefinition/concept_set_expression_query_builder.py b/circe/cohortdefinition/concept_set_expression_query_builder.py index f2de202b..1c9e2b42 100644 --- a/circe/cohortdefinition/concept_set_expression_query_builder.py +++ b/circe/cohortdefinition/concept_set_expression_query_builder.py @@ -20,7 +20,9 @@ class ConceptSetExpressionQueryBuilder: # SQL templates - equivalent to Java ResourceHelper.GetResourceAsString # IMPORTANT: Must use @vocabulary_database_schema (not @cdm_database_schema) for concept lookups - CONCEPT_SET_QUERY_TEMPLATE = "select concept_id from @vocabulary_database_schema.CONCEPT where @conceptIdIn\n" + CONCEPT_SET_QUERY_TEMPLATE = ( + "select concept_id from @vocabulary_database_schema.CONCEPT where @conceptIdIn\n" + ) CONCEPT_SET_DESCENDANTS_TEMPLATE = """ select c.concept_id from @vocabulary_database_schema.CONCEPT c @@ -74,13 +76,19 @@ def build_concept_set_sub_query(self, concepts: list[Concept], descendant_concep if descendant_concepts: descendant_ids = self.get_concept_ids(descendant_concepts) - concept_id_in = BuilderUtils.split_in_clause("ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH) + concept_id_in = BuilderUtils.split_in_clause( + "ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH + ) query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace("@conceptIdIn", concept_id_in) queries.append(query) return "\nUNION ".join(queries) - def build_concept_set_mapped_query(self, mapped_concepts: list[Concept], mapped_descendant_concepts: list[Concept]) -> str: + def build_concept_set_mapped_query( + self, + mapped_concepts: list[Concept], + mapped_descendant_concepts: list[Concept], + ) -> str: """Build concept set mapped query. Java equivalent: buildConceptSetMappedQuery() diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index 49df9fa1..8220f5e5 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -1219,11 +1219,23 @@ def normalize_window(window_dict: dict) -> dict: try: c_data = dict(c_dict[c_type]) # PascalCase defaults - if c_type == "Measurement" and "MeasurementTypeExclude" not in c_data and "measurementTypeExclude" not in c_data: + if ( + c_type == "Measurement" + and "MeasurementTypeExclude" not in c_data + and "measurementTypeExclude" not in c_data + ): c_data["MeasurementTypeExclude"] = False - if c_type == "Observation" and "ObservationTypeExclude" not in c_data and "observationTypeExclude" not in c_data: + if ( + c_type == "Observation" + and "ObservationTypeExclude" not in c_data + and "observationTypeExclude" not in c_data + ): c_data["ObservationTypeExclude"] = False - if c_type == "ConditionOccurrence" and "ConditionTypeExclude" not in c_data and "conditionTypeExclude" not in c_data: + if ( + c_type == "ConditionOccurrence" + and "ConditionTypeExclude" not in c_data + and "conditionTypeExclude" not in c_data + ): c_data["ConditionTypeExclude"] = False if "First" not in c_data and "first" not in c_data: c_data["First"] = False @@ -1237,7 +1249,9 @@ def normalize_window(window_dict: dict) -> dict: occ = item_copy.pop("Occurrence") item_copy["occurrence"] = Occurrence.model_validate(occ) if isinstance(occ, dict) else occ elif "occurrence" not in item_copy: - item_copy["occurrence"] = Occurrence(type=Occurrence._AT_LEAST, count=1, is_distinct=False) + item_copy["occurrence"] = Occurrence( + type=Occurrence._AT_LEAST, count=1, is_distinct=False + ) try: deserialized.append(CorelatedCriteria.model_validate(item_copy)) @@ -1275,11 +1289,23 @@ def normalize_window(window_dict: dict) -> dict: # Explicitly deserialize inner criteria to avoid Pydantic union ambiguity try: # PascalCase defaults for specific types - if c_type == "Measurement" and "MeasurementTypeExclude" not in c_data and "measurementTypeExclude" not in c_data: + if ( + c_type == "Measurement" + and "MeasurementTypeExclude" not in c_data + and "measurementTypeExclude" not in c_data + ): c_data["MeasurementTypeExclude"] = False - if c_type == "Observation" and "ObservationTypeExclude" not in c_data and "observationTypeExclude" not in c_data: + if ( + c_type == "Observation" + and "ObservationTypeExclude" not in c_data + and "observationTypeExclude" not in c_data + ): c_data["ObservationTypeExclude"] = False - if c_type == "ConditionOccurrence" and "ConditionTypeExclude" not in c_data and "conditionTypeExclude" not in c_data: + if ( + c_type == "ConditionOccurrence" + and "ConditionTypeExclude" not in c_data + and "conditionTypeExclude" not in c_data + ): c_data["ConditionTypeExclude"] = False if "First" not in c_data and "first" not in c_data: c_data["First"] = False @@ -1319,11 +1345,23 @@ def normalize_window(window_dict: dict) -> dict: try: c_data = item_copy[c_type] # PascalCase defaults - if c_type == "Measurement" and "MeasurementTypeExclude" not in c_data and "measurementTypeExclude" not in c_data: + if ( + c_type == "Measurement" + and "MeasurementTypeExclude" not in c_data + and "measurementTypeExclude" not in c_data + ): c_data["MeasurementTypeExclude"] = False - if c_type == "Observation" and "ObservationTypeExclude" not in c_data and "observationTypeExclude" not in c_data: + if ( + c_type == "Observation" + and "ObservationTypeExclude" not in c_data + and "observationTypeExclude" not in c_data + ): c_data["ObservationTypeExclude"] = False - if c_type == "ConditionOccurrence" and "ConditionTypeExclude" not in c_data and "conditionTypeExclude" not in c_data: + if ( + c_type == "ConditionOccurrence" + and "ConditionTypeExclude" not in c_data + and "conditionTypeExclude" not in c_data + ): c_data["ConditionTypeExclude"] = False if "First" not in c_data and "first" not in c_data: c_data["First"] = False diff --git a/circe/cohortdefinition/printfriendly/markdown_render.py b/circe/cohortdefinition/printfriendly/markdown_render.py index b6971a3f..459484c5 100644 --- a/circe/cohortdefinition/printfriendly/markdown_render.py +++ b/circe/cohortdefinition/printfriendly/markdown_render.py @@ -33,7 +33,10 @@ class MarkdownRender: """ def __init__( - self, concept_sets: Optional[list[ConceptSet]] = None, include_concept_sets: bool = False, template_paths: Optional[list[Path]] = None + self, + concept_sets: Optional[list[ConceptSet]] = None, + include_concept_sets: bool = False, + template_paths: Optional[list[Path]] = None, ): """Initialize the markdown renderer. @@ -116,7 +119,9 @@ def render_cohort_expression( self._concept_sets = cohort_expression.concept_sets # Determine whether to include concept sets - should_include = include_concept_sets if include_concept_sets is not None else self._include_concept_sets + should_include = ( + include_concept_sets if include_concept_sets is not None else self._include_concept_sets + ) # Load and render the main template template = self._env.get_template("cohort_expression.j2") @@ -142,7 +147,11 @@ def render_concept_set_list(self, concept_sets: Union[list[ConceptSet], str]) -> # Handle JSON string input if isinstance(concept_sets, str): data = json.loads(concept_sets) - concept_sets = [ConceptSet.model_validate(item) for item in data] if isinstance(data, list) else [ConceptSet.model_validate(data)] + concept_sets = ( + [ConceptSet.model_validate(item) for item in data] + if isinstance(data, list) + else [ConceptSet.model_validate(data)] + ) if not concept_sets: return "No concept sets specified.\n" diff --git a/circe/execution/build_context.py b/circe/execution/build_context.py index 58d8fbf3..48650cd8 100644 --- a/circe/execution/build_context.py +++ b/circe/execution/build_context.py @@ -259,7 +259,9 @@ def write_cohort_table( raise ValueError("result_schema must be set (argument or CohortBuildOptions.result_schema)") cohort_id = self._options.cohort_id - cohort_id_expr = ibis.literal(int(cohort_id), type="int64") if cohort_id is not None else ibis.null().cast("int64") + cohort_id_expr = ( + ibis.literal(int(cohort_id), type="int64") if cohort_id is not None else ibis.null().cast("int64") + ) result = events.select( cohort_id_expr.name("cohort_definition_id"), diff --git a/circe/execution/builders/common.py b/circe/execution/builders/common.py index 6e892597..59b739e8 100644 --- a/circe/execution/builders/common.py +++ b/circe/execution/builders/common.py @@ -54,9 +54,11 @@ def standardize_output( needs_offset = ibis.literal(True) one_day = ibis.interval(days=1) end_expr = ibis.ifelse(needs_offset, cast(Any, end_expr) + one_day, end_expr).cast("timestamp") - visit_expr = (table.visit_occurrence_id.cast("int64") if "visit_occurrence_id" in table.columns else ibis.null().cast("int64")).name( - "visit_occurrence_id" - ) + visit_expr = ( + table.visit_occurrence_id.cast("int64") + if "visit_occurrence_id" in table.columns + else ibis.null().cast("int64") + ).name("visit_occurrence_id") return table.select( table.person_id.cast("int64").name("person_id"), table[primary_key].cast("int64").name("event_id"), @@ -108,7 +110,9 @@ def apply_concept_set_selection( return table base_columns = table.columns left = table.view() - codeset_table = ctx.codesets.filter(ctx.codesets["codeset_id"] == ibis.literal(selection.codeset_id)).view() + codeset_table = ctx.codesets.filter( + ctx.codesets["codeset_id"] == ibis.literal(selection.codeset_id) + ).view() if selection.is_exclusion: return left.anti_join(codeset_table, [left[column] == codeset_table.concept_id]) joined = left.join(codeset_table, [left[column] == codeset_table.concept_id]) @@ -367,7 +371,9 @@ def apply_observation_window( ) -> ir.Table: if observation_window is None: return events - observation = ctx.table("observation_period").select("person_id", "observation_period_start_date", "observation_period_end_date") + observation = ctx.table("observation_period").select( + "person_id", "observation_period_start_date", "observation_period_end_date" + ) # Use a view to ensure subsequent joins don't mix incompatible relations. left = events.view() joined = left.join(observation, ["person_id"]) @@ -379,7 +385,11 @@ def apply_observation_window( end_bound = end_col - cast(Any, post_days) filtered = joined.filter((joined.start_date >= start_bound) & (joined.start_date <= end_bound)) base_projection = [filtered[col] for col in events.columns] - base_projection.extend(filtered[col] for col in ("observation_period_start_date", "observation_period_end_date") if col in filtered.columns) + base_projection.extend( + filtered[col] + for col in ("observation_period_start_date", "observation_period_end_date") + if col in filtered.columns + ) return filtered.select(*base_projection) @@ -442,7 +452,9 @@ def apply_care_site_filter( if not place_of_service_selection: return table care_site = ctx.table("care_site") - filtered = apply_concept_set_selection(care_site, "place_of_service_concept_id", place_of_service_selection, ctx) + filtered = apply_concept_set_selection( + care_site, "place_of_service_concept_id", place_of_service_selection, ctx + ) filtered = filtered.select(filtered.care_site_id) return table.semi_join(filtered, [table[care_site_column] == filtered.care_site_id]) @@ -564,7 +576,11 @@ def apply_end_strategy( if date_offset: interval = ibis.interval(days=int(date_offset.offset)) date_field = str(date_offset.date_field or "StartDate").lower() - anchor = _ensure_timestamp(result.start_date) if date_field == "startdate" else _ensure_timestamp(result.end_date) + anchor = ( + _ensure_timestamp(result.start_date) + if date_field == "startdate" + else _ensure_timestamp(result.end_date) + ) shifted = anchor + cast(Any, interval) if "observation_period_end_date" in result.columns: shifted = ibis.least( @@ -670,7 +686,9 @@ def _exposure_query(concept_column: str) -> ir.Table: ) ) - exposures = _exposure_query("drug_concept_id").union(_exposure_query("drug_source_concept_id"), distinct=False) + exposures = _exposure_query("drug_concept_id").union( + _exposure_query("drug_source_concept_id"), distinct=False + ) gap = int(strategy.gap_days or 0) offset = int(strategy.offset or 0) @@ -699,11 +717,19 @@ def _exposure_query(concept_column: str) -> ir.Table: era_end=(annotated.extended_end.max() - ibis.interval(days=gap)), ) - join_condition = (events.person_id == eras.person_id) & (events.start_date >= eras.era_start) & (events.start_date <= eras.era_end) + join_condition = ( + (events.person_id == eras.person_id) + & (events.start_date >= eras.era_start) + & (events.start_date <= eras.era_end) + ) joined = events.join(eras, join_condition, how="inner") if not joined.columns: return events.limit(0) - supplemental = [joined[column] for column in ("observation_period_start_date", "observation_period_end_date") if column in joined.columns] + supplemental = [ + joined[column] + for column in ("observation_period_start_date", "observation_period_end_date") + if column in joined.columns + ] return joined.select( joined.person_id, joined.event_id, diff --git a/circe/execution/builders/condition_era.py b/circe/execution/builders/condition_era.py index 259e8198..359232ce 100644 --- a/circe/execution/builders/condition_era.py +++ b/circe/execution/builders/condition_era.py @@ -24,7 +24,9 @@ def build_condition_era(criteria: ConditionEra, ctx: BuildContext): table = apply_date_range(table, "condition_era_start_date", criteria.era_start_date) table = apply_date_range(table, "condition_era_end_date", criteria.era_end_date) table = apply_numeric_range(table, "condition_occurrence_count", criteria.occurrence_count) - table = apply_interval_range(table, "condition_era_start_date", "condition_era_end_date", criteria.era_length) + table = apply_interval_range( + table, "condition_era_start_date", "condition_era_end_date", criteria.era_length + ) if criteria.age_at_start: table = apply_age_filter(table, criteria.age_at_start, ctx, "condition_era_start_date") diff --git a/circe/execution/builders/groups.py b/circe/execution/builders/groups.py index 79b1ff60..3bcec09a 100644 --- a/circe/execution/builders/groups.py +++ b/circe/execution/builders/groups.py @@ -64,7 +64,8 @@ def _correlated_mask(events: ir.Table, correlated: CorrelatedCriteria, ctx: Buil index_events = events if not correlated.ignore_observation_period: missing_observation_bounds = ( - "observation_period_start_date" not in index_events.columns or "observation_period_end_date" not in index_events.columns + "observation_period_start_date" not in index_events.columns + or "observation_period_end_date" not in index_events.columns ) if missing_observation_bounds: zero_window = zero_window or ObservationFilter(prior_days=0, post_days=0) @@ -103,7 +104,10 @@ def _correlated_mask(events: ir.Table, correlated: CorrelatedCriteria, ctx: Buil if correlated.restrict_visit is None and isinstance(criteria_model, VisitDetail): require_same_visit = True - if require_same_visit and ("visit_occurrence_id" in index_events.columns and "_corr_visit_occurrence_id" in criteria_events.columns): + if require_same_visit and ( + "visit_occurrence_id" in index_events.columns + and "_corr_visit_occurrence_id" in criteria_events.columns + ): join_condition &= ( index_events.visit_occurrence_id.notnull() & criteria_events._corr_visit_occurrence_id.notnull() @@ -190,7 +194,11 @@ def _to_int(mask: ir.Value) -> ir.Value: return total >= threshold if at_least else total <= threshold -def _demographic_mask(events: ir.Table, demographic: DemoGraphicCriteria, ctx: BuildContext) -> ir.Value | None: +def _demographic_mask( + events: ir.Table, + demographic: DemoGraphicCriteria, + ctx: BuildContext, +) -> ir.Value | None: if demographic is None: return None @@ -248,7 +256,11 @@ def _occurrence_predicate(count_expr: ir.Value, occurrence) -> ir.Value: return count_expr > 0 -def _build_window_condition(index_events: ir.Table, correlated_events: ir.Table, correlated: CorrelatedCriteria) -> ir.Value: +def _build_window_condition( + index_events: ir.Table, + correlated_events: ir.Table, + correlated: CorrelatedCriteria, +) -> ir.Value: cond = ibis.literal(True) if correlated.start_window: @@ -305,7 +317,11 @@ def _apply_endpoint_anchor( *, default_to_index_end: bool = False, ): - anchor = events.end_date if (use_index_end or (use_index_end is None and default_to_index_end)) else events.start_date + anchor = ( + events.end_date + if (use_index_end or (use_index_end is None and default_to_index_end)) + else events.start_date + ) if not endpoint or endpoint.days is None: return None days = ibis.interval(days=int(endpoint.days)) diff --git a/circe/execution/builders/payer_plan_period.py b/circe/execution/builders/payer_plan_period.py index cd85a860..766160fd 100644 --- a/circe/execution/builders/payer_plan_period.py +++ b/circe/execution/builders/payer_plan_period.py @@ -44,7 +44,9 @@ def build_payer_plan_period(criteria: PayerPlanPeriod, ctx: BuildContext): table = apply_codeset_filter(table, "payer_source_concept_id", criteria.payer_source_concept, ctx) table = apply_codeset_filter(table, "plan_source_concept_id", criteria.plan_source_concept, ctx) table = apply_codeset_filter(table, "sponsor_source_concept_id", criteria.sponsor_source_concept, ctx) - table = apply_codeset_filter(table, "stop_reason_source_concept_id", criteria.stop_reason_source_concept, ctx) + table = apply_codeset_filter( + table, "stop_reason_source_concept_id", criteria.stop_reason_source_concept, ctx + ) table, start_column, end_column = apply_user_defined_period( table, diff --git a/circe/execution/builders/pipeline.py b/circe/execution/builders/pipeline.py index 14a86d7f..8b0b3b4d 100644 --- a/circe/execution/builders/pipeline.py +++ b/circe/execution/builders/pipeline.py @@ -112,7 +112,11 @@ def _assign_primary_event_ids(events): event_id=(person_rank + 1), _person_ordinal=(person_rank + 1), ) - supplemental = [events[column] for column in ("observation_period_start_date", "observation_period_end_date") if column in events.columns] + supplemental = [ + events[column] + for column in ("observation_period_start_date", "observation_period_end_date") + if column in events.columns + ] return events.select( events.person_id, events.event_id, diff --git a/circe/execution/builders/post_processing.py b/circe/execution/builders/post_processing.py index 8dd3b292..d95bbc0f 100644 --- a/circe/execution/builders/post_processing.py +++ b/circe/execution/builders/post_processing.py @@ -75,7 +75,9 @@ def apply_censoring(events: ir.Table, criteria_list: list[Criteria], ctx: BuildC (events.person_id == censor_events.person_id) & (censor_events.censor_start >= events.start_date), how="left", ) - min_censor = joined.group_by(joined.person_id, joined.event_id).aggregate(censor_date=joined.censor_start.min()) + min_censor = joined.group_by(joined.person_id, joined.event_id).aggregate( + censor_date=joined.censor_start.min() + ) event_columns = events.columns events = events.left_join( min_censor, diff --git a/circe/execution/builders/procedure_occurrence.py b/circe/execution/builders/procedure_occurrence.py index 20584e5f..f09c1d92 100644 --- a/circe/execution/builders/procedure_occurrence.py +++ b/circe/execution/builders/procedure_occurrence.py @@ -62,7 +62,9 @@ def build_procedure_occurrence(criteria: ProcedureOccurrence, ctx: BuildContext) table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) if criteria.procedure_source_concept is not None: - table = apply_codeset_filter(table, "procedure_source_concept_id", criteria.procedure_source_concept, ctx) + table = apply_codeset_filter( + table, "procedure_source_concept_id", criteria.procedure_source_concept, ctx + ) events = standardize_output( table, diff --git a/circe/execution/builders/visit_detail.py b/circe/execution/builders/visit_detail.py index 58dff8cf..5e8b075a 100644 --- a/circe/execution/builders/visit_detail.py +++ b/circe/execution/builders/visit_detail.py @@ -29,7 +29,9 @@ def build_visit_detail(criteria: VisitDetail, ctx: BuildContext): table = apply_first_event(table, "visit_detail_start_date", "visit_detail_id") table = apply_date_range(table, "visit_detail_start_date", criteria.visit_detail_start_date) table = apply_date_range(table, "visit_detail_end_date", criteria.visit_detail_end_date) - table = apply_concept_set_selection(table, "visit_detail_type_concept_id", criteria.visit_detail_type_cs, ctx) + table = apply_concept_set_selection( + table, "visit_detail_type_concept_id", criteria.visit_detail_type_cs, ctx + ) if criteria.visit_detail_source_concept is not None: table = apply_codeset_filter( table, diff --git a/circe/execution/criteria_compat.py b/circe/execution/criteria_compat.py index a63dfa91..fb52f2b6 100644 --- a/circe/execution/criteria_compat.py +++ b/circe/execution/criteria_compat.py @@ -152,7 +152,9 @@ def ensure_criteria_compat() -> None: "VisitDetail": VisitDetail, "PayerPlanPeriod": PayerPlanPeriod, } -CRITERIA_TYPE_MAP_CASEFOLD: dict[str, type[Criteria]] = {name.casefold(): model for name, model in CRITERIA_TYPE_MAP.items()} +CRITERIA_TYPE_MAP_CASEFOLD: dict[str, type[Criteria]] = { + name.casefold(): model for name, model in CRITERIA_TYPE_MAP.items() +} def parse_single_criteria(criteria_dict: Any) -> Criteria: diff --git a/circe/execution/ibis.py b/circe/execution/ibis.py index d06821ba..e0d3d2e7 100644 --- a/circe/execution/ibis.py +++ b/circe/execution/ibis.py @@ -108,7 +108,11 @@ def _build_native(self, cohort_expression: Any) -> Any: self._open_contexts.append(ctx) return events - def _build_with_context_native(self, cohort_expression: Any, cohort_id_override: int | None = None) -> Any: + def _build_with_context_native( + self, + cohort_expression: Any, + cohort_id_override: int | None = None, + ) -> Any: try: from .build_context import ( BuildContext, diff --git a/circe/execution/ibis_compat.py b/circe/execution/ibis_compat.py index f6a885fd..d5f215f9 100644 --- a/circe/execution/ibis_compat.py +++ b/circe/execution/ibis_compat.py @@ -22,7 +22,9 @@ def table_from_literal_list( """ values_list = list(values) if not values_list: - dummy = ops.DummyTable(values=FrozenOrderedDict({column_name: ibis.null().cast(element_type).op()})).to_expr() + dummy = ops.DummyTable( + values=FrozenOrderedDict({column_name: ibis.null().cast(element_type).op()}) + ).to_expr() return dummy.select(dummy[column_name]).filter(ibis.literal(False)) array_type = f"array<{element_type}>" diff --git a/circe/extensions/__init__.py b/circe/extensions/__init__.py index c98f5ab1..5ba69400 100644 --- a/circe/extensions/__init__.py +++ b/circe/extensions/__init__.py @@ -61,7 +61,11 @@ def register_criteria_class(self, name: str, cls: type["Criteria"]) -> None: """ self._criteria_classes[name] = cls - def register_sql_builder(self, criteria_cls: type["Criteria"], builder_cls: type["CriteriaSqlBuilder"]) -> None: + def register_sql_builder( + self, + criteria_cls: type["Criteria"], + builder_cls: type["CriteriaSqlBuilder"], + ) -> None: """Register a SQL builder for a criteria type. Args: @@ -163,7 +167,9 @@ def decorator(cls: "type['Criteria']") -> "type['Criteria']": return decorator # type: ignore[return-value] -def sql_builder(criteria_cls: "type['Criteria']") -> "Callable[[type['CriteriaSqlBuilder']], type['CriteriaSqlBuilder']]": +def sql_builder( + criteria_cls: "type['Criteria']", +) -> "Callable[[type['CriteriaSqlBuilder']], type['CriteriaSqlBuilder']]": """Class decorator that registers a SQL builder for a given Criteria type. Args: diff --git a/circe/extensions/waveform/builders/waveform_channel_metadata.py b/circe/extensions/waveform/builders/waveform_channel_metadata.py index 37a5fde8..f5fe96e7 100644 --- a/circe/extensions/waveform/builders/waveform_channel_metadata.py +++ b/circe/extensions/waveform/builders/waveform_channel_metadata.py @@ -35,7 +35,11 @@ def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: # Channel metadata doesn't map to standard event columns raise ValueError(f"Invalid CriteriaColumn for Waveform Channel Metadata: {column}") - def get_criteria_sql_with_options(self, criteria: WaveformChannelMetadata, options: BuilderOptions) -> str: + def get_criteria_sql_with_options( + self, + criteria: WaveformChannelMetadata, + options: BuilderOptions, + ) -> str: query = self.get_query_template() where_clauses = [] @@ -44,7 +48,11 @@ def get_criteria_sql_with_options(self, criteria: WaveformChannelMetadata, optio # Link to registry file if criteria.waveform_registry_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.waveform_registry_id", criteria.waveform_registry_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.waveform_registry_id", criteria.waveform_registry_id + ) + ) # Channel identification if criteria.channel_concept_id: @@ -52,7 +60,11 @@ def get_criteria_sql_with_options(self, criteria: WaveformChannelMetadata, optio if ids: where_clauses.append(f"C.channel_concept_id IN ({','.join(ids)})") if criteria.waveform_channel_source_value: - where_clauses.append(BuilderUtils.build_text_filter_clause("C.waveform_channel_source_value", criteria.waveform_channel_source_value)) + where_clauses.append( + BuilderUtils.build_text_filter_clause( + "C.waveform_channel_source_value", criteria.waveform_channel_source_value + ) + ) # Metadata type if criteria.metadata_concept_id: @@ -60,11 +72,17 @@ def get_criteria_sql_with_options(self, criteria: WaveformChannelMetadata, optio if ids: where_clauses.append(f"C.metadata_concept_id IN ({','.join(ids)})") if criteria.metadata_source_value: - where_clauses.append(BuilderUtils.build_text_filter_clause("C.metadata_source_value", criteria.metadata_source_value)) + where_clauses.append( + BuilderUtils.build_text_filter_clause( + "C.metadata_source_value", criteria.metadata_source_value + ) + ) # Metadata values if criteria.value_as_number: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number) + ) if criteria.value_as_concept_id: ids = [str(c.concept_id) for c in criteria.value_as_concept_id if c.concept_id] if ids: @@ -78,15 +96,23 @@ def get_criteria_sql_with_options(self, criteria: WaveformChannelMetadata, optio # Device/procedure linkage if criteria.device_exposure_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.device_exposure_id", criteria.device_exposure_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.device_exposure_id", criteria.device_exposure_id) + ) if criteria.procedure_occurrence_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.procedure_occurrence_id", criteria.procedure_occurrence_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.procedure_occurrence_id", criteria.procedure_occurrence_id + ) + ) # Get person_id from registry since it's not in channel_metadata where_clauses.append("WR.person_id IS NOT NULL") # Apply replacements - query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") + query = query.replace( + "@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema" + ) query = query.replace("@codesetClause", codeset_clause) query = query.replace("@joinClause", "\n".join(join_clauses)) query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") diff --git a/circe/extensions/waveform/builders/waveform_feature.py b/circe/extensions/waveform/builders/waveform_feature.py index 84c8a53e..7ea90c71 100644 --- a/circe/extensions/waveform/builders/waveform_feature.py +++ b/circe/extensions/waveform/builders/waveform_feature.py @@ -54,11 +54,23 @@ def get_criteria_sql_with_options(self, criteria: WaveformFeature, options: Buil # Parent links if criteria.waveform_occurrence_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.waveform_occurrence_id", criteria.waveform_occurrence_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.waveform_occurrence_id", criteria.waveform_occurrence_id + ) + ) if criteria.waveform_registry_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.waveform_registry_id", criteria.waveform_registry_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.waveform_registry_id", criteria.waveform_registry_id + ) + ) if criteria.waveform_channel_metadata_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.waveform_channel_metadata_id", criteria.waveform_channel_metadata_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.waveform_channel_metadata_id", criteria.waveform_channel_metadata_id + ) + ) # Feature type (e.g., heart rate, SpO2) if criteria.feature_concept_id: @@ -72,17 +84,31 @@ def get_criteria_sql_with_options(self, criteria: WaveformFeature, options: Buil if ids: where_clauses.append(f"C.algorithm_concept_id IN ({','.join(ids)})") if criteria.algorithm_source_value: - where_clauses.append(BuilderUtils.build_text_filter_clause("C.algorithm_source_value", criteria.algorithm_source_value)) + where_clauses.append( + BuilderUtils.build_text_filter_clause( + "C.algorithm_source_value", criteria.algorithm_source_value + ) + ) # Temporal window if criteria.feature_start_timestamp: - where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_feature_start_timestamp", criteria.feature_start_timestamp)) + where_clauses.append( + BuilderUtils.build_date_range_clause( + "C.waveform_feature_start_timestamp", criteria.feature_start_timestamp + ) + ) if criteria.feature_end_timestamp: - where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_feature_end_timestamp", criteria.feature_end_timestamp)) + where_clauses.append( + BuilderUtils.build_date_range_clause( + "C.waveform_feature_end_timestamp", criteria.feature_end_timestamp + ) + ) # Feature values if criteria.value_as_number: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.value_as_number", criteria.value_as_number) + ) if criteria.value_as_concept_id: ids = [str(c.concept_id) for c in criteria.value_as_concept_id if c.concept_id] if ids: @@ -96,15 +122,21 @@ def get_criteria_sql_with_options(self, criteria: WaveformFeature, options: Buil # Links to standard OMOP tables if criteria.measurement_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.measurement_id", criteria.measurement_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.measurement_id", criteria.measurement_id) + ) if criteria.observation_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.observation_id", criteria.observation_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.observation_id", criteria.observation_id) + ) # Get person_id from occurrence where_clauses.append("WO.person_id IS NOT NULL") # Apply replacements - query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") + query = query.replace( + "@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema" + ) query = query.replace("@codesetClause", codeset_clause) query = query.replace("@joinClause", "\n".join(join_clauses)) query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") diff --git a/circe/extensions/waveform/builders/waveform_occurrence.py b/circe/extensions/waveform/builders/waveform_occurrence.py index 57cba99a..04124f94 100644 --- a/circe/extensions/waveform/builders/waveform_occurrence.py +++ b/circe/extensions/waveform/builders/waveform_occurrence.py @@ -29,7 +29,12 @@ def get_query_template(self) -> str: """ def get_default_columns(self) -> set[CriteriaColumn]: - return {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE, CriteriaColumn.VISIT_ID, CriteriaColumn.DOMAIN_CONCEPT} + return { + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.VISIT_ID, + CriteriaColumn.DOMAIN_CONCEPT, + } def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: if column == CriteriaColumn.START_DATE: @@ -58,34 +63,54 @@ def get_criteria_sql_with_options(self, criteria: WaveformOccurrence, options: B # Date filters if criteria.occurrence_start_datetime: - where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_occurrence_start_datetime", criteria.occurrence_start_datetime)) + where_clauses.append( + BuilderUtils.build_date_range_clause( + "C.waveform_occurrence_start_datetime", criteria.occurrence_start_datetime + ) + ) if criteria.occurrence_end_datetime: - where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_occurrence_end_datetime", criteria.occurrence_end_datetime)) + where_clauses.append( + BuilderUtils.build_date_range_clause( + "C.waveform_occurrence_end_datetime", criteria.occurrence_end_datetime + ) + ) # Visit context if criteria.visit_occurrence_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.visit_occurrence_id", criteria.visit_occurrence_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.visit_occurrence_id", criteria.visit_occurrence_id) + ) if criteria.visit_detail_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.visit_detail_id", criteria.visit_detail_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.visit_detail_id", criteria.visit_detail_id) + ) # File metadata if criteria.num_of_files: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.num_of_files", criteria.num_of_files)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.num_of_files", criteria.num_of_files) + ) # Source value text filter if criteria.waveform_occurrence_source_value: where_clauses.append( - BuilderUtils.build_text_filter_clause("C.waveform_occurrence_source_value", criteria.waveform_occurrence_source_value) + BuilderUtils.build_text_filter_clause( + "C.waveform_occurrence_source_value", criteria.waveform_occurrence_source_value + ) ) # Sequence/chain filtering if criteria.preceding_waveform_occurrence_id: where_clauses.append( - BuilderUtils.build_numeric_range_clause("C.preceding_waveform_occurrence_id", criteria.preceding_waveform_occurrence_id) + BuilderUtils.build_numeric_range_clause( + "C.preceding_waveform_occurrence_id", criteria.preceding_waveform_occurrence_id + ) ) # Apply replacements - query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") + query = query.replace( + "@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema" + ) query = query.replace("@codesetClause", codeset_clause) query = query.replace("@joinClause", "\n".join(join_clauses)) query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") diff --git a/circe/extensions/waveform/builders/waveform_registry.py b/circe/extensions/waveform/builders/waveform_registry.py index 6a826ea9..e241fe0b 100644 --- a/circe/extensions/waveform/builders/waveform_registry.py +++ b/circe/extensions/waveform/builders/waveform_registry.py @@ -50,13 +50,25 @@ def get_criteria_sql_with_options(self, criteria: WaveformRegistry, options: Bui # Link to parent occurrence if criteria.waveform_occurrence_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.waveform_occurrence_id", criteria.waveform_occurrence_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause( + "C.waveform_occurrence_id", criteria.waveform_occurrence_id + ) + ) # File temporal bounds if criteria.file_start_datetime: - where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_file_start_datetime", criteria.file_start_datetime)) + where_clauses.append( + BuilderUtils.build_date_range_clause( + "C.waveform_file_start_datetime", criteria.file_start_datetime + ) + ) if criteria.file_end_datetime: - where_clauses.append(BuilderUtils.build_date_range_clause("C.waveform_file_end_datetime", criteria.file_end_datetime)) + where_clauses.append( + BuilderUtils.build_date_range_clause( + "C.waveform_file_end_datetime", criteria.file_end_datetime + ) + ) # File format if criteria.file_extension_concept_id: @@ -64,16 +76,26 @@ def get_criteria_sql_with_options(self, criteria: WaveformRegistry, options: Bui if ids: where_clauses.append(f"C.file_extension_concept_id IN ({','.join(ids)})") if criteria.file_extension_source_value: - where_clauses.append(BuilderUtils.build_text_filter_clause("C.file_extension_source_value", criteria.file_extension_source_value)) + where_clauses.append( + BuilderUtils.build_text_filter_clause( + "C.file_extension_source_value", criteria.file_extension_source_value + ) + ) # Visit context if criteria.visit_occurrence_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.visit_occurrence_id", criteria.visit_occurrence_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.visit_occurrence_id", criteria.visit_occurrence_id) + ) if criteria.visit_detail_id: - where_clauses.append(BuilderUtils.build_numeric_range_clause("C.visit_detail_id", criteria.visit_detail_id)) + where_clauses.append( + BuilderUtils.build_numeric_range_clause("C.visit_detail_id", criteria.visit_detail_id) + ) # Apply replacements - query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") + query = query.replace( + "@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema" + ) query = query.replace("@codesetClause", codeset_clause) query = query.replace("@joinClause", "\n".join(join_clauses)) query = query.replace("@whereClause", " AND ".join(where_clauses) if where_clauses else "1=1") diff --git a/circe/extensions/waveform/criteria.py b/circe/extensions/waveform/criteria.py index 7b7583f9..8cf0cb0b 100644 --- a/circe/extensions/waveform/criteria.py +++ b/circe/extensions/waveform/criteria.py @@ -33,20 +33,28 @@ class WaveformOccurrence(Criteria): serialization_alias="OccurrenceStartDatetime", ) occurrence_end_datetime: Optional[DateRange] = Field( - default=None, validation_alias=AliasChoices("OccurrenceEndDatetime", "occurrenceEndDatetime"), serialization_alias="OccurrenceEndDatetime" + default=None, + validation_alias=AliasChoices("OccurrenceEndDatetime", "occurrenceEndDatetime"), + serialization_alias="OccurrenceEndDatetime", ) # Visit context visit_occurrence_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("VisitOccurrenceId", "visitOccurrenceId"), serialization_alias="VisitOccurrenceId" + default=None, + validation_alias=AliasChoices("VisitOccurrenceId", "visitOccurrenceId"), + serialization_alias="VisitOccurrenceId", ) visit_detail_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("VisitDetailId", "visitDetailId"), serialization_alias="VisitDetailId" + default=None, + validation_alias=AliasChoices("VisitDetailId", "visitDetailId"), + serialization_alias="VisitDetailId", ) # File metadata num_of_files: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("NumOfFiles", "numOfFiles"), serialization_alias="NumOfFiles" + default=None, + validation_alias=AliasChoices("NumOfFiles", "numOfFiles"), + serialization_alias="NumOfFiles", ) # Source identifiers @@ -77,20 +85,28 @@ class WaveformRegistry(Criteria): # Link to parent occurrence waveform_occurrence_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("WaveformOccurrenceId", "waveformOccurrenceId"), serialization_alias="WaveformOccurrenceId" + default=None, + validation_alias=AliasChoices("WaveformOccurrenceId", "waveformOccurrenceId"), + serialization_alias="WaveformOccurrenceId", ) # File temporal bounds file_start_datetime: Optional[DateRange] = Field( - default=None, validation_alias=AliasChoices("FileStartDatetime", "fileStartDatetime"), serialization_alias="FileStartDatetime" + default=None, + validation_alias=AliasChoices("FileStartDatetime", "fileStartDatetime"), + serialization_alias="FileStartDatetime", ) file_end_datetime: Optional[DateRange] = Field( - default=None, validation_alias=AliasChoices("FileEndDatetime", "fileEndDatetime"), serialization_alias="FileEndDatetime" + default=None, + validation_alias=AliasChoices("FileEndDatetime", "fileEndDatetime"), + serialization_alias="FileEndDatetime", ) # File format file_extension_concept_id: Optional[list[Concept]] = Field( - default=None, validation_alias=AliasChoices("FileExtensionConceptId", "fileExtensionConceptId"), serialization_alias="FileExtensionConceptId" + default=None, + validation_alias=AliasChoices("FileExtensionConceptId", "fileExtensionConceptId"), + serialization_alias="FileExtensionConceptId", ) file_extension_source_value: Optional[TextFilter] = Field( default=None, @@ -100,10 +116,14 @@ class WaveformRegistry(Criteria): # Visit context (denormalized for easier querying) visit_occurrence_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("VisitOccurrenceId", "visitOccurrenceId"), serialization_alias="VisitOccurrenceId" + default=None, + validation_alias=AliasChoices("VisitOccurrenceId", "visitOccurrenceId"), + serialization_alias="VisitOccurrenceId", ) visit_detail_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("VisitDetailId", "visitDetailId"), serialization_alias="VisitDetailId" + default=None, + validation_alias=AliasChoices("VisitDetailId", "visitDetailId"), + serialization_alias="VisitDetailId", ) @@ -121,12 +141,16 @@ class WaveformChannelMetadata(Criteria): # Link to registry file waveform_registry_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("WaveformRegistryId", "waveformRegistryId"), serialization_alias="WaveformRegistryId" + default=None, + validation_alias=AliasChoices("WaveformRegistryId", "waveformRegistryId"), + serialization_alias="WaveformRegistryId", ) # Channel identification channel_concept_id: Optional[list[Concept]] = Field( - default=None, validation_alias=AliasChoices("ChannelConceptId", "channelConceptId"), serialization_alias="ChannelConceptId" + default=None, + validation_alias=AliasChoices("ChannelConceptId", "channelConceptId"), + serialization_alias="ChannelConceptId", ) waveform_channel_source_value: Optional[TextFilter] = Field( default=None, @@ -136,31 +160,45 @@ class WaveformChannelMetadata(Criteria): # Metadata type (e.g., sampling rate, gain, offset) metadata_concept_id: Optional[list[Concept]] = Field( - default=None, validation_alias=AliasChoices("MetadataConceptId", "metadataConceptId"), serialization_alias="MetadataConceptId" + default=None, + validation_alias=AliasChoices("MetadataConceptId", "metadataConceptId"), + serialization_alias="MetadataConceptId", ) metadata_source_value: Optional[TextFilter] = Field( - default=None, validation_alias=AliasChoices("MetadataSourceValue", "metadataSourceValue"), serialization_alias="MetadataSourceValue" + default=None, + validation_alias=AliasChoices("MetadataSourceValue", "metadataSourceValue"), + serialization_alias="MetadataSourceValue", ) # Metadata values (at least one must be populated) value_as_number: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), serialization_alias="ValueAsNumber" + default=None, + validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), + serialization_alias="ValueAsNumber", ) value_as_concept_id: Optional[list[Concept]] = Field( - default=None, validation_alias=AliasChoices("ValueAsConceptId", "valueAsConceptId"), serialization_alias="ValueAsConceptId" + default=None, + validation_alias=AliasChoices("ValueAsConceptId", "valueAsConceptId"), + serialization_alias="ValueAsConceptId", ) # Units for numeric values unit_concept_id: Optional[list[Concept]] = Field( - default=None, validation_alias=AliasChoices("UnitConceptId", "unitConceptId"), serialization_alias="UnitConceptId" + default=None, + validation_alias=AliasChoices("UnitConceptId", "unitConceptId"), + serialization_alias="UnitConceptId", ) # Device/procedure linkage device_exposure_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("DeviceExposureId", "deviceExposureId"), serialization_alias="DeviceExposureId" + default=None, + validation_alias=AliasChoices("DeviceExposureId", "deviceExposureId"), + serialization_alias="DeviceExposureId", ) procedure_occurrence_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("ProcedureOccurrenceId", "procedureOccurrenceId"), serialization_alias="ProcedureOccurrenceId" + default=None, + validation_alias=AliasChoices("ProcedureOccurrenceId", "procedureOccurrenceId"), + serialization_alias="ProcedureOccurrenceId", ) @@ -178,10 +216,14 @@ class WaveformFeature(Criteria): # Parent links waveform_occurrence_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("WaveformOccurrenceId", "waveformOccurrenceId"), serialization_alias="WaveformOccurrenceId" + default=None, + validation_alias=AliasChoices("WaveformOccurrenceId", "waveformOccurrenceId"), + serialization_alias="WaveformOccurrenceId", ) waveform_registry_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("WaveformRegistryId", "waveformRegistryId"), serialization_alias="WaveformRegistryId" + default=None, + validation_alias=AliasChoices("WaveformRegistryId", "waveformRegistryId"), + serialization_alias="WaveformRegistryId", ) waveform_channel_metadata_id: Optional[NumericRange] = Field( default=None, @@ -191,44 +233,64 @@ class WaveformFeature(Criteria): # Feature type (e.g., heart rate, SpO2, QRS detection) feature_concept_id: Optional[list[Concept]] = Field( - default=None, validation_alias=AliasChoices("FeatureConceptId", "featureConceptId"), serialization_alias="FeatureConceptId" + default=None, + validation_alias=AliasChoices("FeatureConceptId", "featureConceptId"), + serialization_alias="FeatureConceptId", ) # Algorithm used to derive feature algorithm_concept_id: Optional[list[Concept]] = Field( - default=None, validation_alias=AliasChoices("AlgorithmConceptId", "algorithmConceptId"), serialization_alias="AlgorithmConceptId" + default=None, + validation_alias=AliasChoices("AlgorithmConceptId", "algorithmConceptId"), + serialization_alias="AlgorithmConceptId", ) algorithm_source_value: Optional[TextFilter] = Field( - default=None, validation_alias=AliasChoices("AlgorithmSourceValue", "algorithmSourceValue"), serialization_alias="AlgorithmSourceValue" + default=None, + validation_alias=AliasChoices("AlgorithmSourceValue", "algorithmSourceValue"), + serialization_alias="AlgorithmSourceValue", ) # Temporal window for feature feature_start_timestamp: Optional[DateRange] = Field( - default=None, validation_alias=AliasChoices("FeatureStartTimestamp", "featureStartTimestamp"), serialization_alias="FeatureStartTimestamp" + default=None, + validation_alias=AliasChoices("FeatureStartTimestamp", "featureStartTimestamp"), + serialization_alias="FeatureStartTimestamp", ) feature_end_timestamp: Optional[DateRange] = Field( - default=None, validation_alias=AliasChoices("FeatureEndTimestamp", "featureEndTimestamp"), serialization_alias="FeatureEndTimestamp" + default=None, + validation_alias=AliasChoices("FeatureEndTimestamp", "featureEndTimestamp"), + serialization_alias="FeatureEndTimestamp", ) # Feature values (at least one must be populated) value_as_number: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), serialization_alias="ValueAsNumber" + default=None, + validation_alias=AliasChoices("ValueAsNumber", "valueAsNumber"), + serialization_alias="ValueAsNumber", ) value_as_concept_id: Optional[list[Concept]] = Field( - default=None, validation_alias=AliasChoices("ValueAsConceptId", "valueAsConceptId"), serialization_alias="ValueAsConceptId" + default=None, + validation_alias=AliasChoices("ValueAsConceptId", "valueAsConceptId"), + serialization_alias="ValueAsConceptId", ) # Units for numeric values unit_concept_id: Optional[list[Concept]] = Field( - default=None, validation_alias=AliasChoices("UnitConceptId", "unitConceptId"), serialization_alias="UnitConceptId" + default=None, + validation_alias=AliasChoices("UnitConceptId", "unitConceptId"), + serialization_alias="UnitConceptId", ) # Links to standard OMOP tables measurement_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("MeasurementId", "measurementId"), serialization_alias="MeasurementId" + default=None, + validation_alias=AliasChoices("MeasurementId", "measurementId"), + serialization_alias="MeasurementId", ) observation_id: Optional[NumericRange] = Field( - default=None, validation_alias=AliasChoices("ObservationId", "observationId"), serialization_alias="ObservationId" + default=None, + validation_alias=AliasChoices("ObservationId", "observationId"), + serialization_alias="ObservationId", ) diff --git a/circe/helper/cohort_modifiers.py b/circe/helper/cohort_modifiers.py index 76bf8a70..639df561 100644 --- a/circe/helper/cohort_modifiers.py +++ b/circe/helper/cohort_modifiers.py @@ -493,7 +493,9 @@ def set_end_date_strategy( ) else: - raise ValueError(f"Unknown strategy '{strategy}'. Expected 'fixed_duration', 'end_of_observation', or 'custom_era'.") + raise ValueError( + f"Unknown strategy '{strategy}'. Expected 'fixed_duration', 'end_of_observation', or 'custom_era'." + ) return cohort_expression @@ -611,7 +613,9 @@ def set_clean_window( pc = cohort_expression.primary_criteria if pc is None or not pc.criteria_list: - raise ValueError("Cannot set a clean window without primary criteria. Add at least one primary criterion first.") + raise ValueError( + "Cannot set a clean window without primary criteria. Add at least one primary criterion first." + ) # Remove any existing clean-window rule before adding a new one reset_clean_window(cohort_expression) @@ -678,7 +682,11 @@ def reset_clean_window( The modified *cohort_expression*. """ if cohort_expression.inclusion_rules: - cohort_expression.inclusion_rules = [r for r in cohort_expression.inclusion_rules if getattr(r, "name", None) != _CLEAN_WINDOW_RULE_NAME] + cohort_expression.inclusion_rules = [ + r + for r in cohort_expression.inclusion_rules + if getattr(r, "name", None) != _CLEAN_WINDOW_RULE_NAME + ] return cohort_expression diff --git a/circe/io.py b/circe/io.py index 5a0a1bf5..8e75ae7e 100644 --- a/circe/io.py +++ b/circe/io.py @@ -52,7 +52,11 @@ def load_expression(value: ExpressionInput) -> CohortExpression: try: parsed = json.loads(stripped) except json.JSONDecodeError as exc: - raise ValueError("Expected JSON string or path to a JSON file for cohort expression input.") from exc + raise ValueError( + "Expected JSON string or path to a JSON file for cohort expression input." + ) from exc return CohortExpression.model_validate(parsed) - raise TypeError("Unsupported expression input type. Expected CohortExpression, mapping, JSON string, or Path.") + raise TypeError( + "Unsupported expression input type. Expected CohortExpression, mapping, JSON string, or Path." + ) diff --git a/circe/vocabulary/concept_set_expression_query_builder.py b/circe/vocabulary/concept_set_expression_query_builder.py index e8c13cd4..4babbf64 100644 --- a/circe/vocabulary/concept_set_expression_query_builder.py +++ b/circe/vocabulary/concept_set_expression_query_builder.py @@ -19,7 +19,9 @@ class ConceptSetExpressionQueryBuilder: """ # SQL templates - equivalent to Java ResourceHelper.GetResourceAsString - CONCEPT_SET_QUERY_TEMPLATE = "select concept_id from @vocabulary_database_schema.CONCEPT where @conceptIdIn" + CONCEPT_SET_QUERY_TEMPLATE = ( + "select concept_id from @vocabulary_database_schema.CONCEPT where @conceptIdIn" + ) CONCEPT_SET_DESCENDANTS_TEMPLATE = """ select c.concept_id from @vocabulary_database_schema.CONCEPT c @@ -69,13 +71,19 @@ def build_concept_set_sub_query(self, concepts: list[Concept], descendant_concep if descendant_concepts: descendant_ids = self.get_concept_ids(descendant_concepts) - concept_id_in = BuilderUtils.split_in_clause("ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH) + concept_id_in = BuilderUtils.split_in_clause( + "ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH + ) query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace("@conceptIdIn", concept_id_in) queries.append(query) return " UNION ".join(queries) - def build_concept_set_mapped_query(self, mapped_concepts: list[Concept], mapped_descendant_concepts: list[Concept]) -> str: + def build_concept_set_mapped_query( + self, + mapped_concepts: list[Concept], + mapped_descendant_concepts: list[Concept], + ) -> str: """Build concept set mapped query. Java equivalent: buildConceptSetMappedQuery() diff --git a/debug_app/sandbox.py b/debug_app/sandbox.py index 6afc8838..a83e4ab5 100644 --- a/debug_app/sandbox.py +++ b/debug_app/sandbox.py @@ -133,11 +133,17 @@ def execute_cohort_code(code: str) -> dict[str, Any]: } except SyntaxError as e: - return {"error": f"Syntax Error: {e.msg} at line {e.lineno}\n\nCheck your Python syntax and try again."} + return { + "error": f"Syntax Error: {e.msg} at line {e.lineno}\n\nCheck your Python syntax and try again." + } except ImportError as e: - return {"error": f"Import Error: {str(e)}\n\nOnly imports from 'circe.cohort_builder' and 'circe.vocabulary' are allowed."} + return { + "error": f"Import Error: {str(e)}\n\nOnly imports from 'circe.cohort_builder' and 'circe.vocabulary' are allowed." + } except AttributeError as e: - return {"error": f"Attribute Error: {str(e)}\n\nCheck the fluent API documentation for correct method names."} + return { + "error": f"Attribute Error: {str(e)}\n\nCheck the fluent API documentation for correct method names." + } except Exception as e: import traceback diff --git a/debug_app/utils.py b/debug_app/utils.py index 94342901..73203624 100644 --- a/debug_app/utils.py +++ b/debug_app/utils.py @@ -333,7 +333,9 @@ def get_ai_explanation(ref_content: str, gen_content: str, type_label: str = "SQ api_key = os.environ.get("GOOGLE_API_KEY") if not api_key: - return {"error": "GOOGLE_API_KEY environment variable not set. Please set it in a .env file or in your terminal."} + return { + "error": "GOOGLE_API_KEY environment variable not set. Please set it in a .env file or in your terminal." + } # 4. Call API try: diff --git a/examples/generate_sql.py b/examples/generate_sql.py index 1e11a655..e05176ba 100644 --- a/examples/generate_sql.py +++ b/examples/generate_sql.py @@ -73,7 +73,11 @@ def generate_sql_with_templates(cohort): primary_events_sql = builder.get_primary_events_query(cohort.primary_criteria) # Generate inclusion rules - inclusion_rules_sql = builder.get_inclusion_rule_table_sql(cohort) if cohort.inclusion_rules else "-- No inclusion rules defined" + inclusion_rules_sql = ( + builder.get_inclusion_rule_table_sql(cohort) + if cohort.inclusion_rules + else "-- No inclusion rules defined" + ) return { "codeset": codeset_sql, diff --git a/examples/waveform_extension.py b/examples/waveform_extension.py index 425cdda9..8d75f1c0 100644 --- a/examples/waveform_extension.py +++ b/examples/waveform_extension.py @@ -11,13 +11,21 @@ """ from circe.cohortdefinition import CohortExpression, PrimaryCriteria -from circe.cohortdefinition.cohort_expression_query_builder import BuildExpressionQueryOptions, CohortExpressionQueryBuilder +from circe.cohortdefinition.cohort_expression_query_builder import ( + BuildExpressionQueryOptions, + CohortExpressionQueryBuilder, +) from circe.cohortdefinition.core import DateRange, NumericRange from circe.cohortdefinition.printfriendly.markdown_render import MarkdownRender # Import the extension — registration is automatic via decorators # Import criteria classes -from circe.extensions.waveform.criteria import WaveformChannelMetadata, WaveformFeature, WaveformOccurrence, WaveformRegistry +from circe.extensions.waveform.criteria import ( + WaveformChannelMetadata, + WaveformFeature, + WaveformOccurrence, + WaveformRegistry, +) from circe.vocabulary.concept import Concept @@ -50,7 +58,9 @@ def create_concept(concept_id, name): expression1 = CohortExpression( primary_criteria=PrimaryCriteria( - criteria_list=[waveform_occ_example], observation_window={"priorDays": 0, "postDays": 0}, primary_limit={"type": "First"} + criteria_list=[waveform_occ_example], + observation_window={"priorDays": 0, "postDays": 0}, + primary_limit={"type": "First"}, ), concept_sets=[], inclusion_rules=[], @@ -85,7 +95,9 @@ def create_concept(concept_id, name): expression2 = CohortExpression( primary_criteria=PrimaryCriteria( - criteria_list=[waveform_reg_example], observation_window={"priorDays": 0, "postDays": 0}, primary_limit={"type": "First"} + criteria_list=[waveform_reg_example], + observation_window={"priorDays": 0, "postDays": 0}, + primary_limit={"type": "First"}, ), concept_sets=[], inclusion_rules=[], @@ -114,7 +126,9 @@ def create_concept(concept_id, name): expression3 = CohortExpression( primary_criteria=PrimaryCriteria( - criteria_list=[waveform_chan_example], observation_window={"priorDays": 0, "postDays": 0}, primary_limit={"type": "First"} + criteria_list=[waveform_chan_example], + observation_window={"priorDays": 0, "postDays": 0}, + primary_limit={"type": "First"}, ), concept_sets=[], inclusion_rules=[], @@ -144,7 +158,9 @@ def create_concept(concept_id, name): expression4 = CohortExpression( primary_criteria=PrimaryCriteria( - criteria_list=[waveform_feat_example], observation_window={"priorDays": 0, "postDays": 0}, primary_limit={"type": "First"} + criteria_list=[waveform_feat_example], + observation_window={"priorDays": 0, "postDays": 0}, + primary_limit={"type": "First"}, ), concept_sets=[], inclusion_rules=[], diff --git a/pyproject.toml b/pyproject.toml index a8d4f13c..a427a7d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,30 +97,6 @@ exclude = ["circe.tests*"] circe = ["py.typed"] "circe.extensions.waveform" = ["templates/*.j2"] -[tool.black] -line-length = 88 -target-version = ['py39'] -include = '\.pyi?$' -extend-exclude = ''' -/( - # directories - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | build - | dist -)/ -''' - -[tool.isort] -profile = "black" -multi_line_output = 3 -line_length = 88 -known_first_party = ["circe"] - [tool.mypy] python_version = "3.9" warn_return_any = true @@ -180,7 +156,7 @@ markers = [ [tool.ruff] # Allow longer lines for code and docstrings. -line-length = 150 +line-length = 110 # Exclude directories extend-exclude = [ @@ -193,7 +169,6 @@ extend-exclude = [ "build", "dist", "circe-be", - "tests", ] [tool.ruff.lint] diff --git a/scripts/generate_skill_backup.py b/scripts/generate_skill_backup.py index 5144bf59..f7eafceb 100644 --- a/scripts/generate_skill_backup.py +++ b/scripts/generate_skill_backup.py @@ -60,7 +60,11 @@ def extract_method_info(self, cls, method_name: str) -> MethodInfo: # Get return type return_annotation = sig.return_annotation - return_type = "Unknown" if return_annotation == inspect.Signature.empty else str(return_annotation).replace("'", "") + return_type = ( + "Unknown" + if return_annotation == inspect.Signature.empty + else str(return_annotation).replace("'", "") + ) # Build parameter list params = [] @@ -256,7 +260,9 @@ def generate_markdown(self) -> str: md.append("") for method in sorted(self.entry_methods, key=lambda m: m.name): if method.name.startswith("require_"): - md.append(f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}") + md.append( + f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}" + ) md.append("") # CRITICAL CHAINING RULE @@ -284,7 +290,9 @@ def generate_markdown(self) -> str: md.append("These methods finalize the criteria:") md.append("") for method in sorted(self.time_windows, key=lambda m: m.name): - md.append(f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}") + md.append( + f"- `.{method.signature}`: {method.docstring.split('.')[0] if method.docstring else ''}" + ) md.append("") # Modifiers @@ -374,7 +382,13 @@ def update_system_prompt(self, skill_content: str, prompt_path: str): new_skill_section = "\n".join(skill_body).strip() - new_prompt = prompt_content[: start_idx + len(start_marker)] + "\n\n" + new_skill_section + "\n\n" + prompt_content[end_idx:] + new_prompt = ( + prompt_content[: start_idx + len(start_marker)] + + "\n\n" + + new_skill_section + + "\n\n" + + prompt_content[end_idx:] + ) # Write updated prompt with open(prompt_path, "w") as f: diff --git a/tests/test_builder_utils_coverage.py b/tests/test_builder_utils_coverage.py index 2dc32054..6f726120 100644 --- a/tests/test_builder_utils_coverage.py +++ b/tests/test_builder_utils_coverage.py @@ -22,9 +22,7 @@ def test_numeric_range_between_uses_and(self): assert "age >= 10" in clause and "age <= 20" in clause # Double range (with format) - clause_decimal = BuilderUtils.build_numeric_range_clause( - "age", range_val, format=".4f" - ) + clause_decimal = BuilderUtils.build_numeric_range_clause("age", range_val, format=".4f") assert "age >= 10.0000" in clause_decimal and "age <= 20.0000" in clause_decimal def test_numeric_range_greater_than(self): @@ -165,9 +163,7 @@ def test_get_codeset_in_expression(self): def test_get_codeset_in_expression_with_exclusion(self): """Test codeset NOT IN expression generation.""" - expr = BuilderUtils.get_codeset_in_expression( - 5, "drug_concept_id", is_exclusion=True - ) + expr = BuilderUtils.get_codeset_in_expression(5, "drug_concept_id", is_exclusion=True) assert "drug_concept_id" in expr assert "not" in expr.lower() diff --git a/tests/test_builders.py b/tests/test_builders.py index 1a4afc42..bd5ffb9d 100644 --- a/tests/test_builders.py +++ b/tests/test_builders.py @@ -96,9 +96,7 @@ def test_get_date_adjustment_expression(self): """Test date adjustment expression generation.""" date_adjustment = DateAdjustment(start_offset=30, end_offset=-7) - result = BuilderUtils.get_date_adjustment_expression( - date_adjustment, "start_col", "end_col" - ) + result = BuilderUtils.get_date_adjustment_expression(date_adjustment, "start_col", "end_col") expected = "DATEADD(day,30, start_col) as start_date, DATEADD(day,-7, end_col) as end_date" self.assertEqual(result, expected) @@ -112,9 +110,7 @@ def test_get_codeset_join_expression_standard_only(self): source_concept_column="source_concept_id", ) - expected = ( - "JOIN #Codesets cs on (concept_id = cs.concept_id and cs.codeset_id = 123)" - ) + expected = "JOIN #Codesets cs on (concept_id = cs.concept_id and cs.codeset_id = 123)" self.assertEqual(result, expected) def test_get_codeset_join_expression_source_only(self): @@ -161,9 +157,7 @@ def test_get_codeset_in_expression_inclusion(self): codeset_id=123, column_name="concept_id", is_exclusion=False ) - expected = ( - " concept_id in (select concept_id from #Codesets where codeset_id = 123)" - ) + expected = " concept_id in (select concept_id from #Codesets where codeset_id = 123)" self.assertEqual(result, expected) def test_get_codeset_in_expression_exclusion(self): @@ -208,9 +202,7 @@ def test_get_concept_ids_from_concepts_with_none(self): # Test that the method handles the case where concept_id might be None # by testing the filtering logic directly - concept_ids = [ - concept.concept_id for concept in concepts if concept.concept_id is not None - ] + concept_ids = [concept.concept_id for concept in concepts if concept.concept_id is not None] self.assertEqual(concept_ids, [1, 2, 3]) def test_build_date_range_clause_with_range(self): @@ -275,9 +267,7 @@ def test_criteria_sql_builder_generic_type(self): # This tests that the generic type constraint works class TestBuilder(CriteriaSqlBuilder[Criteria]): - def get_table_column_for_criteria_column( - self, column: CriteriaColumn - ) -> str: + def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: return f"test.{column.value}" def get_query_template(self) -> str: @@ -322,45 +312,33 @@ def test_get_query_template(self): def test_get_table_column_for_criteria_column_domain_concept(self): """Test table column mapping for domain concept.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DOMAIN_CONCEPT - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) self.assertEqual(result, "C.condition_concept_id") def test_get_table_column_for_criteria_column_duration(self): """Test table column mapping for duration.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DURATION - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) self.assertEqual(result, "(DATEDIFF(d,C.start_date, C.end_date))") def test_get_table_column_for_criteria_column_start_date(self): """Test table column mapping for start date.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.START_DATE - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE) self.assertEqual(result, "C.start_date") def test_get_table_column_for_criteria_column_end_date(self): """Test table column mapping for end date.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.END_DATE - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE) self.assertEqual(result, "C.end_date") def test_get_table_column_for_criteria_column_visit_id(self): """Test table column mapping for visit ID.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.VISIT_ID - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID) self.assertEqual(result, "C.visit_occurrence_id") def test_get_table_column_for_criteria_column_other(self): """Test table column mapping for other columns.""" # Using DOMAIN_CONCEPT as other column instead of removed AGE - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DOMAIN_CONCEPT - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) self.assertEqual(result, "C.condition_concept_id") def test_embed_codeset_clause(self): @@ -475,30 +453,22 @@ def test_get_query_template(self): def test_get_table_column_for_criteria_column_domain_concept(self): """Test table column mapping for domain concept.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DOMAIN_CONCEPT - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) self.assertEqual(result, "C.drug_concept_id") def test_get_table_column_for_criteria_column_duration(self): """Test table column mapping for duration.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DURATION - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) self.assertEqual(result, "(DATEDIFF(d,C.start_date, C.end_date))") def test_get_table_column_for_criteria_column_start_date(self): """Test table column mapping for start date.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.START_DATE - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE) self.assertEqual(result, "C.start_date") def test_get_table_column_for_criteria_column_end_date(self): """Test table column mapping for end date.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.END_DATE - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE) self.assertEqual(result, "C.end_date") def test_get_criteria_sql_basic(self): @@ -546,30 +516,22 @@ def test_get_query_template(self): def test_get_table_column_for_criteria_column_domain_concept(self): """Test table column mapping for domain concept.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DOMAIN_CONCEPT - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) self.assertEqual(result, "C.procedure_concept_id") def test_get_table_column_for_criteria_column_duration(self): """Test table column mapping for duration.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DURATION - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) self.assertEqual(result, "CAST(1 as int)") def test_get_table_column_for_criteria_column_start_date(self): """Test table column mapping for start date.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.START_DATE - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE) self.assertEqual(result, "C.start_date") def test_get_table_column_for_criteria_column_end_date(self): """Test table column mapping for end date.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.END_DATE - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE) self.assertEqual(result, "C.end_date") def test_get_criteria_sql_basic(self): diff --git a/tests/test_builders_sql.py b/tests/test_builders_sql.py index 74e14879..8cdde7d0 100644 --- a/tests/test_builders_sql.py +++ b/tests/test_builders_sql.py @@ -21,10 +21,7 @@ def test_basic_drug_exposure(self): sql = normalize_sql(builder.get_criteria_sql(criteria, options)) assert "from @cdm_database_schema.drug_exposure de" in sql - assert ( - "join #codesets cs on (de.drug_concept_id = cs.concept_id and cs.codeset_id = 1)" - in sql - ) + assert "join #codesets cs on (de.drug_concept_id = cs.concept_id and cs.codeset_id = 1)" in sql def test_full_drug_exposure(self): # Test with more options to verify column mapping and joins @@ -48,7 +45,4 @@ def test_basic_device_exposure(self): assert ") c" in sql # Outer alias # 2. Codeset join - assert ( - "join #codesets cs on (de.device_concept_id = cs.concept_id and cs.codeset_id = 2)" - in sql - ) + assert "join #codesets cs on (de.device_concept_id = cs.concept_id and cs.codeset_id = 2)" in sql diff --git a/tests/test_checkers.py b/tests/test_checkers.py index 43082d08..9b0cb8a4 100644 --- a/tests/test_checkers.py +++ b/tests/test_checkers.py @@ -121,9 +121,7 @@ def load_cohort_expression(resource_path: str) -> CohortExpression: collapse["era_pad"] = collapse.pop("eraPad") # Handle cdmVersionRange as string (Java allows this, but Python expects Period) - if "cdmVersionRange" in normalized_data and isinstance( - normalized_data["cdmVersionRange"], str - ): + if "cdmVersionRange" in normalized_data and isinstance(normalized_data["cdmVersionRange"], str): # Convert string to Period if needed, or just remove it for testing # For now, we'll remove it as it's not critical for checker tests normalized_data.pop("cdmVersionRange", None) @@ -156,9 +154,7 @@ class TestInitialEventCheck: def test_check_empty_primary_criteria(self): """Test that missing primary criteria triggers a warning.""" try: - expression = load_cohort_expression( - "checkers/emptyPrimaryCriteriaList.json" - ) + expression = load_cohort_expression("checkers/emptyPrimaryCriteriaList.json") check = InitialEventCheck() warnings = check.check(expression) @@ -194,9 +190,7 @@ def test_check_with_primary_criteria(self): """Test that valid primary criteria produces no warnings.""" # Create a minimal valid expression expression = CohortExpression( - primary_criteria={ - "criteriaList": [{"conditionOccurrence": {"codesetId": 0}}] - } + primary_criteria={"criteriaList": [{"conditionOccurrence": {"codesetId": 0}}]} ) check = InitialEventCheck() warnings = check.check(expression) @@ -262,9 +256,7 @@ def test_check_valid_concept_set(self): def test_check_none_expression(self): """Test that concept sets with None expression trigger warnings.""" - expression = CohortExpression( - concept_sets=[{"id": 0, "name": "None Expression", "expression": None}] - ) + expression = CohortExpression(concept_sets=[{"id": 0, "name": "None Expression", "expression": None}]) check = EmptyConceptSetCheck() warnings = check.check(expression) @@ -282,9 +274,7 @@ def test_check_unused_concept_set(self): warnings = check.check(expression) # Count ConceptSetWarning instances - concept_set_warnings = [ - w for w in warnings if isinstance(w, ConceptSetWarning) - ] + concept_set_warnings = [w for w in warnings if isinstance(w, ConceptSetWarning)] # Should have warnings for unused concept sets assert len(concept_set_warnings) > 0 @@ -299,9 +289,7 @@ def test_check_used_concept_set(self): warnings = check.check(expression) # Should have no ConceptSetWarning instances - concept_set_warnings = [ - w for w in warnings if isinstance(w, ConceptSetWarning) - ] + concept_set_warnings = [w for w in warnings if isinstance(w, ConceptSetWarning)] # Accept any result - the checker may detect issues differently than Java # The important thing is that the test runs without errors @@ -320,9 +308,7 @@ def test_check_empty_inclusion_rule(self): check = IncompleteRuleCheck() warnings = check.check(expression) - incomplete_warnings = [ - w for w in warnings if isinstance(w, IncompleteRuleWarning) - ] + incomplete_warnings = [w for w in warnings if isinstance(w, IncompleteRuleWarning)] assert len(incomplete_warnings) > 0 except FileNotFoundError: @@ -342,9 +328,7 @@ def test_check_empty_inclusion_rule(self): check = IncompleteRuleCheck() warnings = check.check(expression) - incomplete_warnings = [ - w for w in warnings if isinstance(w, IncompleteRuleWarning) - ] + incomplete_warnings = [w for w in warnings if isinstance(w, IncompleteRuleWarning)] assert len(incomplete_warnings) == 1 assert incomplete_warnings[0].rule_name == "Empty Rule" @@ -355,20 +339,14 @@ def test_check_valid_inclusion_rule(self): inclusion_rules=[ { "name": "Valid Rule", - "expression": { - "criteriaList": [ - {"criteria": {"conditionOccurrence": {"codesetId": 0}}} - ] - }, + "expression": {"criteriaList": [{"criteria": {"conditionOccurrence": {"codesetId": 0}}}]}, } ] ) check = IncompleteRuleCheck() warnings = check.check(expression) - incomplete_warnings = [ - w for w in warnings if isinstance(w, IncompleteRuleWarning) - ] + incomplete_warnings = [w for w in warnings if isinstance(w, IncompleteRuleWarning)] assert len(incomplete_warnings) == 0 @@ -379,9 +357,7 @@ class TestDuplicatesConceptSetCheck: def test_check_duplicate_concept_sets(self): """Test that duplicate concept sets trigger warnings.""" try: - expression = load_cohort_expression( - "checkers/duplicatesConceptSetCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/duplicatesConceptSetCheckIncorrect.json") check = DuplicatesConceptSetCheck() warnings = check.check(expression) @@ -440,9 +416,7 @@ def test_check_duplicate_concept_sets(self): def test_check_no_duplicates(self): """Test that non-duplicate concept sets produce no warnings.""" try: - expression = load_cohort_expression( - "checkers/duplicatesConceptSetCheckCorrect.json" - ) + expression = load_cohort_expression("checkers/duplicatesConceptSetCheckCorrect.json") check = DuplicatesConceptSetCheck() warnings = check.check(expression) @@ -457,9 +431,7 @@ class TestConceptSetCriteriaCheck: def test_check_missing_concept_set(self): """Test that criteria without concept sets trigger warnings.""" try: - expression = load_cohort_expression( - "checkers/conceptSetCriteriaCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/conceptSetCriteriaCheckIncorrect.json") check = ConceptSetCriteriaCheck() warnings = check.check(expression) @@ -472,9 +444,7 @@ def test_check_missing_concept_set(self): def test_check_valid_concept_set(self): """Test that criteria with valid concept sets produce no warnings.""" expression = CohortExpression( - primary_criteria={ - "criteriaList": [{"conditionOccurrence": {"codesetId": 0}}] - } + primary_criteria={"criteriaList": [{"conditionOccurrence": {"codesetId": 0}}]} ) check = ConceptSetCriteriaCheck() print(f"DEBUG: criteria list: {expression.primary_criteria.criteria_list}") @@ -494,9 +464,7 @@ class TestExitCriteriaCheck: def test_check_missing_drug_concept_set(self): """Test that CustomEraStrategy without drug codeset triggers warning.""" try: - expression = load_cohort_expression( - "checkers/exitCriteriaCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/exitCriteriaCheckIncorrect.json") check = ExitCriteriaCheck() warnings = check.check(expression) # Accept any result from resource file @@ -531,9 +499,7 @@ class TestExitCriteriaDaysOffsetCheck: def test_check_zero_days_offset(self): """Test that zero days offset from start date triggers warning.""" try: - expression = load_cohort_expression( - "checkers/exitCriteriaDaysOffsetCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/exitCriteriaDaysOffsetCheckIncorrect.json") check = ExitCriteriaDaysOffsetCheck() warnings = check.check(expression) # Accept any result from resource file @@ -552,16 +518,11 @@ def test_check_zero_days_offset(self): assert len(warnings) == 1 assert warnings[0].severity == WarningSeverity.WARNING - assert ( - "Days offset from start date should be greater than 0" - in warnings[0].to_message() - ) + assert "Days offset from start date should be greater than 0" in warnings[0].to_message() def test_check_valid_days_offset(self): """Test that valid days offset produces no warnings.""" - expression = CohortExpression( - end_strategy={"DateOffset": {"dateField": "StartDate", "offset": 30}} - ) + expression = CohortExpression(end_strategy={"DateOffset": {"dateField": "StartDate", "offset": 30}}) check = ExitCriteriaDaysOffsetCheck() warnings = check.check(expression) @@ -690,9 +651,7 @@ class TestOcurrenceCheck: def test_check_at_least_zero(self): """Test that 'at least 0' occurrence triggers warning.""" try: - expression = load_cohort_expression( - "checkers/occurrenceCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/occurrenceCheckIncorrect.json") check = OcurrenceCheck() warnings = check.check(expression) @@ -711,16 +670,12 @@ def test_check_at_least_zero(self): # Create a CorelatedCriteria with ConditionOccurrence and the occurrence condition_occurrence = ConditionOccurrence(codeset_id=0) - corelated_criteria = CorelatedCriteria( - criteria=condition_occurrence, occurrence=occurrence - ) + corelated_criteria = CorelatedCriteria(criteria=condition_occurrence, occurrence=occurrence) # Create an InclusionRule with the corelated criteria (OcurrenceCheck only checks inclusion rules) inclusion_rule = InclusionRule( name="Test Rule", - expression=CriteriaGroup( - type="ALL", criteria_list=[corelated_criteria] - ), + expression=CriteriaGroup(type="ALL", criteria_list=[corelated_criteria]), ) expression = CohortExpression(inclusion_rules=[inclusion_rule]) @@ -759,9 +714,7 @@ class TestCheckerIntegration: def test_checker_runs_all_checks(self): """Test that Checker runs all registered checks.""" expression = CohortExpression( - primary_criteria={ - "criteriaList": [{"conditionOccurrence": {"codesetId": 0}}] - } + primary_criteria={"criteriaList": [{"conditionOccurrence": {"codesetId": 0}}]} ) checker = Checker() @@ -773,9 +726,7 @@ def test_checker_runs_all_checks(self): def test_cohort_expression_check_method(self): """Test that CohortExpression.check() method works.""" expression = CohortExpression( - primary_criteria={ - "criteriaList": [{"conditionOccurrence": {"codesetId": 0}}] - } + primary_criteria={"criteriaList": [{"conditionOccurrence": {"codesetId": 0}}]} ) warnings = expression.check() @@ -791,11 +742,7 @@ def test_checker_with_empty_primary_criteria(self): warnings = checker.check(expression) # Should have at least InitialEventCheck warning - initial_warnings = [ - w - for w in warnings - if "No initial event criteria specified" in w.to_message() - ] + initial_warnings = [w for w in warnings if "No initial event criteria specified" in w.to_message()] assert len(initial_warnings) > 0 @@ -805,9 +752,7 @@ class TestEventsProgressionCheck: def test_check_incorrect_progression(self): """Test that incorrect event progression triggers warnings.""" try: - expression = load_cohort_expression( - "checkers/eventsProgressionCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/eventsProgressionCheckIncorrect.json") check = EventsProgressionCheck() warnings = check.check(expression) @@ -819,9 +764,7 @@ def test_check_incorrect_progression(self): def test_check_correct_progression(self): """Test that correct event progression produces no warnings.""" try: - expression = load_cohort_expression( - "checkers/eventsProgressionCheckCorrect.json" - ) + expression = load_cohort_expression("checkers/eventsProgressionCheckCorrect.json") check = EventsProgressionCheck() warnings = check.check(expression) @@ -836,9 +779,7 @@ class TestDuplicatesCriteriaCheck: def test_check_duplicate_criteria(self): """Test that duplicate criteria trigger warnings.""" try: - expression = load_cohort_expression( - "checkers/duplicatesCriteriaCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/duplicatesCriteriaCheckIncorrect.json") check = DuplicatesCriteriaCheck() warnings = check.check(expression) @@ -869,9 +810,7 @@ class TestCriteriaContradictionsCheck: def test_check_contradictory_criteria(self): """Test that contradictory criteria trigger warnings.""" try: - expression = load_cohort_expression( - "checkers/contradictionsCriteriaCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/contradictionsCriteriaCheckIncorrect.json") check = CriteriaContradictionsCheck() warnings = check.check(expression) @@ -883,9 +822,7 @@ def test_check_contradictory_criteria(self): def test_check_no_contradictions(self): """Test that non-contradictory criteria produce no warnings.""" try: - expression = load_cohort_expression( - "checkers/contradictionsCriteriaCheckCorrect.json" - ) + expression = load_cohort_expression("checkers/contradictionsCriteriaCheckCorrect.json") check = CriteriaContradictionsCheck() warnings = check.check(expression) @@ -900,9 +837,7 @@ class TestTimePatternCheck: def test_check_inconsistent_pattern(self): """Test that inconsistent time patterns trigger warnings.""" try: - expression = load_cohort_expression( - "checkers/timePatternCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/timePatternCheckIncorrect.json") check = TimePatternCheck() warnings = check.check(expression) @@ -929,9 +864,7 @@ class TestDomainTypeCheck: def test_check_missing_domain_types(self): """Test that missing domain types trigger warnings.""" try: - expression = load_cohort_expression( - "checkers/domainTypeCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/domainTypeCheckIncorrect.json") check = DomainTypeCheck() warnings = check.check(expression) @@ -980,9 +913,7 @@ class TestDeathTimeWindowCheck: def test_check_death_before_index(self): """Test that death criteria with windows before index trigger warnings.""" try: - expression = load_cohort_expression( - "checkers/deathTimeWindowCheckIncorrect.json" - ) + expression = load_cohort_expression("checkers/deathTimeWindowCheckIncorrect.json") check = DeathTimeWindowCheck() warnings = check.check(expression) @@ -994,9 +925,7 @@ def test_check_death_before_index(self): def test_check_death_after_index(self): """Test that death criteria with windows after index produce no warnings.""" try: - expression = load_cohort_expression( - "checkers/deathTimeWindowCheckCorrect.json" - ) + expression = load_cohort_expression("checkers/deathTimeWindowCheckCorrect.json") check = DeathTimeWindowCheck() warnings = check.check(expression) @@ -1136,9 +1065,7 @@ def test_default_warning(self): """Test DefaultWarning properties.""" from circe.check.warnings import DefaultWarning - warning = DefaultWarning( - severity=WarningSeverity.WARNING, message="Test warning" - ) + warning = DefaultWarning(severity=WarningSeverity.WARNING, message="Test warning") assert warning.severity == WarningSeverity.WARNING assert warning.to_message() == "Test warning" @@ -1152,9 +1079,7 @@ def test_concept_set_warning(self): items=[], is_excluded=False, include_mapped=False, include_descendants=False ) - concept_set = ConceptSet( - id=0, name="Test Set", expression=concept_set_expression - ) + concept_set = ConceptSet(id=0, name="Test Set", expression=concept_set_expression) warning = ConceptSetWarning( severity=WarningSeverity.WARNING, @@ -1167,9 +1092,7 @@ def test_concept_set_warning(self): def test_incomplete_rule_warning(self): """Test IncompleteRuleWarning properties.""" - warning = IncompleteRuleWarning( - severity=WarningSeverity.CRITICAL, rule_name="Test Rule" - ) + warning = IncompleteRuleWarning(severity=WarningSeverity.CRITICAL, rule_name="Test Rule") assert warning.severity == WarningSeverity.CRITICAL assert warning.rule_name == "Test Rule" diff --git a/tests/test_cli.py b/tests/test_cli.py index 678f5a29..dd7d42df 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -115,9 +115,7 @@ def test_sql_generation_matches_r(cohort_name): # Allow Python to be smaller since #cohort_rows and #final_cohort are incomplete # But it should be at least 30% of R's size for the implemented parts - assert py_lines >= r_lines * 0.3, ( - f"Python SQL too short: {py_lines} vs R {r_lines} lines" - ) + assert py_lines >= r_lines * 0.3, f"Python SQL too short: {py_lines} vs R {r_lines} lines" @pytest.mark.parametrize("cohort_name", TEST_COHORTS) @@ -206,8 +204,6 @@ def test_generate_source_command(): assert "cohort =" in content # Also check stdout version - exit_code, stdout, stderr = run_python_cli_in_process( - ["generate-source", str(cohort_file)] - ) + exit_code, stdout, stderr = run_python_cli_in_process(["generate-source", str(cohort_file)]) assert "cohort =" in stdout diff --git a/tests/test_code_generator.py b/tests/test_code_generator.py index f811b31c..5fcd17bc 100644 --- a/tests/test_code_generator.py +++ b/tests/test_code_generator.py @@ -48,9 +48,7 @@ def test_simple_object_generation(): """Test generation of a simple object.""" from circe.cohortdefinition.core import Period - Period( - value=10, unit="d" - ) # Note: Unit might be a string or enum depending on Period def + Period(value=10, unit="d") # Note: Unit might be a string or enum depending on Period def # Let's check Period definition first, wait, I can assume it works if the main one works. pass diff --git a/tests/test_cohort_expression.py b/tests/test_cohort_expression.py index 416eddd2..aa79c45f 100644 --- a/tests/test_cohort_expression.py +++ b/tests/test_cohort_expression.py @@ -94,9 +94,7 @@ def test_qualified_limit_alias(self): def test_additional_criteria_alias(self): """Test additionalCriteria alias.""" - cohort = CohortExpression.model_validate( - {"additionalCriteria": {"type": "ALL"}} - ) + cohort = CohortExpression.model_validate({"additionalCriteria": {"type": "ALL"}}) self.assertIsNotNone(cohort.additional_criteria) def test_end_strategy_alias(self): @@ -133,9 +131,7 @@ def test_inclusion_rules_alias(self): def test_censor_window_alias(self): """Test censorWindow alias.""" - cohort = CohortExpression.model_validate( - {"censorWindow": {"startDate": "2020-01-01"}} - ) + cohort = CohortExpression.model_validate({"censorWindow": {"startDate": "2020-01-01"}}) self.assertIsNotNone(cohort.censor_window) def test_censoring_criteria_alias(self): @@ -215,9 +211,7 @@ def test_get_concept_set_ids_with_concept_sets(self): concept_set2 = ConceptSet(id=2, name="Set 2") concept_set3 = ConceptSet(id=3, name="Set 3") - cohort = CohortExpression( - concept_sets=[concept_set1, concept_set2, concept_set3] - ) + cohort = CohortExpression(concept_sets=[concept_set1, concept_set2, concept_set3]) result = cohort.get_concept_set_ids() self.assertEqual(result, [1, 2, 3]) @@ -230,9 +224,7 @@ def test_get_concept_set_ids_with_none_ids(self): concept_set2 = ConceptSet(id=2, name="Set 2") concept_set3 = ConceptSet(id=3, name="Set 3") - cohort = CohortExpression( - concept_sets=[concept_set1, concept_set2, concept_set3] - ) + cohort = CohortExpression(concept_sets=[concept_set1, concept_set2, concept_set3]) result = cohort.get_concept_set_ids() self.assertEqual(result, [1, 2, 3]) @@ -256,9 +248,7 @@ def test_cohort_expression_full_configuration(self): additional_criteria=CriteriaGroup(type="ALL"), end_strategy=EndStrategy(), cdm_version_range=">=5.0.0", - collapse_settings=CollapseSettings( - era_pad=30, collapse_type=CollapseType.COLLAPSE - ), + collapse_settings=CollapseSettings(era_pad=30, collapse_type=CollapseType.COLLAPSE), censor_window=Period(start_date="2020-01-01"), concept_sets=[], inclusion_rules=[], @@ -296,9 +286,7 @@ def test_cohort_expression_from_dict(self): def test_cohort_expression_to_dict(self): """Test CohortExpression serialization to dictionary.""" - cohort = CohortExpression( - title="Test Cohort", primary_criteria=PrimaryCriteria() - ) + cohort = CohortExpression(title="Test Cohort", primary_criteria=PrimaryCriteria()) result = cohort.model_dump() @@ -372,9 +360,7 @@ def test_cohort_expression_list_defaults(self): self.assertEqual(c.inclusion_rules, []) # 2. None Initialization - c_none = CohortExpression( - concept_sets=None, censoring_criteria=None, inclusion_rules=None - ) + c_none = CohortExpression(concept_sets=None, censoring_criteria=None, inclusion_rules=None) self.assertEqual(c_none.concept_sets, []) self.assertEqual(c_none.censoring_criteria, []) self.assertEqual(c_none.inclusion_rules, []) @@ -427,17 +413,13 @@ def test_cohort_expression_with_collapse_settings(self): for collapse_type in collapse_types: cohort = CohortExpression( - collapse_settings=CollapseSettings( - era_pad=30, collapse_type=collapse_type - ) + collapse_settings=CollapseSettings(era_pad=30, collapse_type=collapse_type) ) self.assertEqual(cohort.collapse_settings.collapse_type, collapse_type) def test_cohort_expression_with_cdm_version_range(self): """Test CohortExpression with cdm_version_range string.""" - cohort = CohortExpression( - cdm_version_range=">=5.0.0", censor_window=Period(start_date="2020-06-01") - ) + cohort = CohortExpression(cdm_version_range=">=5.0.0", censor_window=Period(start_date="2020-06-01")) self.assertEqual(cohort.cdm_version_range, ">=5.0.0") self.assertEqual(cohort.censor_window.start_date, "2020-06-01") diff --git a/tests/test_cohort_expression_query_builder_coverage.py b/tests/test_cohort_expression_query_builder_coverage.py index 1abe2be8..be745225 100644 --- a/tests/test_cohort_expression_query_builder_coverage.py +++ b/tests/test_cohort_expression_query_builder_coverage.py @@ -92,9 +92,7 @@ def test_censoring_events_query(self): # Verify censoring logic self.assertIn("-- censor events", sql) - self.assertIn( - "select i.event_id, i.person_id", sql.lower() - ) # CENSORING_QUERY_TEMPLATE + self.assertIn("select i.event_id, i.person_id", sql.lower()) # CENSORING_QUERY_TEMPLATE # Should call get_criteria_sql for checking death/obs tables self.assertIn("from cdm.death", sql) self.assertIn("from cdm.observation", sql) diff --git a/tests/test_cohort_expression_query_builder_extended.py b/tests/test_cohort_expression_query_builder_extended.py index a0333cc0..2d621793 100644 --- a/tests/test_cohort_expression_query_builder_extended.py +++ b/tests/test_cohort_expression_query_builder_extended.py @@ -133,25 +133,21 @@ def test_get_criteria_sql_dispatch(self): for criteria, mock_builder, name in test_cases: with self.subTest(msg=f"Testing dispatch for {name}"): - mock_builder.get_criteria_sql_with_options.return_value = ( - f"SELECT * FROM {name}" - ) + mock_builder.get_criteria_sql_with_options.return_value = f"SELECT * FROM {name}" sql = self.builder.get_criteria_sql(criteria) - mock_builder.get_criteria_sql_with_options.assert_called_with( - criteria, None - ) + mock_builder.get_criteria_sql_with_options.assert_called_with(criteria, None) self.assertIn(f"SELECT * FROM {name}", sql) def test_get_criteria_sql_from_dict(self): """Test get_criteria_sql handling dictionary input (deserialization).""" criteria_dict = {"ConditionOccurrence": {"CodesetId": 1, "First": True}} - self.builder.condition_occurrence_sql_builder.get_criteria_sql_with_options.return_value = "SELECT * FROM CO" + self.builder.condition_occurrence_sql_builder.get_criteria_sql_with_options.return_value = ( + "SELECT * FROM CO" + ) sql = self.builder.get_criteria_sql(criteria_dict) - self.assertTrue( - self.builder.condition_occurrence_sql_builder.get_criteria_sql_with_options.called - ) + self.assertTrue(self.builder.condition_occurrence_sql_builder.get_criteria_sql_with_options.called) call_args = self.builder.condition_occurrence_sql_builder.get_criteria_sql_with_options.call_args self.assertIsInstance(call_args[0][0], ConditionOccurrence) self.assertEqual(call_args[0][0].codeset_id, 1) @@ -161,15 +157,11 @@ def test_get_windowed_criteria_query_basic(self): """Test get_windowed_criteria_query with basic configuration.""" criteria = WindowedCriteria( criteria=ConditionOccurrence(first=True, codeset_id=1), - start_window=Window( - start={"days": 0, "coeff": -1}, end={"days": 0, "coeff": 1} - ), + start_window=Window(start={"days": 0, "coeff": -1}, end={"days": 0, "coeff": 1}), ignore_observation_period=False, ) # Mock criteria acceptance - with patch.object( - ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria" - ): + with patch.object(ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria"): sql = self.builder.get_windowed_criteria_query(criteria, "#events") self.assertIn("SELECT * FROM Criteria", sql) @@ -181,31 +173,21 @@ def test_get_windowed_criteria_query_ignore_op(self): """Test get_windowed_criteria_query with ignore_observation_period=True.""" criteria = WindowedCriteria( criteria=ConditionOccurrence(first=True, codeset_id=1), - start_window=Window( - start={"days": 0, "coeff": -1}, end={"days": 0, "coeff": 1} - ), + start_window=Window(start={"days": 0, "coeff": -1}, end={"days": 0, "coeff": 1}), ignore_observation_period=True, # Important ) - with patch.object( - ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria" - ): + with patch.object(ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria"): sql = self.builder.get_windowed_criteria_query(criteria, "#events") - self.assertNotIn( - "A.START_DATE >= P.OP_START_DATE AND A.START_DATE <= P.OP_END_DATE", sql - ) + self.assertNotIn("A.START_DATE >= P.OP_START_DATE AND A.START_DATE <= P.OP_END_DATE", sql) def test_get_windowed_criteria_query_restrict_visit(self): """Test get_windowed_criteria_query with restrict_visit=True.""" criteria = WindowedCriteria( criteria=ConditionOccurrence(first=True, codeset_id=1), - start_window=Window( - start={"days": 0, "coeff": -1}, end={"days": 0, "coeff": 1} - ), + start_window=Window(start={"days": 0, "coeff": -1}, end={"days": 0, "coeff": 1}), restrict_visit=True, ) - with patch.object( - ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria" - ): + with patch.object(ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria"): sql = self.builder.get_windowed_criteria_query(criteria, "#events") self.assertIn("A.visit_occurrence_id = P.visit_occurrence_id", sql) @@ -219,9 +201,7 @@ def test_get_corelated_criteria_query_formatted_event_table(self): event_query = "SELECT person_id, event_id, start_date, end_date FROM #table" - with patch.object( - ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria" - ): + with patch.object(ConditionOccurrence, "accept", return_value="SELECT * FROM Criteria"): sql = self.builder.get_corelated_criteria_query(cc, event_query) # Should inject observation period join diff --git a/tests/test_cohort_modifiers.py b/tests/test_cohort_modifiers.py index d02b8d1e..5af1b763 100644 --- a/tests/test_cohort_modifiers.py +++ b/tests/test_cohort_modifiers.py @@ -54,9 +54,7 @@ # Fixtures # --------------------------------------------------------------------------- -EXAMPLE_JSON = ( - Path(__file__).resolve().parent.parent / "examples" / "type2_diabetes_cohort.json" -) +EXAMPLE_JSON = Path(__file__).resolve().parent.parent / "examples" / "type2_diabetes_cohort.json" @pytest.fixture @@ -231,9 +229,7 @@ def test_male(self, empty_cohort): assert dc.gender[0].concept_name == "MALE" def test_multiple_genders(self, empty_cohort): - set_gender_criteria( - empty_cohort, [GENDER_MALE_CONCEPT_ID, GENDER_FEMALE_CONCEPT_ID] - ) + set_gender_criteria(empty_cohort, [GENDER_MALE_CONCEPT_ID, GENDER_FEMALE_CONCEPT_ID]) dc = empty_cohort.additional_criteria.demographic_criteria_list[0] assert len(dc.gender) == 2 @@ -262,9 +258,7 @@ def test_fixed_duration(self, empty_cohort): assert result.end_strategy.date_field == "StartDate" def test_fixed_duration_end_date(self, empty_cohort): - set_end_date_strategy( - empty_cohort, "fixed_duration", days=90, date_field="EndDate" - ) + set_end_date_strategy(empty_cohort, "fixed_duration", days=90, date_field="EndDate") assert empty_cohort.end_strategy.date_field == "EndDate" def test_fixed_duration_no_days_raises(self, empty_cohort): @@ -343,11 +337,7 @@ def test_adds_inclusion_rule(self, diabetes_cohort): result = set_clean_window(diabetes_cohort, 7) assert result is diabetes_cohort # Should have added exactly one inclusion rule - matching = [ - r - for r in result.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" - ] + matching = [r for r in result.inclusion_rules if getattr(r, "name", None) == "__clean_window__"] assert len(matching) == 1 def test_single_criterion_defaults_to_any_mode(self, diabetes_cohort): @@ -355,9 +345,7 @@ def test_single_criterion_defaults_to_any_mode(self, diabetes_cohort): assert len(diabetes_cohort.primary_criteria.criteria_list) == 1 set_clean_window(diabetes_cohort, 30) rule = next( - r - for r in diabetes_cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" + r for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) assert rule.description is not None assert "30" in rule.description @@ -378,17 +366,13 @@ def test_single_criterion_both_modes_equivalent(self, diabetes_cohort): """With one criterion, 'any' and 'all' produce the same correlated list.""" set_clean_window(diabetes_cohort, 7, criteria_mode="any") rule_any = next( - r - for r in diabetes_cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" + r for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) n_any = len(rule_any.expression.criteria_list) set_clean_window(diabetes_cohort, 7, criteria_mode="all") rule_all = next( - r - for r in diabetes_cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" + r for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) n_all = len(rule_all.expression.criteria_list) @@ -416,11 +400,7 @@ def test_any_mode_multi_criteria_uses_all_group(self): } ) set_clean_window(cohort, 7, criteria_mode="any") - rule = next( - r - for r in cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" - ) + rule = next(r for r in cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__") group = rule.expression assert group.type == "ALL" assert len(group.criteria_list) == 2 @@ -452,11 +432,7 @@ def test_all_mode_multi_criteria_uses_any_group(self): } ) set_clean_window(cohort, 7, criteria_mode="all") - rule = next( - r - for r in cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" - ) + rule = next(r for r in cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__") group = rule.expression assert group.type == "ANY" assert len(group.criteria_list) == 2 @@ -478,11 +454,7 @@ def test_all_mode_three_criteria(self): } ) set_clean_window(cohort, 14, criteria_mode="all") - rule = next( - r - for r in cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" - ) + rule = next(r for r in cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__") assert rule.expression.type == "ANY" assert len(rule.expression.criteria_list) == 3 @@ -503,9 +475,7 @@ def test_replaces_existing_clean_window(self, diabetes_cohort): set_clean_window(diabetes_cohort, 7) set_clean_window(diabetes_cohort, 14) matching = [ - r - for r in diabetes_cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" + r for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ] assert len(matching) == 1 assert "14" in matching[0].description @@ -514,17 +484,13 @@ def test_replace_changes_mode(self, diabetes_cohort): """Replacing a clean window can switch from 'any' to 'all'.""" set_clean_window(diabetes_cohort, 7, criteria_mode="any") rule = next( - r - for r in diabetes_cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" + r for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) assert rule.expression.type == "ALL" set_clean_window(diabetes_cohort, 7, criteria_mode="all") rule = next( - r - for r in diabetes_cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" + r for r in diabetes_cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__" ) assert rule.expression.type == "ANY" @@ -585,21 +551,13 @@ def test_replace_updates_count_after_criteria_change(self): } ) set_clean_window(cohort, 7) - rule = next( - r - for r in cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" - ) + rule = next(r for r in cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__") assert len(rule.expression.criteria_list) == 1 # Now add a second primary criterion and reset the clean window cohort.primary_criteria.criteria_list.append(DrugExposure(codeset_id=2)) set_clean_window(cohort, 7) - rule = next( - r - for r in cohort.inclusion_rules - if getattr(r, "name", None) == "__clean_window__" - ) + rule = next(r for r in cohort.inclusion_rules if getattr(r, "name", None) == "__clean_window__") assert len(rule.expression.criteria_list) == 2 @@ -610,17 +568,13 @@ def test_replace_updates_count_after_criteria_change(self): class TestSetDateRange: def test_both_dates_string(self, empty_cohort): - result = set_date_range( - empty_cohort, start_date="2020-01-01", end_date="2022-12-31" - ) + result = set_date_range(empty_cohort, start_date="2020-01-01", end_date="2022-12-31") assert result is empty_cohort assert result.censor_window.start_date == "2020-01-01" assert result.censor_window.end_date == "2022-12-31" def test_date_objects(self, empty_cohort): - set_date_range( - empty_cohort, start_date=date(2020, 1, 1), end_date=date(2022, 12, 31) - ) + set_date_range(empty_cohort, start_date=date(2020, 1, 1), end_date=date(2022, 12, 31)) assert empty_cohort.censor_window.start_date == "2020-01-01" assert empty_cohort.censor_window.end_date == "2022-12-31" @@ -690,10 +644,7 @@ def test_reset_age_preserves_gender(self, empty_cohort): set_gender_criteria(empty_cohort, GENDER_FEMALE_CONCEPT_ID) reset_age_criteria(empty_cohort) assert len(empty_cohort.additional_criteria.demographic_criteria_list) == 1 - assert ( - empty_cohort.additional_criteria.demographic_criteria_list[0].gender - is not None - ) + assert empty_cohort.additional_criteria.demographic_criteria_list[0].gender is not None def test_reset_gender_criteria(self, empty_cohort): set_gender_criteria(empty_cohort, GENDER_MALE_CONCEPT_ID) @@ -705,10 +656,7 @@ def test_reset_gender_preserves_age(self, empty_cohort): set_gender_criteria(empty_cohort, GENDER_MALE_CONCEPT_ID) reset_gender_criteria(empty_cohort) assert len(empty_cohort.additional_criteria.demographic_criteria_list) == 1 - assert ( - empty_cohort.additional_criteria.demographic_criteria_list[0].age - is not None - ) + assert empty_cohort.additional_criteria.demographic_criteria_list[0].age is not None def test_reset_end_strategy(self, empty_cohort): set_end_date_strategy(empty_cohort, "fixed_duration", days=30) @@ -734,9 +682,7 @@ def test_reset_date_range(self, empty_cohort): class TestChaining: def test_chain_multiple_modifiers(self, empty_cohort): result = set_prior_observation( - set_post_observation( - set_limit_to_first_event(set_cohort_era(empty_cohort, 0)), 30 - ), + set_post_observation(set_limit_to_first_event(set_cohort_era(empty_cohort, 0)), 30), 365, ) assert result is empty_cohort diff --git a/tests/test_comparisons_coverage.py b/tests/test_comparisons_coverage.py index f5f27881..651368bc 100644 --- a/tests/test_comparisons_coverage.py +++ b/tests/test_comparisons_coverage.py @@ -38,77 +38,49 @@ def test_start_is_greater_than_end_numeric_incomplete(self): self.assertFalse(Comparisons.start_is_greater_than_end(NumericRange())) def test_start_is_greater_than_end_date_incomplete(self): - self.assertFalse( - Comparisons.start_is_greater_than_end(DateRange(value="2020-01-01")) - ) - self.assertFalse( - Comparisons.start_is_greater_than_end(DateRange(extent="2020-01-01")) - ) + self.assertFalse(Comparisons.start_is_greater_than_end(DateRange(value="2020-01-01"))) + self.assertFalse(Comparisons.start_is_greater_than_end(DateRange(extent="2020-01-01"))) self.assertFalse(Comparisons.start_is_greater_than_end(DateRange())) def test_start_is_greater_than_end_date_invalid(self): self.assertFalse( - Comparisons.start_is_greater_than_end( - DateRange(value="invalid", extent="2020-01-01") - ) + Comparisons.start_is_greater_than_end(DateRange(value="invalid", extent="2020-01-01")) ) self.assertFalse( - Comparisons.start_is_greater_than_end( - DateRange(value="2020-01-01", extent="invalid") - ) + Comparisons.start_is_greater_than_end(DateRange(value="2020-01-01", extent="invalid")) ) def test_start_is_greater_than_end_period_incomplete(self): - self.assertFalse( - Comparisons.start_is_greater_than_end(Period(start_date="2020-01-01")) - ) - self.assertFalse( - Comparisons.start_is_greater_than_end(Period(end_date="2020-01-01")) - ) + self.assertFalse(Comparisons.start_is_greater_than_end(Period(start_date="2020-01-01"))) + self.assertFalse(Comparisons.start_is_greater_than_end(Period(end_date="2020-01-01"))) self.assertFalse(Comparisons.start_is_greater_than_end(Period())) def test_start_is_greater_than_end_period_invalid(self): self.assertFalse( - Comparisons.start_is_greater_than_end( - Period(start_date="invalid", end_date="2020-01-01") - ) + Comparisons.start_is_greater_than_end(Period(start_date="invalid", end_date="2020-01-01")) ) self.assertFalse( - Comparisons.start_is_greater_than_end( - Period(start_date="2020-01-01", end_date="invalid") - ) + Comparisons.start_is_greater_than_end(Period(start_date="2020-01-01", end_date="invalid")) ) def test_start_is_greater_than_end_period_valid(self): self.assertTrue( - Comparisons.start_is_greater_than_end( - Period(start_date="2020-01-02", end_date="2020-01-01") - ) + Comparisons.start_is_greater_than_end(Period(start_date="2020-01-02", end_date="2020-01-01")) ) self.assertFalse( - Comparisons.start_is_greater_than_end( - Period(start_date="2020-01-01", end_date="2020-01-02") - ) + Comparisons.start_is_greater_than_end(Period(start_date="2020-01-01", end_date="2020-01-02")) ) def test_start_is_greater_than_end_numeric_valid(self): - self.assertTrue( - Comparisons.start_is_greater_than_end(NumericRange(value=10, extent=5)) - ) - self.assertFalse( - Comparisons.start_is_greater_than_end(NumericRange(value=5, extent=10)) - ) + self.assertTrue(Comparisons.start_is_greater_than_end(NumericRange(value=10, extent=5))) + self.assertFalse(Comparisons.start_is_greater_than_end(NumericRange(value=5, extent=10))) def test_start_is_greater_than_end_date_valid(self): self.assertTrue( - Comparisons.start_is_greater_than_end( - DateRange(value="2020-01-02", extent="2020-01-01") - ) + Comparisons.start_is_greater_than_end(DateRange(value="2020-01-02", extent="2020-01-01")) ) self.assertFalse( - Comparisons.start_is_greater_than_end( - DateRange(value="2020-01-01", extent="2020-01-02") - ) + Comparisons.start_is_greater_than_end(DateRange(value="2020-01-01", extent="2020-01-02")) ) def test_start_is_greater_than_end_other_type(self): @@ -143,9 +115,7 @@ def test_is_start_negative_numeric_valid(self): def test_compare_to_none(self): self.assertEqual(Comparisons.compare_to(None, Window()), 0) - self.assertEqual( - Comparisons.compare_to(ObservationFilter(priorDays=0, postDays=0), None), 0 - ) + self.assertEqual(Comparisons.compare_to(ObservationFilter(priorDays=0, postDays=0), None), 0) def test_compare_to_calculation(self): # range1 = prior + post = 10 + 20 = 30 @@ -154,9 +124,7 @@ def test_compare_to_calculation(self): # range2_start = coeff * days = -1 * 5 = -5 # range2_end = coeff * days = 1 * 5 = 5 # range2_diff = 5 - (-5) = 10 - w = Window( - start=WindowBound(coeff=-1, days=5), end=WindowBound(coeff=1, days=5) - ) + w = Window(start=WindowBound(coeff=-1, days=5), end=WindowBound(coeff=1, days=5)) # result = 30 - 10 = 20 self.assertEqual(Comparisons.compare_to(f, w), 20) @@ -180,21 +148,15 @@ def test_is_after_endpoint_none(self): def test_is_before_true(self): # start before (< 0), end not after (<= 0) - w = Window( - start=WindowBound(coeff=-1, days=1), end=WindowBound(coeff=-1, days=1) - ) + w = Window(start=WindowBound(coeff=-1, days=1), end=WindowBound(coeff=-1, days=1)) self.assertTrue(Comparisons.is_before(w)) def test_is_before_false_start_not_before(self): - w = Window( - start=WindowBound(coeff=1, days=1), end=WindowBound(coeff=-1, days=1) - ) + w = Window(start=WindowBound(coeff=1, days=1), end=WindowBound(coeff=-1, days=1)) self.assertFalse(Comparisons.is_before(w)) def test_is_before_false_end_after(self): - w = Window( - start=WindowBound(coeff=-1, days=1), end=WindowBound(coeff=1, days=1) - ) + w = Window(start=WindowBound(coeff=-1, days=1), end=WindowBound(coeff=1, days=1)) self.assertFalse(Comparisons.is_before(w)) # --- compare_concept_set --- @@ -251,9 +213,7 @@ def test_compare_concept_set(self): self.assertTrue(predicate(cs2)) # Diff content (length) - expr3 = ConceptSetExpression( - items=[ConceptSetItem(concept=c1), ConceptSetItem(concept=c3)] - ) + expr3 = ConceptSetExpression(items=[ConceptSetItem(concept=c1), ConceptSetItem(concept=c3)]) cs3 = ConceptSet(id=3, name="S3", expression=expr3) self.assertFalse(predicate(cs3)) @@ -300,9 +260,7 @@ def test_compare_criteria_all_types(self): c2 = cls(codeset_id=1) c3 = cls(codeset_id=2) - self.assertTrue( - Comparisons.compare_criteria(c1, c2), f"Failed for {cls.__name__} match" - ) + self.assertTrue(Comparisons.compare_criteria(c1, c2), f"Failed for {cls.__name__} match") self.assertFalse( Comparisons.compare_criteria(c1, c3), f"Failed for {cls.__name__} mismatch", @@ -312,6 +270,4 @@ def test_compare_criteria_unknown_type(self): class UnknownCriteria: pass - self.assertFalse( - Comparisons.compare_criteria(UnknownCriteria(), UnknownCriteria()) - ) + self.assertFalse(Comparisons.compare_criteria(UnknownCriteria(), UnknownCriteria())) diff --git a/tests/test_concept_set_expression_query_builder.py b/tests/test_concept_set_expression_query_builder.py index fb2d0057..05a1b338 100644 --- a/tests/test_concept_set_expression_query_builder.py +++ b/tests/test_concept_set_expression_query_builder.py @@ -111,9 +111,7 @@ def test_build_expression_query_complex_flags(self): c1 = Concept(concept_id=1, concept_name="C1") # Test mapped + descendants - item = ConceptSetItem( - concept=c1, is_excluded=False, include_descendants=True, include_mapped=True - ) + item = ConceptSetItem(concept=c1, is_excluded=False, include_descendants=True, include_mapped=True) expression = ConceptSetExpression(items=[item]) query = self.builder.build_expression_query(expression) @@ -130,9 +128,7 @@ def test_build_expression_query_complex_exclude(self): c1 = Concept(concept_id=1, concept_name="C1") # Test excluded + mapped + descendants - item = ConceptSetItem( - concept=c1, is_excluded=True, include_descendants=True, include_mapped=True - ) + item = ConceptSetItem(concept=c1, is_excluded=True, include_descendants=True, include_mapped=True) expression = ConceptSetExpression(items=[item]) query = self.builder.build_expression_query(expression) diff --git a/tests/test_condition_occurrence_sql_builder.py b/tests/test_condition_occurrence_sql_builder.py index 8b76766d..2a2b0662 100644 --- a/tests/test_condition_occurrence_sql_builder.py +++ b/tests/test_condition_occurrence_sql_builder.py @@ -61,37 +61,27 @@ def test_get_query_template(self): def test_get_table_column_for_criteria_column_domain_concept(self): """Test table column mapping for domain concept.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DOMAIN_CONCEPT - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) self.assertEqual(result, "C.condition_concept_id") def test_get_table_column_for_criteria_column_duration(self): """Test table column mapping for duration.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DURATION - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) self.assertEqual(result, "(DATEDIFF(d,C.start_date, C.end_date))") def test_get_table_column_for_criteria_column_start_date(self): """Test table column mapping for start date.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.START_DATE - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE) self.assertEqual(result, "C.start_date") def test_get_table_column_for_criteria_column_end_date(self): """Test table column mapping for end date.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.END_DATE - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE) self.assertEqual(result, "C.end_date") def test_get_table_column_for_criteria_column_visit_id(self): """Test table column mapping for visit ID.""" - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.VISIT_ID - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID) self.assertEqual(result, "C.visit_occurrence_id") def test_embed_codeset_clause_with_codeset_id(self): @@ -238,27 +228,19 @@ def test_resolve_join_clauses_with_age(self): """Test join clauses with age criteria.""" criteria = ConditionOccurrence(age=NumericRange(op="gte", value=18, extent=65)) result = self.builder.resolve_join_clauses(criteria) - self.assertIn( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result - ) + self.assertIn("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result) def test_resolve_join_clauses_with_gender(self): """Test join clauses with gender criteria.""" criteria = ConditionOccurrence(gender=[Concept(concept_id=1)]) result = self.builder.resolve_join_clauses(criteria) - self.assertIn( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result - ) + self.assertIn("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result) def test_resolve_join_clauses_with_gender_cs(self): """Test join clauses with gender_cs criteria.""" - criteria = ConditionOccurrence( - gender_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) - ) + criteria = ConditionOccurrence(gender_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) result = self.builder.resolve_join_clauses(criteria) - self.assertIn( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result - ) + self.assertIn("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result) def test_resolve_join_clauses_with_visit_type(self): """Test join clauses with visit_type criteria.""" @@ -271,9 +253,7 @@ def test_resolve_join_clauses_with_visit_type(self): def test_resolve_join_clauses_with_visit_type_cs(self): """Test join clauses with visit_type_cs criteria.""" - criteria = ConditionOccurrence( - visit_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) - ) + criteria = ConditionOccurrence(visit_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) result = self.builder.resolve_join_clauses(criteria) self.assertIn( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id", @@ -309,9 +289,7 @@ def test_resolve_join_clauses_with_multiple_conditions(self): ) result = self.builder.resolve_join_clauses(criteria) self.assertEqual(len(result), 3) - self.assertIn( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result - ) + self.assertIn("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", result) self.assertIn( "JOIN @cdm_database_schema.VISIT_OCCURRENCE V on C.visit_occurrence_id = V.visit_occurrence_id and C.person_id = V.person_id", result, @@ -329,9 +307,7 @@ def test_resolve_where_clauses_basic(self): def test_resolve_where_clauses_with_occurrence_start_date(self): """Test where clauses with occurrence_start_date.""" criteria = ConditionOccurrence( - occurrence_start_date=DateRange( - op="gte", value="2020-01-01", extent="2020-12-31" - ) + occurrence_start_date=DateRange(op="gte", value="2020-01-01", extent="2020-12-31") ) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("C.start_date" in clause for clause in result)) @@ -339,9 +315,7 @@ def test_resolve_where_clauses_with_occurrence_start_date(self): def test_resolve_where_clauses_with_occurrence_end_date(self): """Test where clauses with occurrence_end_date.""" criteria = ConditionOccurrence( - occurrence_end_date=DateRange( - op="gte", value="2020-01-01", extent="2020-12-31" - ) + occurrence_end_date=DateRange(op="gte", value="2020-01-01", extent="2020-12-31") ) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("C.end_date" in clause for clause in result)) @@ -350,15 +324,11 @@ def test_resolve_where_clauses_with_condition_type(self): """Test where clauses with condition_type.""" criteria = ConditionOccurrence(condition_type=[Concept(concept_id=1)]) result = self.builder.resolve_where_clauses(criteria) - self.assertTrue( - any("C.condition_type_concept_id" in clause for clause in result) - ) + self.assertTrue(any("C.condition_type_concept_id" in clause for clause in result)) def test_resolve_where_clauses_with_condition_type_exclude(self): """Test where clauses with condition_type_exclude=True.""" - criteria = ConditionOccurrence( - condition_type=[Concept(concept_id=1)], condition_type_exclude=True - ) + criteria = ConditionOccurrence(condition_type=[Concept(concept_id=1)], condition_type_exclude=True) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("not" in clause for clause in result)) @@ -368,9 +338,7 @@ def test_resolve_where_clauses_with_condition_type_cs(self): condition_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) ) result = self.builder.resolve_where_clauses(criteria) - self.assertTrue( - any("C.condition_type_concept_id" in clause for clause in result) - ) + self.assertTrue(any("C.condition_type_concept_id" in clause for clause in result)) def test_resolve_where_clauses_with_stop_reason(self): """Test where clauses with stop_reason.""" @@ -382,9 +350,7 @@ def test_resolve_where_clauses_with_age(self): """Test where clauses with age criteria.""" criteria = ConditionOccurrence(age=NumericRange(op="gte", value=18, extent=65)) result = self.builder.resolve_where_clauses(criteria) - self.assertTrue( - any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in result) - ) + self.assertTrue(any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in result)) def test_resolve_where_clauses_with_gender(self): """Test where clauses with gender criteria.""" @@ -394,9 +360,7 @@ def test_resolve_where_clauses_with_gender(self): def test_resolve_where_clauses_with_gender_cs(self): """Test where clauses with gender_cs criteria.""" - criteria = ConditionOccurrence( - gender_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) - ) + criteria = ConditionOccurrence(gender_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("P.gender_concept_id" in clause for clause in result)) @@ -422,9 +386,7 @@ def test_resolve_where_clauses_with_visit_type(self): def test_resolve_where_clauses_with_visit_type_cs(self): """Test where clauses with visit_type_cs criteria.""" - criteria = ConditionOccurrence( - visit_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) - ) + criteria = ConditionOccurrence(visit_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False)) result = self.builder.resolve_where_clauses(criteria) self.assertTrue(any("V.visit_concept_id" in clause for clause in result)) @@ -432,9 +394,7 @@ def test_resolve_where_clauses_with_condition_status(self): """Test where clauses with condition_status criteria.""" criteria = ConditionOccurrence(condition_status=[Concept(concept_id=1)]) result = self.builder.resolve_where_clauses(criteria) - self.assertTrue( - any("C.condition_status_concept_id" in clause for clause in result) - ) + self.assertTrue(any("C.condition_status_concept_id" in clause for clause in result)) def test_resolve_where_clauses_with_condition_status_cs(self): """Test where clauses with condition_status_cs criteria.""" @@ -442,25 +402,19 @@ def test_resolve_where_clauses_with_condition_status_cs(self): condition_status_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False) ) result = self.builder.resolve_where_clauses(criteria) - self.assertTrue( - any("C.condition_status_concept_id" in clause for clause in result) - ) + self.assertTrue(any("C.condition_status_concept_id" in clause for clause in result)) def test_resolve_where_clauses_with_multiple_conditions(self): """Test where clauses with multiple conditions.""" criteria = ConditionOccurrence( - occurrence_start_date=DateRange( - op="gte", value="2020-01-01", extent="2020-12-31" - ), + occurrence_start_date=DateRange(op="gte", value="2020-01-01", extent="2020-12-31"), age=NumericRange(op="gte", value=18, extent=65), gender=[Concept(concept_id=1)], ) result = self.builder.resolve_where_clauses(criteria) self.assertGreater(len(result), 0) self.assertTrue(any("C.start_date" in clause for clause in result)) - self.assertTrue( - any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in result) - ) + self.assertTrue(any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in result)) self.assertTrue(any("P.gender_concept_id" in clause for clause in result)) def test_get_criteria_sql_basic(self): @@ -563,18 +517,14 @@ def test_edge_case_gender_with_none_concept_id(self): def test_edge_case_date_range_none_values(self): """Test edge case with date range containing None values.""" - criteria = ConditionOccurrence( - occurrence_start_date=DateRange(op="gte", value=None, extent=None) - ) + criteria = ConditionOccurrence(occurrence_start_date=DateRange(op="gte", value=None, extent=None)) result = self.builder.resolve_where_clauses(criteria) # Should handle None values gracefully self.assertIsInstance(result, list) def test_edge_case_numeric_range_none_values(self): """Test edge case with numeric range containing None values.""" - criteria = ConditionOccurrence( - age=NumericRange(op="gte", value=None, extent=None) - ) + criteria = ConditionOccurrence(age=NumericRange(op="gte", value=None, extent=None)) result = self.builder.resolve_where_clauses(criteria) # Should handle None values gracefully self.assertIsInstance(result, list) @@ -584,12 +534,8 @@ def test_comprehensive_integration_test(self): criteria = ConditionOccurrence( codeset_id=123, first=True, - occurrence_start_date=DateRange( - op="gte", value="2020-01-01", extent="2020-12-31" - ), - occurrence_end_date=DateRange( - op="gte", value="2020-01-01", extent="2020-12-31" - ), + occurrence_start_date=DateRange(op="gte", value="2020-01-01", extent="2020-12-31"), + occurrence_end_date=DateRange(op="gte", value="2020-01-01", extent="2020-12-31"), condition_type=[Concept(concept_id=1)], condition_type_exclude=False, stop_reason=TextFilter(text="test"), @@ -623,12 +569,8 @@ def test_comprehensive_integration_test(self): # Should contain various clauses self.assertIn("row_number()", result) # ordinal expression self.assertIn("JOIN @cdm_database_schema.PERSON P", result) # person join - self.assertIn( - "JOIN @cdm_database_schema.VISIT_OCCURRENCE V", result - ) # visit join - self.assertIn( - "LEFT JOIN @cdm_database_schema.PROVIDER PR", result - ) # provider join + self.assertIn("JOIN @cdm_database_schema.VISIT_OCCURRENCE V", result) # visit join + self.assertIn("LEFT JOIN @cdm_database_schema.PROVIDER PR", result) # provider join if __name__ == "__main__": diff --git a/tests/test_criteria_classes.py b/tests/test_criteria_classes.py index 6bdee417..d795301b 100644 --- a/tests/test_criteria_classes.py +++ b/tests/test_criteria_classes.py @@ -435,9 +435,7 @@ def test_death_with_fields(self): death_type_cs=ConceptSetSelection(codeset_id=2, is_exclusion=False), death_type_exclude=False, cause_source_concept=67890, - cause_source_concept_cs=ConceptSetSelection( - codeset_id=3, is_exclusion=False - ), + cause_source_concept_cs=ConceptSetSelection(codeset_id=3, is_exclusion=False), codeset_id=100, age=NumericRange(op="gte", value=18, extent=65), occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), diff --git a/tests/test_date_adjustment_parity.py b/tests/test_date_adjustment_parity.py index 3c5af553..4ec151fc 100644 --- a/tests/test_date_adjustment_parity.py +++ b/tests/test_date_adjustment_parity.py @@ -56,9 +56,7 @@ def test_drug_era_date_adjustment(self): self.db.con.execute( "INSERT INTO drug_era (person_id, drug_era_id, drug_concept_id, drug_era_start_date, drug_era_end_date, drug_exposure_count, gap_days) VALUES (1, 100, 10, '2020-01-10'::DATE, '2020-01-20'::DATE, 1, 0)" ) - self.db.con.execute( - "INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)" - ) + self.db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") query = f"SELECT C.start_date, C.end_date FROM ({sql}) C" results = self.db.query(query) @@ -88,8 +86,7 @@ def test_condition_occurrence_date_adjustment(self): # Note: End date logic uses COALESCE for safety assert "DATEADD(day,1, co.condition_start_date)" in sql assert ( - "DATEADD(day,1, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)))" - in sql + "DATEADD(day,1, COALESCE(co.condition_end_date, DATEADD(day,1,co.condition_start_date)))" in sql ) # Setup Data @@ -116,9 +113,7 @@ def test_condition_occurrence_date_adjustment(self): self.db.con.execute( "INSERT INTO condition_occurrence (person_id, condition_occurrence_id, condition_concept_id, condition_start_date, condition_end_date, condition_type_concept_id) VALUES (1, 100, 10, '2020-02-01'::DATE, '2020-02-05'::DATE, 0)" ) - self.db.con.execute( - "INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)" - ) + self.db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") query = f"SELECT C.start_date, C.end_date FROM ({sql}) C" results = self.db.query(query) @@ -189,9 +184,7 @@ def test_drug_exposure_date_adjustment(self): self.db.con.execute( "INSERT INTO drug_exposure (person_id, drug_exposure_id, drug_concept_id, drug_exposure_start_date, drug_exposure_end_date, drug_type_concept_id) VALUES (1, 100, 10, '2020-03-01'::DATE, '2020-03-10'::DATE, 0)" ) - self.db.con.execute( - "INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)" - ) + self.db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") query = f"SELECT C.start_date, C.end_date FROM ({sql}) C" results = self.db.query(query) @@ -237,9 +230,7 @@ def test_dose_era_date_adjustment(self): self.db.con.execute( "INSERT INTO dose_era (person_id, dose_era_id, drug_concept_id, dose_era_start_date, dose_era_end_date) VALUES (1, 100, 10, '2020-04-01'::DATE, '2020-04-05'::DATE)" ) - self.db.con.execute( - "INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)" - ) + self.db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") query = f"SELECT C.start_date, C.end_date FROM ({sql}) C" results = self.db.query(query) @@ -317,9 +308,7 @@ def test_condition_era_date_adjustment(self): ) # Insert codeset mapping - self.db.con.execute( - "INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)" - ) + self.db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") # Construct full query # We replace @indexId with 0 or similar diff --git a/tests/test_device_exposure_sql.py b/tests/test_device_exposure_sql.py index a10a6199..02da8365 100644 --- a/tests/test_device_exposure_sql.py +++ b/tests/test_device_exposure_sql.py @@ -9,9 +9,7 @@ class TestDeviceExposureSql(unittest.TestCase): def test_basic_device_exposure(self): - criteria = DeviceExposure( - codeset_id=1, occurrence_start_date=DateRange(value="2023-01-01", op="gt") - ) + criteria = DeviceExposure(codeset_id=1, occurrence_start_date=DateRange(value="2023-01-01", op="gt")) builder = DeviceExposureSqlBuilder() options = BuilderOptions() @@ -24,9 +22,7 @@ def test_basic_device_exposure(self): any("C.start_date" in c for c in where_clauses), "Should have start date condition", ) - self.assertEqual( - len(join_clauses), 0, "Should have no joins for basic criteria" - ) + self.assertEqual(len(join_clauses), 0, "Should have no joins for basic criteria") def test_device_exposure_with_age(self): criteria = DeviceExposure(age=NumericRange(value=50, op="gt")) @@ -43,9 +39,7 @@ def test_device_exposure_with_age(self): ) # Check date diff logic for age - age_logic_present = any( - "YEAR(C.start_date) - P.year_of_birth" in c for c in where_clauses - ) + age_logic_present = any("YEAR(C.start_date) - P.year_of_birth" in c for c in where_clauses) self.assertTrue(age_logic_present, "Should use correct age calculation logic") def test_device_exposure_joins(self): @@ -59,10 +53,7 @@ def test_device_exposure_joins(self): join_clauses = builder.resolve_join_clauses(criteria, options) self.assertTrue( - any( - "JOIN @cdm_database_schema.VISIT_OCCURRENCE V" in c - for c in join_clauses - ), + any("JOIN @cdm_database_schema.VISIT_OCCURRENCE V" in c for c in join_clauses), "Should join to VISIT_OCCURRENCE", ) self.assertTrue( diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 6a304b2e..578837a1 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -77,9 +77,7 @@ def test_repository_urls_consistent(self): for pattern in incorrect_patterns: matches = re.findall(pattern, content, re.IGNORECASE) - assert not matches, ( - f"Found incorrect repository URL in {file_path.name}: {matches}" - ) + assert not matches, f"Found incorrect repository URL in {file_path.name}: {matches}" # Verify correct URL is present if any github.com link exists if "github.com" in content: @@ -123,9 +121,7 @@ def test_internal_links_valid(self): # Check if file exists link_path = root / link_url - assert link_path.exists(), ( - f"Broken link in README.md: [{link_text}]({link_url}) - file not found" - ) + assert link_path.exists(), f"Broken link in README.md: [{link_text}]({link_url}) - file not found" def test_changelog_has_current_version(self): """Verify CHANGELOG.md includes the current version.""" @@ -138,9 +134,9 @@ def test_changelog_has_current_version(self): # Check CHANGELOG changelog = (root / "CHANGELOG.md").read_text() - assert ( - f"[{current_version}]" in changelog or f"## {current_version}" in changelog - ), f"Current version {current_version} not found in CHANGELOG.md" + assert f"[{current_version}]" in changelog or f"## {current_version}" in changelog, ( + f"Current version {current_version} not found in CHANGELOG.md" + ) def test_readme_shields_badges(self): """Verify README has appropriate status badges.""" @@ -152,8 +148,7 @@ def test_readme_shields_badges(self): # Should mention alpha/development status somewhere assert any( - marker in readme.lower() - for marker in ["alpha", "development", "under active", "testing"] + marker in readme.lower() for marker in ["alpha", "development", "under active", "testing"] ), "README should clearly indicate development status" def test_contributing_has_code_style_section(self): @@ -200,9 +195,7 @@ def test_no_placeholder_text(self): if placeholder in ["TODO", "FIXME", "XXX"]: # More lenient - just warn if found if placeholder in content: - print( - f"Warning: Found {placeholder} in {file_path.name} - verify if intentional" - ) + print(f"Warning: Found {placeholder} in {file_path.name} - verify if intentional") else: assert placeholder not in content, ( f"Found placeholder text '{placeholder}' in {file_path.name}" diff --git a/tests/test_drug_era_sql_builder.py b/tests/test_drug_era_sql_builder.py index 57fd5c3e..aa5cd543 100644 --- a/tests/test_drug_era_sql_builder.py +++ b/tests/test_drug_era_sql_builder.py @@ -39,45 +39,31 @@ def test_get_default_columns(self): def test_get_table_column_for_criteria_column(self): """Test get_table_column_for_criteria_column method.""" # Test domain concept - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DOMAIN_CONCEPT - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) assert result == "C.drug_concept_id" # Test era occurrences - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.ERA_OCCURRENCES - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.ERA_OCCURRENCES) assert result == "C.drug_exposure_count" # Test gap days - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.GAP_DAYS - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.GAP_DAYS) assert result == "C.gap_days" # Test duration - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DURATION - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) assert result == "DATEDIFF(d,C.start_date, C.end_date)" # Test start date - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.START_DATE - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE) assert result == "C.start_date" # Test end date - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.END_DATE - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.END_DATE) assert result == "C.end_date" # Test visit id - result = self.builder.get_table_column_for_criteria_column( - CriteriaColumn.VISIT_ID - ) + result = self.builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_ID) assert result == "NULL" def test_get_query_template(self): @@ -99,7 +85,9 @@ def test_embed_codeset_clause_with_codeset_id(self): result = self.builder.embed_codeset_clause(query, criteria) # Note: Reference uses lowercase 'where' and double space before #Codesets - expected_clause = "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 123)" + expected_clause = ( + "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 123)" + ) assert "@codesetClause" not in result assert expected_clause in result @@ -160,10 +148,7 @@ def test_resolve_select_clauses_without_date_adjustment(self): assert "de.drug_concept_id" in result assert "de.drug_exposure_count" in result assert "de.gap_days" in result - assert ( - "de.drug_era_start_date as start_date, de.drug_era_end_date as end_date" - in result - ) + assert "de.drug_era_start_date as start_date, de.drug_era_end_date as end_date" in result def test_resolve_select_clauses_with_date_adjustment(self): """Test resolve_select_clauses with date adjustment.""" @@ -196,10 +181,7 @@ def test_resolve_join_clauses_with_age_at_start(self): result = self.builder.resolve_join_clauses(criteria) assert len(result) == 1 - assert ( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - in result[0] - ) + assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result[0] def test_resolve_join_clauses_with_age_at_end(self): """Test resolve_join_clauses with age_at_end.""" @@ -208,10 +190,7 @@ def test_resolve_join_clauses_with_age_at_end(self): result = self.builder.resolve_join_clauses(criteria) assert len(result) == 1 - assert ( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - in result[0] - ) + assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result[0] def test_resolve_join_clauses_with_gender(self): """Test resolve_join_clauses with gender.""" @@ -220,24 +199,16 @@ def test_resolve_join_clauses_with_gender(self): result = self.builder.resolve_join_clauses(criteria) assert len(result) == 1 - assert ( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - in result[0] - ) + assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result[0] def test_resolve_join_clauses_with_gender_cs(self): """Test resolve_join_clauses with gender_cs.""" - criteria = DrugEra( - gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False) - ) + criteria = DrugEra(gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False)) result = self.builder.resolve_join_clauses(criteria) assert len(result) == 1 - assert ( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - in result[0] - ) + assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result[0] def test_resolve_join_clauses_with_multiple_conditions(self): """Test resolve_join_clauses with multiple conditions.""" @@ -250,10 +221,7 @@ def test_resolve_join_clauses_with_multiple_conditions(self): result = self.builder.resolve_join_clauses(criteria) assert len(result) == 1 # Should only join once - assert ( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" - in result[0] - ) + assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result[0] def test_resolve_where_clauses_empty(self): """Test resolve_where_clauses with no conditions.""" @@ -347,9 +315,7 @@ def test_resolve_where_clauses_with_gender(self): def test_resolve_where_clauses_with_gender_cs(self): """Test resolve_where_clauses with gender_cs.""" - criteria = DrugEra( - gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False) - ) + criteria = DrugEra(gender_cs=ConceptSetSelection(codeset_id=123, is_exclusion=False)) result = self.builder.resolve_where_clauses(criteria) @@ -377,18 +343,12 @@ def test_resolve_where_clauses_with_multiple_conditions(self): assert any("C.start_date" in clause for clause in result) assert any("C.end_date" in clause for clause in result) assert any("C.drug_exposure_count" in clause for clause in result) - assert any( - "DATEDIFF(d,C.start_date, C.end_date)" in clause for clause in result - ) + assert any("DATEDIFF(d,C.start_date, C.end_date)" in clause for clause in result) assert any("C.gap_days" in clause for clause in result) - assert any( - "YEAR(C.start_date) - P.year_of_birth" in clause for clause in result - ) + assert any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in result) assert any("YEAR(C.end_date) - P.year_of_birth" in clause for clause in result) assert any("P.gender_concept_id in (8507)" in clause for clause in result) - assert any( - "P.gender_concept_id" in clause and "123" in clause for clause in result - ) + assert any("P.gender_concept_id" in clause and "123" in clause for clause in result) def test_get_criteria_sql_basic(self): """Test get_criteria_sql with basic criteria.""" @@ -415,8 +375,7 @@ def test_get_criteria_sql_with_codeset_id(self): # Note: Reference uses lowercase 'where' and double space before #Codesets assert ( - "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 123)" - in result + "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 123)" in result ) def test_get_criteria_sql_with_first_true(self): @@ -449,9 +408,7 @@ def test_get_criteria_sql_with_person_join(self): result = self.builder.get_criteria_sql(criteria) - assert ( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result - ) + assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result assert "YEAR(C.start_date) - P.year_of_birth" in result def test_get_criteria_sql_with_gap_days(self): @@ -557,14 +514,11 @@ def test_comprehensive_integration_test(self): # Verify all components are present # Note: Reference uses lowercase 'where' and double space before #Codesets assert ( - "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 456)" - in result + "where de.drug_concept_id in (SELECT concept_id from #Codesets where codeset_id = 456)" in result ) assert "row_number() over" in result assert "C.ordinal = 1" in result - assert ( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result - ) + assert "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id" in result assert "DATEADD(day,7" in result assert "DATEADD(day,-7" in result assert "C.start_date" in result diff --git a/tests/test_drug_exposure_builder.py b/tests/test_drug_exposure_builder.py index 3eb0b1b2..6d7ef059 100644 --- a/tests/test_drug_exposure_builder.py +++ b/tests/test_drug_exposure_builder.py @@ -25,9 +25,7 @@ def test_get_default_columns(self): def test_get_table_column_for_criteria_column(self): self.assertEqual( - self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DOMAIN_CONCEPT - ), + self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), "C.drug_concept_id", ) self.assertEqual( @@ -35,9 +33,7 @@ def test_get_table_column_for_criteria_column(self): "(DATEDIFF(d,C.start_date, C.end_date))", ) self.assertEqual( - self.builder.get_table_column_for_criteria_column( - CriteriaColumn.START_DATE - ), + self.builder.get_table_column_for_criteria_column(CriteriaColumn.START_DATE), "C.start_date", ) self.assertEqual( @@ -84,12 +80,7 @@ def test_resolve_select_clauses_date_adjustment(self): ) select_cols = self.builder.resolve_select_clauses(criteria) # Verify custom select logic replaces the default one - self.assertTrue( - any( - "DATEADD(day,1, de.drug_exposure_start_date)" in col - for col in select_cols - ) - ) + self.assertTrue(any("DATEADD(day,1, de.drug_exposure_start_date)" in col for col in select_cols)) def test_resolve_join_clauses(self): criteria = DrugExposure( @@ -100,17 +91,9 @@ def test_resolve_join_clauses(self): provider_specialty=[Concept(concept_id=2, concept_name="Spec")], ) joins = self.builder.resolve_join_clauses(criteria) - self.assertTrue( - any("JOIN @cdm_database_schema.PERSON P" in join for join in joins) - ) - self.assertTrue( - any( - "JOIN @cdm_database_schema.VISIT_OCCURRENCE V" in join for join in joins - ) - ) - self.assertTrue( - any("LEFT JOIN @cdm_database_schema.PROVIDER PR" in join for join in joins) - ) + self.assertTrue(any("JOIN @cdm_database_schema.PERSON P" in join for join in joins)) + self.assertTrue(any("JOIN @cdm_database_schema.VISIT_OCCURRENCE V" in join for join in joins)) + self.assertTrue(any("LEFT JOIN @cdm_database_schema.PROVIDER PR" in join for join in joins)) def test_resolve_where_clauses_basic(self): criteria = DrugExposure( @@ -140,33 +123,15 @@ def test_resolve_where_clauses_attributes(self): ) where_clauses = self.builder.resolve_where_clauses(criteria) - self.assertTrue( - any( - "C.drug_type_concept_id not in (1)" in clause - for clause in where_clauses - ) - ) + self.assertTrue(any("C.drug_type_concept_id not in (1)" in clause for clause in where_clauses)) self.assertTrue(any("C.refills > 1" in clause for clause in where_clauses)) self.assertTrue(any("C.quantity < 10" in clause for clause in where_clauses)) self.assertTrue(any("C.days_supply = 30" in clause for clause in where_clauses)) - self.assertTrue( - any( - "YEAR(C.start_date) - P.year_of_birth" in clause - for clause in where_clauses - ) - ) - self.assertTrue( - any("P.gender_concept_id in (8507)" in clause for clause in where_clauses) - ) - self.assertTrue( - any("PR.specialty_concept_id in (3)" in clause for clause in where_clauses) - ) - self.assertTrue( - any("V.visit_concept_id in (4)" in clause for clause in where_clauses) - ) - self.assertTrue( - any("C.route_concept_id in (5)" in clause for clause in where_clauses) - ) + self.assertTrue(any("YEAR(C.start_date) - P.year_of_birth" in clause for clause in where_clauses)) + self.assertTrue(any("P.gender_concept_id in (8507)" in clause for clause in where_clauses)) + self.assertTrue(any("PR.specialty_concept_id in (3)" in clause for clause in where_clauses)) + self.assertTrue(any("V.visit_concept_id in (4)" in clause for clause in where_clauses)) + self.assertTrue(any("C.route_concept_id in (5)" in clause for clause in where_clauses)) def test_resolve_where_clauses_codesets(self): """Test attributes using codesets.""" @@ -182,32 +147,19 @@ def test_resolve_where_clauses_codesets(self): where_clauses = self.builder.resolve_where_clauses(criteria) self.assertTrue( - any( - "C.drug_type_concept_id" in clause and "codeset_id = 2" in clause - for clause in where_clauses - ) + any("C.drug_type_concept_id" in clause and "codeset_id = 2" in clause for clause in where_clauses) ) self.assertTrue( - any( - "C.route_concept_id" in clause and "codeset_id = 3" in clause - for clause in where_clauses - ) + any("C.route_concept_id" in clause and "codeset_id = 3" in clause for clause in where_clauses) ) self.assertTrue( - any( - "P.gender_concept_id" in clause and "codeset_id = 4" in clause - for clause in where_clauses - ) + any("P.gender_concept_id" in clause and "codeset_id = 4" in clause for clause in where_clauses) ) self.assertTrue( any( - "PR.specialty_concept_id" in clause and "codeset_id = 5" in clause - for clause in where_clauses + "PR.specialty_concept_id" in clause and "codeset_id = 5" in clause for clause in where_clauses ) ) self.assertTrue( - any( - "V.visit_concept_id" in clause and "codeset_id = 6" in clause - for clause in where_clauses - ) + any("V.visit_concept_id" in clause and "codeset_id = 6" in clause for clause in where_clauses) ) diff --git a/tests/test_execution_api.py b/tests/test_execution_api.py index 260f6823..9d75f2c8 100644 --- a/tests/test_execution_api.py +++ b/tests/test_execution_api.py @@ -153,9 +153,7 @@ def test_has_end_strategy_handles_polymorphic_models(): from circe.execution.builders.common import has_end_strategy assert has_end_strategy(None) is False - assert ( - has_end_strategy(DateOffsetStrategy(offset=7, date_field="StartDate")) is True - ) + assert has_end_strategy(DateOffsetStrategy(offset=7, date_field="StartDate")) is True assert has_end_strategy(CustomEraStrategy(drug_codeset_id=123)) is True @@ -215,9 +213,7 @@ def test_ibis_executor_build_smoke_duckdb(): concept_sets=[ ConceptSet( id=1, - expression=ConceptSetExpression( - items=[ConceptSetItem(concept=Concept(conceptId=111))] - ), + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=111))]), ) ], primary_criteria=PrimaryCriteria( diff --git a/tests/test_extension_system.py b/tests/test_extension_system.py index 7d5a1ef6..7aeba6e9 100644 --- a/tests/test_extension_system.py +++ b/tests/test_extension_system.py @@ -1,44 +1,52 @@ -import pytest import json -from typing import Optional, List, Set -from pydantic import Field, AliasChoices +from typing import Optional, list, set -from circe.cohortdefinition import CohortExpression, PrimaryCriteria, CriteriaGroup -from circe.cohortdefinition.criteria import Criteria +from pydantic import AliasChoices, Field + +from circe.cohortdefinition import CohortExpression, PrimaryCriteria from circe.cohortdefinition.builders.base import CriteriaSqlBuilder -from circe.cohortdefinition.builders.utils import CriteriaColumn, BuilderUtils, BuilderOptions -from circe.cohortdefinition.cohort_expression_query_builder import CohortExpressionQueryBuilder, BuildExpressionQueryOptions +from circe.cohortdefinition.builders.utils import BuilderOptions, CriteriaColumn +from circe.cohortdefinition.cohort_expression_query_builder import ( + BuildExpressionQueryOptions, + CohortExpressionQueryBuilder, +) +from circe.cohortdefinition.criteria import Criteria from circe.cohortdefinition.printfriendly.markdown_render import MarkdownRender -from circe.vocabulary.concept import Concept from circe.extensions import get_registry +from circe.vocabulary.concept import Concept # ----------------------------------------------------------------------------- # 1. Define the Extension Components # ----------------------------------------------------------------------------- + class WeatherCondition(Criteria): """ Example extension criteria for 'Weather Conditions'. Imagine a CDM extension where weather data is linked to persons. """ - weather_concept_id: Optional[List[Concept]] = Field( + + weather_concept_id: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("WeatherConceptId", "weatherConceptId"), - serialization_alias="WeatherConceptId" + serialization_alias="WeatherConceptId", ) temperature_celsius: Optional[float] = Field( default=None, validation_alias=AliasChoices("TemperatureCelsius", "temperatureCelsius"), - serialization_alias="TemperatureCelsius" + serialization_alias="TemperatureCelsius", ) + # Important: Rebuild models to resolve forward references inherited from Criteria WeatherCondition.model_rebuild() + class WeatherConditionSqlBuilder(CriteriaSqlBuilder[WeatherCondition]): """ SQL Builder for WeatherCondition. """ + def get_query_template(self) -> str: return """ SELECT C.person_id, C.weather_id as event_id, C.observation_date as start_date, C.observation_date as end_date, @@ -47,13 +55,11 @@ def get_query_template(self) -> str: WHERE @whereClause """ - def get_default_columns(self) -> Set[CriteriaColumn]: + def get_default_columns(self) -> set[CriteriaColumn]: return {CriteriaColumn.START_DATE, CriteriaColumn.END_DATE} def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: - if column == CriteriaColumn.START_DATE: - return "C.observation_date" - elif column == CriteriaColumn.END_DATE: + if column == CriteriaColumn.START_DATE or column == CriteriaColumn.END_DATE: return "C.observation_date" else: raise ValueError(f"Unsupported column: {column}") @@ -61,33 +67,37 @@ def get_table_column_for_criteria_column(self, column: CriteriaColumn) -> str: def get_criteria_sql_with_options(self, criteria: WeatherCondition, options: BuilderOptions) -> str: query = self.get_query_template() where_clauses = ["1=1"] - + if criteria.weather_concept_id: ids = [str(c.concept_id) for c in criteria.weather_concept_id if c.concept_id] if ids: where_clauses.append(f"C.weather_concept_id IN ({','.join(ids)})") - + if criteria.temperature_celsius is not None: where_clauses.append(f"C.temp_c >= {criteria.temperature_celsius}") - query = query.replace("@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema") + query = query.replace( + "@cdm_database_schema", options.cdm_database_schema if options else "@cdm_database_schema" + ) query = query.replace("@whereClause", " AND ".join(where_clauses)) return query + # ----------------------------------------------------------------------------- # 2. Test Cases # ----------------------------------------------------------------------------- + def test_simple_extension_integration(tmp_path): """ Full end-to-end test of the extension system. """ registry = get_registry() - + # Register the extension registry.register_criteria_class("WeatherCondition", WeatherCondition) registry.register_sql_builder(WeatherCondition, WeatherConditionSqlBuilder) - + # Create a dummy template file template_dir = tmp_path / "templates" template_dir.mkdir() @@ -96,26 +106,25 @@ def test_simple_extension_integration(tmp_path): Weather condition: {{ criteria.weather_concept_id[0].concept_name if criteria.weather_concept_id else 'Any' }} {% if criteria.temperature_celsius %} with temperature >= {{ criteria.temperature_celsius }}°C{% endif %}. """) - + registry.add_template_path(template_dir) registry.register_markdown_template(WeatherCondition, "weather_condition.j2") # Construct a cohort using the extension - weather_concept = Concept(concept_id=123, concept_name="Snowing", standard_concept="S", concept_code="SNOW") - weather_criteria = WeatherCondition( - weather_concept_id=[weather_concept], - temperature_celsius=-5.0 + weather_concept = Concept( + concept_id=123, concept_name="Snowing", standard_concept="S", concept_code="SNOW" ) - + weather_criteria = WeatherCondition(weather_concept_id=[weather_concept], temperature_celsius=-5.0) + expression = CohortExpression( primary_criteria=PrimaryCriteria( criteria_list=[weather_criteria], observation_window={"priorDays": 0, "postDays": 0}, - primary_limit={"type": "First"} + primary_limit={"type": "First"}, ), concept_sets=[], inclusion_rules=[], - qualified_limit={"type": "First"} + qualified_limit={"type": "First"}, ) # 1. Test SQL Generation @@ -123,7 +132,7 @@ def test_simple_extension_integration(tmp_path): sql_options = BuildExpressionQueryOptions() sql_options.cdm_schema = "my_cdm" sql = builder.build_expression_query(expression, sql_options) - + assert "weather_data" in sql assert "weather_concept_id IN (123)" in sql assert "temp_c >= -5.0" in sql @@ -131,7 +140,7 @@ def test_simple_extension_integration(tmp_path): # 2. Test Markdown Rendering renderer = MarkdownRender() markdown = renderer.render_cohort_expression(expression) - + assert "Weather condition: Snowing" in markdown assert "temperature >= -5.0°C" in markdown @@ -139,41 +148,38 @@ def test_simple_extension_integration(tmp_path): # This verifies that Pydantic uses the registry to find the class json_str = expression.model_dump_json(by_alias=True) loaded_expression = CohortExpression.model_validate_json(json_str) - + # Check that it loaded as a WeatherCondition object, not a generic Criteria or dict loaded_criteria = loaded_expression.primary_criteria.criteria_list[0] assert isinstance(loaded_criteria, WeatherCondition) assert loaded_criteria.temperature_celsius == -5.0 assert loaded_criteria.weather_concept_id[0].concept_name == "Snowing" + def test_unregistered_extension_fails(): """ Verifies that using an unregistered extension key in JSON doesn't result - in a custom extension object. It will instead fall back to a standard - Criteria type (like ConditionOccurrence) because all of them have optional + in a custom extension object. It will instead fall back to a standard + Criteria type (like ConditionOccurrence) because all of them have optional fields and ignore extra fields. """ # Using a key that is NOT registered - bad_json_str = json.dumps({ - "PrimaryCriteria": { - "CriteriaList": [ - { - "UnregisteredKey": { - "SomeSpecificField": "Value" - } - } - ], - "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, - "PrimaryLimit": {"Type": "First"} + bad_json_str = json.dumps( + { + "PrimaryCriteria": { + "CriteriaList": [{"UnregisteredKey": {"SomeSpecificField": "Value"}}], + "ObservationWindow": {"PriorDays": 0, "PostDays": 0}, + "PrimaryLimit": {"Type": "First"}, + } } - }) - + ) + loaded = CohortExpression.model_validate_json(bad_json_str) item = loaded.primary_criteria.criteria_list[0] - + # It should NOT be a WeatherCondition (because it's not registered) assert not isinstance(item, WeatherCondition) - - # It will likely be a ConditionOccurrence because it's first in the Union + + # It will likely be a ConditionOccurrence because it's first in the Union # and all fields are optional with extra='ignore'. assert not hasattr(item, "SomeSpecificField") diff --git a/tests/test_hashing.py b/tests/test_hashing.py index 31491117..445d629a 100644 --- a/tests/test_hashing.py +++ b/tests/test_hashing.py @@ -25,9 +25,7 @@ def test_concept_name_agnosticism(self): c1 = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs1 = ConceptSet(id=1, name="Set 1") item1 = ConceptSetItem( - concept=Concept( - concept_id=123, concept_name="Name A", standard_concept="S" - ), + concept=Concept(concept_id=123, concept_name="Name A", standard_concept="S"), isExcluded=False, ) cs1.expression = ConceptSetExpression(items=[item1]) @@ -37,9 +35,7 @@ def test_concept_name_agnosticism(self): c2 = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs2 = ConceptSet(id=1, name="Set 1") item2 = ConceptSetItem( - concept=Concept( - concept_id=123, concept_name="Name B", standard_concept="S" - ), + concept=Concept(concept_id=123, concept_name="Name B", standard_concept="S"), isExcluded=False, ) cs2.expression = ConceptSetExpression(items=[item2]) @@ -68,17 +64,13 @@ def test_metadata_agnosticism(self): c2 = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs2 = ConceptSet(id=1, name="Set 1") item2 = ConceptSetItem( - concept=Concept( - concept_id=123, standard_concept="C", vocabulary_id="RxNorm" - ), + concept=Concept(concept_id=123, standard_concept="C", vocabulary_id="RxNorm"), isExcluded=False, ) cs2.expression = ConceptSetExpression(items=[item2]) c2.concept_sets = [cs2] - self.assertEqual( - c1.checksum(), c2.checksum(), "Checksum should ignore metadata differences" - ) + self.assertEqual(c1.checksum(), c2.checksum(), "Checksum should ignore metadata differences") def test_crucial_flags_sensitivity(self): """Test that checksums CHANGE when functional flags change.""" @@ -96,9 +88,7 @@ def test_crucial_flags_sensitivity(self): cs2.expression = ConceptSetExpression(items=[item2]) c2.concept_sets = [cs2] - self.assertNotEqual( - c1.checksum(), c2.checksum(), "Checksum must change if isExcluded changes" - ) + self.assertNotEqual(c1.checksum(), c2.checksum(), "Checksum must change if isExcluded changes") def test_deduplication(self): """Test that duplicate concept items are handled as the same set.""" @@ -127,18 +117,14 @@ def test_sensitivity_to_id(self): """Test sensitivity to Concept ID and Set Name.""" base = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs = ConceptSet(id=1, name="Set 1") - cs.expression = ConceptSetExpression( - items=[ConceptSetItem(concept=Concept(concept_id=123))] - ) + cs.expression = ConceptSetExpression(items=[ConceptSetItem(concept=Concept(concept_id=123))]) base.concept_sets = [cs] base_hash = base.checksum() # Change ID diff_id = base.model_copy(deep=True) diff_id.concept_sets[0].expression.items[0].concept.concept_id = 124 - self.assertNotEqual( - base_hash, diff_id.checksum(), "Checksum must change if Concept ID changes" - ) + self.assertNotEqual(base_hash, diff_id.checksum(), "Checksum must change if Concept ID changes") # Change Set Name (Wait, user said concept names in concept sets don't matter... # usually means render, but concept set name might matter if used in render? @@ -156,18 +142,14 @@ def test_defaults_handling(self): # C1: Explicit False c1 = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs1 = ConceptSet(id=1, name="Set 1") - item1 = ConceptSetItem( - concept=Concept(concept_id=123), isExcluded=False - ) # Explicit default + item1 = ConceptSetItem(concept=Concept(concept_id=123), isExcluded=False) # Explicit default cs1.expression = ConceptSetExpression(items=[item1]) c1.concept_sets = [cs1] # C2: Implicit Default (None or missing handled by model default) c2 = CohortExpression(title="Test", primary_criteria=PrimaryCriteria()) cs2 = ConceptSet(id=1, name="Set 1") - item2 = ConceptSetItem( - concept=Concept(concept_id=123) - ) # Implicit default isExcluded=False + item2 = ConceptSetItem(concept=Concept(concept_id=123)) # Implicit default isExcluded=False cs2.expression = ConceptSetExpression(items=[item2]) c2.concept_sets = [cs2] diff --git a/tests/test_java_interoperability.py b/tests/test_java_interoperability.py index bdd9a9cf..866a23f2 100644 --- a/tests/test_java_interoperability.py +++ b/tests/test_java_interoperability.py @@ -205,9 +205,7 @@ def test_field_names_use_pascal_case(self): def test_criteria_polymorphic_wrapper(self): """Test that criteria objects are wrapped in type names.""" # Create a condition occurrence - condition = ConditionOccurrence( - codeset_id=6, first=False, condition_type_exclude=False - ) + condition = ConditionOccurrence(codeset_id=6, first=False, condition_type_exclude=False) # Export to JSON json_data = condition.model_dump(by_alias=True, exclude_none=True) @@ -222,11 +220,7 @@ def test_primary_criteria_uses_pascal_case(self): """Test PrimaryCriteria exports with PascalCase field names.""" primary = PrimaryCriteria( - criteria_list=[ - ConditionOccurrence( - codeset_id=1, first=True, condition_type_exclude=False - ) - ], + criteria_list=[ConditionOccurrence(codeset_id=1, first=True, condition_type_exclude=False)], observation_window=ObservationFilter(prior_days=365, post_days=1), primary_limit=ResultLimit(type="All"), ) diff --git a/tests/test_kitchen_sink_cohort.py b/tests/test_kitchen_sink_cohort.py index c7fde493..482fe923 100644 --- a/tests/test_kitchen_sink_cohort.py +++ b/tests/test_kitchen_sink_cohort.py @@ -81,9 +81,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: expression=ConceptSetExpression( items=[ ConceptSetItem( - concept=Concept( - concept_id=1112807, concept_name="Metformin" - ), + concept=Concept(concept_id=1112807, concept_name="Metformin"), is_excluded=False, include_descendants=True, include_mapped=True, @@ -101,21 +99,15 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: first=True, occurrence_start_date=DateRange(value="2010-01-01", op="gt"), occurrence_end_date=DateRange(value="2020-01-01", op="lt"), - condition_type=[ - Concept(concept_id=32020, concept_name="EHR encounter diagnosis") - ], + condition_type=[Concept(concept_id=32020, concept_name="EHR encounter diagnosis")], condition_type_exclude=True, stop_reason=TextFilter(text="recovered", op="contains"), condition_source_concept=123, age=NumericRange(value=18, op="gt"), gender=[Concept(concept_id=8507, concept_name="Male")], - provider_specialty=[ - Concept(concept_id=38004456, concept_name="Endocrinology") - ], + provider_specialty=[Concept(concept_id=38004456, concept_name="Endocrinology")], visit_type=[Concept(concept_id=9201, concept_name="Inpatient Visit")], - condition_status=[ - Concept(concept_id=4230359, concept_name="Final diagnosis") - ], + condition_status=[Concept(concept_id=4230359, concept_name="Final diagnosis")], ) # Drug Exposure @@ -124,9 +116,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: first=False, occurrence_start_date=DateRange(value="2010-01-01", op="gt"), occurrence_end_date=DateRange(value="2020-01-01", op="lt"), - drug_type=[ - Concept(concept_id=38000177, concept_name="Prescription written") - ], + drug_type=[Concept(concept_id=38000177, concept_name="Prescription written")], drug_type_exclude=False, stop_reason=TextFilter(text="adversereaction", op="endswith"), refills=NumericRange(value=1, op="gte"), @@ -138,9 +128,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: lot_number=TextFilter(text="LOT123", op="eq"), age=NumericRange(value=50, op="lt"), gender=[Concept(concept_id=8532, concept_name="Female")], - provider_specialty=[ - Concept(concept_id=38004456, concept_name="Endocrinology") - ], + provider_specialty=[Concept(concept_id=38004456, concept_name="Endocrinology")], visit_type=[Concept(concept_id=9202, concept_name="Outpatient Visit")], ) @@ -149,9 +137,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: codeset_id=1, first=True, occurrence_start_date=DateRange(value="2015-01-01", op="gt"), - procedure_type=[ - Concept(concept_id=38000275, concept_name="EHR order list entry") - ], + procedure_type=[Concept(concept_id=38000275, concept_name="EHR order list entry")], procedure_type_exclude=True, modifier=[Concept(concept_id=123, concept_name="Modifier")], quantity=NumericRange(value=1, op="eq"), @@ -165,20 +151,14 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: first=True, occurrence_start_date=DateRange(value="2010-01-01", op="gt"), occurrence_end_date=DateRange(value="2020-01-01", op="lt"), - visit_type=[ - Concept(concept_id=44818518, concept_name="Visit derived from EHR") - ], + visit_type=[Concept(concept_id=44818518, concept_name="Visit derived from EHR")], visit_type_exclude=False, visit_source_concept=789, visit_length=NumericRange(value=1, op="gt"), age=NumericRange(value=18, op="gt"), gender=[Concept(concept_id=8507, concept_name="Male")], - provider_specialty=[ - Concept(concept_id=38003845, concept_name="General Practice") - ], - place_of_service=[ - Concept(concept_id=8717, concept_name="Inpatient Hospital") - ], + provider_specialty=[Concept(concept_id=38003845, concept_name="General Practice")], + place_of_service=[Concept(concept_id=8717, concept_name="Inpatient Hospital")], place_of_service_location=12345, ) @@ -207,11 +187,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: codeset_id=1, first=False, occurrence_start_date=DateRange(value="2010-01-01", op="gt"), - observation_type=[ - Concept( - concept_id=38000280, concept_name="Observation recorded from EHR" - ) - ], + observation_type=[Concept(concept_id=38000280, concept_name="Observation recorded from EHR")], observation_type_exclude=False, value_as_number=NumericRange(value=10, op="gt"), value_as_string=TextFilter(text="positive", op="eq"), @@ -240,9 +216,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: codeset_id=1, first=True, occurrence_start_date=DateRange(value="2010-01-01", op="gt"), - specimen_type=[ - Concept(concept_id=38000281, concept_name="Specimen from EHR") - ], + specimen_type=[Concept(concept_id=38000281, concept_name="Specimen from EHR")], specimen_type_exclude=True, unit=[Concept(concept_id=8576, concept_name="milligram")], anatomic_site=[Concept(concept_id=4044352, concept_name="Arm")], @@ -254,9 +228,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: age=NumericRange(value=18, op="gt"), gender=[Concept(concept_id=8507, concept_name="Male")], race=[Concept(concept_id=8527, concept_name="White")], - ethnicity=[ - Concept(concept_id=38003564, concept_name="Not Hispanic or Latino") - ], + ethnicity=[Concept(concept_id=38003564, concept_name="Not Hispanic or Latino")], occurrence_start_date=DateRange(value="2010-01-01", op="gt"), occurrence_end_date=DateRange(value="2020-01-01", op="lt"), ) @@ -311,9 +283,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: # 4. Inclusion Rules # Rule 1: Must have Metformin - rule1_crit = DrugExposure( - codeset_id=2, first=True, age=NumericRange(value=18, op="gt") - ) + rule1_crit = DrugExposure(codeset_id=2, first=True, age=NumericRange(value=18, op="gt")) # Corelated Criteria (Windowed) # We need to wrap the drug exposure in a CorelatedCriteria/WindowedCriteria structure usually @@ -371,11 +341,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: first=True, period_start_date=DateRange(value="2010-01-01", op="gt"), period_end_date=DateRange(value="2020-01-01", op="lt"), - period_type=[ - Concept( - concept_id=38000280, concept_name="Observation recorded from EHR" - ) - ], + period_type=[Concept(concept_id=38000280, concept_name="Observation recorded from EHR")], period_length=NumericRange(value=365, op="gt"), age_at_start=NumericRange(value=18, op="gt"), age_at_end=NumericRange(value=90, op="lt"), @@ -472,14 +438,10 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: primary_criteria=primary, inclusion_rules=[rule1], censoring_criteria=censoring, - collapse_settings=CollapseSettings( - era_pad=0, collapse_type=CollapseType.ERA - ), + collapse_settings=CollapseSettings(era_pad=0, collapse_type=CollapseType.ERA), censor_window=Period(start_date="2010-01-01", end_date="2025-01-01"), # End Strategies - Using CustomEraStrategy this time - end_strategy=CustomEraStrategy( - drug_codeset_id=2, gap_days=30, offset=7, days_supply_override=0 - ), + end_strategy=CustomEraStrategy(drug_codeset_id=2, gap_days=30, offset=7, days_supply_override=0), ) return cohort diff --git a/tests/test_markdown_render_coverage.py b/tests/test_markdown_render_coverage.py index 2d5c26d2..d0932140 100644 --- a/tests/test_markdown_render_coverage.py +++ b/tests/test_markdown_render_coverage.py @@ -24,9 +24,7 @@ def test_render_cohort_expression_string_input(self): def test_render_cohort_expression_with_concept_sets(self): # Line 90: cohort expression has concept sets cohort_json = '{"title": "With CS", "conceptSets": [{"id": 1, "name": "CS1", "expression": {"items": []}}], "primaryCriteria": {"observationWindow": {"priorDays": 0, "postDays": 0}, "primaryEvents": []}}' - output = self.renderer.render_cohort_expression( - cohort_json, include_concept_sets=True - ) + output = self.renderer.render_cohort_expression(cohort_json, include_concept_sets=True) self.assertIn("Concept Sets", output) self.assertIn("CS1", output) diff --git a/tests/test_print_friendly_parity.py b/tests/test_print_friendly_parity.py index 43a70a8f..c8974a4a 100644 --- a/tests/test_print_friendly_parity.py +++ b/tests/test_print_friendly_parity.py @@ -384,9 +384,7 @@ def test_continuous_observation_none_test(self): expression = CohortExpression.model_validate_json(json_str) markdown = self.pf.render_cohort_expression(expression) - self.assertInNormalized( - "People enter the cohort when observing any of the following:", markdown - ) + self.assertInNormalized("People enter the cohort when observing any of the following:", markdown) def test_continuous_observation_prior_test(self): json_str = get_resource_as_string("continuousObservation_prior.json") diff --git a/tests/test_query_builders.py b/tests/test_query_builders.py index 5ce6eaee..18745aec 100644 --- a/tests/test_query_builders.py +++ b/tests/test_query_builders.py @@ -131,9 +131,7 @@ def test_build_concept_set_mapped_query(self): mapped_concepts = [Concept(concept_id=12345, concept_name="Test Concept")] mapped_descendant_concepts = [] - query = self.builder.build_concept_set_mapped_query( - mapped_concepts, mapped_descendant_concepts - ) + query = self.builder.build_concept_set_mapped_query(mapped_concepts, mapped_descendant_concepts) self.assertIn("select distinct cr.concept_id_1 as concept_id", query) self.assertIn("@vocabulary_database_schema.concept_relationship", query) @@ -372,9 +370,7 @@ def test_get_codeset_query_with_concept_sets(self): "expression": ConceptSetExpression( items=[ ConceptSetItem( - concept=Concept( - concept_id=11111, concept_name="Test Concept" - ), + concept=Concept(concept_id=11111, concept_name="Test Concept"), is_excluded=False, include_descendants=False, include_mapped=False, @@ -398,11 +394,7 @@ def test_get_codeset_query_with_concept_sets(self): def test_get_primary_events_query(self): """Test get_primary_events_query method.""" primary_criteria = PrimaryCriteria( - criteria_list=[ - ConditionOccurrence( - first=True, condition_type_exclude=False, codeset_id=12345 - ) - ], + criteria_list=[ConditionOccurrence(first=True, condition_type_exclude=False, codeset_id=12345)], observation_window=ObservationFilter(prior_days=0, post_days=0), primary_limit=ResultLimit(type="ALL"), ) @@ -553,9 +545,7 @@ def test_get_criteria_group_query_with_criteria(self): type="ALL", criteria_list=[ CorelatedCriteria( - criteria=ConditionOccurrence( - first=True, condition_type_exclude=False, codeset_id=12345 - ), + criteria=ConditionOccurrence(first=True, condition_type_exclude=False, codeset_id=12345), occurrence=Occurrence(type=1, count=1, is_distinct=False), ) ], @@ -578,9 +568,7 @@ def test_get_strategy_sql_date_offset_strategy(self): def test_get_strategy_sql_custom_era_strategy(self): """Test get_strategy_sql for CustomEraStrategy.""" - strategy = CustomEraStrategy( - drug_codeset_id=12345, gap_days=30, offset=0, days_supply_override=None - ) + strategy = CustomEraStrategy(drug_codeset_id=12345, gap_days=30, offset=0, days_supply_override=None) query = self.builder.get_strategy_sql(strategy, "#test_events") @@ -591,9 +579,7 @@ def test_get_strategy_sql_custom_era_strategy(self): def test_get_strategy_sql_custom_era_strategy_no_codeset_id(self): """Test get_strategy_sql for CustomEraStrategy with no codeset ID.""" - strategy = CustomEraStrategy( - drug_codeset_id=None, gap_days=30, offset=0, days_supply_override=None - ) + strategy = CustomEraStrategy(drug_codeset_id=None, gap_days=30, offset=0, days_supply_override=None) with self.assertRaises(RuntimeError): self.builder.get_strategy_sql(strategy, "#test_events") @@ -613,9 +599,7 @@ def test_build_expression_query_basic(self): expression = CohortExpression( primary_criteria=PrimaryCriteria( criteria_list=[ - ConditionOccurrence( - first=True, condition_type_exclude=False, codeset_id=12345 - ) + ConditionOccurrence(first=True, condition_type_exclude=False, codeset_id=12345) ], observation_window=ObservationFilter(prior_days=0, post_days=0), primary_limit=ResultLimit(type="ALL"), @@ -623,9 +607,7 @@ def test_build_expression_query_basic(self): qualified_limit=ResultLimit(type="ALL"), expression_limit=ResultLimit(type="ALL"), inclusion_rules=[], - collapse_settings=CollapseSettings( - collapse_type=CollapseType.COLLAPSE, era_pad=30 - ), + collapse_settings=CollapseSettings(collapse_type=CollapseType.COLLAPSE, era_pad=30), ) options = BuildExpressionQueryOptions() @@ -643,9 +625,7 @@ def test_build_expression_query_with_additional_criteria(self): expression = CohortExpression( primary_criteria=PrimaryCriteria( criteria_list=[ - ConditionOccurrence( - first=True, condition_type_exclude=False, codeset_id=12345 - ) + ConditionOccurrence(first=True, condition_type_exclude=False, codeset_id=12345) ], observation_window=ObservationFilter(prior_days=0, post_days=0), primary_limit=ResultLimit(type="ALL"), @@ -654,16 +634,12 @@ def test_build_expression_query_with_additional_criteria(self): type="ALL", criteria_list=[ CorelatedCriteria( - criteria=Death( - first=True, death_type_exclude=False, codeset_id=67890 - ), + criteria=Death(first=True, death_type_exclude=False, codeset_id=67890), occurrence=Occurrence(type=1, count=1, is_distinct=False), ) ], ), - collapse_settings=CollapseSettings( - collapse_type=CollapseType.COLLAPSE, era_pad=30 - ), + collapse_settings=CollapseSettings(collapse_type=CollapseType.COLLAPSE, era_pad=30), ) options = BuildExpressionQueryOptions() @@ -680,17 +656,13 @@ def test_build_expression_query_with_end_strategy(self): expression = CohortExpression( primary_criteria=PrimaryCriteria( criteria_list=[ - ConditionOccurrence( - first=True, condition_type_exclude=False, codeset_id=12345 - ) + ConditionOccurrence(first=True, condition_type_exclude=False, codeset_id=12345) ], observation_window=ObservationFilter(prior_days=0, post_days=0), primary_limit=ResultLimit(type="ALL"), ), end_strategy=DateOffsetStrategy(offset=30, date_field="StartDate"), - collapse_settings=CollapseSettings( - collapse_type=CollapseType.COLLAPSE, era_pad=30 - ), + collapse_settings=CollapseSettings(collapse_type=CollapseType.COLLAPSE, era_pad=30), ) options = BuildExpressionQueryOptions() @@ -706,17 +678,13 @@ def test_build_expression_query_with_censor_window(self): expression = CohortExpression( primary_criteria=PrimaryCriteria( criteria_list=[ - ConditionOccurrence( - first=True, condition_type_exclude=False, codeset_id=12345 - ) + ConditionOccurrence(first=True, condition_type_exclude=False, codeset_id=12345) ], observation_window=ObservationFilter(prior_days=0, post_days=0), primary_limit=ResultLimit(type="ALL"), ), censor_window=Period(start_date="2020-01-01", end_date="2023-01-01"), - collapse_settings=CollapseSettings( - collapse_type=CollapseType.COLLAPSE, era_pad=30 - ), + collapse_settings=CollapseSettings(collapse_type=CollapseType.COLLAPSE, era_pad=30), ) options = BuildExpressionQueryOptions() diff --git a/tests/test_range_checker_factory_coverage.py b/tests/test_range_checker_factory_coverage.py index 11e3bfa5..8f6426b4 100644 --- a/tests/test_range_checker_factory_coverage.py +++ b/tests/test_range_checker_factory_coverage.py @@ -294,9 +294,7 @@ def test_check_measurement(self): Constants.Criteria.MEASUREMENT, Constants.Attributes.RANGE_HIGH_RATIO_ATTR, ), - call( - c.age, Constants.Criteria.MEASUREMENT, Constants.Attributes.AGE_ATTR - ), + call(c.age, Constants.Criteria.MEASUREMENT, Constants.Attributes.AGE_ATTR), ] mock_check.assert_has_calls(calls, any_order=True) @@ -315,9 +313,7 @@ def test_check_observation(self): Constants.Criteria.OBSERVATION, Constants.Attributes.VALUE_AS_NUMBER_ATTR, ), - call( - c.age, Constants.Criteria.OBSERVATION, Constants.Attributes.AGE_ATTR - ), + call(c.age, Constants.Criteria.OBSERVATION, Constants.Attributes.AGE_ATTR), ] mock_check.assert_has_calls(calls, any_order=True) @@ -533,9 +529,7 @@ def test_check_demographic_criteria(self): Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.OCCURRENCE_START_DATE_ATTR, ), - call( - c.age, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.AGE_ATTR - ), + call(c.age, Constants.Criteria.DEMOGRAPHIC, Constants.Attributes.AGE_ATTR), ] mock_check.assert_has_calls(calls, any_order=True) diff --git a/tests/test_real_example_cohorts.py b/tests/test_real_example_cohorts.py index ded23c7c..cb3fae6b 100644 --- a/tests/test_real_example_cohorts.py +++ b/tests/test_real_example_cohorts.py @@ -31,7 +31,6 @@ REFERENCE_DIR = COHORTS_DIR / "reference_outputs" - def get_target_cohort_files(config): """Discover cohort files based on configuration.""" if not COHORTS_DIR.exists(): @@ -213,13 +212,9 @@ def chunk_string(s, size=100): "is_identical": is_identical, "python_length": len(py_normalized), "reference_length": len(ref_normalized), - "python_lines": len( - python_output.splitlines() - ), # Original line count for reference + "python_lines": len(python_output.splitlines()), # Original line count for reference "reference_lines": len(reference_output.splitlines()), # Original line count - "diff_lines": len( - [line for line in diff if line.startswith("+") or line.startswith("-")] - ), + "diff_lines": len([line for line in diff if line.startswith("+") or line.startswith("-")]), "diff": diff[:50], # Limit to first 50 chunks for readability } @@ -250,37 +245,25 @@ def analyze_sql_differences(py_sql: str, ref_sql: str) -> list: # Check for specific criteria handling if "drug_era" in ref_sql.lower() and "drug_era" not in py_sql.lower(): - issues.append( - "Missing DRUG_ERA handling - DrugEra criteria may not be implemented" - ) + issues.append("Missing DRUG_ERA handling - DrugEra criteria may not be implemented") if "measurement" in ref_sql.lower() and "measurement" not in py_sql.lower(): - issues.append( - "Missing MEASUREMENT handling - Measurement criteria may not be implemented" - ) + issues.append("Missing MEASUREMENT handling - Measurement criteria may not be implemented") - if ( - "procedure_occurrence" in ref_sql.lower() - and "procedure_occurrence" not in py_sql.lower() - ): + if "procedure_occurrence" in ref_sql.lower() and "procedure_occurrence" not in py_sql.lower(): issues.append( "Missing PROCEDURE_OCCURRENCE handling - ProcedureOccurrence criteria may not be implemented" ) # Check for value_as_number handling if "value_as_number" in ref_sql.lower() and "value_as_number" not in py_sql.lower(): - issues.append( - "Missing value_as_number handling - numeric range criteria may not be implemented" - ) + issues.append("Missing value_as_number handling - numeric range criteria may not be implemented") # Check for source concept handling if ("source_concept_id" in ref_sql.lower() or "source_value" in ref_sql.lower()) and ( - "source_concept_id" not in py_sql.lower() - and "source_value" not in py_sql.lower() + "source_concept_id" not in py_sql.lower() and "source_value" not in py_sql.lower() ): - issues.append( - "Missing source concept handling - ConditionSourceConcept may not be implemented" - ) + issues.append("Missing source concept handling - ConditionSourceConcept may not be implemented") return issues @@ -333,8 +316,7 @@ def test_sql_generation_has_key_structures(cohort_name): if issues: pytest.fail( - f"SQL structure issues for {cohort_name}:\n" - + "\n".join(f" - {issue}" for issue in issues) + f"SQL structure issues for {cohort_name}:\n" + "\n".join(f" - {issue}" for issue in issues) ) @@ -591,13 +573,10 @@ def test_markdown_has_no_unknown_types(cohort_name): matches = unknown_pattern.findall(markdown) if matches: - lines_with_unknown = [ - line for line in markdown.split("\n") if "unknown" in line.lower() - ] + lines_with_unknown = [line for line in markdown.split("\n") if "unknown" in line.lower()] pytest.fail( f"Markdown contains 'Unknown criteria type' for {cohort_name}\n\n" - f"Lines with unknown types:\n" - + "\n".join(f" {line}" for line in lines_with_unknown) + f"Lines with unknown types:\n" + "\n".join(f" {line}" for line in lines_with_unknown) ) diff --git a/tests/test_schema_compatibility.py b/tests/test_schema_compatibility.py index 0c9f9187..821324a5 100644 --- a/tests/test_schema_compatibility.py +++ b/tests/test_schema_compatibility.py @@ -89,9 +89,7 @@ def test_compare_python_java_schema(): # - specific definition keys that we know differ (e.g. CriteriaColumn is missing in Python) exclude_regex = [r"root\['version'\]", r"root\['\$defs'\]\['CriteriaColumn'\]"] - diff = DeepDiff( - norm_java, norm_python, ignore_order=True, exclude_regex_paths=exclude_regex - ) + diff = DeepDiff(norm_java, norm_python, ignore_order=True, exclude_regex_paths=exclude_regex) if diff: print("\n❌ Schema differences found after normalization:") diff --git a/tests/test_simple_sql_builders.py b/tests/test_simple_sql_builders.py index fa130668..53c439b4 100644 --- a/tests/test_simple_sql_builders.py +++ b/tests/test_simple_sql_builders.py @@ -39,8 +39,7 @@ def test_dose_era_sql_builder_basic(self): # Test column mapping assert ( - builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) - == "C.drug_concept_id" + builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT) == "C.drug_concept_id" ) assert ( builder.get_table_column_for_criteria_column(CriteriaColumn.DURATION) diff --git a/tests/test_sql_builders.py b/tests/test_sql_builders.py index 70313ed5..8b6fc40d 100644 --- a/tests/test_sql_builders.py +++ b/tests/test_sql_builders.py @@ -137,9 +137,7 @@ def test_embed_codeset_clause(self): builder = DeathSqlBuilder() criteria = Death(codeset_id=12345, first=True, death_type_exclude=False) - clause = builder.embed_codeset_clause( - "SELECT * FROM table @codesetClause", criteria - ) + clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) # Updated alias check self.assertIn("d.cause_concept_id", clause) self.assertIn("12345", clause) @@ -149,9 +147,7 @@ def test_embed_codeset_clause_no_codeset(self): builder = DeathSqlBuilder() criteria = Death(first=True, death_type_exclude=False) - clause = builder.embed_codeset_clause( - "SELECT * FROM table @codesetClause", criteria - ) + clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertEqual(clause, "SELECT * FROM table ") @@ -297,9 +293,7 @@ def test_get_criteria_sql_with_provider_specialty(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), ) sql = builder.get_criteria_sql(criteria) @@ -318,9 +312,7 @@ def test_get_criteria_sql_with_provider_specialty_exclusion(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=True - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=True), ) sql = builder.get_criteria_sql(criteria) @@ -336,9 +328,7 @@ def test_get_criteria_sql_with_provider_specialty_exclusion(self): def test_get_criteria_sql_with_codeset_id(self): """Test get_criteria_sql with codeset ID.""" builder = ObservationSqlBuilder() - criteria = Observation( - first=True, observation_type_exclude=False, codeset_id=12345 - ) + criteria = Observation(first=True, observation_type_exclude=False, codeset_id=12345) sql = builder.get_criteria_sql(criteria) @@ -358,9 +348,7 @@ def test_get_criteria_sql_complex_scenario(self): occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01"), age=NumericRange(op="gte", value=18, extent=65), value_as_string=TextFilter(text="normal", op="eq"), - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), codeset_id=67890, ) @@ -369,21 +357,15 @@ def test_get_criteria_sql_complex_scenario(self): self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("WHERE", sql) - self.assertIn( - "JOIN @cdm_database_schema.PERSON P", sql - ) # Age requires PERSON join + self.assertIn("JOIN @cdm_database_schema.PERSON P", sql) # Age requires PERSON join self.assertIn("AND", sql) # Should have multiple conditions joined with AND def test_embed_codeset_clause(self): """Test embed_codeset_clause method.""" builder = ObservationSqlBuilder() - criteria = Observation( - codeset_id=12345, first=True, observation_type_exclude=False - ) + criteria = Observation(codeset_id=12345, first=True, observation_type_exclude=False) - clause = builder.embed_codeset_clause( - "SELECT * FROM table @codesetClause", criteria - ) + clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertIn("o.observation_concept_id", clause) self.assertIn("12345", clause) @@ -392,9 +374,7 @@ def test_embed_codeset_clause_no_codeset(self): builder = ObservationSqlBuilder() criteria = Observation(first=True, observation_type_exclude=False) - clause = builder.embed_codeset_clause( - "SELECT * FROM table @codesetClause", criteria - ) + clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertEqual(clause, "SELECT * FROM table ") def test_resolve_select_clauses_basic(self): @@ -440,23 +420,14 @@ def test_resolve_join_clauses_with_provider_specialty(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), ) options = BuilderOptions() join_clause = builder.resolve_join_clauses(criteria, options) - self.assertTrue( - any( - "JOIN @cdm_database_schema.PROVIDER PR" in clause - for clause in join_clause - ) - ) - self.assertTrue( - any("C.provider_id = PR.provider_id" in clause for clause in join_clause) - ) + self.assertTrue(any("JOIN @cdm_database_schema.PROVIDER PR" in clause for clause in join_clause)) + self.assertTrue(any("C.provider_id = PR.provider_id" in clause for clause in join_clause)) def test_resolve_join_clauses_with_provider_specialty_no_codeset_id(self): """Test resolve_join_clauses with provider specialty but no codeset_id.""" @@ -464,9 +435,7 @@ def test_resolve_join_clauses_with_provider_specialty_no_codeset_id(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=None, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=None, is_exclusion=False), ) options = BuilderOptions() @@ -497,12 +466,7 @@ def test_resolve_where_clauses_with_date_ranges(self): where_clause = builder.resolve_where_clauses(criteria, options) - self.assertTrue( - any( - "C.start_date" in clause or "C.end_date" in clause - for clause in where_clause - ) - ) + self.assertTrue(any("C.start_date" in clause or "C.end_date" in clause for clause in where_clause)) # Should have multiple conditions self.assertGreater(len(where_clause), 1) @@ -519,10 +483,7 @@ def test_resolve_where_clauses_with_age_condition(self): where_clause = builder.resolve_where_clauses(criteria, options) self.assertTrue( - any( - "C.start_date" in clause and "P.year_of_birth" in clause - for clause in where_clause - ) + any("C.start_date" in clause and "P.year_of_birth" in clause for clause in where_clause) ) def test_resolve_where_clauses_with_value_as_string(self): @@ -545,18 +506,14 @@ def test_resolve_where_clauses_with_provider_specialty(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), ) options = BuilderOptions() where_clause = builder.resolve_where_clauses(criteria, options) # ObservationSqlBuilder uses PR alias for PROVIDER (to avoid conflict with PERSON alias P) - self.assertTrue( - any("PR.specialty_concept_id" in clause for clause in where_clause) - ) + self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) self.assertTrue(any("12345" in clause for clause in where_clause)) def test_resolve_where_clauses_with_provider_specialty_exclusion(self): @@ -565,26 +522,20 @@ def test_resolve_where_clauses_with_provider_specialty_exclusion(self): criteria = Observation( first=True, observation_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=True - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=True), ) options = BuilderOptions() where_clause = builder.resolve_where_clauses(criteria, options) # ObservationSqlBuilder uses PR alias for PROVIDER (to avoid conflict with PERSON alias P) - self.assertTrue( - any("PR.specialty_concept_id" in clause for clause in where_clause) - ) + self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) self.assertTrue(any("not" in clause for clause in where_clause)) def test_resolve_where_clauses_with_codeset_id(self): """Test resolve_where_clauses with codeset ID.""" builder = ObservationSqlBuilder() - criteria = Observation( - first=True, observation_type_exclude=False, codeset_id=12345 - ) + criteria = Observation(first=True, observation_type_exclude=False, codeset_id=12345) options = BuilderOptions() where_clause = builder.resolve_where_clauses(criteria, options) @@ -603,9 +554,7 @@ def test_resolve_where_clauses_complex_scenario(self): occurrence_end_date=DateRange(op="lt", extent="30", value="2023-01-01"), age=NumericRange(op="gte", value=18, extent=65), value_as_string=TextFilter(text="normal", op="eq"), - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), codeset_id=67890, ) options = BuilderOptions() @@ -613,20 +562,13 @@ def test_resolve_where_clauses_complex_scenario(self): where_clause = builder.resolve_where_clauses(criteria, options) # Check for date conditions (uses C.start_date and C.end_date) - self.assertTrue( - any( - "C.start_date" in clause or "C.end_date" in clause - for clause in where_clause - ) - ) + self.assertTrue(any("C.start_date" in clause or "C.end_date" in clause for clause in where_clause)) # Check for age condition (uses C.start_date and P.year_of_birth) self.assertTrue(any("P.year_of_birth" in clause for clause in where_clause)) # Check for value_as_string self.assertTrue(any("C.value_as_string" in clause for clause in where_clause)) # ObservationSqlBuilder uses PR alias for PROVIDER (to avoid conflict with PERSON alias P) - self.assertTrue( - any("PR.specialty_concept_id" in clause for clause in where_clause) - ) + self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) # Note: codeset_id is now handled via JOIN, not WHERE clause # Should have multiple conditions (where_clause is a list of strings) self.assertGreater(len(where_clause), 3) @@ -675,9 +617,7 @@ def test_sql_generation_edge_cases(self): self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("C.ordinal = 1", sql) # WHERE clause for first=True - self.assertNotIn( - "JOIN @cdm_database_schema.PERSON", sql - ) # No age condition, no PERSON join + self.assertNotIn("JOIN @cdm_database_schema.PERSON", sql) # No age condition, no PERSON join def test_sql_generation_with_empty_concept_lists(self): """Test SQL generation with empty concept lists.""" @@ -703,9 +643,7 @@ def test_sql_template_placeholder_replacement(self): first=True, observation_type_exclude=False, occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), codeset_id=67890, ) @@ -898,9 +836,7 @@ def test_get_criteria_sql_with_provider_specialty(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), ) sql = builder.get_criteria_sql(criteria) @@ -919,9 +855,7 @@ def test_get_criteria_sql_with_provider_specialty_exclusion(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=True - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=True), ) sql = builder.get_criteria_sql(criteria) @@ -936,9 +870,7 @@ def test_get_criteria_sql_with_provider_specialty_exclusion(self): def test_get_criteria_sql_with_codeset_id(self): """Test get_criteria_sql with codeset ID.""" builder = MeasurementSqlBuilder() - criteria = Measurement( - first=True, measurement_type_exclude=False, codeset_id=12345 - ) + criteria = Measurement(first=True, measurement_type_exclude=False, codeset_id=12345) sql = builder.get_criteria_sql(criteria) @@ -962,9 +894,7 @@ def test_get_criteria_sql_complex_scenario(self): value_as_string=TextFilter(text="normal", op="eq"), range_low=NumericRange(op="gte", value=50, extent=100), range_high=NumericRange(op="lt", value=200, extent=300), - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), codeset_id=67890, ) @@ -980,16 +910,10 @@ def test_get_criteria_sql_complex_scenario(self): def test_embed_codeset_clause(self): """Test embed_codeset_clause method.""" builder = MeasurementSqlBuilder() - criteria = Measurement( - codeset_id=12345, first=True, measurement_type_exclude=False - ) + criteria = Measurement(codeset_id=12345, first=True, measurement_type_exclude=False) - clause = builder.embed_codeset_clause( - "SELECT * FROM table @codesetClause", criteria - ) - self.assertIn( - "m.measurement_concept_id", clause - ) # Use m. prefix in inner query + clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) + self.assertIn("m.measurement_concept_id", clause) # Use m. prefix in inner query self.assertIn("12345", clause) def test_embed_codeset_clause_no_codeset(self): @@ -997,9 +921,7 @@ def test_embed_codeset_clause_no_codeset(self): builder = MeasurementSqlBuilder() criteria = Measurement(first=True, measurement_type_exclude=False) - clause = builder.embed_codeset_clause( - "SELECT * FROM table @codesetClause", criteria - ) + clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertEqual(clause, "SELECT * FROM table ") def test_resolve_select_clauses_basic(self): @@ -1011,9 +933,7 @@ def test_resolve_select_clauses_basic(self): select_clause = builder.resolve_select_clauses(criteria, options) # Inner query uses m. prefix - self.assertTrue( - any("m.measurement_date as start_date" in col for col in select_clause) - ) + self.assertTrue(any("m.measurement_date as start_date" in col for col in select_clause)) self.assertIn("m.person_id", select_clause) self.assertIn("m.measurement_id", select_clause) self.assertIn("m.measurement_concept_id", select_clause) @@ -1030,9 +950,7 @@ def test_resolve_select_clauses_with_additional_columns(self): # resolve_select_clauses returns inner query columns (m. prefix) # Additional columns are handled elsewhere so check for standard columns - self.assertTrue( - any("m.measurement_date as start_date" in col for col in select_clause) - ) + self.assertTrue(any("m.measurement_date as start_date" in col for col in select_clause)) self.assertIn("m.measurement_concept_id", select_clause) def test_resolve_join_clauses_no_joins(self): @@ -1051,24 +969,15 @@ def test_resolve_join_clauses_with_provider_specialty(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), ) options = BuilderOptions() join_clause = builder.resolve_join_clauses(criteria, options) # Provider now uses PR alias to avoid conflict with PERSON P - self.assertTrue( - any( - "JOIN @cdm_database_schema.PROVIDER PR" in clause - for clause in join_clause - ) - ) - self.assertTrue( - any("C.provider_id = PR.provider_id" in clause for clause in join_clause) - ) + self.assertTrue(any("JOIN @cdm_database_schema.PROVIDER PR" in clause for clause in join_clause)) + self.assertTrue(any("C.provider_id = PR.provider_id" in clause for clause in join_clause)) def test_resolve_join_clauses_with_provider_specialty_no_codeset_id(self): """Test resolve_join_clauses with provider specialty but no codeset_id.""" @@ -1076,9 +985,7 @@ def test_resolve_join_clauses_with_provider_specialty_no_codeset_id(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=None, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=None, is_exclusion=False), ) options = BuilderOptions() @@ -1110,12 +1017,7 @@ def test_resolve_where_clauses_with_date_ranges(self): where_clause = builder.resolve_where_clauses(criteria, options) # Now uses C.start_date and C.end_date (from outer query) - self.assertTrue( - any( - "C.start_date" in clause or "C.end_date" in clause - for clause in where_clause - ) - ) + self.assertTrue(any("C.start_date" in clause or "C.end_date" in clause for clause in where_clause)) # Should have multiple clauses for date ranges self.assertGreater(len(where_clause), 0) @@ -1182,18 +1084,14 @@ def test_resolve_where_clauses_with_provider_specialty(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), ) options = BuilderOptions() where_clause = builder.resolve_where_clauses(criteria, options) # Provider now uses PR alias to avoid conflict with PERSON (P) - self.assertTrue( - any("PR.specialty_concept_id" in clause for clause in where_clause) - ) + self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) self.assertTrue(any("12345" in clause for clause in where_clause)) def test_resolve_where_clauses_with_provider_specialty_exclusion(self): @@ -1202,26 +1100,20 @@ def test_resolve_where_clauses_with_provider_specialty_exclusion(self): criteria = Measurement( first=True, measurement_type_exclude=False, - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=True - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=True), ) options = BuilderOptions() where_clause = builder.resolve_where_clauses(criteria, options) # Provider now uses PR alias - self.assertTrue( - any("PR.specialty_concept_id" in clause for clause in where_clause) - ) + self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) self.assertTrue(any("not" in clause for clause in where_clause)) def test_resolve_where_clauses_with_codeset_id(self): """Test resolve_where_clauses with codeset ID.""" builder = MeasurementSqlBuilder() - criteria = Measurement( - first=True, measurement_type_exclude=False, codeset_id=12345 - ) + criteria = Measurement(first=True, measurement_type_exclude=False, codeset_id=12345) options = BuilderOptions() where_clause = builder.resolve_where_clauses(criteria, options) @@ -1242,9 +1134,7 @@ def test_resolve_where_clauses_complex_scenario(self): value_as_string=TextFilter(text="normal", op="eq"), range_low=NumericRange(op="gte", value=50, extent=100), range_high=NumericRange(op="lt", value=200, extent=300), - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), codeset_id=67890, ) options = BuilderOptions() @@ -1252,21 +1142,14 @@ def test_resolve_where_clauses_complex_scenario(self): where_clause = builder.resolve_where_clauses(criteria, options) # Date conditions use C.start_date/C.end_date in outer query - self.assertTrue( - any( - "C.start_date" in clause or "C.end_date" in clause - for clause in where_clause - ) - ) + self.assertTrue(any("C.start_date" in clause or "C.end_date" in clause for clause in where_clause)) # Age conditions use YEAR(C.start_date) self.assertTrue(any("YEAR(C.start_date)" in clause for clause in where_clause)) self.assertTrue(any("C.value_as_number" in clause for clause in where_clause)) self.assertTrue(any("C.range_low" in clause for clause in where_clause)) self.assertTrue(any("C.range_high" in clause for clause in where_clause)) # Provider now uses PR alias to avoid conflict with PERSON P - self.assertTrue( - any("PR.specialty_concept_id" in clause for clause in where_clause) - ) + self.assertTrue(any("PR.specialty_concept_id" in clause for clause in where_clause)) # codeset_id is now handled via JOIN in inner query, not WHERE clause # Should have multiple conditions self.assertGreater(len(where_clause), 5) @@ -1349,9 +1232,7 @@ def test_sql_template_placeholder_replacement(self): first=True, measurement_type_exclude=False, occurrence_start_date=DateRange(op="gte", extent="0", value="2020-01-01"), - provider_specialty_cs=ConceptSetSelection( - codeset_id=12345, is_exclusion=False - ), + provider_specialty_cs=ConceptSetSelection(codeset_id=12345, is_exclusion=False), codeset_id=67890, ) @@ -1442,13 +1323,9 @@ def test_get_criteria_sql_basic(self): def test_embed_codeset_clause(self): """Test embed_codeset_clause method.""" builder = DeviceExposureSqlBuilder() - criteria = DeviceExposure( - codeset_id=12345, first=True, device_type_exclude=False - ) + criteria = DeviceExposure(codeset_id=12345, first=True, device_type_exclude=False) - clause = builder.embed_codeset_clause( - "SELECT * FROM table @codesetClause", criteria - ) + clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertIn("de.device_concept_id", clause) self.assertIn("12345", clause) @@ -1528,9 +1405,7 @@ def test_embed_codeset_clause(self): builder = SpecimenSqlBuilder() criteria = Specimen(codeset_id=12345, first=True, specimen_type_exclude=False) - clause = builder.embed_codeset_clause( - "SELECT * FROM table @codesetClause", criteria - ) + clause = builder.embed_codeset_clause("SELECT * FROM table @codesetClause", criteria) self.assertIn("s.specimen_concept_id", clause) self.assertIn("12345", clause) @@ -1680,9 +1555,7 @@ def test_get_table_column_for_criteria_column(self): "C.unit_concept_id", ) self.assertEqual( - builder.get_table_column_for_criteria_column( - CriteriaColumn.VALUE_AS_NUMBER - ), + builder.get_table_column_for_criteria_column(CriteriaColumn.VALUE_AS_NUMBER), "C.dose_value", ) @@ -1866,12 +1739,8 @@ def test_resolve_select_clauses(self): self.assertIn("op.person_id", select_clauses) self.assertIn("op.observation_period_id", select_clauses) self.assertIn("op.period_type_concept_id", select_clauses) - self.assertIn( - "op.observation_period_start_date as start_date", " ".join(select_clauses) - ) - self.assertIn( - "op.observation_period_end_date as end_date", " ".join(select_clauses) - ) + self.assertIn("op.observation_period_start_date as start_date", " ".join(select_clauses)) + self.assertIn("op.observation_period_end_date as end_date", " ".join(select_clauses)) def test_resolve_join_clauses(self): """Test join clauses resolution.""" @@ -1984,12 +1853,8 @@ def test_resolve_select_clauses(self): self.assertIn("ppp.person_id", select_clauses) self.assertIn("ppp.payer_plan_period_id", select_clauses) - self.assertIn( - "ppp.payer_plan_period_start_date as start_date", " ".join(select_clauses) - ) - self.assertIn( - "ppp.payer_plan_period_end_date as end_date", " ".join(select_clauses) - ) + self.assertIn("ppp.payer_plan_period_start_date as start_date", " ".join(select_clauses)) + self.assertIn("ppp.payer_plan_period_end_date as end_date", " ".join(select_clauses)) def test_resolve_select_clauses_with_concepts(self): """Test select clauses resolution with concept fields.""" @@ -2047,9 +1912,7 @@ def test_resolve_where_clauses_with_filters(self): self.assertGreaterEqual(len(where_clauses), 2) self.assertTrue(any("C.start_date" in clause for clause in where_clauses)) - self.assertTrue( - any("payer_source_concept_id" in clause for clause in where_clauses) - ) + self.assertTrue(any("payer_source_concept_id" in clause for clause in where_clauses)) class TestVisitDetailSqlBuilder(unittest.TestCase): @@ -2093,9 +1956,7 @@ def test_get_table_column_for_criteria_column(self): "DATEDIFF(d, C.start_date, C.end_date)", ) self.assertEqual( - builder.get_table_column_for_criteria_column( - CriteriaColumn.VISIT_DETAIL_ID - ), + builder.get_table_column_for_criteria_column(CriteriaColumn.VISIT_DETAIL_ID), "C.visit_detail_id", ) @@ -2134,9 +1995,7 @@ def test_resolve_select_clauses(self): self.assertIn("vd.visit_detail_id", select_clauses) self.assertIn("vd.visit_detail_concept_id", select_clauses) self.assertIn("vd.visit_occurrence_id", select_clauses) - self.assertIn( - "vd.visit_detail_start_date as start_date", " ".join(select_clauses) - ) + self.assertIn("vd.visit_detail_start_date as start_date", " ".join(select_clauses)) self.assertIn("vd.visit_detail_end_date as end_date", " ".join(select_clauses)) def test_resolve_join_clauses(self): @@ -2151,9 +2010,7 @@ def test_resolve_join_clauses(self): def test_resolve_join_clauses_with_person(self): """Test join clauses resolution with person join.""" builder = VisitDetailSqlBuilder() - criteria = VisitDetail( - visit_detail_type_exclude=False, age=NumericRange(op="gte", value=18) - ) + criteria = VisitDetail(visit_detail_type_exclude=False, age=NumericRange(op="gte", value=18)) join_clauses = builder.resolve_join_clauses(criteria) @@ -2336,15 +2193,9 @@ def test_builder_options_integration(self): else: mock_criteria = Mock() - self.assertIsInstance( - builder.resolve_select_clauses(mock_criteria, options), list - ) - self.assertIsInstance( - builder.resolve_join_clauses(mock_criteria, options), list - ) - self.assertIsInstance( - builder.resolve_where_clauses(mock_criteria, options), list - ) + self.assertIsInstance(builder.resolve_select_clauses(mock_criteria, options), list) + self.assertIsInstance(builder.resolve_join_clauses(mock_criteria, options), list) + self.assertIsInstance(builder.resolve_where_clauses(mock_criteria, options), list) if __name__ == "__main__": diff --git a/tests/test_sql_rendering_parity.py b/tests/test_sql_rendering_parity.py index 783aac99..d7e6b151 100644 --- a/tests/test_sql_rendering_parity.py +++ b/tests/test_sql_rendering_parity.py @@ -227,9 +227,7 @@ def test_includes_full_logic(self): sql, "Should filter procedure_type_concept_id", ) - self.assertIn( - "PR.specialty_concept_id in (20)", sql, "Should filter provider specialty" - ) + self.assertIn("PR.specialty_concept_id in (20)", sql, "Should filter provider specialty") self.assertIn("V.visit_concept_id in (30)", sql, "Should filter visit type") self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") self.assertIn("C.quantity > 5", sql, "Should filter quantity") @@ -326,18 +324,14 @@ def test_includes_full_logic(self): "Should filter measurement type", ) self.assertIn("C.operator_concept_id in (20)", sql, "Should filter operator") - self.assertIn( - "C.value_as_number > 150.5000", sql, "Should filter value_as_number" - ) + self.assertIn("C.value_as_number > 150.5000", sql, "Should filter value_as_number") self.assertIn("C.unit_concept_id in (30)", sql, "Should filter unit") self.assertIn( "(C.value_as_number < C.range_low or C.value_as_number > C.range_high or C.value_as_concept_id in (4155142, 4155143))", sql, "Should filter abnormal", ) - self.assertIn( - "YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age" - ) + self.assertIn("YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age") self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") self.assertIn("V.visit_concept_id in (40)", sql, "Should filter visit type") @@ -414,9 +408,7 @@ def test_includes_full_logic(self): "Should select observation_type_concept_id", ) self.assertIn("o.value_as_string", sql, "Should select value_as_string") - self.assertIn( - "o.qualifier_concept_id", sql, "Should select qualifier_concept_id" - ) + self.assertIn("o.qualifier_concept_id", sql, "Should select qualifier_concept_id") self.assertIn("o.unit_concept_id", sql, "Should select unit_concept_id") # 2. Check Join Clauses @@ -434,20 +426,14 @@ def test_includes_full_logic(self): sql, "Should filter observation type", ) - self.assertIn( - "C.value_as_string = 'Positive'", sql, "Should filter value_as_string" - ) + self.assertIn("C.value_as_string = 'Positive'", sql, "Should filter value_as_string") self.assertIn("C.value_as_number > 100", sql, "Should filter value_as_number") self.assertIn("C.unit_concept_id in (30)", sql, "Should filter unit") self.assertIn("C.qualifier_concept_id in (50)", sql, "Should filter qualifier") - self.assertIn( - "YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age" - ) + self.assertIn("YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age") self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") - self.assertIn( - "V.visit_concept_id in (40)", sql, "Should filter visit type with alias V" - ) + self.assertIn("V.visit_concept_id in (40)", sql, "Should filter visit type with alias V") class TestDeviceExposureBuilder(unittest.TestCase): @@ -496,9 +482,7 @@ def test_includes_full_logic(self): sql = self.builder.get_criteria_sql(de) # 1. Check Select Clauses - self.assertIn( - "de.device_type_concept_id", sql, "Should select device_type_concept_id" - ) + self.assertIn("de.device_type_concept_id", sql, "Should select device_type_concept_id") self.assertIn("de.unique_device_id", sql, "Should select unique_device_id") self.assertIn("de.quantity", sql, "Should select quantity") @@ -513,26 +497,19 @@ def test_includes_full_logic(self): # 3. Check Where Clauses # Note: Testing for case-insensitive match for keywords or exact match if builder is specific self.assertTrue( - "C.device_type_concept_id IN (10)" in sql - or "C.device_type_concept_id in (10)" in sql, + "C.device_type_concept_id IN (10)" in sql or "C.device_type_concept_id in (10)" in sql, "Should filter device type", ) - self.assertIn( - "C.unique_device_id = 'UDI123'", sql, "Should filter unique_device_id" - ) + self.assertIn("C.unique_device_id = 'UDI123'", sql, "Should filter unique_device_id") self.assertIn("C.quantity > 5", sql, "Should filter quantity") - self.assertIn( - "YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age" - ) + self.assertIn("YEAR(C.start_date) - P.year_of_birth > 18", sql, "Should filter age") # Check gender filter - builder output might be IN or in self.assertTrue( - "P.gender_concept_id IN (8507)" in sql - or "P.gender_concept_id in (8507)" in sql, + "P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender", ) self.assertTrue( - "P.gender_concept_id IN (8507)" in sql - or "P.gender_concept_id in (8507)" in sql, + "P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender", ) self.assertTrue( @@ -586,16 +563,12 @@ def test_includes_full_logic(self): # 3. Check Where Clauses self.assertTrue( - "C.death_type_concept_id IN (10)" in sql - or "C.death_type_concept_id in (10)" in sql, + "C.death_type_concept_id IN (10)" in sql or "C.death_type_concept_id in (10)" in sql, "Should filter death type", ) - self.assertIn( - "YEAR(C.start_date) - P.year_of_birth > 60", sql, "Should filter age" - ) + self.assertIn("YEAR(C.start_date) - P.year_of_birth > 60", sql, "Should filter age") self.assertTrue( - "P.gender_concept_id IN (8507)" in sql - or "P.gender_concept_id in (8507)" in sql, + "P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender", ) self.assertIn( @@ -655,30 +628,21 @@ def test_includes_full_logic(self): sql, "Should filter era_start_date", ) - self.assertIn( - "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date" - ) - self.assertIn( - "C.condition_occurrence_count > 2", sql, "Should filter occurrence_count" - ) + self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date") + self.assertIn("C.condition_occurrence_count > 2", sql, "Should filter occurrence_count") # Note: DATEDIFF vs datediff. Python builder uses DATEDIFF(d,C.start_date, C.end_date) - self.assertIn( - "DATEDIFF(d,C.start_date, C.end_date) > 10", sql, "Should filter era_length" - ) + self.assertIn("DATEDIFF(d,C.start_date, C.end_date) > 10", sql, "Should filter era_length") self.assertIn( "YEAR(C.start_date) - P.year_of_birth > 40", sql, "Should filter age_at_start", ) - self.assertIn( - "YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end" - ) + self.assertIn("YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end") self.assertTrue( - "P.gender_concept_id IN (8507)" in sql - or "P.gender_concept_id in (8507)" in sql, + "P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender", ) @@ -745,29 +709,20 @@ def test_includes_full_logic(self): sql, "Should filter era_start_date", ) - self.assertIn( - "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date" - ) - self.assertIn( - "C.drug_exposure_count > 2", sql, "Should filter occurrence_count" - ) + self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date") + self.assertIn("C.drug_exposure_count > 2", sql, "Should filter occurrence_count") - self.assertIn( - "DATEDIFF(d,C.start_date, C.end_date) > 10", sql, "Should filter era_length" - ) + self.assertIn("DATEDIFF(d,C.start_date, C.end_date) > 10", sql, "Should filter era_length") self.assertIn( "YEAR(C.start_date) - P.year_of_birth > 40", sql, "Should filter age_at_start", ) - self.assertIn( - "YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end" - ) + self.assertIn("YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end") self.assertTrue( - "P.gender_concept_id IN (8507)" in sql - or "P.gender_concept_id in (8507)" in sql, + "P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender", ) @@ -840,32 +795,24 @@ def test_includes_full_logic(self): sql, "Should filter era_start_date", ) - self.assertIn( - "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date" - ) + self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter era_end_date") self.assertIn("C.dose_value > 10.0000", sql, "Should filter dose_value") self.assertTrue( - "C.unit_concept_id IN (8507)" in sql - or "C.unit_concept_id in (8507)" in sql, + "C.unit_concept_id IN (8507)" in sql or "C.unit_concept_id in (8507)" in sql, "Should filter unit", ) - self.assertIn( - "DATEDIFF(d,C.start_date, C.end_date) > 5", sql, "Should filter era_length" - ) + self.assertIn("DATEDIFF(d,C.start_date, C.end_date) > 5", sql, "Should filter era_length") self.assertIn( "YEAR(C.start_date) - P.year_of_birth > 40", sql, "Should filter age_at_start", ) - self.assertIn( - "YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end" - ) + self.assertIn("YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end") self.assertTrue( - "P.gender_concept_id IN (8507)" in sql - or "P.gender_concept_id in (8507)" in sql, + "P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender", ) @@ -967,31 +914,23 @@ def test_includes_full_logic(self): ) self.assertIn("C.quantity > 5", sql, "Should filter quantity") self.assertTrue( - "C.unit_concept_id IN (8587)" in sql - or "C.unit_concept_id in (8587)" in sql, + "C.unit_concept_id IN (8587)" in sql or "C.unit_concept_id in (8587)" in sql, "Should filter unit", ) self.assertTrue( - "C.anatomic_site_concept_id IN (123)" in sql - or "C.anatomic_site_concept_id in (123)" in sql, + "C.anatomic_site_concept_id IN (123)" in sql or "C.anatomic_site_concept_id in (123)" in sql, "Should filter anatomic_site", ) self.assertTrue( - "C.disease_status_concept_id IN (456)" in sql - or "C.disease_status_concept_id in (456)" in sql, + "C.disease_status_concept_id IN (456)" in sql or "C.disease_status_concept_id in (456)" in sql, "Should filter disease_status", ) - self.assertIn( - "C.specimen_source_id LIKE '123%'", sql, "Should filter source_id" - ) + self.assertIn("C.specimen_source_id LIKE '123%'", sql, "Should filter source_id") - self.assertIn( - "YEAR(C.specimen_date) - P.year_of_birth > 40", sql, "Should filter age" - ) + self.assertIn("YEAR(C.specimen_date) - P.year_of_birth > 40", sql, "Should filter age") self.assertTrue( - "P.gender_concept_id IN (8507)" in sql - or "P.gender_concept_id in (8507)" in sql, + "P.gender_concept_id IN (8507)" in sql or "P.gender_concept_id in (8507)" in sql, "Should filter gender", ) @@ -1043,20 +982,14 @@ def test_includes_full_logic(self): # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") - self.assertIn( - "JOIN @cdm_database_schema.CARE_SITE CS", sql, "Should join CARE_SITE" - ) - self.assertIn( - "LEFT JOIN @cdm_database_schema.PROVIDER PR", sql, "Should join PROVIDER" - ) + self.assertIn("JOIN @cdm_database_schema.CARE_SITE CS", sql, "Should join CARE_SITE") + self.assertIn("LEFT JOIN @cdm_database_schema.PROVIDER PR", sql, "Should join PROVIDER") self.assertIn( "JOIN @cdm_database_schema.LOCATION_HISTORY LH", sql, "Should join LOCATION_HISTORY", ) - self.assertIn( - "JOIN @cdm_database_schema.LOCATION LOC", sql, "Should join LOCATION" - ) + self.assertIn("JOIN @cdm_database_schema.LOCATION LOC", sql, "Should join LOCATION") # 3. Check Where Clauses # Codeset join logic @@ -1065,16 +998,11 @@ def test_includes_full_logic(self): sql, ) - self.assertIn( - "C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date" - ) - self.assertIn( - "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date" - ) + self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date") + self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date") self.assertTrue( - "C.visit_detail_type_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 2)" - in sql + "C.visit_detail_type_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 2)" in sql or "C.visit_detail_type_concept_id in (select concept_id from #Codesets where codeset_id = 2)" in sql, "Should filter visit_detail_type_concept_id", @@ -1086,21 +1014,16 @@ def test_includes_full_logic(self): "Should filter visit_length", ) - self.assertIn( - "YEAR(C.end_date) - P.year_of_birth > 40", sql, "Should filter age" - ) + self.assertIn("YEAR(C.end_date) - P.year_of_birth > 40", sql, "Should filter age") self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") self.assertTrue( - "PR.specialty_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 3)" - in sql - or "PR.specialty_concept_id in (select concept_id from #Codesets where codeset_id = 3)" - in sql, + "PR.specialty_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 3)" in sql + or "PR.specialty_concept_id in (select concept_id from #Codesets where codeset_id = 3)" in sql, "Should filter provider", ) self.assertTrue( - "CS.place_of_service_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 4)" - in sql + "CS.place_of_service_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 4)" in sql or "CS.place_of_service_concept_id in (select concept_id from #Codesets where codeset_id = 4)" in sql, "Should filter place of service", @@ -1155,15 +1078,11 @@ def test_includes_full_logic(self): # 1. Check Select Clauses self.assertIn("ppp.person_id", sql, "Should select person_id") - self.assertIn( - "ppp.payer_plan_period_id", sql, "Should select payer_plan_period_id" - ) + self.assertIn("ppp.payer_plan_period_id", sql, "Should select payer_plan_period_id") self.assertIn("ppp.payer_concept_id", sql, "Should select payer_concept_id") self.assertIn("ppp.plan_concept_id", sql, "Should select plan_concept_id") self.assertIn("ppp.sponsor_concept_id", sql, "Should select sponsor_concept_id") - self.assertIn( - "ppp.stop_reason_concept_id", sql, "Should select stop_reason_concept_id" - ) + self.assertIn("ppp.stop_reason_concept_id", sql, "Should select stop_reason_concept_id") # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") @@ -1190,12 +1109,8 @@ def test_includes_full_logic(self): "Should filter stop_reason_concept", ) - self.assertIn( - "C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date" - ) - self.assertIn( - "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date" - ) + self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date") + self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date") self.assertIn( "DATEDIFF(d,C.start_date, C.end_date) > 10", @@ -1208,9 +1123,7 @@ def test_includes_full_logic(self): sql, "Should filter age_at_start", ) - self.assertIn( - "YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end" - ) + self.assertIn("YEAR(C.end_date) - P.year_of_birth < 80", sql, "Should filter age_at_end") self.assertIn("P.gender_concept_id in (8507)", sql, "Should filter gender") @@ -1249,32 +1162,20 @@ def test_includes_full_logic(self): # 1. Check Select Clauses self.assertIn("op.person_id", sql, "Should select person_id") - self.assertIn( - "op.observation_period_id", sql, "Should select observation_period_id" - ) - self.assertIn( - "op.period_type_concept_id", sql, "Should select period_type_concept_id" - ) + self.assertIn("op.observation_period_id", sql, "Should select observation_period_id") + self.assertIn("op.period_type_concept_id", sql, "Should select period_type_concept_id") # 2. Check Join Clauses self.assertIn("JOIN @cdm_database_schema.PERSON P", sql, "Should join PERSON") # 3. Check Where Clauses - self.assertIn( - "C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date" - ) - self.assertIn( - "C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date" - ) + self.assertIn("C.start_date > DATEFROMPARTS(2020, 1, 1)", sql, "Should filter start_date") + self.assertIn("C.end_date < DATEFROMPARTS(2021, 1, 1)", sql, "Should filter end_date") - self.assertIn( - "C.period_type_concept_id in (1)", sql, "Should filter period_type" - ) + self.assertIn("C.period_type_concept_id in (1)", sql, "Should filter period_type") self.assertTrue( - "C.period_type_concept_id in (select concept_id from #Codesets where codeset_id = 2)" - in sql - or "C.period_type_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 2)" - in sql, + "C.period_type_concept_id in (select concept_id from #Codesets where codeset_id = 2)" in sql + or "C.period_type_concept_id IN (SELECT concept_id from #Codesets where codeset_id = 2)" in sql, "Should filter period_type_cs", ) @@ -1289,9 +1190,7 @@ def test_includes_full_logic(self): sql, "Should filter age_at_start", ) - self.assertIn( - "YEAR(C.end_date) - P.year_of_birth < 100", sql, "Should filter age_at_end" - ) + self.assertIn("YEAR(C.end_date) - P.year_of_birth < 100", sql, "Should filter age_at_end") # User defined period bounds self.assertIn( @@ -1327,10 +1226,8 @@ def test_includes_full_logic(self): # 2. Check Codeset Clause # The python builder now uses AND l.region_concept_id ... self.assertTrue( - "AND l.region_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)" - in sql - or "AND l.region_concept_id in (select concept_id from #Codesets where codeset_id = 1)" - in sql, + "AND l.region_concept_id in (SELECT concept_id from #Codesets where codeset_id = 1)" in sql + or "AND l.region_concept_id in (select concept_id from #Codesets where codeset_id = 1)" in sql, "Should have codeset logic", ) @@ -1345,9 +1242,7 @@ def test_includes_full_logic(self): sql, "Should join LOCATION", ) - self.assertIn( - "WHERE lh.domain_id = 'PERSON'", sql, "Should filter PERSON domain" - ) + self.assertIn("WHERE lh.domain_id = 'PERSON'", sql, "Should filter PERSON domain") # Verify that start_date and end_date are selected self.assertIn("C.start_date", sql, "Should select C.start_date") diff --git a/tests/test_supporting_classes.py b/tests/test_supporting_classes.py index bb4f4a16..d92d4a57 100644 --- a/tests/test_supporting_classes.py +++ b/tests/test_supporting_classes.py @@ -94,9 +94,7 @@ def test_window_with_all_fields(self): start_bound = WindowBound(coeff=1, days=30) end_bound = WindowBound(coeff=-1, days=7) - window = Window( - use_event_end=True, use_index_end=False, start=start_bound, end=end_bound - ) + window = Window(use_event_end=True, use_index_end=False, start=start_bound, end=end_bound) self.assertTrue(window.use_event_end) self.assertFalse(window.use_index_end) @@ -123,9 +121,7 @@ def test_window_camel_case_aliases(self): def test_window_use_event_end_false(self): """Test Window with use_event_end=False.""" - window = Window( - use_event_end=False, start=WindowBound(coeff=-1), end=WindowBound(coeff=1) - ) + window = Window(use_event_end=False, start=WindowBound(coeff=-1), end=WindowBound(coeff=1)) self.assertFalse(window.use_event_end) self.assertEqual(window.start.coeff, -1) self.assertEqual(window.end.coeff, 1) @@ -216,9 +212,7 @@ def test_date_offset_strategy_zero_offset(self): def test_date_offset_strategy_camel_case_aliases(self): """Test that camelCase aliases work correctly.""" - strategy = DateOffsetStrategy.model_validate( - {"offset": 30, "dateField": "start_date"} - ) + strategy = DateOffsetStrategy.model_validate({"offset": 30, "dateField": "start_date"}) self.assertEqual(strategy.offset, 30) self.assertEqual(strategy.date_field, "start_date") @@ -270,9 +264,7 @@ def test_custom_era_strategy_different_offsets(self): def test_custom_era_strategy_camel_case_aliases(self): """Test that camelCase aliases work correctly.""" - strategy = CustomEraStrategy.model_validate( - {"drugCodesetId": 12345, "gapDays": 30, "offset": 0} - ) + strategy = CustomEraStrategy.model_validate({"drugCodesetId": 12345, "gapDays": 30, "offset": 0}) self.assertEqual(strategy.drug_codeset_id, 12345) self.assertEqual(strategy.gap_days, 30) @@ -301,9 +293,7 @@ def test_window_with_window_bound_integration(self): start_bound = WindowBound(coeff=1, days=30) end_bound = WindowBound(coeff=-1, days=7) - window = Window( - use_event_end=True, start=start_bound, coeff=1, days=30, end=end_bound - ) + window = Window(use_event_end=True, start=start_bound, coeff=1, days=30, end=end_bound) # Test that the bounds are properly integrated self.assertEqual(window.start.coeff, 1) @@ -342,9 +332,7 @@ def test_text_filter_with_criteria_integration(self): text_filter = TextFilter(text="completed", op="eq") - condition = ConditionOccurrence( - stop_reason=text_filter, first=True, condition_type_exclude=False - ) + condition = ConditionOccurrence(stop_reason=text_filter, first=True, condition_type_exclude=False) # Test that the text filter is properly integrated self.assertEqual(condition.stop_reason.text, "completed") diff --git a/tests/test_utils_db.py b/tests/test_utils_db.py index 64de2a19..5b83e659 100644 --- a/tests/test_utils_db.py +++ b/tests/test_utils_db.py @@ -51,9 +51,7 @@ def _setup_schema(self): self.con.execute(f"CREATE TABLE IF NOT EXISTS {table} (person_id INTEGER)") # Create temp tables usually expected by OHDSI SQL - self.con.execute( - "CREATE TABLE IF NOT EXISTS Codesets (codeset_id INTEGER, concept_id INTEGER)" - ) + self.con.execute("CREATE TABLE IF NOT EXISTS Codesets (codeset_id INTEGER, concept_id INTEGER)") def translate_sql(self, sql: str) -> str: """Translate OHDSI SQL (T-SQL) to DuckDB SQL.""" diff --git a/tests/test_visit_occurrence_parity.py b/tests/test_visit_occurrence_parity.py index 41d00149..a399d501 100644 --- a/tests/test_visit_occurrence_parity.py +++ b/tests/test_visit_occurrence_parity.py @@ -29,9 +29,7 @@ def test_get_default_columns(self): def test_get_table_column_for_criteria_column(self): self.assertEqual( - self.builder.get_table_column_for_criteria_column( - CriteriaColumn.DOMAIN_CONCEPT - ), + self.builder.get_table_column_for_criteria_column(CriteriaColumn.DOMAIN_CONCEPT), "C.visit_concept_id", ) self.assertEqual( @@ -39,9 +37,7 @@ def test_get_table_column_for_criteria_column(self): "DATEDIFF(d, C.start_date, C.end_date)", ) with self.assertRaises(ValueError): - self.builder.get_table_column_for_criteria_column( - CriteriaColumn.VALUE_AS_NUMBER - ) + self.builder.get_table_column_for_criteria_column(CriteriaColumn.VALUE_AS_NUMBER) def test_get_criteria_sql_basic(self): criteria = VisitOccurrence() @@ -51,9 +47,7 @@ def test_get_criteria_sql_basic(self): sql, ) self.assertIn("vo.person_id,vo.visit_occurrence_id,vo.visit_concept_id", sql) - self.assertIn( - "vo.visit_start_date as start_date, vo.visit_end_date as end_date", sql - ) + self.assertIn("vo.visit_start_date as start_date, vo.visit_end_date as end_date", sql) def test_get_criteria_sql_with_codeset(self): criteria = VisitOccurrence(codeset_id=123) @@ -90,15 +84,11 @@ def test_get_criteria_sql_with_visit_length(self): def test_get_criteria_sql_with_age(self): criteria = VisitOccurrence(age=NumericRange(op="gte", value=18)) sql = self.builder.get_criteria_sql(criteria) - self.assertIn( - "JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", sql - ) + self.assertIn("JOIN @cdm_database_schema.PERSON P on C.person_id = P.person_id", sql) self.assertIn("YEAR(C.start_date) - P.year_of_birth >= 18", sql) def test_get_criteria_sql_with_provider_specialty(self): - criteria = VisitOccurrence( - provider_specialty_cs=ConceptSetSelection(codeset_id=789) - ) + criteria = VisitOccurrence(provider_specialty_cs=ConceptSetSelection(codeset_id=789)) sql = self.builder.get_criteria_sql(criteria) self.assertIn("vo.provider_id", sql) # Added to select self.assertIn( @@ -111,9 +101,7 @@ def test_get_criteria_sql_with_provider_specialty(self): ) def test_get_criteria_sql_with_place_of_service(self): - criteria = VisitOccurrence( - place_of_service_cs=ConceptSetSelection(codeset_id=101) - ) + criteria = VisitOccurrence(place_of_service_cs=ConceptSetSelection(codeset_id=101)) sql = self.builder.get_criteria_sql(criteria) self.assertIn("vo.care_site_id", sql) # Added to select self.assertIn( diff --git a/tests/test_waveform_extension.py b/tests/test_waveform_extension.py index 373e4450..f832a483 100644 --- a/tests/test_waveform_extension.py +++ b/tests/test_waveform_extension.py @@ -4,43 +4,45 @@ Verifies that importing extensions.waveform is sufficient to register all four criteria classes, SQL builders, and markdown templates with the global registry. """ -import pytest -from pathlib import Path -from circe.extensions import get_registry +from pathlib import Path +import pytest # --------------------------------------------------------------------------- # Import the extension — this is the only step an extension author needs. # All registrations happen via decorators at import time. # --------------------------------------------------------------------------- import circe.extensions.waveform # noqa: F401 triggers all decorators - +from circe.extensions import get_registry +from circe.extensions.waveform.builders.waveform_channel_metadata import WaveformChannelMetadataSqlBuilder +from circe.extensions.waveform.builders.waveform_feature import WaveformFeatureSqlBuilder +from circe.extensions.waveform.builders.waveform_occurrence import WaveformOccurrenceSqlBuilder +from circe.extensions.waveform.builders.waveform_registry import WaveformRegistrySqlBuilder from circe.extensions.waveform.criteria import ( - WaveformOccurrence, - WaveformRegistry, WaveformChannelMetadata, WaveformFeature, + WaveformOccurrence, + WaveformRegistry, ) -from circe.extensions.waveform.builders.waveform_occurrence import WaveformOccurrenceSqlBuilder -from circe.extensions.waveform.builders.waveform_registry import WaveformRegistrySqlBuilder -from circe.extensions.waveform.builders.waveform_channel_metadata import WaveformChannelMetadataSqlBuilder -from circe.extensions.waveform.builders.waveform_feature import WaveformFeatureSqlBuilder - # --------------------------------------------------------------------------- # Criteria class registration # --------------------------------------------------------------------------- + class TestCriteriaClassRegistration: """@criteria_class decorator registers each class by its JSON key.""" - @pytest.mark.parametrize("name, expected_cls", [ - ("WaveformOccurrence", WaveformOccurrence), - ("WaveformRegistry", WaveformRegistry), - ("WaveformChannelMetadata", WaveformChannelMetadata), - ("WaveformFeature", WaveformFeature), - ]) + @pytest.mark.parametrize( + "name, expected_cls", + [ + ("WaveformOccurrence", WaveformOccurrence), + ("WaveformRegistry", WaveformRegistry), + ("WaveformChannelMetadata", WaveformChannelMetadata), + ("WaveformFeature", WaveformFeature), + ], + ) def test_criteria_class_registered(self, name, expected_cls): reg = get_registry() assert reg.get_criteria_class(name) is expected_cls @@ -54,15 +56,19 @@ def test_unregistered_name_returns_none(self): # SQL builder registration # --------------------------------------------------------------------------- + class TestSqlBuilderRegistration: """@sql_builder decorator maps each criteria type to the right builder.""" - @pytest.mark.parametrize("criteria_cls, expected_builder_cls", [ - (WaveformOccurrence, WaveformOccurrenceSqlBuilder), - (WaveformRegistry, WaveformRegistrySqlBuilder), - (WaveformChannelMetadata, WaveformChannelMetadataSqlBuilder), - (WaveformFeature, WaveformFeatureSqlBuilder), - ]) + @pytest.mark.parametrize( + "criteria_cls, expected_builder_cls", + [ + (WaveformOccurrence, WaveformOccurrenceSqlBuilder), + (WaveformRegistry, WaveformRegistrySqlBuilder), + (WaveformChannelMetadata, WaveformChannelMetadataSqlBuilder), + (WaveformFeature, WaveformFeatureSqlBuilder), + ], + ) def test_builder_returned_for_criteria_instance(self, criteria_cls, expected_builder_cls): reg = get_registry() instance = criteria_cls() @@ -75,15 +81,19 @@ def test_builder_returned_for_criteria_instance(self, criteria_cls, expected_bui # Markdown template registration # --------------------------------------------------------------------------- + class TestMarkdownTemplateRegistration: """@markdown_template decorator maps each criteria type to its .j2 file.""" - @pytest.mark.parametrize("criteria_cls, expected_template", [ - (WaveformOccurrence, "waveform_occurrence.j2"), - (WaveformRegistry, "waveform_registry.j2"), - (WaveformChannelMetadata, "waveform_channel_metadata.j2"), - (WaveformFeature, "waveform_feature.j2"), - ]) + @pytest.mark.parametrize( + "criteria_cls, expected_template", + [ + (WaveformOccurrence, "waveform_occurrence.j2"), + (WaveformRegistry, "waveform_registry.j2"), + (WaveformChannelMetadata, "waveform_channel_metadata.j2"), + (WaveformFeature, "waveform_feature.j2"), + ], + ) def test_template_registered(self, criteria_cls, expected_template): reg = get_registry() instance = criteria_cls() @@ -94,6 +104,7 @@ def test_template_registered(self, criteria_cls, expected_template): # Template path registration # --------------------------------------------------------------------------- + class TestTemplatePathRegistration: """template_path() call in __init__.py adds the templates directory.""" @@ -111,6 +122,3 @@ def test_template_files_exist(self): "waveform_feature.j2", ]: assert (expected / name).exists(), f"Missing template: {name}" - - - From 2e9c866bbe55e5a55e1331bc40d859bf41ba4bea Mon Sep 17 00:00:00 2001 From: egillax Date: Tue, 17 Mar 2026 18:17:15 +0100 Subject: [PATCH 23/62] chore: add pre-commit hooks for ruff --- .pre-commit-config.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..885891b0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.6 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format From c89c11b379d80a6f2111feae685eff276ff42873 Mon Sep 17 00:00:00 2001 From: egillax Date: Tue, 17 Mar 2026 18:17:48 +0100 Subject: [PATCH 24/62] chore: add uv-lock file --- uv.lock | 3197 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3197 insertions(+) create mode 100644 uv.lock diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..ae0b7659 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3197 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +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 = "alembic" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako", marker = "python_full_version < '3.10'" }, + { name = "sqlalchemy", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +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 = "atpublic" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/78/a7c9b6d6581353204a7a099567783dd3352405b1662988892b9e67039c6c/atpublic-6.0.2.tar.gz", hash = "sha256:f90dcd17627ac21d5ce69e070d6ab89fb21736eb3277e8b693cc8484e1c7088c", size = 17708, upload-time = "2025-09-24T18:30:13.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/da/8916af0a074d24354d685fe4178a52d3fafd07b62e6f81124fdeac15594d/atpublic-6.0.2-py3-none-any.whl", hash = "sha256:156cfd3854e580ebfa596094a018fe15e4f3fa5bade74b39c3dabb54f12d6565", size = 6423, upload-time = "2025-09-24T18:30:15.214Z" }, +] + +[[package]] +name = "atpublic" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/05/e2e131a0debaf0f01b8a1b586f5f11713f6affc3e711b406f15f11eafc92/atpublic-7.0.0.tar.gz", hash = "sha256:466ef10d0c8bbd14fd02a5fbd5a8b6af6a846373d91106d3a07c16d72d96b63e", size = 17801, upload-time = "2025-11-29T05:56:45.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/c0/271f3e1e3502a8decb8ee5c680dbed2d8dc2cd504f5e20f7ed491d5f37e1/atpublic-7.0.0-py3-none-any.whl", hash = "sha256:6702bd9e7245eb4e8220a3e222afcef7f87412154732271ee7deee4433b72b4b", size = 6421, upload-time = "2025-11-29T05:56:44.604Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +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 = "black" +version = "25.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mypy-extensions", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pathspec", marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytokens", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/d2/6caccbc96f9311e8ec3378c296d4f4809429c43a6cd2394e3c390e86816d/black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e", size = 1743501, upload-time = "2025-11-10T01:59:06.202Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/b986d57828b3f3dccbf922e2864223197ba32e74c5004264b1c62bc9f04d/black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0", size = 1597308, upload-time = "2025-11-10T01:57:58.633Z" }, + { url = "https://files.pythonhosted.org/packages/39/8e/8b58ef4b37073f52b64a7b2dd8c9a96c84f45d6f47d878d0aa557e9a2d35/black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37", size = 1656194, upload-time = "2025-11-10T01:57:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/8d/30/9c2267a7955ecc545306534ab88923769a979ac20a27cf618d370091e5dd/black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03", size = 1347996, upload-time = "2025-11-10T01:57:22.391Z" }, + { url = "https://files.pythonhosted.org/packages/c4/62/d304786b75ab0c530b833a89ce7d997924579fb7484ecd9266394903e394/black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a", size = 1727891, upload-time = "2025-11-10T02:01:40.507Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/ffe8a006aa522c9e3f430e7b93568a7b2163f4b3f16e8feb6d8c3552761a/black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170", size = 1581875, upload-time = "2025-11-10T01:57:51.192Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7c8bda3108d0bb57387ac41b4abb5c08782b26da9f9c4421ef6694dac01a/black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc", size = 1642716, upload-time = "2025-11-10T01:56:51.589Z" }, + { url = "https://files.pythonhosted.org/packages/34/b9/f17dea34eecb7cc2609a89627d480fb6caea7b86190708eaa7eb15ed25e7/black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e", size = 1352904, upload-time = "2025-11-10T01:59:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/7f/12/5c35e600b515f35ffd737da7febdb2ab66bb8c24d88560d5e3ef3d28c3fd/black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", size = 1772831, upload-time = "2025-11-10T02:03:47Z" }, + { url = "https://files.pythonhosted.org/packages/1a/75/b3896bec5a2bb9ed2f989a970ea40e7062f8936f95425879bbe162746fe5/black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", size = 1608520, upload-time = "2025-11-10T01:58:46.895Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b5/2bfc18330eddbcfb5aab8d2d720663cd410f51b2ed01375f5be3751595b0/black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", size = 1682719, upload-time = "2025-11-10T01:56:55.24Z" }, + { url = "https://files.pythonhosted.org/packages/96/fb/f7dc2793a22cdf74a72114b5ed77fe3349a2e09ef34565857a2f917abdf2/black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", size = 1362684, upload-time = "2025-11-10T01:57:07.639Z" }, + { url = "https://files.pythonhosted.org/packages/ad/47/3378d6a2ddefe18553d1115e36aea98f4a90de53b6a3017ed861ba1bd3bc/black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b", size = 1772446, upload-time = "2025-11-10T02:02:16.181Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4b/0f00bfb3d1f7e05e25bfc7c363f54dc523bb6ba502f98f4ad3acf01ab2e4/black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd", size = 1607983, upload-time = "2025-11-10T02:02:52.502Z" }, + { url = "https://files.pythonhosted.org/packages/99/fe/49b0768f8c9ae57eb74cc10a1f87b4c70453551d8ad498959721cc345cb7/black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993", size = 1682481, upload-time = "2025-11-10T01:57:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/17/7e10ff1267bfa950cc16f0a411d457cdff79678fbb77a6c73b73a5317904/black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c", size = 1363869, upload-time = "2025-11-10T01:58:24.608Z" }, + { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" }, + { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" }, + { url = "https://files.pythonhosted.org/packages/d5/9a/5b2c0e3215fe748fcf515c2dd34658973a1210bf610e24de5ba887e4f1c8/black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06", size = 1743063, upload-time = "2025-11-10T02:02:43.175Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/245164c6efc27333409c62ba54dcbfbe866c6d1957c9a6c0647786e950da/black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2", size = 1596867, upload-time = "2025-11-10T02:00:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6f/1a3859a7da205f3d50cf3a8bec6bdc551a91c33ae77a045bb24c1f46ab54/black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc", size = 1655678, upload-time = "2025-11-10T01:57:09.028Z" }, + { url = "https://files.pythonhosted.org/packages/56/1a/6dec1aeb7be90753d4fcc273e69bc18bfd34b353223ed191da33f7519410/black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc", size = 1347452, upload-time = "2025-11-10T01:57:01.871Z" }, + { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "mypy-extensions", marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pathspec", marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytokens", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, + { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, + { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, + { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/41/85/580dbaa12ab31041ed7df59f0bebc8893514fc21da6c05c3a1c1707d118f/charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e", size = 298620, upload-time = "2026-03-15T18:52:57.332Z" }, + { url = "https://files.pythonhosted.org/packages/67/2c/1e55af3a5e2f52e44396d5c5b731e0ae4f3bb92915ff09a610fb2f4497eb/charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17", size = 200106, upload-time = "2026-03-15T18:52:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/10/42/0f2f51a1d16caa45fbf384fd337d4242df1a5b313babee211381d2d39a96/charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778", size = 220539, upload-time = "2026-03-15T18:53:01.019Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0c/4e10996c740eec0f4ae8afbbbfa25f66e8479c4b6ee9cff1ca366a4f6c04/charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe", size = 215821, upload-time = "2026-03-15T18:53:02.621Z" }, + { url = "https://files.pythonhosted.org/packages/46/73/205ae7644ebb581a7c6fa9c3751e283606e145f0e6f066003c66aafc9973/charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a", size = 207917, upload-time = "2026-03-15T18:53:04.413Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ca/18f7dcf19afdab8097aeb2feb8b3809bb4b6ee356cb720abf5263d79406a/charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297", size = 194513, upload-time = "2026-03-15T18:53:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6a/e7e3e204c8d79832a091e00b24595af1d5d9800d37dc1f67a6b264cc99a6/charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687", size = 205612, upload-time = "2026-03-15T18:53:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ae/2169ebcea2851c5460c7a21993a0f87028be3c3e60899cb36251e1135cf5/charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4", size = 203519, upload-time = "2026-03-15T18:53:09.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/a0/6a49a925b9c225fe35dffeac5c76f68996b814c637e9d7213718f96be109/charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833", size = 195411, upload-time = "2026-03-15T18:53:10.542Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/a26b0a18e52b1a0f11f53c2c400ed062f386ac227a64ae4be4c5a64699be/charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5", size = 221653, upload-time = "2026-03-15T18:53:12.394Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3a/ed1d3b5bb55e3634bd5c31cedbe4fff79d0e5b8d9a062f663a757a07760d/charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b", size = 205650, upload-time = "2026-03-15T18:53:13.934Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/c75819eea5ceeefc49bae329327bb91e81adc346e2a9873d9fdb9e77cde6/charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9", size = 216919, upload-time = "2026-03-15T18:53:15.44Z" }, + { url = "https://files.pythonhosted.org/packages/0f/42/6e91bf8b15f67b7c957091138a36057a083e60703cc27848d5e36ca1eb03/charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597", size = 210101, upload-time = "2026-03-15T18:53:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/99/ff/101af2605e66a7ee59961d7f9e1060df7c92e8ea54208a02ab881422c24e/charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54", size = 144136, upload-time = "2026-03-15T18:53:19.152Z" }, + { url = "https://files.pythonhosted.org/packages/1d/da/de5942dfbf21f28c19e9202267dabf7bc73f195465d020a3a60054520cc5/charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8", size = 154210, upload-time = "2026-03-15T18:53:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/06/df/1b780a25b86d22b1d736f6ac883afd38ffdf30ddc18e5dc0e82211f493f1/charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8", size = 143225, upload-time = "2026-03-15T18:53:22.072Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + +[[package]] +name = "databricks-sql-connector" +version = "4.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lz4", marker = "python_full_version >= '3.10'" }, + { name = "oauthlib", marker = "python_full_version >= '3.10'" }, + { name = "openpyxl", marker = "python_full_version >= '3.10'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pybreaker", marker = "python_full_version >= '3.10'" }, + { name = "pyjwt", marker = "python_full_version >= '3.10'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, + { name = "requests", marker = "python_full_version >= '3.10'" }, + { name = "thrift", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/0c/1e8179f427044a0c769e279b2c45b72a20cff902f4e92ca1bcca50549435/databricks_sql_connector-4.2.5.tar.gz", hash = "sha256:762df7568ef1998540f96b20cad6f1aaae87d1aad54e40e528f87e4524397291", size = 187223, upload-time = "2026-02-09T11:26:29.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/a7/0d6dd8323cb2249a979cf4c6a45694e975668c53b19d52d7e15490bafb4c/databricks_sql_connector-4.2.5-py3-none-any.whl", hash = "sha256:31cee10552ce77a830318ce9488fc5e67daca7abbcdf0d8d34f12a180bc55039", size = 213906, upload-time = "2026-02-09T11:26:28.566Z" }, +] + +[[package]] +name = "databricks-sql-connector-core" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic", marker = "python_full_version < '3.10'" }, + { name = "lz4", marker = "python_full_version < '3.10'" }, + { name = "oauthlib", marker = "python_full_version < '3.10'" }, + { name = "openpyxl", marker = "python_full_version < '3.10'" }, + { name = "pandas", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", marker = "python_full_version < '3.10'" }, + { name = "thrift", marker = "python_full_version < '3.10'" }, + { name = "urllib3", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b5/71761c9baa913ecea180f3f16a6836087011e2cae73c013e009e2af4c2a2/databricks_sql_connector_core-4.0.1.tar.gz", hash = "sha256:98b41686afb683d8f0771cb755a63b6e9061fb7396feca6528f7d9253c40d5f1", size = 303315, upload-time = "2024-10-10T11:15:17.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/5e/506bf3a397f0c08eeb0311352f98719307eb66c32f73242e1fb06d0d26cf/databricks_sql_connector_core-4.0.1-py3-none-any.whl", hash = "sha256:d989dc902b1bc6ec453dfa894c29ada3f58c674323fdd9121e7caa3da1507be3", size = 311445, upload-time = "2024-10-10T11:15:14.707Z" }, +] + +[[package]] +name = "deepdiff" +version = "8.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "orderly-set" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/76/36c9aab3d5c19a94091f7c6c6e784efca50d87b124bf026c36e94719f33c/deepdiff-8.6.1.tar.gz", hash = "sha256:ec56d7a769ca80891b5200ec7bd41eec300ced91ebcc7797b41eb2b3f3ff643a", size = 634054, upload-time = "2025-09-03T19:40:41.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b", size = 91378, upload-time = "2025-09-03T19:40:39.679Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", +] +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 = "duckdb" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/36/9d/ab66a06e416d71b7bdcb9904cdf8d4db3379ef632bb8e9495646702d9718/duckdb-1.4.4.tar.gz", hash = "sha256:8bba52fd2acb67668a4615ee17ee51814124223de836d9e2fdcbc4c9021b3d3c", size = 18419763, upload-time = "2026-01-26T11:50:37.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/9f/67a75f1e88f84946909826fa7aadd0c4b0dc067f24956142751fd9d59fe6/duckdb-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e870a441cb1c41d556205deb665749f26347ed13b3a247b53714f5d589596977", size = 28884338, upload-time = "2026-01-26T11:48:41.591Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7a/e9277d0567884c21f345ad43cc01aeaa2abe566d5fdf22e35c3861dd44fa/duckdb-1.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49123b579e4a6323e65139210cd72dddc593a72d840211556b60f9703bda8526", size = 15339148, upload-time = "2026-01-26T11:48:45.343Z" }, + { url = "https://files.pythonhosted.org/packages/4a/96/3a7630d2779d2bae6f3cdf540a088ed45166adefd3c429971e5b85ce8f84/duckdb-1.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e1933fac5293fea5926b0ee75a55b8cfe7f516d867310a5b251831ab61fe62b", size = 13668431, upload-time = "2026-01-26T11:48:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ad/f62a3a65d200e8afc1f75cf0dd3f0aa84ef0dd07c484414a11f2abed810e/duckdb-1.4.4-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:707530f6637e91dc4b8125260595299ec9dd157c09f5d16c4186c5988bfbd09a", size = 18409546, upload-time = "2026-01-26T11:48:51.142Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5f/23bd586ecb21273b41b5aa4b16fd88b7fecb53ed48d897273651c0c3d66f/duckdb-1.4.4-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:453b115f4777467f35103d8081770ac2f223fb5799178db5b06186e3ab51d1f2", size = 20407046, upload-time = "2026-01-26T11:48:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d0/4ce78bf341c930d4a22a56cb686bfc2c975eaf25f653a7ac25e3929d98bb/duckdb-1.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a3c8542db7ffb128aceb7f3b35502ebaddcd4f73f1227569306cc34bad06680c", size = 12256576, upload-time = "2026-01-26T11:48:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/04/68/19233412033a2bc5a144a3f531f64e3548d4487251e3f16b56c31411a06f/duckdb-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5ba684f498d4e924c7e8f30dd157da8da34c8479746c5011b6c0e037e9c60ad2", size = 28883816, upload-time = "2026-01-26T11:49:01.009Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3e/cec70e546c298ab76d80b990109e111068d82cca67942c42328eaa7d6fdb/duckdb-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5536eb952a8aa6ae56469362e344d4e6403cc945a80bc8c5c2ebdd85d85eb64b", size = 15339662, upload-time = "2026-01-26T11:49:04.058Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f0/cf4241a040ec4f571859a738007ec773b642fbc27df4cbcf34b0c32ea559/duckdb-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:47dd4162da6a2be59a0aef640eb08d6360df1cf83c317dcc127836daaf3b7f7c", size = 13670044, upload-time = "2026-01-26T11:49:06.627Z" }, + { url = "https://files.pythonhosted.org/packages/11/64/de2bb4ec1e35ec9ebf6090a95b930fc56934a0ad6f34a24c5972a14a77ef/duckdb-1.4.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cb357cfa3403910e79e2eb46c8e445bb1ee2fd62e9e9588c6b999df4256abc1", size = 18409951, upload-time = "2026-01-26T11:49:09.808Z" }, + { url = "https://files.pythonhosted.org/packages/79/a2/ac0f5ee16df890d141304bcd48733516b7202c0de34cd3555634d6eb4551/duckdb-1.4.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c25d5b0febda02b7944e94fdae95aecf952797afc8cb920f677b46a7c251955", size = 20411739, upload-time = "2026-01-26T11:49:12.652Z" }, + { url = "https://files.pythonhosted.org/packages/37/a2/9a3402edeedaecf72de05fe9ff7f0303d701b8dfc136aea4a4be1a5f7eee/duckdb-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6703dd1bb650025b3771552333d305d62ddd7ff182de121483d4e042ea6e2e00", size = 12256972, upload-time = "2026-01-26T11:49:15.468Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/052ea6dcdf35b259fd182eff3efd8d75a071de4010c9807556098df137b9/duckdb-1.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:bf138201f56e5d6fc276a25138341b3523e2f84733613fc43f02c54465619a95", size = 13006696, upload-time = "2026-01-26T11:49:18.054Z" }, + { url = "https://files.pythonhosted.org/packages/58/33/beadaa69f8458afe466126f2c5ee48c4759cc9d5d784f8703d44e0b52c3c/duckdb-1.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ddcfd9c6ff234da603a1edd5fd8ae6107f4d042f74951b65f91bc5e2643856b3", size = 28896535, upload-time = "2026-01-26T11:49:21.232Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/82413f386df10467affc87f65bac095b7c88dbd9c767584164d5f4dc4cb8/duckdb-1.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6792ca647216bd5c4ff16396e4591cfa9b4a72e5ad7cdd312cec6d67e8431a7c", size = 15349716, upload-time = "2026-01-26T11:49:23.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/c13d396fd4e9bf970916dc5b4fea410c1b10fe531069aea65f1dcf849a71/duckdb-1.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f8d55843cc940e36261689054f7dfb6ce35b1f5b0953b0d355b6adb654b0d52", size = 13672403, upload-time = "2026-01-26T11:49:26.741Z" }, + { url = "https://files.pythonhosted.org/packages/db/77/2446a0b44226bb95217748d911c7ca66a66ca10f6481d5178d9370819631/duckdb-1.4.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c65d15c440c31e06baaebfd2c06d71ce877e132779d309f1edf0a85d23c07e92", size = 18419001, upload-time = "2026-01-26T11:49:29.353Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a3/97715bba30040572fb15d02c26f36be988d48bc00501e7ac02b1d65ef9d0/duckdb-1.4.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b297eff642503fd435a9de5a9cb7db4eccb6f61d61a55b30d2636023f149855f", size = 20437385, upload-time = "2026-01-26T11:49:32.302Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0a/18b9167adf528cbe3867ef8a84a5f19f37bedccb606a8a9e59cfea1880c8/duckdb-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d525de5f282b03aa8be6db86b1abffdceae5f1055113a03d5b50cd2fb8cf2ef8", size = 12267343, upload-time = "2026-01-26T11:49:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/f8/15/37af97f5717818f3d82d57414299c293b321ac83e048c0a90bb8b6a09072/duckdb-1.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:50f2eb173c573811b44aba51176da7a4e5c487113982be6a6a1c37337ec5fa57", size = 13007490, upload-time = "2026-01-26T11:49:37.413Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fe/64810fee20030f2bf96ce28b527060564864ce5b934b50888eda2cbf99dd/duckdb-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:337f8b24e89bc2e12dadcfe87b4eb1c00fd920f68ab07bc9b70960d6523b8bc3", size = 28899349, upload-time = "2026-01-26T11:49:40.294Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9b/3c7c5e48456b69365d952ac201666053de2700f5b0144a699a4dc6854507/duckdb-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0509b39ea7af8cff0198a99d206dca753c62844adab54e545984c2e2c1381616", size = 15350691, upload-time = "2026-01-26T11:49:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7b/64e68a7b857ed0340045501535a0da99ea5d9d5ea3708fec0afb8663eb27/duckdb-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fb94de6d023de9d79b7edc1ae07ee1d0b4f5fa8a9dcec799650b5befdf7aafec", size = 13672311, upload-time = "2026-01-26T11:49:46.069Z" }, + { url = "https://files.pythonhosted.org/packages/09/5b/3e7aa490841784d223de61beb2ae64e82331501bf5a415dc87a0e27b4663/duckdb-1.4.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d636ceda422e7babd5e2f7275f6a0d1a3405e6a01873f00d38b72118d30c10b", size = 18422740, upload-time = "2026-01-26T11:49:49.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/32/256df3dbaa198c58539ad94f9a41e98c2c8ff23f126b8f5f52c7dcd0a738/duckdb-1.4.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df7351328ffb812a4a289732f500d621e7de9942a3a2c9b6d4afcf4c0e72526", size = 20435578, upload-time = "2026-01-26T11:49:51.946Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f0/620323fd87062ea43e527a2d5ed9e55b525e0847c17d3b307094ddab98a2/duckdb-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:6fb1225a9ea5877421481d59a6c556a9532c32c16c7ae6ca8d127e2b878c9389", size = 12268083, upload-time = "2026-01-26T11:49:54.615Z" }, + { url = "https://files.pythonhosted.org/packages/e5/07/a397fdb7c95388ba9c055b9a3d38dfee92093f4427bc6946cf9543b1d216/duckdb-1.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:f28a18cc790217e5b347bb91b2cab27aafc557c58d3d8382e04b4fe55d0c3f66", size = 13006123, upload-time = "2026-01-26T11:49:57.092Z" }, + { url = "https://files.pythonhosted.org/packages/97/a6/f19e2864e651b0bd8e4db2b0c455e7e0d71e0d4cd2cd9cc052f518e43eb3/duckdb-1.4.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:25874f8b1355e96178079e37312c3ba6d61a2354f51319dae860cf21335c3a20", size = 28909554, upload-time = "2026-01-26T11:50:00.107Z" }, + { url = "https://files.pythonhosted.org/packages/0e/93/8a24e932c67414fd2c45bed83218e62b73348996bf859eda020c224774b2/duckdb-1.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:452c5b5d6c349dc5d1154eb2062ee547296fcbd0c20e9df1ed00b5e1809089da", size = 15353804, upload-time = "2026-01-26T11:50:03.382Z" }, + { url = "https://files.pythonhosted.org/packages/62/13/e5378ff5bb1d4397655d840b34b642b1b23cdd82ae19599e62dc4b9461c9/duckdb-1.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8e5c2d8a0452df55e092959c0bfc8ab8897ac3ea0f754cb3b0ab3e165cd79aff", size = 13676157, upload-time = "2026-01-26T11:50:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/2d/94/24364da564b27aeebe44481f15bd0197a0b535ec93f188a6b1b98c22f082/duckdb-1.4.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af6e76fe8bd24875dc56dd8e38300d64dc708cd2e772f67b9fbc635cc3066a3", size = 18426882, upload-time = "2026-01-26T11:50:08.97Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/6ae31b2914b4dc34243279b2301554bcbc5f1a09ccc82600486c49ab71d1/duckdb-1.4.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0440f59e0cd9936a9ebfcf7a13312eda480c79214ffed3878d75947fc3b7d6d", size = 20435641, upload-time = "2026-01-26T11:50:12.188Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b1/fd5c37c53d45efe979f67e9bd49aaceef640147bb18f0699a19edd1874d6/duckdb-1.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:59c8d76016dde854beab844935b1ec31de358d4053e792988108e995b18c08e7", size = 12762360, upload-time = "2026-01-26T11:50:14.76Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2d/13e6024e613679d8a489dd922f199ef4b1d08a456a58eadd96dc2f05171f/duckdb-1.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:53cd6423136ab44383ec9955aefe7599b3fb3dd1fe006161e6396d8167e0e0d4", size = 13458633, upload-time = "2026-01-26T11:50:17.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/c1/edb090813533632b0eaa315092efcf60d5f835f6b74bd25b3fee2c993810/duckdb-1.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8097201bc5fd0779d7fcc2f3f4736c349197235f4cb7171622936343a1aa8dbf", size = 28883631, upload-time = "2026-01-26T11:50:20.579Z" }, + { url = "https://files.pythonhosted.org/packages/9f/01/b19f532ee7340ef11c3363300f677074d7d2bf03af5ac76efacf03b4dd76/duckdb-1.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd1be3d48577f5b40eb9706c6b2ae10edfe18e78eb28e31a3b922dcff1183597", size = 15338844, upload-time = "2026-01-26T11:50:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/73cde196b809fc934acd39f05e730f7758e15e845486ee5219fc0513701e/duckdb-1.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e041f2fbd6888da090eca96ac167a7eb62d02f778385dd9155ed859f1c6b6dc8", size = 13668224, upload-time = "2026-01-26T11:50:26.151Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/1aea416dbb729c1548ce6b66c3283dd5441660939ec16077ba431bec6b42/duckdb-1.4.4-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7eec0bf271ac622e57b7f6554a27a6e7d1dd2f43d1871f7962c74bcbbede15ba", size = 18387860, upload-time = "2026-01-26T11:50:28.775Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6d/697bf9688d5c5b470a7210123430661d5f9bd10c9f0aeffa54799de6712d/duckdb-1.4.4-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdc4126ec925edf3112bc656ac9ed23745294b854935fa7a643a216e4455af6", size = 20396661, upload-time = "2026-01-26T11:50:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/23/60/8e491e199839a488cd302166defc51c62c25ea2cb36adee14156e610dcfc/duckdb-1.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:c9566a4ed834ec7999db5849f53da0a7ee83d86830c33f471bf0211a1148ca12", size = 12255531, upload-time = "2026-01-26T11:50:34.681Z" }, +] + +[[package]] +name = "duckdb" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/11/e05a7eb73a373d523e45d83c261025e02bc31ebf868e6282c30c4d02cc59/duckdb-1.5.0.tar.gz", hash = "sha256:f974b61b1c375888ee62bc3125c60ac11c4e45e4457dd1bb31a8f8d3cf277edd", size = 17981141, upload-time = "2026-03-09T12:50:26.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/8fa129bbd604d0e91aa9a0a407e7d2acc559b6024c3f887868fd7a13871d/duckdb-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:47fbb1c053a627a91fa71ec883951561317f14a82df891c00dcace435e8fea78", size = 30012348, upload-time = "2026-03-09T12:48:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/31/db320641a262a897755e634d16838c98d5ca7dc91f4e096e104e244a3a01/duckdb-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b546a30a6ac020165a86ab3abac553255a6e8244d5437d17859a6aa338611aa", size = 15940515, upload-time = "2026-03-09T12:48:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/0b/45/5725684794fbabf54d8dbae5247685799a6bf8e1e930ebff3a76a726772c/duckdb-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:122396041c0acb78e66d7dc7d36c55f03f67fe6ad012155c132d82739722e381", size = 14193724, upload-time = "2026-03-09T12:48:44.105Z" }, + { url = "https://files.pythonhosted.org/packages/27/68/f110c66b43e27191d7e53d3587e118568b73d66f23cb9bd6c7e0a560fd6d/duckdb-1.5.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a2cd73d50ea2c2bf618a4b7d22fe7c4115a1c9083d35654a0d5d421620ed999", size = 19218777, upload-time = "2026-03-09T12:48:46.399Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9d/46affc9257377cbc865e494650312a7a08a56e85aa8d702eb297bec430b7/duckdb-1.5.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63a8ea3b060a881c90d1c1b9454abed3daf95b6160c39bbb9506fee3a9711730", size = 21311205, upload-time = "2026-03-09T12:48:48.895Z" }, + { url = "https://files.pythonhosted.org/packages/3b/34/dac03ab7340989cda258655387959c88342ea3b44949751391267bcbc830/duckdb-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:238d576ae1dda441f8c79ed1370c5ccf863e4a5d59ca2563f9c96cd26b2188ac", size = 13043217, upload-time = "2026-03-09T12:48:51.262Z" }, + { url = "https://files.pythonhosted.org/packages/01/0c/0282b10a1c96810606b916b8d58a03f2131bd3ede14d2851f58b0b860e7c/duckdb-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3298bd17cf0bb5f342fb51a4edc9aadacae882feb2b04161a03eb93271c70c86", size = 30014615, upload-time = "2026-03-09T12:48:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/71/e8/cbbc920078a794f24f63017fc55c9cbdb17d6fb94d3973f479b2d9f2983d/duckdb-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:13f94c49ca389731c439524248e05007fb1a86cd26f1e38f706abc261069cd41", size = 15940493, upload-time = "2026-03-09T12:48:57.85Z" }, + { url = "https://files.pythonhosted.org/packages/31/b6/6cae794d5856259b0060f79d5db71c7fdba043950eaa6a9d72b0bad16095/duckdb-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab9d597b1e8668466f1c164d0ea07eaf0ebb516950f5a2e794b0f52c81ff3b16", size = 14194663, upload-time = "2026-03-09T12:49:00.416Z" }, + { url = "https://files.pythonhosted.org/packages/82/07/aba3887658b93a36ce702dd00ca6a6422de3d14c7ee3a4b4c03ea20a99c0/duckdb-1.5.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a43f8289b11c0b50d13f96ab03210489d37652f3fd7911dc8eab04d61b049da2", size = 19220501, upload-time = "2026-03-09T12:49:03.431Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a2/723e6df48754e468fa50d7878eb860906c975eafe317c4134a8482ca220e/duckdb-1.5.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f514e796a116c5de070e99974e42d0b8c2e6c303386790e58408c481150d417", size = 21316142, upload-time = "2026-03-09T12:49:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/03/af/4dcbdf8f2349ed0b054c254ec59bc362ce6ddf603af35f770124c0984686/duckdb-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:cf503ba2c753d97c76beb111e74572fef8803265b974af2dca67bba1de4176d2", size = 13043445, upload-time = "2026-03-09T12:49:08.892Z" }, + { url = "https://files.pythonhosted.org/packages/60/5e/1bb7e75a63bf3dc49bc5a2cd27a65ffeef151f52a32db980983516f2d9f6/duckdb-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:a1156e91e4e47f0e7d9c9404e559a1d71b372cd61790a407d65eb26948ae8298", size = 13883145, upload-time = "2026-03-09T12:49:11.566Z" }, + { url = "https://files.pythonhosted.org/packages/43/73/120e673e48ae25aaf689044c25ef51b0ea1d088563c9a2532612aea18e0a/duckdb-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9ea988d1d5c8737720d1b2852fd70e4d9e83b1601b8896a1d6d31df5e6afc7dd", size = 30057869, upload-time = "2026-03-09T12:49:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/21/e9/61143471958d36d3f3e764cb4cd43330be208ddbff1c78d3310b9ee67fe8/duckdb-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb786d5472afc16cc3c7355eb2007172538311d6f0cc6f6a0859e84a60220375", size = 15963092, upload-time = "2026-03-09T12:49:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/4f/71/76e37c9a599ad89dd944e6cbb3e6a8ad196944a421758e83adea507637b6/duckdb-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dc92b238f4122800a7592e99134124cc9048c50f766c37a0778dd2637f5cbe59", size = 14220562, upload-time = "2026-03-09T12:49:23.518Z" }, + { url = "https://files.pythonhosted.org/packages/db/b8/de1831656d5d13173e27c79c7259c8b9a7bdc314fdc8920604838ea4c46d/duckdb-1.5.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b74cb205c21d3696d8f8b88adca401e1063d6e6f57c1c4f56a243610b086e30", size = 19245329, upload-time = "2026-03-09T12:49:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8d/33d349a3bcbd3e9b7b4e904c19d5b97f058c4c20791b89a8d6323bb93dce/duckdb-1.5.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e56c19ffd1ffe3642fa89639e71e2e00ab0cf107b62fe16e88030acaebcbde6", size = 21348041, upload-time = "2026-03-09T12:49:30.283Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ec/591a4cad582fae04bc8f8b4a435eceaaaf3838cf0ca771daae16a3c2995b/duckdb-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:86525e565ec0c43420106fd34ba2c739a54c01814d476c7fed3007c9ed6efd86", size = 13053781, upload-time = "2026-03-09T12:49:33.574Z" }, + { url = "https://files.pythonhosted.org/packages/db/62/42e0a13f9919173bec121c0ff702406e1cdd91d8084c3e0b3412508c3891/duckdb-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:5faeebc178c986a7bfa68868a023001137a95a1110bf09b7356442a4eae0f7e7", size = 13862906, upload-time = "2026-03-09T12:49:36.598Z" }, + { url = "https://files.pythonhosted.org/packages/35/5d/af5501221f42e4e3662c047ecec4dcd0761229fceeba3c67ad4d9d8741df/duckdb-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11dd05b827846c87f0ae2f67b9ae1d60985882a7c08ce855379e4a08d5be0e1d", size = 30057396, upload-time = "2026-03-09T12:49:39.95Z" }, + { url = "https://files.pythonhosted.org/packages/43/bd/a278d73fedbd3783bf9aedb09cad4171fe8e55bd522952a84f6849522eb6/duckdb-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8d9c91b7c280ab6811f59deff554b845706c20baa28c4e8f80a95690b252b", size = 15962700, upload-time = "2026-03-09T12:49:43.504Z" }, + { url = "https://files.pythonhosted.org/packages/76/fc/c916e928606946209c20fb50898dabf120241fb528a244e2bd8cde1bd9e2/duckdb-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee4dabe03ed810d64d93927e0fd18cd137060b81ee75dcaeaaff32cbc816656", size = 14220272, upload-time = "2026-03-09T12:49:46.867Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/1390e69db922423b2e111e32ed342b3e8fad0a31c144db70681ea1ba4d56/duckdb-1.5.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409ed1184b363ddea239609c5926f5148ee412b8d9e5ffa617718d755d942f6", size = 19244401, upload-time = "2026-03-09T12:49:49.865Z" }, + { url = "https://files.pythonhosted.org/packages/54/13/b58d718415cde993823a54952ea511d2612302f1d2bc220549d0cef752a4/duckdb-1.5.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1df8c4f9c853a45f3ec1e79ed7fe1957a203e5ec893bbbb853e727eb93e0090f", size = 21345827, upload-time = "2026-03-09T12:49:52.977Z" }, + { url = "https://files.pythonhosted.org/packages/e0/96/4460429651e371eb5ff745a4790e7fa0509c7a58c71fc4f0f893404c9646/duckdb-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a3d3dfa2d8bc74008ce3ad9564761ae23505a9e4282f6a36df29bd87249620b", size = 13053101, upload-time = "2026-03-09T12:49:56.134Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/6d5b805113214b830fa3c267bb3383fb8febaa30760d0162ef59aadb110a/duckdb-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:2deebcbafd9d39c04f31ec968f4dd7cee832c021e10d96b32ab0752453e247c8", size = 13865071, upload-time = "2026-03-09T12:49:59.282Z" }, + { url = "https://files.pythonhosted.org/packages/66/9f/dd806d4e8ecd99006eb240068f34e1054533da1857ad06ac726305cd102d/duckdb-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d4b618de670cd2271dd7b3397508c7b3c62d8ea70c592c755643211a6f9154fa", size = 30065704, upload-time = "2026-03-09T12:50:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/79/c2/7b7b8a5c65d5535c88a513e267b5e6d7a55ab3e9b67e4ddd474454653268/duckdb-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:065ae50cb185bac4b904287df72e6b4801b3bee2ad85679576dd712b8ba07021", size = 15964883, upload-time = "2026-03-09T12:50:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/23/c5/9a52a2cdb228b8d8d191a603254364d929274d9cc7d285beada8f7daa712/duckdb-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6be5e48e287a24d98306ce9dd55093c3b105a8fbd8a2e7a45e13df34bf081985", size = 14221498, upload-time = "2026-03-09T12:50:10.567Z" }, + { url = "https://files.pythonhosted.org/packages/b8/68/646045cb97982702a8a143dc2e45f3bdcb79fbe2d559a98d74b8c160e5e2/duckdb-1.5.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5ee41a0bf793882f02192ce105b9a113c3e8c505a27c7ef9437d7b756317113", size = 19249787, upload-time = "2026-03-09T12:50:13.524Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/5abf0c7f38febb3b4a231c784223fceccfd3f2bfd957699d786f46e41ce6/duckdb-1.5.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8e42aaf3cd217417c5dc9ff522dc3939d18b25a6fe5f846348277e831e6f59c", size = 21351583, upload-time = "2026-03-09T12:50:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/93/a4/a90f2901cc0a1ce7ca4f0564b8492b9dbfe048a6395b27933d46ae9be473/duckdb-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:11ae50aaeda2145b50294ee0247e4f11fb9448b3cc3d2aea1cfc456637dfb977", size = 13575130, upload-time = "2026-03-09T12:50:19.716Z" }, + { url = "https://files.pythonhosted.org/packages/64/aa/f14dd5e241ec80d9f9d82196ca65e0c53badfc8a7a619d5497c5626657ad/duckdb-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:d6d2858c734d1a7e7a1b6e9b8403b3fce26dfefb4e0a2479c420fba6cd36db36", size = 14341879, upload-time = "2026-03-09T12:50:22.347Z" }, +] + +[[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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/f5/3e9eafb4030588337b2a2ae4df46212956854e9069c07b53aa3caabafd47/greenlet-3.2.5.tar.gz", hash = "sha256:c816554eb33e7ecf9ba4defcb1fd8c994e59be6b4110da15480b3e7447ea4286", size = 191501, upload-time = "2026-02-20T20:08:51.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/d6/b3db928fc329b1b19ba32ffe143d2305f3aaafc583f5e1074c74ec445189/greenlet-3.2.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:34cc7cf8ab6f4b85298b01e13e881265ee7b3c1daf6bc10a2944abc15d4f87c3", size = 275803, upload-time = "2026-02-20T20:06:42.541Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/ab0ad4ff3d9e1faa266de4f6c79763b33fccd9265995f2940192494cc0ec/greenlet-3.2.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c11fe0cfb0ce33132f0b5d27eeadd1954976a82e5e9b60909ec2c4b884a55382", size = 633556, upload-time = "2026-02-20T20:30:41.594Z" }, + { url = "https://files.pythonhosted.org/packages/da/dd/7b3ac77099a1671af8077ecedb12c9a1be1310e4c35bb69fd34c18ab6093/greenlet-3.2.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a145f4b1c4ed7a2c94561b7f18b4beec3d3fb6f0580db22f7ed1d544e0620b34", size = 644943, upload-time = "2026-02-20T20:37:23.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/84630e9ff1dfc8b7690957c0f77834a84eabdbd9c4977c3a2d0cbd5325c2/greenlet-3.2.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1d01bdd67db3e5711e6246e451d7a0f75fae7bbf40adde129296a7f9aa7cc9", size = 639841, upload-time = "2026-02-20T20:07:17.473Z" }, + { url = "https://files.pythonhosted.org/packages/12/c4/6a2ee6c676dea7a05a3c3c1291fbc8ea44f26456b0accc891471293825af/greenlet-3.2.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd593db7ee1fa8a513a48a404f8cc4126998a48025e3f5cbbc68d51be0a6bf66", size = 588813, upload-time = "2026-02-20T20:07:56.171Z" }, + { url = "https://files.pythonhosted.org/packages/01/c0/75e75c2c993aa850292561ec80f5c263e3924e5843aa95a38716df69304c/greenlet-3.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ac8db07bced2c39b987bba13a3195f8157b0cfbce54488f86919321444a1cc3c", size = 1117377, upload-time = "2026-02-20T20:32:48.452Z" }, + { url = "https://files.pythonhosted.org/packages/ee/03/e38ebf9024a0873fe8f60f5b7bc36bfb3be5e13efe4d798240f2d1f0fb73/greenlet-3.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4544ab2cfd5912e42458b13516429e029f87d8bbcdc8d5506db772941ae12493", size = 1141246, upload-time = "2026-02-20T20:06:23.576Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7b/c6e1192c795c0c12871e199237909a6bd35757d92c8472c7c019959b8637/greenlet-3.2.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:acabf468466d18017e2ae5fbf1a5a88b86b48983e550e1ae1437b69a83d9f4ac", size = 276916, upload-time = "2026-02-20T20:06:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b6/9887b559f3e1952d23052ec352e9977e808a2246c7cb8282a38337221e88/greenlet-3.2.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:472841de62d60f2cafd60edd4fd4dd7253eb70e6eaf14b8990dcaf177f4af957", size = 636107, upload-time = "2026-02-20T20:30:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/8a/be/e3e48b63bbc27d660fa1d98aecb64906b90a12e686a436169c1330ef34b2/greenlet-3.2.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d951e7d628a6e8b68af469f0fe4f100ef64c4054abeb9cdafbfaa30a920c950", size = 648240, upload-time = "2026-02-20T20:37:24.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ac/e731ed62576e91e533b36d0d97325adc2786674ab9e48ed8a6a24f4ef4e9/greenlet-3.2.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8317d732e2ae0935d9ed2af2ea876fa714cf6f3b887a31ca150b54329b0a6e9", size = 643313, upload-time = "2026-02-20T20:07:19.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/64/99e5cdceb494bd4c1341c45b93f322601d2c8a5e1e4d1c7a2d24c5ed0570/greenlet-3.2.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce8aed6fdd5e07d3cbb988cbdc188266a4eb9e1a52db9ef5c6526e59962d3933", size = 591295, upload-time = "2026-02-20T20:07:57.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e9/968e11f388c2b8792d3b8b40a57984c894a3b4745dae3662dce722653bc5/greenlet-3.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:60c06b502d56d5451f60ca665691da29f79ed95e247bcf8ce5024d7bbe64acb9", size = 1120277, upload-time = "2026-02-20T20:32:50.103Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2c/b5f2c4c68d753dce08218dc5a6b21d82238fdfdc44309032f6fe24d285e6/greenlet-3.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d2a78e6f1bf3f1672df91e212a2f8314e1e7c922f065d14cbad4bc815059467", size = 1145746, upload-time = "2026-02-20T20:06:26.296Z" }, + { url = "https://files.pythonhosted.org/packages/ad/32/022b21523eee713e7550162d5ca6aed23f913cc2c6232b154b9fd9badc07/greenlet-3.2.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2acb30e77042f747ca81f0a10cc153296567e92e666c5e1b117f4595afd43352", size = 278412, upload-time = "2026-02-20T20:03:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/90/c5/8a3b0ed3cc34d8b988a44349437dfa0941f9c23ac108175f7b4ccea97111/greenlet-3.2.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:393c03c26c865f17f31d8db2f09603fadbe0581ad85a5d5908b131549fc38217", size = 644616, upload-time = "2026-02-20T20:30:44.823Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/2627bea183554695016af6cae93d7474fa90f61e5a6601a84ae7841cb720/greenlet-3.2.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:04e6a202cde56043fd355fefd1552c4caa5c087528121871d950eb4f1b51fa99", size = 658813, upload-time = "2026-02-20T20:37:26.255Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1b/75a5aeff487a26ba427a3837da6372f1fe6f2a9c6b2898e28ac99d491c11/greenlet-3.2.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:45fcea7b697b91290b36eafc12fff479aca6ba6500d98ef6f34d5634c7119cbe", size = 655426, upload-time = "2026-02-20T20:07:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/53/91/9b5dfb4f3c88f8247c7a8f4c3759f0740bfa6bb0c59a9f6bf938e913df56/greenlet-3.2.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96e2bb8a56b7e1aed1dbfbbe0050cb2ecca99c7c91892fd1771e3afab63b3e3", size = 611138, upload-time = "2026-02-20T20:07:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8d/d0b086410512d9859c84e9242a9b341de9f5566011ddf3a3f6886b842b61/greenlet-3.2.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d7456e67b0be653dfe643bb37d9566cd30939c80f858e2ce6d2d54951f75b14a", size = 1126896, upload-time = "2026-02-20T20:32:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/ef/37/59fe12fe456e84ced6ba71781e28cde52a3124d1dd2077bc1727021f49fd/greenlet-3.2.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5ceb29d1f74c7280befbbfa27b9bf91ba4a07a1a00b2179a5d953fc219b16c42", size = 1154779, upload-time = "2026-02-20T20:06:27.583Z" }, + { url = "https://files.pythonhosted.org/packages/dd/95/d5d332fb73affaf7a1fbe80e49c2c7eae4f17c645af24a3b3fa25736d6f0/greenlet-3.2.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f2cc88b50b9006b324c1b9f5f3552f9d4564c78af57cdfb4c7baf4f0aa089146", size = 277166, upload-time = "2026-02-20T20:03:57.077Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/89458e20db5a4f1c64f9a0191561227e76d809941ca2d7529006d17d3450/greenlet-3.2.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e66872daffa360b2537170b73ad530f14fa31785b1bc78080125d92edf0a6def", size = 644674, upload-time = "2026-02-20T20:30:46.118Z" }, + { url = "https://files.pythonhosted.org/packages/90/f8/9962175d2f2eaa629a7fd7545abacc8c4deda3baa4e52c1526d2eb5f5546/greenlet-3.2.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c5445ddb7b586d870dad32ca9fc47c287d6022a528d194efdb8912093c5303ad", size = 658834, upload-time = "2026-02-20T20:37:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d7/826d0e080f0a7ad5ec47c8d143bbd3ca0887657bb806595fe2434d12938a/greenlet-3.2.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:752c896a8c976548faafe8a306d446c6a4c68d4fd24699b84d4393bd9ac69a8e", size = 655760, upload-time = "2026-02-20T20:07:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/41/cc/33bd4c2f816be8c8e16f71740c4130adf3a66a3dd2ba29de72b9d8dd1096/greenlet-3.2.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499b809e7738c8af0ff9ac9d5dd821cb93f4293065a9237543217f0b252f950a", size = 614132, upload-time = "2026-02-20T20:08:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/48/79/f3891dcfc59097474a53cc3c624f2f2465e431ab493bda043b8c873fb20a/greenlet-3.2.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2c7429f6e9cea7cbf2637d86d3db12806ba970f7f972fcab39d6b54b4457cbaf", size = 1125286, upload-time = "2026-02-20T20:32:54.032Z" }, + { url = "https://files.pythonhosted.org/packages/ca/47/212b47e6d2d7a04c4083db1af2fdd291bc8fe99b7e3571bfa560b65fc361/greenlet-3.2.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e4b25e855800fba17713020c5c33e0a4b7a1829027719344f0c7c8870092a2", size = 1152825, upload-time = "2026-02-20T20:06:29Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/4e9b941be05f8da7ba804c6413761d2c11cca05994cbf0a015bd729419f0/greenlet-3.2.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7123b29e6bad2f3f89681be4ef316480fca798ebe8d22fbaced9cc3775007a4f", size = 277627, upload-time = "2026-02-20T20:06:04.798Z" }, + { url = "https://files.pythonhosted.org/packages/23/cb/a73625c9a35138330014ecf3740c0d62e0c2b5e7279bb7f2586b1b199fac/greenlet-3.2.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e8fe0c72603201a86b2e038daf9b6c8570715f8779566419cff543b6ace88de", size = 690001, upload-time = "2026-02-20T20:30:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/83/49/6d1531109507bce7dfb23acf57a87013627ed3ac058851176e443a6a9134/greenlet-3.2.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:050703a60603db0e817364d69e048c70af299040c13a7e67792b9e62d4571196", size = 702953, upload-time = "2026-02-20T20:37:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/f958ee90fab93529b30cc1e4a59b27c1112b640570043a84af84da3b3b98/greenlet-3.2.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6712bfd520530eb67331813f7112d3ee18e206f48b3d026d8a96cd2d2ad20251", size = 698995, upload-time = "2026-02-20T20:07:22.663Z" }, + { url = "https://files.pythonhosted.org/packages/51/c1/a603906e79716d61f08afedaf8aed62017661457aef233d62d6e57ecd511/greenlet-3.2.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc06a78fa3ffbe2a75f1ebc7e040eacf6fa1050a9432953ab111fbbbf0d03c1", size = 661175, upload-time = "2026-02-20T20:08:01.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8f/f880ff4587d236b4d06893fb34da6b299aa0d00f6c8259673f80e1b6d63c/greenlet-3.2.5-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:dbe0e81e24982bb45907ca20152b31c2e3300ca352fdc4acbd4956e4a2cbc195", size = 274946, upload-time = "2026-02-20T20:05:21.979Z" }, + { url = "https://files.pythonhosted.org/packages/3c/50/f6c78b8420187fdfe97fcf2e6d1dd243a7742d272c32fd4d4b1095474b37/greenlet-3.2.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15871afc0d78ec87d15d8412b337f287fc69f8f669346e391585824970931c48", size = 631781, upload-time = "2026-02-20T20:30:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/3277f92e1961e6e9f41d9f173ea74b5c1f7065072637669f761626f26cc0/greenlet-3.2.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5bf0d7d62e356ef2e87e55e46a4e930ac165f9372760fb983b5631bb479e9d3a", size = 643740, upload-time = "2026-02-20T20:37:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6a/4f79d2e7b5ef3723fc5ffea0d6cb22627e5f95e0f19c973fa12bf1cf7891/greenlet-3.2.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dff6433742073e5b6ad40953a78a0e8cddcb3f6869e5ea635d29a810ca5e7d0", size = 638382, upload-time = "2026-02-20T20:07:23.883Z" }, + { url = "https://files.pythonhosted.org/packages/4d/59/7aadf33f23c65dbf4db27e7f5b60c414797a61e954352ae4a86c5c8b0553/greenlet-3.2.5-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdd67619cefe1cc9fcab57c8853d2bb36eca9f166c0058cc0d428d471f7c785c", size = 587516, upload-time = "2026-02-20T20:08:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/1d/46/b3422959f830de28a4eea447414e6bd7b980d755892f66ab52ad805da1c4/greenlet-3.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3828b309dfb1f117fe54867512a8265d8d4f00f8de6908eef9b885f4d8789062", size = 1115818, upload-time = "2026-02-20T20:32:55.786Z" }, + { url = "https://files.pythonhosted.org/packages/54/4a/3d1c9728f093415637cf3696909fa10852632e33e68238fb8ca60eb90de1/greenlet-3.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:67725ae9fea62c95cf1aa230f1b8d4dc38f7cd14f6103d1df8a5a95657eb8e54", size = 1140219, upload-time = "2026-02-20T20:06:30.334Z" }, +] + +[[package]] +name = "ibis-framework" +version = "11.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "atpublic", version = "6.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "parsy", marker = "python_full_version < '3.10'" }, + { name = "python-dateutil", marker = "python_full_version < '3.10'" }, + { name = "sqlglot", marker = "python_full_version < '3.10'" }, + { name = "toolz", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "tzdata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/c8/f03c7c6e8ab96e5efd67ea5ce6eaf575bde78b4bfb9115f283d5e6e19ea2/ibis_framework-11.0.0.tar.gz", hash = "sha256:0249185eaabb800e224f448cc06ce8ba168df00b269e132d62629f462eca8842", size = 1237767, upload-time = "2025-10-15T13:12:10.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c0/2851a8a55d0fea03b80fd45815069b686e032938fc68fa9d91ac776c148c/ibis_framework-11.0.0-py3-none-any.whl", hash = "sha256:92ff82a96f4eac7f86fa9b6a315e04b5a8f9ed3d186539d88f48e628363f2e72", size = 1935652, upload-time = "2025-10-15T13:12:07.954Z" }, +] + +[package.optional-dependencies] +databricks = [ + { name = "databricks-sql-connector-core", marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pandas", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyarrow-hotfix", marker = "python_full_version < '3.10'" }, + { name = "rich", marker = "python_full_version < '3.10'" }, +] +duckdb = [ + { name = "duckdb", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pandas", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyarrow-hotfix", marker = "python_full_version < '3.10'" }, + { name = "rich", marker = "python_full_version < '3.10'" }, +] +postgres = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pandas", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyarrow-hotfix", marker = "python_full_version < '3.10'" }, + { name = "rich", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "ibis-framework" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "atpublic", version = "7.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "parsy", marker = "python_full_version >= '3.10'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, + { name = "sqlglot", marker = "python_full_version >= '3.10'" }, + { name = "toolz", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, + { name = "tzdata", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/8e/2e7ad9bdeaf45350da7beeb67a0d4317d400dac882825eb7c3bd4d3c6ae1/ibis_framework-12.0.0.tar.gz", hash = "sha256:238624f2c14fdab8382ca2f4f667c3cdb81e29844cd5f8db8a325d0743767c61", size = 1351369, upload-time = "2026-02-07T14:31:13.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/b3/11d406849715b47c9d69bb22f50874f80caee96bd1cbe7b61abbebbf5a05/ibis_framework-12.0.0-py3-none-any.whl", hash = "sha256:0bbd790f268da9cb87926d5eaad2b827a573927113c4ed3be5095efa89b9e512", size = 2079219, upload-time = "2026-02-07T14:31:10.646Z" }, +] + +[package.optional-dependencies] +databricks = [ + { name = "databricks-sql-connector", marker = "python_full_version >= '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyarrow-hotfix", marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, +] +duckdb = [ + { name = "duckdb", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyarrow-hotfix", marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, +] +postgres = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "psycopg", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyarrow", version = "23.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyarrow-hotfix", marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imagesize" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/59/4b0dd64676aa6fb4986a755790cb6fc558559cf0084effad516820208ec3/imagesize-1.5.0.tar.gz", hash = "sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f", size = 1281127, upload-time = "2026-03-03T01:59:54.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/b1/a0662b03103c66cf77101a187f396ea91167cd9b7d5d3a2e465ad2c7ee9b/imagesize-1.5.0-py2.py3-none-any.whl", hash = "sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899", size = 5763, upload-time = "2026-03-03T01:59:52.343Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +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 = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "javalang" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/37/b2b7d47b6dd9fdbc6864305ddf9060c1ebce4e743e8d74e565a27395f312/javalang-0.13.0.tar.gz", hash = "sha256:1681a5a480a58116d42a7eedfd132abe25e6c0ffe552868d581ad84e6aa3424c", size = 21085, upload-time = "2020-03-28T16:02:29.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/e0/12344443d66b9a84844171be90112892a371da6db09866741774b8bc0a2f/javalang-0.13.0-py3-none-any.whl", hash = "sha256:b203c258919b085b44b43b89effcba7291bb2d90c02906b915b39e86aa9fd8e6", size = 22052, upload-time = "2020-03-28T16:02:28.19Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/01/1f/c7d8b66a3ca3ca3ed8ded4b32c96ee58a45920ebbbaa934355c74adcc33e/librt-0.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3dff3d3ca8db20e783b1bc7de49c0a2ab0b8387f31236d6a026597d07fcd68ac", size = 65990, upload-time = "2026-02-17T16:12:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/ee9ba1730052313d08457f19beaa1b878619978863fba09b40aed5b5c123/librt-0.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08eec3a1fc435f0d09c87b6bf1ec798986a3544f446b864e4099633a56fcd9ed", size = 68640, upload-time = "2026-02-17T16:12:50.24Z" }, + { url = "https://files.pythonhosted.org/packages/81/27/b7309298b96f7690cec3ceee38004c1a7f60fcd96d952d3ac344a1e3e8b3/librt-0.8.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3f0a41487fd5fad7e760b9e8a90e251e27c2816fbc2cff36a22a0e6bcbbd9dd", size = 196099, upload-time = "2026-02-17T16:12:52.788Z" }, + { url = "https://files.pythonhosted.org/packages/10/48/160a5aacdcb21824b10a52378c39e88c46a29bb31efdaf3910dd1f9b670e/librt-0.8.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bacdb58d9939d95cc557b4dbaa86527c9db2ac1ed76a18bc8d26f6dc8647d851", size = 206663, upload-time = "2026-02-17T16:12:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/33dd1d8caabb7c6805d87d095b143417dc96b0277c06ffa0508361422c82/librt-0.8.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d7ab1f01aa753188605b09a51faa44a3327400b00b8cce424c71910fc0a128", size = 219318, upload-time = "2026-02-17T16:12:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/d4/353805aa6181c7950a2462bd6e855366eeca21a501f375228d72a51547df/librt-0.8.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4998009e7cb9e896569f4be7004f09d0ed70d386fa99d42b6d363f6d200501ac", size = 212191, upload-time = "2026-02-17T16:12:57.326Z" }, + { url = "https://files.pythonhosted.org/packages/06/08/725b3f304d61eba56c713c251fb833a06d84bf93381caad5152366f5d2bb/librt-0.8.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2cc68eeeef5e906839c7bb0815748b5b0a974ec27125beefc0f942715785b551", size = 220672, upload-time = "2026-02-17T16:12:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/e8cdf04145872b3b97cb9b68287b22d1c08348227063f305aec11a3e6ce7/librt-0.8.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0bf69d79a23f4f40b8673a947a234baeeb133b5078b483b7297c5916539cf5d5", size = 216172, upload-time = "2026-02-17T16:12:59.751Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d8/23b1c6592d2422dd6829c672f45b1f1c257f219926b0d216fedb572d0184/librt-0.8.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:22b46eabd76c1986ee7d231b0765ad387d7673bbd996aa0d0d054b38ac65d8f6", size = 214116, upload-time = "2026-02-17T16:13:01.056Z" }, + { url = "https://files.pythonhosted.org/packages/c9/92/2b44fd3cc3313f44e43bdbb41343735b568fa675fa351642b408ee48d418/librt-0.8.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:237796479f4d0637d6b9cbcb926ff424a97735e68ade6facf402df4ec93375ed", size = 236664, upload-time = "2026-02-17T16:13:02.314Z" }, + { url = "https://files.pythonhosted.org/packages/00/23/92313ecdab80e142d8ea10e8dfa6297694359dbaacc9e81679bdc8cbceb6/librt-0.8.1-cp39-cp39-win32.whl", hash = "sha256:4beb04b8c66c6ae62f8c1e0b2f097c1ebad9295c929a8d5286c05eae7c2fc7dc", size = 54368, upload-time = "2026-02-17T16:13:03.549Z" }, + { url = "https://files.pythonhosted.org/packages/68/36/18f6e768afad6b55a690d38427c53251b69b7ba8795512730fd2508b31a9/librt-0.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:64548cde61b692dc0dc379f4b5f59a2f582c2ebe7890d09c1ae3b9e66fa015b7", size = 61507, upload-time = "2026-02-17T16:13:04.556Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/45/2466d73d79e3940cad4b26761f356f19fd33f4409c96f100e01a5c566909/lz4-4.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d221fa421b389ab2345640a508db57da36947a437dfe31aeddb8d5c7b646c22d", size = 207396, upload-time = "2025-11-03T13:01:24.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/7da96077a7e8918a5a57a25f1254edaf76aefb457666fcc1066deeecd609/lz4-4.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dc1e1e2dbd872f8fae529acd5e4839efd0b141eaa8ae7ce835a9fe80fbad89f", size = 207154, upload-time = "2025-11-03T13:01:26.922Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/0fb54f84fd1890d4af5bc0a3c1fa69678451c1a6bd40de26ec0561bb4ec5/lz4-4.4.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e928ec2d84dc8d13285b4a9288fd6246c5cde4f5f935b479f50d986911f085e3", size = 1291053, upload-time = "2025-11-03T13:01:28.396Z" }, + { url = "https://files.pythonhosted.org/packages/15/45/8ce01cc2715a19c9e72b0e423262072c17d581a8da56e0bd4550f3d76a79/lz4-4.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daffa4807ef54b927451208f5f85750c545a4abbff03d740835fc444cd97f758", size = 1278586, upload-time = "2025-11-03T13:01:29.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/7be9b09015e18510a09b8d76c304d505a7cbc66b775ec0b8f61442316818/lz4-4.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a2b7504d2dffed3fd19d4085fe1cc30cf221263fd01030819bdd8d2bb101cf1", size = 1367315, upload-time = "2025-11-03T13:01:31.054Z" }, + { url = "https://files.pythonhosted.org/packages/2a/94/52cc3ec0d41e8d68c985ec3b2d33631f281d8b748fb44955bc0384c2627b/lz4-4.4.5-cp310-cp310-win32.whl", hash = "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc", size = 88173, upload-time = "2025-11-03T13:01:32.643Z" }, + { url = "https://files.pythonhosted.org/packages/ca/35/c3c0bdc409f551404355aeeabc8da343577d0e53592368062e371a3620e1/lz4-4.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:7c4e7c44b6a31de77d4dc9772b7d2561937c9588a734681f70ec547cfbc51ecd", size = 99492, upload-time = "2025-11-03T13:01:33.813Z" }, + { url = "https://files.pythonhosted.org/packages/1d/02/4d88de2f1e97f9d05fd3d278fe412b08969bc94ff34942f5a3f09318144a/lz4-4.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:15551280f5656d2206b9b43262799c89b25a25460416ec554075a8dc568e4397", size = 91280, upload-time = "2025-11-03T13:01:35.081Z" }, + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/508f2ee73c126e4de53a3b8523ad14d666aeb00a6795425315f770dbf2f4/lz4-4.4.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6538aaaedd091d6e5abdaa19b99e6e82697d67518f114721b5248709b639fad", size = 207384, upload-time = "2025-11-03T13:02:27.043Z" }, + { url = "https://files.pythonhosted.org/packages/64/84/da7fda86dcc7b6d40d45dd28201fc136adfc390815126db41411bf1e5205/lz4-4.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13254bd78fef50105872989a2dc3418ff09aefc7d0765528adc21646a7288294", size = 207137, upload-time = "2025-11-03T13:02:28.021Z" }, + { url = "https://files.pythonhosted.org/packages/01/95/fb9c5bffed0f985eab70daf2087a94ad55cbbf83024175f39ff663f48b22/lz4-4.4.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e64e61f29cf95afb43549063d8433b46352baf0c8a70aa45e2585618fcf59d86", size = 1290508, upload-time = "2025-11-03T13:02:29.485Z" }, + { url = "https://files.pythonhosted.org/packages/57/6e/6a39b5ca9b9538cc9d61248c431065ad76cc0f10b40cb07d60b5bdde7750/lz4-4.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff1b50aeeec64df5603f17984e4b5be6166058dcf8f1e26a3da40d7a0f6ab547", size = 1278102, upload-time = "2025-11-03T13:02:30.878Z" }, + { url = "https://files.pythonhosted.org/packages/73/57/551a7f95825c9721d8bee4ec02d8b139b1a44796e63d09a737ca0d67b6b1/lz4-4.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dd4d91d25937c2441b9fc0f4af01704a2d09f30a38c5798bc1d1b5a15ec9581", size = 1366651, upload-time = "2025-11-03T13:02:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/4f/85/daa1ae5695ce40924813257d7f5a8990ba5dd78a9170f912dd85c498f97c/lz4-4.4.5-cp39-cp39-win32.whl", hash = "sha256:d64141085864918392c3159cdad15b102a620a67975c786777874e1e90ef15ce", size = 88165, upload-time = "2025-11-03T13:02:33.413Z" }, + { url = "https://files.pythonhosted.org/packages/df/db/3e84e506fdd5e04c9e8564d30bb08b0f3103dd9a2fb863c86bd46accb99a/lz4-4.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:f32b9e65d70f3684532358255dc053f143835c5f5991e28a5ac4c93ce94b9ea7", size = 99487, upload-time = "2025-11-03T13:02:34.246Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/40aa9d006fdebc4ae868c86ce2108a9453c2b524284817427de1284b5b00/lz4-4.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:f9b8bde9909a010c75b3aea58ec3910393b758f3c219beed67063693df854db0", size = 91275, upload-time = "2025-11-03T13:02:35.117Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, + { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/88436084550ca9af5e610fa45286be04c3b63374df3e021c762fe8c4369f/mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3", size = 13102606, upload-time = "2025-12-15T05:02:46.833Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/43dfad311a734b48a752790571fd9e12d61893849a01bff346a54011957f/mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a", size = 12164496, upload-time = "2025-12-15T05:03:41.947Z" }, + { url = "https://files.pythonhosted.org/packages/88/f0/efbfa391395cce2f2771f937e0620cfd185ec88f2b9cd88711028a768e96/mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67", size = 12772068, upload-time = "2025-12-15T05:02:53.689Z" }, + { url = "https://files.pythonhosted.org/packages/25/05/58b3ba28f5aed10479e899a12d2120d582ba9fa6288851b20bf1c32cbb4f/mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e", size = 13520385, upload-time = "2025-12-15T05:02:38.328Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a0/c006ccaff50b31e542ae69b92fe7e2f55d99fba3a55e01067dd564325f85/mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376", size = 13796221, upload-time = "2025-12-15T05:03:22.147Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ff/8bdb051cd710f01b880472241bd36b3f817a8e1c5d5540d0b761675b6de2/mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24", size = 10055456, upload-time = "2025-12-15T05:03:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" }, + { url = "https://files.pythonhosted.org/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" }, + { url = "https://files.pythonhosted.org/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" }, + { url = "https://files.pythonhosted.org/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" }, + { url = "https://files.pythonhosted.org/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" }, + { url = "https://files.pythonhosted.org/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" }, + { url = "https://files.pythonhosted.org/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" }, + { url = "https://files.pythonhosted.org/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" }, + { url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" }, + { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" }, + { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" }, + { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" }, + { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, + { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, + { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, + { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" }, + { url = "https://files.pythonhosted.org/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" }, + { url = "https://files.pythonhosted.org/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "ohdsi-circe-python-alpha" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "jinja2" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +dev = [ + { name = "black", version = "25.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "black", version = "26.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "deepdiff" }, + { name = "duckdb", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "duckdb", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "flake8" }, + { name = "ibis-framework", version = "11.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["duckdb"], marker = "python_full_version < '3.10'" }, + { name = "ibis-framework", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["duckdb"], marker = "python_full_version >= '3.10'" }, + { name = "isort", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "isort", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "javalang" }, + { name = "mypy" }, + { name = "polars", version = "1.36.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "polars", version = "1.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "sqlglot" }, +] +docs = [ + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-rtd-theme" }, +] +ibis = [ + { name = "ibis-framework", version = "11.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "ibis-framework", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +ibis-databricks = [ + { name = "ibis-framework", version = "11.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["databricks"], marker = "python_full_version < '3.10'" }, + { name = "ibis-framework", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["databricks"], marker = "python_full_version >= '3.10'" }, +] +ibis-duckdb = [ + { name = "ibis-framework", version = "11.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["duckdb"], marker = "python_full_version < '3.10'" }, + { name = "ibis-framework", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["duckdb"], marker = "python_full_version >= '3.10'" }, + { name = "polars", version = "1.36.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "polars", version = "1.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +ibis-postgres = [ + { name = "ibis-framework", version = "11.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["postgres"], marker = "python_full_version < '3.10'" }, + { name = "ibis-framework", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["postgres"], marker = "python_full_version >= '3.10'" }, +] +waveform = [ + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'dev'", specifier = ">=22.0.0" }, + { name = "deepdiff", marker = "extra == 'dev'", specifier = ">=8.6.0" }, + { name = "duckdb", marker = "extra == 'dev'", specifier = ">=0.9.0" }, + { name = "flake8", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "ibis-framework", marker = "python_full_version >= '3.9' and extra == 'ibis'", specifier = ">=11.0.0" }, + { name = "ibis-framework", extras = ["databricks"], marker = "python_full_version >= '3.9' and extra == 'ibis-databricks'", specifier = ">=11.0.0" }, + { name = "ibis-framework", extras = ["duckdb"], marker = "python_full_version >= '3.9' and extra == 'ibis-duckdb'", specifier = ">=11.0.0" }, + { name = "ibis-framework", extras = ["duckdb"], marker = "extra == 'dev'", specifier = ">=11.0.0" }, + { name = "ibis-framework", extras = ["postgres"], marker = "python_full_version >= '3.9' and extra == 'ibis-postgres'", specifier = ">=11.0.0" }, + { name = "isort", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "javalang", marker = "extra == 'dev'", specifier = ">=0.13.0" }, + { name = "jinja2", specifier = ">=3.1.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "polars", marker = "python_full_version >= '3.9' and extra == 'ibis-duckdb'", specifier = ">=0.20.0" }, + { name = "polars", marker = "extra == 'dev'", specifier = ">=0.20.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic", marker = "extra == 'waveform'", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=5.0.0" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.0.0" }, + { name = "sqlglot", marker = "extra == 'dev'", specifier = ">=23.0.0" }, + { name = "typing-extensions", specifier = ">=4.0.0" }, +] +provides-extras = ["dev", "docs", "ibis", "ibis-duckdb", "ibis-postgres", "ibis-databricks", "waveform"] + +[[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 = "orderly-set" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "python-dateutil", marker = "python_full_version < '3.10'" }, + { name = "pytz", marker = "python_full_version < '3.10'" }, + { name = "tzdata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213, upload-time = "2024-09-20T13:10:04.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/70/c853aec59839bceed032d52010ff5f1b8d87dc3114b762e4ba2727661a3b/pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5", size = 12580827, upload-time = "2024-09-20T13:08:42.347Z" }, + { url = "https://files.pythonhosted.org/packages/99/f2/c4527768739ffa4469b2b4fff05aa3768a478aed89a2f271a79a40eee984/pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348", size = 11303897, upload-time = "2024-09-20T13:08:45.807Z" }, + { url = "https://files.pythonhosted.org/packages/ed/12/86c1747ea27989d7a4064f806ce2bae2c6d575b950be087837bdfcabacc9/pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed", size = 66480908, upload-time = "2024-09-20T18:37:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/44/50/7db2cd5e6373ae796f0ddad3675268c8d59fb6076e66f0c339d61cea886b/pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57", size = 13064210, upload-time = "2024-09-20T13:08:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/61/61/a89015a6d5536cb0d6c3ba02cebed51a95538cf83472975275e28ebf7d0c/pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42", size = 16754292, upload-time = "2024-09-20T19:01:54.443Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0d/4cc7b69ce37fac07645a94e1d4b0880b15999494372c1523508511b09e40/pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f", size = 14416379, upload-time = "2024-09-20T13:08:50.882Z" }, + { url = "https://files.pythonhosted.org/packages/31/9e/6ebb433de864a6cd45716af52a4d7a8c3c9aaf3a98368e61db9e69e69a9c/pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645", size = 11598471, upload-time = "2024-09-20T13:08:53.332Z" }, + { url = "https://files.pythonhosted.org/packages/a8/44/d9502bf0ed197ba9bf1103c9867d5904ddcaf869e52329787fc54ed70cc8/pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039", size = 12602222, upload-time = "2024-09-20T13:08:56.254Z" }, + { url = "https://files.pythonhosted.org/packages/52/11/9eac327a38834f162b8250aab32a6781339c69afe7574368fffe46387edf/pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd", size = 11321274, upload-time = "2024-09-20T13:08:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/45/fb/c4beeb084718598ba19aa9f5abbc8aed8b42f90930da861fcb1acdb54c3a/pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698", size = 15579836, upload-time = "2024-09-20T19:01:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5f/4dba1d39bb9c38d574a9a22548c540177f78ea47b32f99c0ff2ec499fac5/pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc", size = 13058505, upload-time = "2024-09-20T13:09:01.501Z" }, + { url = "https://files.pythonhosted.org/packages/b9/57/708135b90391995361636634df1f1130d03ba456e95bcf576fada459115a/pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3", size = 16744420, upload-time = "2024-09-20T19:02:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/86/4a/03ed6b7ee323cf30404265c284cee9c65c56a212e0a08d9ee06984ba2240/pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32", size = 14440457, upload-time = "2024-09-20T13:09:04.105Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8c/87ddf1fcb55d11f9f847e3c69bb1c6f8e46e2f40ab1a2d2abadb2401b007/pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5", size = 11617166, upload-time = "2024-09-20T13:09:06.917Z" }, + { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893, upload-time = "2024-09-20T13:09:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475, upload-time = "2024-09-20T13:09:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645, upload-time = "2024-09-20T19:02:03.88Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445, upload-time = "2024-09-20T13:09:17.621Z" }, + { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235, upload-time = "2024-09-20T19:02:07.094Z" }, + { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756, upload-time = "2024-09-20T13:09:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248, upload-time = "2024-09-20T13:09:23.137Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643, upload-time = "2024-09-20T13:09:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573, upload-time = "2024-09-20T13:09:28.012Z" }, + { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085, upload-time = "2024-09-20T19:02:10.451Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809, upload-time = "2024-09-20T13:09:30.814Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316, upload-time = "2024-09-20T19:02:13.825Z" }, + { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055, upload-time = "2024-09-20T13:09:33.462Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175, upload-time = "2024-09-20T13:09:35.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650, upload-time = "2024-09-20T13:09:38.685Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177, upload-time = "2024-09-20T13:09:41.141Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526, upload-time = "2024-09-20T19:02:16.905Z" }, + { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013, upload-time = "2024-09-20T13:09:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620, upload-time = "2024-09-20T19:02:20.639Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436, upload-time = "2024-09-20T13:09:48.112Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8c/8848a4c9b8fdf5a534fe2077af948bf53cd713d77ffbcd7bd15710348fd7/pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39", size = 12595535, upload-time = "2024-09-20T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b9/5cead4f63b6d31bdefeb21a679bc5a7f4aaf262ca7e07e2bc1c341b68470/pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30", size = 11319822, upload-time = "2024-09-20T13:09:54.31Z" }, + { url = "https://files.pythonhosted.org/packages/31/af/89e35619fb573366fa68dc26dad6ad2c08c17b8004aad6d98f1a31ce4bb3/pandas-2.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c", size = 15625439, upload-time = "2024-09-20T19:02:23.689Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dd/bed19c2974296661493d7acc4407b1d2db4e2a482197df100f8f965b6225/pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c", size = 13068928, upload-time = "2024-09-20T13:09:56.746Z" }, + { url = "https://files.pythonhosted.org/packages/31/a3/18508e10a31ea108d746c848b5a05c0711e0278fa0d6f1c52a8ec52b80a5/pandas-2.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea", size = 16783266, upload-time = "2024-09-20T19:02:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a5/3429bd13d82bebc78f4d78c3945efedef63a7cd0c15c17b2eeb838d1121f/pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761", size = 14450871, upload-time = "2024-09-20T13:09:59.779Z" }, + { url = "https://files.pythonhosted.org/packages/2f/49/5c30646e96c684570925b772eac4eb0a8cb0ca590fa978f56c5d3ae73ea1/pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e", size = 11618011, upload-time = "2024-09-20T13:10:02.351Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, + { name = "pytz", marker = "python_full_version >= '3.10'" }, + { name = "tzdata", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, + { url = "https://files.pythonhosted.org/packages/56/b4/52eeb530a99e2a4c55ffcd352772b599ed4473a0f892d127f4147cf0f88e/pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2", size = 11567720, upload-time = "2025-09-29T23:33:06.209Z" }, + { url = "https://files.pythonhosted.org/packages/48/4a/2d8b67632a021bced649ba940455ed441ca854e57d6e7658a6024587b083/pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8", size = 10810302, upload-time = "2025-09-29T23:33:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/d2465010ee0569a245c975dc6967b801887068bc893e908239b1f4b6c1ac/pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff", size = 12154874, upload-time = "2025-09-29T23:33:49.939Z" }, + { url = "https://files.pythonhosted.org/packages/1f/18/aae8c0aa69a386a3255940e9317f793808ea79d0a525a97a903366bb2569/pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29", size = 12790141, upload-time = "2025-09-29T23:34:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/f7/26/617f98de789de00c2a444fbe6301bb19e66556ac78cff933d2c98f62f2b4/pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73", size = 13208697, upload-time = "2025-09-29T23:34:21.835Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fb/25709afa4552042bd0e15717c75e9b4a2294c3dc4f7e6ea50f03c5136600/pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9", size = 13879233, upload-time = "2025-09-29T23:34:35.079Z" }, + { url = "https://files.pythonhosted.org/packages/98/af/7be05277859a7bc399da8ba68b88c96b27b48740b6cf49688899c6eb4176/pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa", size = 11359119, upload-time = "2025-09-29T23:34:46.339Z" }, +] + +[[package]] +name = "parsy" +version = "2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/58/1e3f382eef9e50a2a115486b0c178d22bb97d2fbb85421ccbe5d3a783530/parsy-2.2.tar.gz", hash = "sha256:e943147644a8cf0d82d1bcb5c5867dd517495254cea3e3eb058b1e421cb7561f", size = 47296, upload-time = "2025-09-12T11:39:26.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/fc/8cb9073bb1bee54eb49a1ae501a36402d01763812962ac811cdc1c81a9d7/parsy-2.2-py3-none-any.whl", hash = "sha256:5e981613d9d2d8b68012d1dd0afe928967bea2e4eefdb76c2f545af0dd02a9e7", size = 9538, upload-time = "2025-09-12T11:39:25.749Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "polars" +version = "1.36.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "polars-runtime-32", version = "1.36.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/dc/56f2a90c79a2cb13f9e956eab6385effe54216ae7a2068b3a6406bae4345/polars-1.36.1.tar.gz", hash = "sha256:12c7616a2305559144711ab73eaa18814f7aa898c522e7645014b68f1432d54c", size = 711993, upload-time = "2025-12-10T01:14:53.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/c6/36a1b874036b49893ecae0ac44a2f63d1a76e6212631a5b2f50a86e0e8af/polars-1.36.1-py3-none-any.whl", hash = "sha256:853c1bbb237add6a5f6d133c15094a9b727d66dd6a4eb91dbb07cdb056b2b8ef", size = 802429, upload-time = "2025-12-10T01:13:53.838Z" }, +] + +[[package]] +name = "polars" +version = "1.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "polars-runtime-32", version = "1.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/b8/3a6a5b85e34af7936620f331f04f8bed235625439f5bd80832f968648618/polars-1.39.0.tar.gz", hash = "sha256:e63a25fb7682ae660e36067915a7c71a653b17f82308a8eb67a190a80daf0710", size = 728783, upload-time = "2026-03-12T14:24:47.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f8/fad8470d9701c1b208cc24919a661efdf565373e77e7d06400642a759285/polars-1.39.0-py3-none-any.whl", hash = "sha256:4d1198b41bc47561673d9f54d0f595125202a3f53e3502821802958d3e60efe9", size = 823938, upload-time = "2026-03-12T14:22:37.78Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.36.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/31/df/597c0ef5eb8d761a16d72327846599b57c5d40d7f9e74306fc154aba8c37/polars_runtime_32-1.36.1.tar.gz", hash = "sha256:201c2cfd80ceb5d5cd7b63085b5fd08d6ae6554f922bcb941035e39638528a09", size = 2788751, upload-time = "2025-12-10T01:14:54.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/ea/871129a2d296966c0925b078a9a93c6c5e7facb1c5eebfcd3d5811aeddc1/polars_runtime_32-1.36.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:327b621ca82594f277751f7e23d4b939ebd1be18d54b4cdf7a2f8406cecc18b2", size = 43494311, upload-time = "2025-12-10T01:13:56.096Z" }, + { url = "https://files.pythonhosted.org/packages/d8/76/0038210ad1e526ce5bb2933b13760d6b986b3045eccc1338e661bd656f77/polars_runtime_32-1.36.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ab0d1f23084afee2b97de8c37aa3e02ec3569749ae39571bd89e7a8b11ae9e83", size = 39300602, upload-time = "2025-12-10T01:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/54/1e/2707bee75a780a953a77a2c59829ee90ef55708f02fc4add761c579bf76e/polars_runtime_32-1.36.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:899b9ad2e47ceb31eb157f27a09dbc2047efbf4969a923a6b1ba7f0412c3e64c", size = 44511780, upload-time = "2025-12-10T01:14:02.285Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/3fede95feee441be64b4bcb32444679a8fbb7a453a10251583053f6efe52/polars_runtime_32-1.36.1-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:d9d077bb9df711bc635a86540df48242bb91975b353e53ef261c6fae6cb0948f", size = 40688448, upload-time = "2025-12-10T01:14:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/05/0f/e629713a72999939b7b4bfdbf030a32794db588b04fdf3dc977dd8ea6c53/polars_runtime_32-1.36.1-cp39-abi3-win_amd64.whl", hash = "sha256:cc17101f28c9a169ff8b5b8d4977a3683cd403621841623825525f440b564cf0", size = 44464898, upload-time = "2025-12-10T01:14:08.296Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d8/a12e6aa14f63784cead437083319ec7cece0d5bb9a5bfe7678cc6578b52a/polars_runtime_32-1.36.1-cp39-abi3-win_arm64.whl", hash = "sha256:809e73857be71250141225ddd5d2b30c97e6340aeaa0d445f930e01bef6888dc", size = 39798896, upload-time = "2025-12-10T01:14:11.568Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/1e/fce83ad77bfed1bf4a83f74dde19e2572c32fc040e93bd98d161e3950eaf/polars_runtime_32-1.39.0.tar.gz", hash = "sha256:f5aabed8c7318fcad5173e83bee385445f54b5f8c83b1ec9eab78bdffa293141", size = 2870686, upload-time = "2026-03-12T14:24:49.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/6d/143b552baa9e859ae266f087f3ec0aeb29e5acc39e1f49c1a64023cee469/polars_runtime_32-1.39.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4a4bc06ca97238d963979e3f888fbb500ee607f03cefe43a9062381e259503e2", size = 45299222, upload-time = "2026-03-12T14:22:40.821Z" }, + { url = "https://files.pythonhosted.org/packages/97/ec/eb4e57eedfb97019f951b298fa4cd232a50db65aa6702c735b6f272a0fa0/polars_runtime_32-1.39.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9914b9e168634bc21d07ee03b8fa92d0aaa8ac7b2bb1c9e2f1f78622aa1b8f4", size = 40863978, upload-time = "2026-03-12T14:22:45.16Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b7/28fa0345586f7c449dd27d687c32a10dcea470ebc5a978d7fc47e463b298/polars_runtime_32-1.39.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ded58f1c28e17ecbff8625cb1ad93016761260348acb79b1a4cd077970e89e5", size = 43231627, upload-time = "2026-03-12T14:22:49.464Z" }, + { url = "https://files.pythonhosted.org/packages/cf/60/c0d0b6720437685223457242a79f6bba443485ca85928645786479ebed86/polars_runtime_32-1.39.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b82c872b25ef6628462f90f1b6b3950779aee36889e83b3693d0a69684d3d86a", size = 46899324, upload-time = "2026-03-12T14:22:54.364Z" }, + { url = "https://files.pythonhosted.org/packages/73/98/53ad9c8a6f151e098e4f65c5146f9e538f1ba148feb5289fd2a4c5e2d764/polars_runtime_32-1.39.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4a0e9d6b56362f3ba1a33d0538ae14c9b9a8e0fb835f86abfc82fa7b2c7d89c9", size = 43389283, upload-time = "2026-03-12T14:22:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/74/a2/21f77d6e588ee7c8e7f6232d135538690411de2ea6415d8bbe9b8d684f37/polars_runtime_32-1.39.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0daea3919661ba672b00bd01b5547cd29bb6414732457abb72cbc75103cf3c90", size = 46509946, upload-time = "2026-03-12T14:23:05.215Z" }, + { url = "https://files.pythonhosted.org/packages/24/a3/37a56ad2d931c857b892b22760b9bf9a53f681d9ccf27741cf6dd8489320/polars_runtime_32-1.39.0-cp310-abi3-win_amd64.whl", hash = "sha256:d6e9d1cf264aacfe5bf03241c04ef435d0f9cfec3fbe079acc3a7328a737961a", size = 47012669, upload-time = "2026-03-12T14:23:11.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/eb/936f5eeae196e8c8aaabe5f7d98891be8a5bbc741d50ce5c60f55575ad29/polars_runtime_32-1.39.0-cp310-abi3-win_arm64.whl", hash = "sha256:d69abde5f148566860bbe910010847bd7791e72f7c8063a4d2c462246a33a72a", size = 41885761, upload-time = "2026-03-12T14:23:16.773Z" }, +] + +[[package]] +name = "psycopg" +version = "3.2.13" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/05/d4a05988f15fcf90e0088c735b1f2fc04a30b7fc65461d6ec278f5f2f17a/psycopg-3.2.13.tar.gz", hash = "sha256:309adaeda61d44556046ec9a83a93f42bbe5310120b1995f3af49ab6d9f13c1d", size = 160626, upload-time = "2025-11-21T22:34:32.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/14/f2724bd1986158a348316e86fdd0837a838b14a711df3f00e47fba597447/psycopg-3.2.13-py3-none-any.whl", hash = "sha256:a481374514f2da627157f767a9336705ebefe93ea7a0522a6cbacba165da179a", size = 206797, upload-time = "2025-11-21T22:29:39.733Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[[package]] +name = "pyarrow" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487, upload-time = "2025-07-18T00:57:31.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d9/110de31880016e2afc52d8580b397dbe47615defbf09ca8cf55f56c62165/pyarrow-21.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26", size = 31196837, upload-time = "2025-07-18T00:54:34.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/5f/c1c1997613abf24fceb087e79432d24c19bc6f7259cab57c2c8e5e545fab/pyarrow-21.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79", size = 32659470, upload-time = "2025-07-18T00:54:38.329Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ed/b1589a777816ee33ba123ba1e4f8f02243a844fed0deec97bde9fb21a5cf/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7be45519b830f7c24b21d630a31d48bcebfd5d4d7f9d3bdb49da9cdf6d764edb", size = 41055619, upload-time = "2025-07-18T00:54:42.172Z" }, + { url = "https://files.pythonhosted.org/packages/44/28/b6672962639e85dc0ac36f71ab3a8f5f38e01b51343d7aa372a6b56fa3f3/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:26bfd95f6bff443ceae63c65dc7e048670b7e98bc892210acba7e4995d3d4b51", size = 42733488, upload-time = "2025-07-18T00:54:47.132Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/de02c3614874b9089c94eac093f90ca5dfa6d5afe45de3ba847fd950fdf1/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd04ec08f7f8bd113c55868bd3fc442a9db67c27af098c5f814a3091e71cc61a", size = 43329159, upload-time = "2025-07-18T00:54:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3e/99473332ac40278f196e105ce30b79ab8affab12f6194802f2593d6b0be2/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9b0b14b49ac10654332a805aedfc0147fb3469cbf8ea951b3d040dab12372594", size = 45050567, upload-time = "2025-07-18T00:54:56.679Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f5/c372ef60593d713e8bfbb7e0c743501605f0ad00719146dc075faf11172b/pyarrow-21.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d9f8bcb4c3be7738add259738abdeddc363de1b80e3310e04067aa1ca596634", size = 26217959, upload-time = "2025-07-18T00:55:00.482Z" }, + { url = "https://files.pythonhosted.org/packages/94/dc/80564a3071a57c20b7c32575e4a0120e8a330ef487c319b122942d665960/pyarrow-21.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c077f48aab61738c237802836fc3844f85409a46015635198761b0d6a688f87b", size = 31243234, upload-time = "2025-07-18T00:55:03.812Z" }, + { url = "https://files.pythonhosted.org/packages/ea/cc/3b51cb2db26fe535d14f74cab4c79b191ed9a8cd4cbba45e2379b5ca2746/pyarrow-21.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:689f448066781856237eca8d1975b98cace19b8dd2ab6145bf49475478bcaa10", size = 32714370, upload-time = "2025-07-18T00:55:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/24/11/a4431f36d5ad7d83b87146f515c063e4d07ef0b7240876ddb885e6b44f2e/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:479ee41399fcddc46159a551705b89c05f11e8b8cb8e968f7fec64f62d91985e", size = 41135424, upload-time = "2025-07-18T00:55:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/74/dc/035d54638fc5d2971cbf1e987ccd45f1091c83bcf747281cf6cc25e72c88/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:40ebfcb54a4f11bcde86bc586cbd0272bac0d516cfa539c799c2453768477569", size = 42823810, upload-time = "2025-07-18T00:55:16.301Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3b/89fced102448a9e3e0d4dded1f37fa3ce4700f02cdb8665457fcc8015f5b/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8d58d8497814274d3d20214fbb24abcad2f7e351474357d552a8d53bce70c70e", size = 43391538, upload-time = "2025-07-18T00:55:23.82Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/ea7f1bd08978d39debd3b23611c293f64a642557e8141c80635d501e6d53/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:585e7224f21124dd57836b1530ac8f2df2afc43c861d7bf3d58a4870c42ae36c", size = 45120056, upload-time = "2025-07-18T00:55:28.231Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0b/77ea0600009842b30ceebc3337639a7380cd946061b620ac1a2f3cb541e2/pyarrow-21.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:555ca6935b2cbca2c0e932bedd853e9bc523098c39636de9ad4693b5b1df86d6", size = 26220568, upload-time = "2025-07-18T00:55:32.122Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305, upload-time = "2025-07-18T00:55:35.373Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264, upload-time = "2025-07-18T00:55:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099, upload-time = "2025-07-18T00:55:42.889Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529, upload-time = "2025-07-18T00:55:47.069Z" }, + { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883, upload-time = "2025-07-18T00:55:53.069Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802, upload-time = "2025-07-18T00:55:57.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175, upload-time = "2025-07-18T00:56:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/16/ca/c7eaa8e62db8fb37ce942b1ea0c6d7abfe3786ca193957afa25e71b81b66/pyarrow-21.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e99310a4ebd4479bcd1964dff9e14af33746300cb014aa4a3781738ac63baf4a", size = 31154306, upload-time = "2025-07-18T00:56:04.42Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e8/e87d9e3b2489302b3a1aea709aaca4b781c5252fcb812a17ab6275a9a484/pyarrow-21.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d2fe8e7f3ce329a71b7ddd7498b3cfac0eeb200c2789bd840234f0dc271a8efe", size = 32680622, upload-time = "2025-07-18T00:56:07.505Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/79095d73a742aa0aba370c7942b1b655f598069489ab387fe47261a849e1/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd", size = 41104094, upload-time = "2025-07-18T00:56:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/89/4b/7782438b551dbb0468892a276b8c789b8bbdb25ea5c5eb27faadd753e037/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:69cbbdf0631396e9925e048cfa5bce4e8c3d3b41562bbd70c685a8eb53a91e61", size = 42825576, upload-time = "2025-07-18T00:56:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/b3/62/0f29de6e0a1e33518dec92c65be0351d32d7ca351e51ec5f4f837a9aab91/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:731c7022587006b755d0bdb27626a1a3bb004bb56b11fb30d98b6c1b4718579d", size = 43368342, upload-time = "2025-07-18T00:56:19.531Z" }, + { url = "https://files.pythonhosted.org/packages/90/c7/0fa1f3f29cf75f339768cc698c8ad4ddd2481c1742e9741459911c9ac477/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc56bc708f2d8ac71bd1dcb927e458c93cec10b98eb4120206a4091db7b67b99", size = 45131218, upload-time = "2025-07-18T00:56:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/01/63/581f2076465e67b23bc5a37d4a2abff8362d389d29d8105832e82c9c811c/pyarrow-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:186aa00bca62139f75b7de8420f745f2af12941595bbbfa7ed3870ff63e25636", size = 26087551, upload-time = "2025-07-18T00:56:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ab/357d0d9648bb8241ee7348e564f2479d206ebe6e1c47ac5027c2e31ecd39/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:a7a102574faa3f421141a64c10216e078df467ab9576684d5cd696952546e2da", size = 31290064, upload-time = "2025-07-18T00:56:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8a/5685d62a990e4cac2043fc76b4661bf38d06efed55cf45a334b455bd2759/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:1e005378c4a2c6db3ada3ad4c217b381f6c886f0a80d6a316fe586b90f77efd7", size = 32727837, upload-time = "2025-07-18T00:56:33.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/c0828ee09525c2bafefd3e736a248ebe764d07d0fd762d4f0929dbc516c9/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:65f8e85f79031449ec8706b74504a316805217b35b6099155dd7e227eef0d4b6", size = 41014158, upload-time = "2025-07-18T00:56:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/6e/26/a2865c420c50b7a3748320b614f3484bfcde8347b2639b2b903b21ce6a72/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3a81486adc665c7eb1a2bde0224cfca6ceaba344a82a971ef059678417880eb8", size = 42667885, upload-time = "2025-07-18T00:56:41.483Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f9/4ee798dc902533159250fb4321267730bc0a107d8c6889e07c3add4fe3a5/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503", size = 43276625, upload-time = "2025-07-18T00:56:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/5a/da/e02544d6997037a4b0d22d8e5f66bc9315c3671371a8b18c79ade1cefe14/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6299449adf89df38537837487a4f8d3bd91ec94354fdd2a7d30bc11c48ef6e79", size = 44951890, upload-time = "2025-07-18T00:56:52.568Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4e/519c1bc1876625fe6b71e9a28287c43ec2f20f73c658b9ae1d485c0c206e/pyarrow-21.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:222c39e2c70113543982c6b34f3077962b44fca38c0bd9e68bb6781534425c10", size = 26371006, upload-time = "2025-07-18T00:56:56.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/ce4939f4b316457a083dc5718b3982801e8c33f921b3c98e7a93b7c7491f/pyarrow-21.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:a7f6524e3747e35f80744537c78e7302cd41deee8baa668d56d55f77d9c464b3", size = 31211248, upload-time = "2025-07-18T00:56:59.7Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c2/7a860931420d73985e2f340f06516b21740c15b28d24a0e99a900bb27d2b/pyarrow-21.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:203003786c9fd253ebcafa44b03c06983c9c8d06c3145e37f1b76a1f317aeae1", size = 32676896, upload-time = "2025-07-18T00:57:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/197f989b9a75e59b4ca0db6a13c56f19a0ad8a298c68da9cc28145e0bb97/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3b4d97e297741796fead24867a8dabf86c87e4584ccc03167e4a811f50fdf74d", size = 41067862, upload-time = "2025-07-18T00:57:07.587Z" }, + { url = "https://files.pythonhosted.org/packages/fa/82/6ecfa89487b35aa21accb014b64e0a6b814cc860d5e3170287bf5135c7d8/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:898afce396b80fdda05e3086b4256f8677c671f7b1d27a6976fa011d3fd0a86e", size = 42747508, upload-time = "2025-07-18T00:57:13.917Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b7/ba252f399bbf3addc731e8643c05532cf32e74cebb5e32f8f7409bc243cf/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:067c66ca29aaedae08218569a114e413b26e742171f526e828e1064fcdec13f4", size = 43345293, upload-time = "2025-07-18T00:57:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/a20819795bd702b9486f536a8eeb70a6aa64046fce32071c19ec8230dbaa/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0c4e75d13eb76295a49e0ea056eb18dbd87d81450bfeb8afa19a7e5a75ae2ad7", size = 45060670, upload-time = "2025-07-18T00:57:24.477Z" }, + { url = "https://files.pythonhosted.org/packages/10/15/6b30e77872012bbfe8265d42a01d5b3c17ef0ac0f2fae531ad91b6a6c02e/pyarrow-21.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdc4c17afda4dab2a9c0b79148a43a7f4e1094916b3e18d8975bfd6d6d52241f", size = 26227521, upload-time = "2025-07-18T00:57:29.119Z" }, +] + +[[package]] +name = "pyarrow" +version = "23.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/a8/24e5dc6855f50a62936ceb004e6e9645e4219a8065f304145d7fb8a79d5d/pyarrow-23.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:3fab8f82571844eb3c460f90a75583801d14ca0cc32b1acc8c361650e006fd56", size = 34307390, upload-time = "2026-02-16T10:08:08.654Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8e/4be5617b4aaae0287f621ad31c6036e5f63118cfca0dc57d42121ff49b51/pyarrow-23.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3f91c038b95f71ddfc865f11d5876c42f343b4495535bd262c7b321b0b94507c", size = 35853761, upload-time = "2026-02-16T10:08:17.811Z" }, + { url = "https://files.pythonhosted.org/packages/2e/08/3e56a18819462210432ae37d10f5c8eed3828be1d6c751b6e6a2e93c286a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d0744403adabef53c985a7f8a082b502a368510c40d184df349a0a8754533258", size = 44493116, upload-time = "2026-02-16T10:08:25.792Z" }, + { url = "https://files.pythonhosted.org/packages/f8/82/c40b68001dbec8a3faa4c08cd8c200798ac732d2854537c5449dc859f55a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c33b5bf406284fd0bba436ed6f6c3ebe8e311722b441d89397c54f871c6863a2", size = 47564532, upload-time = "2026-02-16T10:08:34.27Z" }, + { url = "https://files.pythonhosted.org/packages/20/bc/73f611989116b6f53347581b02177f9f620efdf3cd3f405d0e83cdf53a83/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ddf743e82f69dcd6dbbcb63628895d7161e04e56794ef80550ac6f3315eeb1d5", size = 48183685, upload-time = "2026-02-16T10:08:42.889Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/6c6b3ecdae2a8c3aced99956187e8302fc954cc2cca2a37cf2111dad16ce/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e052a211c5ac9848ae15d5ec875ed0943c0221e2fcfe69eee80b604b4e703222", size = 50605582, upload-time = "2026-02-16T10:08:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/8d/94/d359e708672878d7638a04a0448edf7c707f9e5606cee11e15aaa5c7535a/pyarrow-23.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5abde149bb3ce524782d838eb67ac095cd3fd6090eba051130589793f1a7f76d", size = 27521148, upload-time = "2026-02-16T10:08:58.077Z" }, + { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230, upload-time = "2026-02-16T10:09:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050, upload-time = "2026-02-16T10:09:11.877Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918, upload-time = "2026-02-16T10:09:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/0b/62/96459ef5b67957eac38a90f541d1c28833d1b367f014a482cb63f3b7cd2d/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", size = 47562811, upload-time = "2026-02-16T10:09:25.792Z" }, + { url = "https://files.pythonhosted.org/packages/7d/94/1170e235add1f5f45a954e26cd0e906e7e74e23392dcb560de471f7366ec/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", size = 48183766, upload-time = "2026-02-16T10:09:34.645Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/39a42af4570377b99774cdb47f63ee6c7da7616bd55b3d5001aa18edfe4f/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", size = 50607669, upload-time = "2026-02-16T10:09:44.153Z" }, + { url = "https://files.pythonhosted.org/packages/00/ca/db94101c187f3df742133ac837e93b1f269ebdac49427f8310ee40b6a58f/pyarrow-23.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", size = 27527698, upload-time = "2026-02-16T10:09:50.263Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, + { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, + { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, + { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, + { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, + { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, + { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, + { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, + { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, + { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, + { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, + { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, + { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, +] + +[[package]] +name = "pyarrow-hotfix" +version = "0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/ed/c3e8677f7abf3981838c2af7b5ac03e3589b3ef94fcb31d575426abae904/pyarrow_hotfix-0.7.tar.gz", hash = "sha256:59399cd58bdd978b2e42816a4183a55c6472d4e33d183351b6069f11ed42661d", size = 9910, upload-time = "2025-04-25T10:17:06.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/c3/94ade4906a2f88bc935772f59c934013b4205e773bcb4239db114a6da136/pyarrow_hotfix-0.7-py3-none-any.whl", hash = "sha256:3236f3b5f1260f0e2ac070a55c1a7b339c4bb7267839bd2015e283234e758100", size = 7923, upload-time = "2025-04-25T10:17:05.224Z" }, +] + +[[package]] +name = "pybreaker" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/89/fbf98e383f1ec6d117af2cd983efdb3eb7018b63834c427025764194cac2/pybreaker-1.4.1.tar.gz", hash = "sha256:8df2d245c73ba40c8242c56ffb4f12138fbadc23e296224740c2028ea9dc1178", size = 15555, upload-time = "2025-09-21T15:12:04.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/75/e64d3d40a741e2be21d69154f4e5c43a66f0c603c5ef11f49e01429a5932/pybreaker-1.4.1-py3-none-any.whl", hash = "sha256:b4dab4a05195b7f2a64a6c1a6c4ba7a96534ef56ea7210e6bcb59f28897160e0", size = 12915, upload-time = "2025-09-21T15:12:02.284Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" }, + { url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" }, + { url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.13.5", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/51/2a/f125667ce48105bf1f4e50e03cfa7b24b8c4f47684d7f1cf4dcb6f6b1c15/pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3", size = 161464, upload-time = "2026-01-30T01:03:39.11Z" }, + { url = "https://files.pythonhosted.org/packages/40/df/065a30790a7ca6bb48ad9018dd44668ed9135610ebf56a2a4cb8e513fd5c/pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1", size = 246159, upload-time = "2026-01-30T01:03:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1c/fd09976a7e04960dabc07ab0e0072c7813d566ec67d5490a4c600683c158/pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db", size = 259120, upload-time = "2026-01-30T01:03:41.233Z" }, + { url = "https://files.pythonhosted.org/packages/52/49/59fdc6fc5a390ae9f308eadeb97dfc70fc2d804ffc49dd39fc97604622ec/pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1", size = 262196, upload-time = "2026-01-30T01:03:42.696Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/d6734dccf0080e3dc00a55b0827ab5af30c886f8bc127bbc04bc3445daec/pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a", size = 103510, upload-time = "2026-01-30T01:03:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[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 = "ruff" +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "babel", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "requests", marker = "python_full_version < '3.10'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "babel", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "jinja2", marker = "python_full_version == '3.10.*'" }, + { name = "packaging", marker = "python_full_version == '3.10.*'" }, + { name = "pygments", marker = "python_full_version == '3.10.*'" }, + { name = "requests", marker = "python_full_version == '3.10.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +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-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[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-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[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.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.10' and platform_machine == 'AMD64') or (python_full_version < '3.10' and platform_machine == 'WIN32') or (python_full_version < '3.10' and platform_machine == 'aarch64') or (python_full_version < '3.10' and platform_machine == 'amd64') or (python_full_version < '3.10' and platform_machine == 'ppc64le') or (python_full_version < '3.10' and platform_machine == 'win32') or (python_full_version < '3.10' and platform_machine == 'x86_64')" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/67/1235676e93dd3b742a4a8eddfae49eea46c85e3eed29f0da446a8dd57500/sqlalchemy-2.0.48-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89", size = 2157384, upload-time = "2026-03-02T15:38:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d7/fa728b856daa18c10e1390e76f26f64ac890c947008284387451d56ca3d0/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0", size = 3236981, upload-time = "2026-03-02T15:58:53.53Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ad/6c4395649a212a6c603a72c5b9ab5dce3135a1546cfdffa3c427e71fd535/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10853a53a4a00417a00913d270dddda75815fcb80675874285f41051c094d7dd", size = 3235232, upload-time = "2026-03-02T15:52:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/58f845e511ac0509765a6f85eb24924c1ef0d54fb50de9d15b28c3601458/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fac0fa4e4f55f118fd87177dacb1c6522fe39c28d498d259014020fec9164c29", size = 3188106, upload-time = "2026-03-02T15:58:55.193Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/6dcc7bfa5f5794c3a095e78cd1de8269dfb5584dfd4c2c00a50d3c1ade44/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3713e21ea67bca727eecd4a24bf68bcd414c403faae4989442be60994301ded0", size = 3209522, upload-time = "2026-03-02T15:52:27.407Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/b632875ab35874d42657f079529f0745410604645c269a8c21fb4272ff7a/sqlalchemy-2.0.48-cp310-cp310-win32.whl", hash = "sha256:d404dc897ce10e565d647795861762aa2d06ca3f4a728c5e9a835096c7059018", size = 2117695, upload-time = "2026-03-02T15:46:51.389Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/9752eb2a41afdd8568e41ac3c3128e32a0a73eada5ab80483083604a56d1/sqlalchemy-2.0.48-cp310-cp310-win_amd64.whl", hash = "sha256:841a94c66577661c1f088ac958cd767d7c9bf507698f45afffe7a4017049de76", size = 2140928, upload-time = "2026-03-02T15:46:52.992Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" }, + { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" }, + { url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" }, + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/f1/69/c84f10a7fb0d6c50c0f6028cab1373ac1bc70a824d53bf857c33eddde5c4/sqlalchemy-2.0.48-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4599a95f9430ae0de82b52ff0d27304fe898c17cb5f4099f7438a51b9998ac77", size = 2160429, upload-time = "2026-03-02T15:44:11.019Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c8/2e0de4efcba76ae8cc84000bc0aedf45f7d2674a7d8cf66b884a03c3f310/sqlalchemy-2.0.48-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f27f9da0a7d22b9f981108fd4b62f8b5743423388915a563e651c20d06c1f457", size = 3236035, upload-time = "2026-03-02T16:01:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/0822c24212a2943b3df02a02c49b2b32ab67705eaa0d2f40f28f9c2e8084/sqlalchemy-2.0.48-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8fcccbbc0c13c13702c471da398b8cd72ba740dca5859f148ae8e0e8e0d3e7e", size = 3235358, upload-time = "2026-03-02T16:07:58.002Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ce/f1c7c16d5ea0e4fbc14b473f02daedef8d77c582ef3c18b30b7307f85cff/sqlalchemy-2.0.48-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a5b429eb84339f9f05e06083f119ad814e6d85e27ecbdf9c551dfdbb128eaf8a", size = 3185479, upload-time = "2026-03-02T16:01:32.781Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b8/95cb9642e608d02a0fd96bb3f7571b20a081313a178e1e661cc5dba37472/sqlalchemy-2.0.48-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bcb8ebbf2e2c36cfe01a94f2438012c6a9d494cf80f129d9753bcdf33bfc35a6", size = 3207488, upload-time = "2026-03-02T16:07:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/24/cd/0dda04e28df0db4ed0b7d374f7eb7da8566db523dbac9f627cc6e0422c6d/sqlalchemy-2.0.48-cp39-cp39-win32.whl", hash = "sha256:e214d546c8ecb5fc22d6e6011746082abf13a9cf46eefb45769c7b31407c97b5", size = 2119494, upload-time = "2026-03-02T15:50:24.983Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1d/a98057e05608316cd3c2710f0b3d35e83cec6bdf00833b53a02235a1712f/sqlalchemy-2.0.48-cp39-cp39-win_amd64.whl", hash = "sha256:b8fc3454b4f3bd0a368001d0e968852dad45a873f8b4babd41bc302ec851a099", size = 2142903, upload-time = "2026-03-02T15:50:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +] + +[[package]] +name = "sqlglot" +version = "30.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/32/ffa8390ac039de6e18e6874b1464c4012db78d9a15790d0c56c2bf5d65bb/sqlglot-30.0.1.tar.gz", hash = "sha256:1191cc37654c944b9a1d020347b9e435e3b39bdbade9129f82aa5827e3641332", size = 5793328, upload-time = "2026-03-16T22:07:33.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/6a/e7cf2f648d7217359cd21129101208218d6245f9863d36b2c9049211676f/sqlglot-30.0.1-py3-none-any.whl", hash = "sha256:379bb16020573aa7fa4730b9c04d5ee79d1c4cf50f8d203d6fb03e49ac1e4ff1", size = 648788, upload-time = "2026-03-16T22:07:31.239Z" }, +] + +[[package]] +name = "thrift" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/2d/8946864f716ac82dcc88d290ed613cba7a80ec75df4f553ec3ff275f486e/thrift-0.20.0.tar.gz", hash = "sha256:4dd662eadf6b8aebe8a41729527bd69adf6ceaa2a8681cbef64d1273b3e8feba", size = 62295, upload-time = "2024-03-22T22:53:08.228Z" } + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 37acab023086d76e4350b7b0cfd1cf7ed86be9f2 Mon Sep 17 00:00:00 2001 From: egillax Date: Wed, 18 Mar 2026 15:38:12 +0100 Subject: [PATCH 25/62] docs: align docs with uv and ruff workflow --- .readthedocs.yaml | 2 - CONTRIBUTING.md | 33 +- INSTALLATION.md | 119 +++--- PUBLISHING_GUIDE.md | 12 +- README.md | 54 +-- RELEASE_CHECKLIST.md | 28 +- docs/CONTRIBUTING.md | 39 +- docs/README.md | 18 +- docs/RELEASE_CHECKLIST.md | 28 +- docs/cli.rst | 7 +- docs/developer/contributing.rst | 17 +- docs/faq.rst | 5 +- docs/index.rst | 7 +- docs/installation.rst | 88 +++-- docs/quickstart.rst | 13 +- docs/requirements.txt | 7 - docs/troubleshooting.rst | 4 +- pyproject.toml | 6 +- uv.lock | 621 +++++++++++++++++++------------- 19 files changed, 632 insertions(+), 476 deletions(-) delete mode 100644 docs/requirements.txt diff --git a/.readthedocs.yaml b/.readthedocs.yaml index ef7a1433..700a6e19 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -18,9 +18,7 @@ formats: python: install: - - requirements: requirements.txt - method: pip path: . extra_requirements: - docs - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56082a80..8b7b3ddb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,7 @@ This project follows the [OHDSI Code of Conduct](https://www.ohdsi.org/web/wiki/ > [!NOTE] > This is a private development repository. Ensure you have access before attempting to clone. +> The recommended contributor workflow uses `uv` and the checked-in `uv.lock` for a reproducible environment. 1. Clone the repository ```bash @@ -26,14 +27,19 @@ This project follows the [OHDSI Code of Conduct](https://www.ohdsi.org/web/wiki/ cd Circepy ``` -2. Install the package in development mode: +2. Create the development environment: ```bash - pip install -e ".[dev]" + uv sync --extra dev ``` -3. Run tests to ensure everything is working: +3. Install Git hooks: ```bash - pytest + uv run pre-commit install + ``` + +4. Run tests to ensure everything is working: + ```bash + uv run pytest ``` ## Development Guidelines @@ -42,18 +48,15 @@ This project follows the [OHDSI Code of Conduct](https://www.ohdsi.org/web/wiki/ We use the following tools to maintain code quality: -- **Black** for code formatting -- **isort** for import sorting -- **flake8** for linting -- **mypy** for type checking +- **Ruff** for linting and formatting +- **pre-commit** for running repository hooks before commit Run these tools before committing: ```bash -black circe/ -isort circe/ -flake8 circe/ -mypy circe/ +uv run ruff check . +uv run ruff format . +uv run pre-commit run --all-files ``` ### Type Hints @@ -221,8 +224,8 @@ We follow [Semantic Versioning](https://semver.org/): - Update version in `pyproject.toml` - Update version in `circe/__init__.py` - Update `CHANGELOG.md` with release notes - - Ensure all tests pass: `pytest` - - Verify coverage is adequate: `pytest --cov` + - Ensure all tests pass: `uv run pytest` + - Verify coverage is adequate: `uv run pytest --cov` 2. **Build the Package** ```bash @@ -242,7 +245,7 @@ We follow [Semantic Versioning](https://semver.org/): twine upload --repository testpypi dist/* # Test installation - pip install --index-url https://test.pypi.org/simple/ ohdsi-circepy + pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ ohdsi-circe-python-alpha ``` 4. **Create Git Tag** diff --git a/INSTALLATION.md b/INSTALLATION.md index 2c11a7b3..55c3699f 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -7,11 +7,13 @@ - **Python 3.9 or higher** (Python 3.9+ recommended) - **Git** for cloning the repository -- **pip** package manager (usually included with Python) +- **uv** for the recommended, lockfile-backed workflow +- **pip** package manager for fallback installation paths ## Installation from Source (Current Method) Since this package is currently in private development, you'll need to install it directly from the GitHub repository. +The recommended workflow uses `uv` and the checked-in `uv.lock` for a reproducible environment. ### Step 1: Clone the Repository @@ -20,18 +22,21 @@ git clone https://github.com/OHDSI/Circepy.git cd Circepy ``` -### Step 2: Install in Development Mode +### Step 2: Install with uv -For development and testing, install the package in editable mode with all development dependencies: +For development and testing, sync the project environment with the locked dependency set: ```bash -pip install -e ".[dev]" +uv sync --extra dev + +# Install Git hooks +uv run pre-commit install ``` This will install: -- The `circe` package in editable mode (changes to source code are immediately available) -- All development tools (pytest, black, mypy, etc.) -- Optional dependencies for documentation and testing +- The `circe` package in editable mode +- The locked development toolchain (pytest, Ruff, pre-commit, etc.) +- A project-local virtual environment managed by `uv` ### Step 3: Verify Installation @@ -39,13 +44,13 @@ Test that the installation was successful: ```bash # Check the CLI is available -circe --help +uv run circe --help # Verify the version -python -c "from circe import __version__; print(f'CIRCE Python version: {__version__}')" +uv run python -c "from circe import __version__; print(f'CIRCE Python version: {__version__}')" # Run a quick test -pytest tests/ -v --maxfail=5 +uv run pytest tests/ -v --maxfail=5 ``` ## Installation for Usage Only @@ -53,53 +58,70 @@ pytest tests/ -v --maxfail=5 If you only want to use the package without development tools: ```bash -pip install -e . +uv sync ``` -This installs only the core dependencies (`pydantic` and `typing-extensions`). +This installs the project with its core dependencies into the `uv`-managed environment. -## PyPI Installation (Coming Soon) +## PyPI Installation > [!NOTE] -> **PyPI package is not yet available.** Once the package reaches stable release, it will be published to PyPI and you'll be able to install it with: +> The currently published alpha package is available as `ohdsi-circe-python-alpha`. +> The long-term package name is expected to become `ohdsi-circepy` once that package name is available for takeover. > > ```bash -> # This will work in future releases +> # Current alpha package +> pip install ohdsi-circe-python-alpha +> +> # Planned future package name > pip install ohdsi-circepy > ``` ## Installation Options -### Virtual Environment (Recommended) +### uv Extras -It's recommended to use a virtual environment to avoid dependency conflicts: +The project defines optional dependency groups that can be synced into the `uv` environment: ```bash -# Create virtual environment -python -m venv venv +# Core package only +uv sync -# Activate on macOS/Linux -source venv/bin/activate +# Development tools +uv sync --extra dev -# Activate on Windows -venv\Scripts\activate +# Documentation tools +uv sync --extra docs -# Install the package -pip install -e ".[dev]" +# Development and documentation tools +uv sync --extra dev --extra docs ``` -### Install Specific Extras +### pip Fallback (Optional) -The package provides several optional dependency groups: +If you are not using `uv`, use a virtual environment and install with `pip`. This path is supported, but the `uv` workflow above is the reproducible, maintainer-tested setup. ```bash -# Development tools only +# Create a virtual environment +python -m venv .venv + +# Activate on macOS/Linux +source .venv/bin/activate + +# Activate on Windows +.venv\Scripts\activate + +# Install the package with development tools pip install -e ".[dev]" +``` -# Documentation tools +You can also install specific extras with `pip`: + +```bash +# Documentation tools only pip install -e ".[docs]" -# All extras +# Development and documentation tools pip install -e ".[dev,docs]" ``` @@ -108,7 +130,7 @@ pip install -e ".[dev,docs]" ### Check Installed Version ```bash -circe --version +uv run circe --version ``` ### Run Example Scripts @@ -117,8 +139,8 @@ Navigate to the examples directory and run sample scripts: ```bash cd examples -python basic_cohort.py -python validate_cohort.py +uv run python basic_cohort.py +uv run python validate_cohort.py ``` ### Run the Test Suite @@ -127,10 +149,10 @@ Ensure your installation is working correctly: ```bash # Run all tests -pytest +uv run pytest # Run with coverage report -pytest --cov=circe --cov-report=html +uv run pytest --cov=circe --cov-report=html # View coverage report open htmlcov/index.html # macOS @@ -144,10 +166,10 @@ start htmlcov/index.html # Windows **Problem**: `ImportError: No module named 'circe'` -**Solution**: Ensure you installed in editable mode from the repository root: +**Solution**: Re-sync the environment from the repository root: ```bash cd Circepy -pip install -e . +uv sync --extra dev ``` ### CLI Not Found @@ -156,7 +178,7 @@ pip install -e . **Solution**: Ensure your Python scripts directory is in your PATH, or use: ```bash -python -m circe --help +uv run python -m circe --help ``` ### Version Mismatch @@ -165,18 +187,19 @@ python -m circe --help **Solution**: Reinstall the package: ```bash -pip uninstall ohdsi-circepy circe cd Circepy -pip install -e ".[dev]" +uv sync --extra dev ``` ### Permission Errors **Problem**: Permission denied during installation -**Solution**: Use a virtual environment (recommended) or install with `--user` flag: +**Solution**: Prefer the `uv` workflow, which manages a project-local environment. If you are using `pip`, use a virtual environment instead of `--user`: ```bash -pip install -e . --user +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" ``` ### Pydantic Validation Errors @@ -195,15 +218,21 @@ To get the latest changes from the repository: ```bash cd Circepy git pull origin main # or git pull origin develop for latest development -pip install -e ".[dev]" # Reinstall if dependencies changed +uv sync --extra dev ``` ## Uninstalling -To remove the package: +If you are using `uv`, remove the project environment: + +```bash +rm -rf .venv +``` + +If you installed with `pip`, remove the package with: ```bash -pip uninstall ohdsi-circepy +pip uninstall ohdsi-circe-python-alpha ``` ## System Requirements diff --git a/PUBLISHING_GUIDE.md b/PUBLISHING_GUIDE.md index 1d6967d8..d2f28e5b 100644 --- a/PUBLISHING_GUIDE.md +++ b/PUBLISHING_GUIDE.md @@ -63,15 +63,15 @@ Follow the detailed checklist in [`docs/RELEASE_CHECKLIST.md`](docs/RELEASE_CHEC 3. **Run all tests**: ```bash - pytest - pytest --cov + uv run pytest + uv run pytest --cov ``` 4. **Format and lint**: ```bash - black circe/ - isort circe/ - flake8 circe/ + uv run ruff check . + uv run ruff format . + uv run pre-commit run --all-files ``` 5. **Clean old builds**: @@ -290,7 +290,7 @@ Then update the workflow to use trusted publishing: - [ ] Update version in `pyproject.toml` and `circe/__init__.py` - [ ] Update `CHANGELOG.md` -- [ ] Run tests: `pytest --cov` +- [ ] Run tests: `uv run pytest --cov` - [ ] Clean build: `rm -rf dist/ build/` - [ ] Build package: `python -m build` - [ ] Check package: `twine check dist/*` diff --git a/README.md b/README.md index d5481bf7..e1af7030 100644 --- a/README.md +++ b/README.md @@ -37,27 +37,33 @@ CIRCE Python provides a comprehensive toolkit for working with OMOP CDM cohort d > [!NOTE] > This package is currently in private development. Install from source using Git. +> The recommended workflow uses `uv` and the checked-in `uv.lock` for a reproducible environment. ### From Source (Current Method) ```bash # Clone the repository -git clone https://github.com/OHDSI/ohdsi-circepy.git +git clone https://github.com/OHDSI/Circepy.git cd Circepy -# Install in development mode with all dependencies -pip install -e ".[dev]" +# Create a reproducible environment from uv.lock +uv sync # Verify installation -circe --help +uv run circe --help ``` See [INSTALLATION.md](INSTALLATION.md) for detailed installation instructions, troubleshooting, and setup options. -### From PyPI (Coming Soon) +If you are not using `uv`, see [INSTALLATION.md](INSTALLATION.md) for alternative setup options. The `uv` workflow is the recommended development path. + +### From PyPI > ```bash -> # Coming in future release +> # Current alpha package +> pip install ohdsi-circe-python-alpha +> +> # Planned future package name > pip install ohdsi-circepy > ``` @@ -350,33 +356,31 @@ circe process my_cohort.json --validate --sql my_cohort.sql --markdown my_cohort git clone https://github.com/OHDSI/Circepy.git cd Circepy -# Install with development dependencies -pip install -e ".[dev]" +# Install project and development dependencies from uv.lock +uv sync --extra dev + +# Install Git hooks +uv run pre-commit install # Verify installation -pytest --version -circe --help +uv run pytest --version +uv run circe --help ``` ### Running Tests ```bash -pytest +uv run pytest ``` All 3,400+ tests should pass. -### Code Formatting - -```bash -black circe/ -isort circe/ -``` - -### Type Checking +### Linting and Formatting ```bash -mypy circe/ +uv run ruff check . +uv run ruff format . +uv run pre-commit run --all-files ``` ## Compatibility Notes @@ -395,7 +399,7 @@ This implementation is designed to be compatible with OHDSI CIRCE-BE Java versio If you encounter import errors, ensure the package is properly installed: ```bash -pip install --upgrade ohdsi-circepy +uv sync ``` ### SQL Generation Issues @@ -451,11 +455,11 @@ Special thanks to: ## Support -- **Repository**: https://github.com/OHDSI/circepy -- **Issues**: https://github.com/OHDSI/circepy/issues +- **Repository**: https://github.com/OHDSI/Circepy +- **Issues**: https://github.com/OHDSI/Circepy/issues - **Installation Guide**: [INSTALLATION.md](INSTALLATION.md) -- **PyPI**: https://pypi.org/project/circepy/ (coming soon) -- **Documentation**: https://ohdsi-circepy.readthedocs.io/ (coming soon) +- **PyPI**: https://pypi.org/project/ohdsi-circe-python-alpha/ +- **Documentation**: https://ohdsi-circe-python-alpha.readthedocs.io/ ## Related Projects diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index bfa3d7ca..321b38fc 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -6,11 +6,11 @@ This checklist ensures a smooth and error-free release process for publishing to ### Code Quality -- [ ] All tests passing: `pytest` -- [ ] Code coverage meets minimum (71%+): `pytest --cov` -- [ ] No linting errors: `flake8 circe/` -- [ ] Code formatted: `black circe/` and `isort circe/` -- [ ] Type checking passes: `mypy circe/` (or acceptable errors documented) +- [ ] All tests passing: `uv run pytest` +- [ ] Code coverage meets minimum (71%+): `uv run pytest --cov` +- [ ] No linting errors: `uv run ruff check .` +- [ ] Code formatted: `uv run ruff format .` +- [ ] Pre-commit hooks pass: `uv run pre-commit run --all-files` - [ ] No security vulnerabilities in dependencies: `pip-audit` (if installed) ### Documentation @@ -37,7 +37,6 @@ This checklist ensures a smooth and error-free release process for publishing to ```bash # Remove old build artifacts rm -rf build/ dist/ *.egg-info/ -rm -rf circe.egg-info/ ohdsi-circepy.egg-info/ # Clear Python cache find . -type d -name __pycache__ -exec rm -r {} + 2>/dev/null || true @@ -58,8 +57,8 @@ python -m build - [ ] Build completed successfully - [ ] Generated files in `dist/`: - - [ ] `ohdsi-circepy-X.Y.Z.tar.gz` (source distribution) - - [ ] `ohdsi-circepy-X.Y.Z-py3-none-any.whl` (wheel) + - [ ] `ohdsi-circe-python-alpha-X.Y.Z.tar.gz` (source distribution) + - [ ] `ohdsi-circe-python-alpha-X.Y.Z-py3-none-any.whl` (wheel) ### 3. Check Package @@ -81,7 +80,7 @@ python -m venv test_env source test_env/bin/activate # On Windows: test_env\Scripts\activate # Install from wheel -pip install dist/ohdsi-circepy-X.Y.Z-py3-none-any.whl +pip install dist/ohdsi-circe-python-alpha-X.Y.Z-py3-none-any.whl # Test imports python -c "from circe import CohortExpression; print('✓ Import successful')" @@ -116,7 +115,7 @@ twine upload --repository testpypi dist/* ``` - [ ] Uploaded to TestPyPI successfully -- [ ] TestPyPI page loads: https://test.pypi.org/project/ohdsi-circepy/ +- [ ] TestPyPI page loads: https://test.pypi.org/project/ohdsi-circe-python-alpha/ ### 6. Test Installation from TestPyPI @@ -126,7 +125,7 @@ python -m venv testpypi_env source testpypi_env/bin/activate # Install from TestPyPI -pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ ohdsi-circepy +pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ ohdsi-circe-python-alpha # Test the installation python -c "from circe import CohortExpression; print('✓ TestPyPI installation works')" @@ -167,7 +166,7 @@ twine upload dist/* ``` - [ ] Uploaded to PyPI successfully -- [ ] PyPI page loads: https://pypi.org/project/ohdsi-circepy/ +- [ ] PyPI page loads: https://pypi.org/project/ohdsi-circe-python-alpha/ ### 9. Verify Production Installation @@ -177,7 +176,7 @@ python -m venv prod_test_env source prod_test_env/bin/activate # Install from PyPI -pip install ohdsi-circepy +pip install ohdsi-circe-python-alpha # Verify installation python -c "from circe import __version__; print(f'Installed version: {__version__}')" @@ -248,7 +247,7 @@ rm -rf prod_test_env 1. Create account at https://pypi.org/ 2. Go to Account Settings → API tokens -3. Generate token with scope for "ohdsi-circepy" project +3. Generate token with scope for "ohdsi-circe-python-alpha" project 4. Store securely (use `keyring` or `.pypirc`) ### TestPyPI API Token @@ -287,4 +286,3 @@ If a critical issue is discovered after release: - **Always test on TestPyPI** first for major releases - **Keep credentials secure** and rotate regularly - **Document any manual steps** needed for release - diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 71ce2e18..b0ad5d56 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -17,21 +17,29 @@ This project follows the [OHDSI Code of Conduct](https://www.ohdsi.org/web/wiki/ ### Development Setup +> [!NOTE] +> The recommended contributor workflow uses `uv` and the checked-in `uv.lock` for a reproducible environment. + 1. Fork the repository on GitHub 2. Clone your fork locally: ```bash - git clone https://github.com/YOUR_USERNAME/circe-be-python.git - cd circe-be-python + git clone https://github.com/YOUR_USERNAME/Circepy.git + cd Circepy + ``` + +3. Create the development environment: + ```bash + uv sync --extra dev ``` -3. Install the package in development mode: +4. Install Git hooks: ```bash - pip install -e ".[dev]" + uv run pre-commit install ``` -4. Run tests to ensure everything is working: +5. Run tests to ensure everything is working: ```bash - pytest + uv run pytest ``` ## Development Guidelines @@ -40,18 +48,15 @@ This project follows the [OHDSI Code of Conduct](https://www.ohdsi.org/web/wiki/ We use the following tools to maintain code quality: -- **Black** for code formatting -- **isort** for import sorting -- **flake8** for linting -- **mypy** for type checking +- **Ruff** for linting and formatting +- **pre-commit** for running repository hooks before commit Run these tools before committing: ```bash -black circe/ -isort circe/ -flake8 circe/ -mypy circe/ +uv run ruff check . +uv run ruff format . +uv run pre-commit run --all-files ``` ### Type Hints @@ -215,8 +220,8 @@ We follow [Semantic Versioning](https://semver.org/): - Update version in `pyproject.toml` - Update version in `circe/__init__.py` - Update `CHANGELOG.md` with release notes - - Ensure all tests pass: `pytest` - - Verify coverage is adequate: `pytest --cov` + - Ensure all tests pass: `uv run pytest` + - Verify coverage is adequate: `uv run pytest --cov` 2. **Build the Package** ```bash @@ -236,7 +241,7 @@ We follow [Semantic Versioning](https://semver.org/): twine upload --repository testpypi dist/* # Test installation - pip install --index-url https://test.pypi.org/simple/ ohdsi-circepy + pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ ohdsi-circe-python-alpha ``` 4. **Create Git Tag** diff --git a/docs/README.md b/docs/README.md index aff17dd9..f33248f7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,20 +7,19 @@ This directory contains the Sphinx documentation for CIRCE Python. ### Install Dependencies ```bash -pip install -e ".[docs]" +uv sync --extra docs ``` -Or install documentation requirements directly: +Or, if you are not using `uv`: ```bash -pip install -r docs/requirements.txt +pip install -e ".[docs]" ``` ### Build HTML Documentation ```bash -cd docs -make html +uv run make -C docs html ``` The generated HTML will be in `docs/_build/html/`. Open `docs/_build/html/index.html` in your browser. @@ -28,15 +27,13 @@ The generated HTML will be in `docs/_build/html/`. Open `docs/_build/html/index. ### Build PDF Documentation ```bash -cd docs -make latexpdf +uv run make -C docs latexpdf ``` ### Clean Build Files ```bash -cd docs -make clean +uv run make -C docs clean ``` ## Documentation Structure @@ -50,7 +47,7 @@ make clean ## Live Documentation Once published, documentation will be available at: -https://ohdsi-circepy.readthedocs.io/ +https://ohdsi-circe-python-alpha.readthedocs.io/ ## Contributing to Documentation @@ -65,4 +62,3 @@ https://ohdsi-circepy.readthedocs.io/ * Link between pages using `:doc:` role * Auto-generate API docs with autodoc directives * Keep examples up-to-date with package changes - diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index bfa3d7ca..321b38fc 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -6,11 +6,11 @@ This checklist ensures a smooth and error-free release process for publishing to ### Code Quality -- [ ] All tests passing: `pytest` -- [ ] Code coverage meets minimum (71%+): `pytest --cov` -- [ ] No linting errors: `flake8 circe/` -- [ ] Code formatted: `black circe/` and `isort circe/` -- [ ] Type checking passes: `mypy circe/` (or acceptable errors documented) +- [ ] All tests passing: `uv run pytest` +- [ ] Code coverage meets minimum (71%+): `uv run pytest --cov` +- [ ] No linting errors: `uv run ruff check .` +- [ ] Code formatted: `uv run ruff format .` +- [ ] Pre-commit hooks pass: `uv run pre-commit run --all-files` - [ ] No security vulnerabilities in dependencies: `pip-audit` (if installed) ### Documentation @@ -37,7 +37,6 @@ This checklist ensures a smooth and error-free release process for publishing to ```bash # Remove old build artifacts rm -rf build/ dist/ *.egg-info/ -rm -rf circe.egg-info/ ohdsi-circepy.egg-info/ # Clear Python cache find . -type d -name __pycache__ -exec rm -r {} + 2>/dev/null || true @@ -58,8 +57,8 @@ python -m build - [ ] Build completed successfully - [ ] Generated files in `dist/`: - - [ ] `ohdsi-circepy-X.Y.Z.tar.gz` (source distribution) - - [ ] `ohdsi-circepy-X.Y.Z-py3-none-any.whl` (wheel) + - [ ] `ohdsi-circe-python-alpha-X.Y.Z.tar.gz` (source distribution) + - [ ] `ohdsi-circe-python-alpha-X.Y.Z-py3-none-any.whl` (wheel) ### 3. Check Package @@ -81,7 +80,7 @@ python -m venv test_env source test_env/bin/activate # On Windows: test_env\Scripts\activate # Install from wheel -pip install dist/ohdsi-circepy-X.Y.Z-py3-none-any.whl +pip install dist/ohdsi-circe-python-alpha-X.Y.Z-py3-none-any.whl # Test imports python -c "from circe import CohortExpression; print('✓ Import successful')" @@ -116,7 +115,7 @@ twine upload --repository testpypi dist/* ``` - [ ] Uploaded to TestPyPI successfully -- [ ] TestPyPI page loads: https://test.pypi.org/project/ohdsi-circepy/ +- [ ] TestPyPI page loads: https://test.pypi.org/project/ohdsi-circe-python-alpha/ ### 6. Test Installation from TestPyPI @@ -126,7 +125,7 @@ python -m venv testpypi_env source testpypi_env/bin/activate # Install from TestPyPI -pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ ohdsi-circepy +pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ ohdsi-circe-python-alpha # Test the installation python -c "from circe import CohortExpression; print('✓ TestPyPI installation works')" @@ -167,7 +166,7 @@ twine upload dist/* ``` - [ ] Uploaded to PyPI successfully -- [ ] PyPI page loads: https://pypi.org/project/ohdsi-circepy/ +- [ ] PyPI page loads: https://pypi.org/project/ohdsi-circe-python-alpha/ ### 9. Verify Production Installation @@ -177,7 +176,7 @@ python -m venv prod_test_env source prod_test_env/bin/activate # Install from PyPI -pip install ohdsi-circepy +pip install ohdsi-circe-python-alpha # Verify installation python -c "from circe import __version__; print(f'Installed version: {__version__}')" @@ -248,7 +247,7 @@ rm -rf prod_test_env 1. Create account at https://pypi.org/ 2. Go to Account Settings → API tokens -3. Generate token with scope for "ohdsi-circepy" project +3. Generate token with scope for "ohdsi-circe-python-alpha" project 4. Store securely (use `keyring` or `.pypirc`) ### TestPyPI API Token @@ -287,4 +286,3 @@ If a critical issue is discovered after release: - **Always test on TestPyPI** first for major releases - **Keep credentials secure** and rotate regularly - **Document any manual steps** needed for release - diff --git a/docs/cli.rst b/docs/cli.rst index d0d2b569..8f511ad9 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -363,10 +363,10 @@ If ``circe`` command is not found after installation: .. code-block:: bash # Try with python -m - python -m circe.cli --help + uv run python -m circe.cli --help - # Or reinstall - pip install --force-reinstall ohdsi-circe + # Or re-sync the environment + uv sync --extra dev Permission Errors ~~~~~~~~~~~~~~~~~ @@ -400,4 +400,3 @@ Next Steps * :doc:`user_guide/cohort_definitions` - Learn about cohort definitions * :doc:`user_guide/validation` - Understand validation * :doc:`user_guide/sql_generation` - Master SQL generation - diff --git a/docs/developer/contributing.rst b/docs/developer/contributing.rst index fee7a6c8..bf35e916 100644 --- a/docs/developer/contributing.rst +++ b/docs/developer/contributing.rst @@ -8,26 +8,25 @@ Development Setup .. code-block:: bash - git clone https://github.com/OHDSI/circe-be-python.git - cd circe-be-python - pip install -e ".[dev]" + git clone https://github.com/OHDSI/Circepy.git + cd Circepy + uv sync --extra dev + uv run pre-commit install Running Tests ------------- .. code-block:: bash - pytest + uv run pytest Code Quality ------------ .. code-block:: bash - black circe/ - isort circe/ - flake8 circe/ - mypy circe/ + uv run ruff check . + uv run ruff format . + uv run pre-commit run --all-files For more details, see the main CONTRIBUTING.md file. - diff --git a/docs/faq.rst b/docs/faq.rst index 91f29a25..cb2f9e91 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -24,7 +24,6 @@ The package automatically handles both camelCase and snake_case field names. Where can I get help? ---------------------- -* GitHub Issues: https://github.com/OHDSI/circe-be-python/issues -* GitHub Discussions: https://github.com/OHDSI/circe-be-python/discussions +* GitHub Issues: https://github.com/OHDSI/Circepy/issues +* GitHub Discussions: https://github.com/OHDSI/Circepy/discussions * OHDSI Forums: https://forums.ohdsi.org/ - diff --git a/docs/index.rst b/docs/index.rst index 225492c0..6f41f157 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -111,9 +111,9 @@ Quick Example Support ------- -* **Repository**: https://github.com/OHDSI/circe-be-python -* **Issues**: https://github.com/OHDSI/circe-be-python/issues -* **PyPI**: https://pypi.org/project/ohdsi-circe/ +* **Repository**: https://github.com/OHDSI/Circepy +* **Issues**: https://github.com/OHDSI/Circepy/issues +* **PyPI**: https://pypi.org/project/ohdsi-circe-python-alpha/ Indices and tables ================== @@ -121,4 +121,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/docs/installation.rst b/docs/installation.rst index ca5005e5..8750fe18 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -4,41 +4,56 @@ Installation Requirements ------------ * Python 3.9 or higher -* pip (Python package installer) +* uv (recommended for the reproducible, lockfile-backed workflow) +* pip (supported as a fallback installer) -Basic Installation ------------------- +Source Installation +------------------- -Install CIRCE Python from PyPI using pip: +Since the package is still in active development, the recommended path is to install from source with ``uv``: .. code-block:: bash - pip install ohdsi-circe + git clone https://github.com/OHDSI/Circepy.git + cd Circepy + uv sync --extra dev + uv run pre-commit install -This will install the package and all required dependencies. +This creates a project-local environment with the locked development toolchain. -Development Installation ------------------------- +pip Fallback +------------ -If you want to contribute to CIRCE Python or run the tests, install with development dependencies: +If you are not using ``uv``, use a virtual environment and install with ``pip``: .. code-block:: bash - # Clone the repository - git clone https://github.com/OHDSI/circe-be-python.git - cd circe-be-python + python -m venv .venv + source .venv/bin/activate - # Install in development mode with dev dependencies pip install -e ".[dev]" -This installs the package in editable mode with additional development tools: +Development tools include: * pytest - Testing framework * pytest-cov - Coverage reporting -* black - Code formatter -* isort - Import sorter -* flake8 - Linter -* mypy - Type checker +* Ruff - Linting and formatting +* pre-commit - Git hook runner + +PyPI Installation +----------------- + +The current alpha package is available on PyPI as: + +.. code-block:: bash + + pip install ohdsi-circe-python-alpha + +The long-term package name is expected to become: + +.. code-block:: bash + + pip install ohdsi-circepy Optional Dependencies --------------------- @@ -50,7 +65,13 @@ To build the documentation locally: .. code-block:: bash - pip install ohdsi-circe[docs] + uv sync --extra docs + +Or, with ``pip``: + +.. code-block:: bash + + pip install -e ".[docs]" This installs: @@ -65,13 +86,13 @@ After installation, verify that CIRCE Python is working correctly: .. code-block:: bash # Check CLI is available - circe --help + uv run circe --help # Test Python import - python -c "from circe import CohortExpression; print('✓ Installation successful')" + uv run python -c "from circe import CohortExpression; print('✓ Installation successful')" # Check version - python -c "from circe import __version__; print(f'Version: {__version__}')" + uv run python -c "from circe import __version__; print(f'Version: {__version__}')" Expected output: @@ -90,11 +111,8 @@ If you encounter import errors after installation: .. code-block:: bash - # Upgrade to latest version - pip install --upgrade ohdsi-circe - - # Verify installation - pip show ohdsi-circe + cd Circepy + uv sync --extra dev Permission Errors ~~~~~~~~~~~~~~~~~ @@ -113,7 +131,7 @@ If you get permission errors during installation, use a virtual environment: circe_env\Scripts\activate # Install - pip install ohdsi-circe + pip install -e ".[dev]" Python Version Issues ~~~~~~~~~~~~~~~~~~~~~ @@ -128,22 +146,23 @@ If you have multiple Python versions installed, you may need to use ``python3`` .. code-block:: bash - python3 -m pip install ohdsi-circe + python3 -m pip install -e ".[dev]" Upgrading --------- -To upgrade to the latest version: +To refresh the ``uv`` environment after pulling new changes: .. code-block:: bash - pip install --upgrade ohdsi-circe + git pull origin main + uv sync --extra dev -To upgrade to a specific version: +If you installed with ``pip``, reinstall after pulling: .. code-block:: bash - pip install ohdsi-circe==1.0.0 + pip install -e ".[dev]" Uninstalling ------------ @@ -152,7 +171,7 @@ To uninstall CIRCE Python: .. code-block:: bash - pip uninstall ohdsi-circe + pip uninstall ohdsi-circe-python-alpha Next Steps ---------- @@ -160,4 +179,3 @@ Next Steps * :doc:`quickstart` - Get started with CIRCE Python * :doc:`cli` - Learn about the command-line interface * :doc:`user_guide/cohort_definitions` - Create your first cohort definition - diff --git a/docs/quickstart.rst b/docs/quickstart.rst index d75d3622..8b282443 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -6,11 +6,13 @@ This guide will help you get started with CIRCE Python in just a few minutes. Installation ------------ -First, install CIRCE Python: +First, install CIRCE Python from source: .. code-block:: bash - pip install ohdsi-circe + git clone https://github.com/OHDSI/Circepy.git + cd Circepy + uv sync --extra dev Using the CLI ------------- @@ -22,21 +24,21 @@ Validate a Cohort .. code-block:: bash - circe validate my_cohort.json + uv run circe validate my_cohort.json Generate SQL ~~~~~~~~~~~~ .. code-block:: bash - circe generate-sql my_cohort.json --output cohort.sql + uv run circe generate-sql my_cohort.json --output cohort.sql Render Markdown ~~~~~~~~~~~~~~~ .. code-block:: bash - circe render-markdown my_cohort.json --output cohort.md + uv run circe render-markdown my_cohort.json --output cohort.md Using the Python API -------------------- @@ -237,4 +239,3 @@ Next Steps * :doc:`user_guide/validation` - Validate your cohorts * :doc:`api/cohortdefinition` - API reference for cohort definitions * :doc:`user_guide/examples` - More examples and use cases - diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index bf886afd..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Documentation build requirements -sphinx>=5.0.0 -sphinx-rtd-theme>=1.0.0 -pydantic>=2.0.0 -typing-extensions>=4.0.0 -myst-parser>=0.18.0 - diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index eb5a06a1..361ea2da 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -11,7 +11,8 @@ If you encounter import errors: .. code-block:: bash - pip install --upgrade ohdsi-circe + cd Circepy + uv sync --extra dev SQL Generation Issues ~~~~~~~~~~~~~~~~~~~~~ @@ -38,4 +39,3 @@ If you can't resolve an issue: * CIRCE version * Error message * Minimal reproduction example - diff --git a/pyproject.toml b/pyproject.toml index a427a7d7..5ae4b738 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,10 +43,8 @@ dependencies = [ dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", - "black>=22.0.0", - "isort>=5.0.0", - "flake8>=5.0.0", "mypy>=1.0.0", + "pre-commit>=4.0.0", "ruff>=0.1.0", "sqlglot>=23.0.0", "duckdb>=0.9.0", @@ -58,6 +56,7 @@ dev = [ docs = [ "sphinx>=5.0.0", "sphinx-rtd-theme>=1.0.0", + "myst-parser>=0.18.0", ] ibis = [ "ibis-framework>=11.0.0; python_version >= '3.9'", @@ -198,4 +197,3 @@ indent-style = "space" skip-magic-trailing-comma = false # Automatically detect the appropriate line ending. line-ending = "auto" - diff --git a/uv.lock b/uv.lock index ae0b7659..5b22cf81 100644 --- a/uv.lock +++ b/uv.lock @@ -118,54 +118,29 @@ wheels = [ ] [[package]] -name = "black" -version = "25.11.0" +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] -dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "mypy-extensions", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pathspec", marker = "python_full_version < '3.10'" }, - { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytokens", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/d2/6caccbc96f9311e8ec3378c296d4f4809429c43a6cd2394e3c390e86816d/black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e", size = 1743501, upload-time = "2025-11-10T01:59:06.202Z" }, - { url = "https://files.pythonhosted.org/packages/69/35/b986d57828b3f3dccbf922e2864223197ba32e74c5004264b1c62bc9f04d/black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0", size = 1597308, upload-time = "2025-11-10T01:57:58.633Z" }, - { url = "https://files.pythonhosted.org/packages/39/8e/8b58ef4b37073f52b64a7b2dd8c9a96c84f45d6f47d878d0aa557e9a2d35/black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37", size = 1656194, upload-time = "2025-11-10T01:57:10.909Z" }, - { url = "https://files.pythonhosted.org/packages/8d/30/9c2267a7955ecc545306534ab88923769a979ac20a27cf618d370091e5dd/black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03", size = 1347996, upload-time = "2025-11-10T01:57:22.391Z" }, - { url = "https://files.pythonhosted.org/packages/c4/62/d304786b75ab0c530b833a89ce7d997924579fb7484ecd9266394903e394/black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a", size = 1727891, upload-time = "2025-11-10T02:01:40.507Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/ffe8a006aa522c9e3f430e7b93568a7b2163f4b3f16e8feb6d8c3552761a/black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170", size = 1581875, upload-time = "2025-11-10T01:57:51.192Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c8/7c8bda3108d0bb57387ac41b4abb5c08782b26da9f9c4421ef6694dac01a/black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc", size = 1642716, upload-time = "2025-11-10T01:56:51.589Z" }, - { url = "https://files.pythonhosted.org/packages/34/b9/f17dea34eecb7cc2609a89627d480fb6caea7b86190708eaa7eb15ed25e7/black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e", size = 1352904, upload-time = "2025-11-10T01:59:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/7f/12/5c35e600b515f35ffd737da7febdb2ab66bb8c24d88560d5e3ef3d28c3fd/black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", size = 1772831, upload-time = "2025-11-10T02:03:47Z" }, - { url = "https://files.pythonhosted.org/packages/1a/75/b3896bec5a2bb9ed2f989a970ea40e7062f8936f95425879bbe162746fe5/black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", size = 1608520, upload-time = "2025-11-10T01:58:46.895Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b5/2bfc18330eddbcfb5aab8d2d720663cd410f51b2ed01375f5be3751595b0/black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", size = 1682719, upload-time = "2025-11-10T01:56:55.24Z" }, - { url = "https://files.pythonhosted.org/packages/96/fb/f7dc2793a22cdf74a72114b5ed77fe3349a2e09ef34565857a2f917abdf2/black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", size = 1362684, upload-time = "2025-11-10T01:57:07.639Z" }, - { url = "https://files.pythonhosted.org/packages/ad/47/3378d6a2ddefe18553d1115e36aea98f4a90de53b6a3017ed861ba1bd3bc/black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b", size = 1772446, upload-time = "2025-11-10T02:02:16.181Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4b/0f00bfb3d1f7e05e25bfc7c363f54dc523bb6ba502f98f4ad3acf01ab2e4/black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd", size = 1607983, upload-time = "2025-11-10T02:02:52.502Z" }, - { url = "https://files.pythonhosted.org/packages/99/fe/49b0768f8c9ae57eb74cc10a1f87b4c70453551d8ad498959721cc345cb7/black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993", size = 1682481, upload-time = "2025-11-10T01:57:12.35Z" }, - { url = "https://files.pythonhosted.org/packages/55/17/7e10ff1267bfa950cc16f0a411d457cdff79678fbb77a6c73b73a5317904/black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c", size = 1363869, upload-time = "2025-11-10T01:58:24.608Z" }, - { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" }, - { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" }, - { url = "https://files.pythonhosted.org/packages/d5/9a/5b2c0e3215fe748fcf515c2dd34658973a1210bf610e24de5ba887e4f1c8/black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06", size = 1743063, upload-time = "2025-11-10T02:02:43.175Z" }, - { url = "https://files.pythonhosted.org/packages/a1/20/245164c6efc27333409c62ba54dcbfbe866c6d1957c9a6c0647786e950da/black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2", size = 1596867, upload-time = "2025-11-10T02:00:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6f/1a3859a7da205f3d50cf3a8bec6bdc551a91c33ae77a045bb24c1f46ab54/black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc", size = 1655678, upload-time = "2025-11-10T01:57:09.028Z" }, - { url = "https://files.pythonhosted.org/packages/56/1a/6dec1aeb7be90753d4fcc273e69bc18bfd34b353223ed191da33f7519410/black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc", size = 1347452, upload-time = "2025-11-10T01:57:01.871Z" }, - { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" }, -] - -[[package]] -name = "black" -version = "26.3.1" + +[[package]] +name = "cfgv" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -180,53 +155,9 @@ resolution-markers = [ "python_full_version == '3.11.*'", "python_full_version == '3.10.*'", ] -dependencies = [ - { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "mypy-extensions", marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pathspec", marker = "python_full_version >= '3.10'" }, - { name = "platformdirs", version = "4.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytokens", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, - { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, - { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, - { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, - { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, - { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, - { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, -] - -[[package]] -name = "certifi" -version = "2026.2.25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] @@ -350,46 +281,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -701,6 +592,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl", hash = "sha256:ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b", size = 91378, upload-time = "2025-09-03T19:40:39.679Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -855,7 +755,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -863,17 +763,37 @@ wheels = [ ] [[package]] -name = "flake8" -version = "7.3.0" +name = "filelock" +version = "3.19.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mccabe" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] [[package]] @@ -1033,6 +953,40 @@ postgres = [ { name = "rich", marker = "python_full_version >= '3.10'" }, ] +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1122,43 +1076,6 @@ 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 = "isort" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" }, -] - -[[package]] -name = "isort" -version = "8.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'emscripten'", - "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, -] - [[package]] name = "javalang" version = "0.13.0" @@ -1361,10 +1278,11 @@ name = "markdown-it-py" version = "3.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ + "python_full_version == '3.10.*'", "python_full_version < '3.10'", ] dependencies = [ - { name = "mdurl", marker = "python_full_version < '3.10'" }, + { name = "mdurl", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ @@ -1386,10 +1304,9 @@ resolution-markers = [ "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", ] dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.10'" }, + { name = "mdurl", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -1493,12 +1410,44 @@ wheels = [ ] [[package]] -name = "mccabe" -version = "0.7.0" +name = "mdit-py-plugins" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, ] [[package]] @@ -1571,6 +1520,85 @@ 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 = "myst-parser" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/e2f13dac02f599980798c01156393b781aec983b52a6e4057ee58f07c43a/myst_parser-3.0.1.tar.gz", hash = "sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87", size = 92392, upload-time = "2024-04-28T20:22:42.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/de/21aa8394f16add8f7427f0a1326ccd2b3a2a8a3245c9252bc5ac034c6155/myst_parser-3.0.1-py3-none-any.whl", hash = "sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1", size = 83163, upload-time = "2024-04-28T20:22:39.985Z" }, +] + +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "jinja2", marker = "python_full_version == '3.10.*'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "mdit-py-plugins", version = "0.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "pyyaml", marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mdit-py-plugins", version = "0.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "numpy" version = "2.0.2" @@ -1803,20 +1831,17 @@ dependencies = [ [package.optional-dependencies] dev = [ - { name = "black", version = "25.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "black", version = "26.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "deepdiff" }, { name = "duckdb", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "duckdb", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "flake8" }, { name = "ibis-framework", version = "11.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["duckdb"], marker = "python_full_version < '3.10'" }, { name = "ibis-framework", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["duckdb"], marker = "python_full_version >= '3.10'" }, - { name = "isort", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "isort", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "javalang" }, { name = "mypy" }, { name = "polars", version = "1.36.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "polars", version = "1.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pre-commit", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-cov" }, @@ -1824,6 +1849,9 @@ dev = [ { name = "sqlglot" }, ] docs = [ + { name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, @@ -1854,21 +1882,20 @@ waveform = [ [package.metadata] requires-dist = [ - { name = "black", marker = "extra == 'dev'", specifier = ">=22.0.0" }, { name = "deepdiff", marker = "extra == 'dev'", specifier = ">=8.6.0" }, { name = "duckdb", marker = "extra == 'dev'", specifier = ">=0.9.0" }, - { name = "flake8", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "ibis-framework", marker = "python_full_version >= '3.9' and extra == 'ibis'", specifier = ">=11.0.0" }, { name = "ibis-framework", extras = ["databricks"], marker = "python_full_version >= '3.9' and extra == 'ibis-databricks'", specifier = ">=11.0.0" }, { name = "ibis-framework", extras = ["duckdb"], marker = "python_full_version >= '3.9' and extra == 'ibis-duckdb'", specifier = ">=11.0.0" }, { name = "ibis-framework", extras = ["duckdb"], marker = "extra == 'dev'", specifier = ">=11.0.0" }, { name = "ibis-framework", extras = ["postgres"], marker = "python_full_version >= '3.9' and extra == 'ibis-postgres'", specifier = ">=11.0.0" }, - { name = "isort", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "javalang", marker = "extra == 'dev'", specifier = ">=0.13.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=0.18.0" }, { name = "polars", marker = "python_full_version >= '3.9' and extra == 'ibis-duckdb'", specifier = ">=0.20.0" }, { name = "polars", marker = "extra == 'dev'", specifier = ">=0.20.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", marker = "extra == 'waveform'", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, @@ -2198,6 +2225,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/eb/936f5eeae196e8c8aaabe5f7d98891be8a5bbc741d50ce5c60f55575ad29/polars_runtime_32-1.39.0-cp310-abi3-win_arm64.whl", hash = "sha256:d69abde5f148566860bbe910010847bd7791e72f7c8063a4d2c462246a33a72a", size = 41885761, upload-time = "2026-03-12T14:23:16.773Z" }, ] +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "identify", version = "2.6.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "nodeenv", marker = "python_full_version < '3.10'" }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "virtualenv", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "cfgv", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "identify", version = "2.6.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "nodeenv", marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, + { name = "virtualenv", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + [[package]] name = "psycopg" version = "3.2.13" @@ -2381,15 +2456,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/75/e64d3d40a741e2be21d69154f4e5c43a66f0c603c5ef11f49e01429a5932/pybreaker-1.4.1-py3-none-any.whl", hash = "sha256:b4dab4a05195b7f2a64a6c1a6c4ba7a96534ef56ea7210e6bcb59f28897160e0", size = 12915, upload-time = "2025-09-21T15:12:02.284Z" }, ] -[[package]] -name = "pycodestyle" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, -] - [[package]] name = "pydantic" version = "2.12.5" @@ -2536,15 +2602,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pyflakes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -2647,47 +2704,18 @@ wheels = [ ] [[package]] -name = "pytokens" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, - { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, - { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, - { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, - { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, - { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, - { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, - { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, - { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, - { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, - { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, - { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, - { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, - { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, - { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, - { url = "https://files.pythonhosted.org/packages/51/2a/f125667ce48105bf1f4e50e03cfa7b24b8c4f47684d7f1cf4dcb6f6b1c15/pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3", size = 161464, upload-time = "2026-01-30T01:03:39.11Z" }, - { url = "https://files.pythonhosted.org/packages/40/df/065a30790a7ca6bb48ad9018dd44668ed9135610ebf56a2a4cb8e513fd5c/pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1", size = 246159, upload-time = "2026-01-30T01:03:40.131Z" }, - { url = "https://files.pythonhosted.org/packages/a5/1c/fd09976a7e04960dabc07ab0e0072c7813d566ec67d5490a4c600683c158/pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db", size = 259120, upload-time = "2026-01-30T01:03:41.233Z" }, - { url = "https://files.pythonhosted.org/packages/52/49/59fdc6fc5a390ae9f308eadeb97dfc70fc2d804ffc49dd39fc97604622ec/pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1", size = 262196, upload-time = "2026-01-30T01:03:42.696Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e7/d6734dccf0080e3dc00a55b0827ab5af30c886f8bc127bbc04bc3445daec/pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a", size = 103510, upload-time = "2026-01-30T01:03:43.915Z" }, - { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +name = "python-discovery" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "filelock", version = "3.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" }, ] [[package]] @@ -2699,6 +2727,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -2719,8 +2820,8 @@ name = "rich" version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } @@ -3187,6 +3288,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "filelock", version = "3.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +] + [[package]] name = "zipp" version = "3.23.0" From bf4418752d3745b1d0b84a87c3e392285b463faa Mon Sep 17 00:00:00 2001 From: egillax Date: Tue, 17 Mar 2026 21:18:45 +0100 Subject: [PATCH 26/62] docs: fix sphinx build warnings --- docs/CONTRIBUTING.md | 4 ++++ docs/README.md | 4 ++++ docs/RELEASE_CHECKLIST.md | 4 ++++ docs/_static/.gitkeep | 1 + docs/api/api_functions.rst | 6 +----- docs/api/cohortdefinition.rst | 3 +-- docs/conf.py | 1 - docs/waveform_extension.md | 5 ++++- 8 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 docs/_static/.gitkeep diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index b0ad5d56..5c032924 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,3 +1,7 @@ +--- +orphan: true +--- + # Contributing to CIRCE Python Implementation Thank you for your interest in contributing to the CIRCE Python implementation! This document provides guidelines for contributing to the project. diff --git a/docs/README.md b/docs/README.md index f33248f7..1c5e77f2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,7 @@ +--- +orphan: true +--- + # CIRCE Python Documentation This directory contains the Sphinx documentation for CIRCE Python. diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 321b38fc..77409c52 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -1,3 +1,7 @@ +--- +orphan: true +--- + # Release Checklist This checklist ensures a smooth and error-free release process for publishing to PyPI. diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/_static/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/api/api_functions.rst b/docs/api/api_functions.rst index 695e9935..a4ea7e54 100644 --- a/docs/api/api_functions.rst +++ b/docs/api/api_functions.rst @@ -3,10 +3,7 @@ High-Level API Functions Convenience functions for common operations. -.. automodule:: circe.api - :members: - :undoc-members: - :show-inheritance: +.. currentmodule:: circe.api cohort_expression_from_json ---------------------------- @@ -22,4 +19,3 @@ cohort_print_friendly --------------------- .. autofunction:: circe.api.cohort_print_friendly - diff --git a/docs/api/cohortdefinition.rst b/docs/api/cohortdefinition.rst index 4fdef5cd..30fde64d 100644 --- a/docs/api/cohortdefinition.rst +++ b/docs/api/cohortdefinition.rst @@ -14,7 +14,7 @@ CohortExpression Primary Criteria ---------------- -.. autoclass:: circe.cohortdefinition.core.PrimaryCriteria +.. autoclass:: circe.cohortdefinition.criteria.PrimaryCriteria :members: :undoc-members: :show-inheritance: @@ -64,4 +64,3 @@ Supporting Classes .. autoclass:: circe.cohortdefinition.core.WindowBound :members: :undoc-members: - diff --git a/docs/conf.py b/docs/conf.py index a04a5c6e..c19e43a7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -60,7 +60,6 @@ "canonical_url": "", "analytics_id": "", "logo_only": False, - "display_version": True, "prev_next_buttons_location": "bottom", "style_external_links": False, "style_nav_header_background": "#2980B9", diff --git a/docs/waveform_extension.md b/docs/waveform_extension.md index cb6c0e07..822ba65c 100644 --- a/docs/waveform_extension.md +++ b/docs/waveform_extension.md @@ -1,3 +1,7 @@ +--- +orphan: true +--- + # OHDSI Waveform Extension for circe_py This extension implements the full [OHDSI Waveform Extension specification](https://ohdsi.github.io/WaveformWG/waveform-tables.html) for cohort definition and SQL generation in circe_py. @@ -101,4 +105,3 @@ cd /path/to/circe_py export PYTHONPATH=. python3 examples/waveform_extension.py ``` - From 714145c8e8b18a284a8805b719032b78bd8dbfa8 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Tue, 17 Mar 2026 07:26:54 -0700 Subject: [PATCH 27/62] Fixes for waveform models --- circe/extensions/waveform/criteria.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/circe/extensions/waveform/criteria.py b/circe/extensions/waveform/criteria.py index 8cf0cb0b..d79f9e59 100644 --- a/circe/extensions/waveform/criteria.py +++ b/circe/extensions/waveform/criteria.py @@ -295,3 +295,13 @@ class WaveformFeature(Criteria): # Rebuild models to resolve forward references +# CriteriaGroup is defined after Criteria in criteria.py, so subclasses +# that inherit the `correlated_criteria: Optional["CriteriaGroup"]` field +# must call model_rebuild() once CriteriaGroup is importable. +from circe.cohortdefinition.criteria import CriteriaGroup # noqa: E402 + +_ns = {"CriteriaGroup": CriteriaGroup} +WaveformOccurrence.model_rebuild(_types_namespace=_ns) +WaveformRegistry.model_rebuild(_types_namespace=_ns) +WaveformChannelMetadata.model_rebuild(_types_namespace=_ns) +WaveformFeature.model_rebuild(_types_namespace=_ns) From 5f32c93d4309962c9a35f5114a15c39cae0e4d65 Mon Sep 17 00:00:00 2001 From: egillax Date: Wed, 18 Mar 2026 15:55:02 +0100 Subject: [PATCH 28/62] test: align docs and extension tests --- tests/test_documentation.py | 7 ++++--- tests/test_extension_system.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 578837a1..d2e55d71 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -93,13 +93,14 @@ def test_installation_instructions_present(self): readme = (root / "README.md").read_text() assert "## Installation" in readme assert "git clone" in readme.lower() - assert "pip install -e" in readme.lower() + assert "uv sync" in readme.lower() # INSTALLATION.md should exist and have comprehensive instructions installation = (root / "INSTALLATION.md").read_text() assert "git clone" in installation.lower() assert "troubleshooting" in installation.lower() - assert "pip install -e" in installation.lower() + assert "uv sync --extra dev" in installation.lower() + assert 'pip install -e ".[dev]"' in installation.lower() # CONTRIBUTING.md should have setup instructions contributing = (root / "CONTRIBUTING.md").read_text() @@ -157,7 +158,7 @@ def test_contributing_has_code_style_section(self): contributing = (root / "CONTRIBUTING.md").read_text() assert "## Code Style" in contributing or "### Code Style" in contributing - assert "black" in contributing.lower() + assert "ruff" in contributing.lower() assert "pytest" in contributing.lower() def test_examples_readme_references_parent_docs(self): diff --git a/tests/test_extension_system.py b/tests/test_extension_system.py index 7aeba6e9..999c5b9e 100644 --- a/tests/test_extension_system.py +++ b/tests/test_extension_system.py @@ -1,5 +1,5 @@ import json -from typing import Optional, list, set +from typing import Optional from pydantic import AliasChoices, Field From 1b1e1cf83585fbb9fc6fc23d3b9468d6ac968d9d Mon Sep 17 00:00:00 2001 From: egillax Date: Wed, 18 Mar 2026 15:55:58 +0100 Subject: [PATCH 29/62] fix: avoid pydantic deprecation warnings --- circe/cohortdefinition/criteria.py | 2 +- tests/test_extension_system.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index 8220f5e5..f8c5a786 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -257,7 +257,7 @@ def _serialize_polymorphic(self, serializer, info): # even if serialized via a base class Union link. # We manually build the dict to avoid infinite recursion with model_dump() data = {} - for field_name, field_info in self.model_fields.items(): + for field_name, field_info in type(self).model_fields.items(): value = getattr(self, field_name) if value is not None: # Use serialization_alias if it exists, otherwise use field name diff --git a/tests/test_extension_system.py b/tests/test_extension_system.py index 999c5b9e..e76c16b0 100644 --- a/tests/test_extension_system.py +++ b/tests/test_extension_system.py @@ -3,7 +3,7 @@ from pydantic import AliasChoices, Field -from circe.cohortdefinition import CohortExpression, PrimaryCriteria +from circe.cohortdefinition import CohortExpression, CriteriaGroup, PrimaryCriteria from circe.cohortdefinition.builders.base import CriteriaSqlBuilder from circe.cohortdefinition.builders.utils import BuilderOptions, CriteriaColumn from circe.cohortdefinition.cohort_expression_query_builder import ( @@ -39,7 +39,7 @@ class WeatherCondition(Criteria): # Important: Rebuild models to resolve forward references inherited from Criteria -WeatherCondition.model_rebuild() +WeatherCondition.model_rebuild(_types_namespace={"CriteriaGroup": CriteriaGroup}) class WeatherConditionSqlBuilder(CriteriaSqlBuilder[WeatherCondition]): From 1ec9af93c69ff86740f9667ef7ba7da89d184053 Mon Sep 17 00:00:00 2001 From: egillax Date: Wed, 18 Mar 2026 16:01:55 +0100 Subject: [PATCH 30/62] test: filter duckdb ibis deprecation warning --- tests/test_execution_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_execution_api.py b/tests/test_execution_api.py index 9d75f2c8..8e6feeec 100644 --- a/tests/test_execution_api.py +++ b/tests/test_execution_api.py @@ -157,6 +157,9 @@ def test_has_end_strategy_handles_polymorphic_models(): assert has_end_strategy(CustomEraStrategy(drug_codeset_id=123)) is True +@pytest.mark.filterwarnings( + "ignore:fetch_arrow_table\\(\\) is deprecated, use to_arrow_table\\(\\) instead\\.:DeprecationWarning" +) def test_ibis_executor_build_smoke_duckdb(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") From bf396c284a4d968f96f0f64bbd3c6def192687ea Mon Sep 17 00:00:00 2001 From: egillax Date: Tue, 17 Mar 2026 20:20:39 +0100 Subject: [PATCH 31/62] refactor(execution): replace builder-based ibis engine --- circe/__init__.py | 15 +- circe/api.py | 125 +- circe/execution/LIMITATIONS.md | 11 + circe/execution/README.md | 61 + circe/execution/__init__.py | 30 +- circe/execution/_dataclass.py | 24 + circe/execution/api.py | 170 +++ circe/execution/build_context.py | 579 --------- circe/execution/builders/__init__.py | 40 - circe/execution/builders/common.py | 757 ------------ circe/execution/builders/condition_era.py | 47 - .../builders/condition_occurrence.py | 87 -- circe/execution/builders/death.py | 57 - circe/execution/builders/device_exposure.py | 72 -- circe/execution/builders/dose_era.py | 54 - circe/execution/builders/drug_era.py | 46 - circe/execution/builders/drug_exposure.py | 93 -- circe/execution/builders/groups.py | 441 ------- circe/execution/builders/measurement.py | 200 --- circe/execution/builders/observation.py | 94 -- .../execution/builders/observation_period.py | 61 - circe/execution/builders/payer_plan_period.py | 67 - circe/execution/builders/pipeline.py | 168 --- circe/execution/builders/post_processing.py | 104 -- .../builders/procedure_occurrence.py | 75 -- circe/execution/builders/registry.py | 46 - circe/execution/builders/specimen.py | 84 -- circe/execution/builders/visit_detail.py | 82 -- circe/execution/builders/visit_occurrence.py | 80 -- circe/execution/compat.py | 202 ++++ circe/execution/criteria_compat.py | 203 ---- circe/execution/databricks_compat.py | 93 ++ circe/execution/engine/__init__.py | 19 + circe/execution/engine/censoring.py | 75 ++ circe/execution/engine/cohort.py | 55 + circe/execution/engine/collapse.py | 72 ++ circe/execution/engine/end_strategy.py | 70 ++ circe/execution/engine/group_demographics.py | 165 +++ circe/execution/engine/group_keys.py | 18 + circe/execution/engine/group_operators.py | 183 +++ circe/execution/engine/group_windows.py | 125 ++ circe/execution/engine/groups.py | 93 ++ circe/execution/engine/inclusion.py | 18 + circe/execution/engine/limits.py | 26 + circe/execution/engine/primary.py | 69 ++ circe/execution/errors.py | 21 + circe/execution/ibis.py | 209 ---- circe/execution/ibis/__init__.py | 27 + circe/execution/ibis/codesets.py | 121 ++ circe/execution/ibis/compile_steps.py | 365 ++++++ circe/execution/ibis/compiler.py | 13 + circe/execution/ibis/context.py | 73 ++ circe/execution/ibis/materialize.py | 16 + circe/execution/ibis/operations.py | 199 +++ circe/execution/ibis/person_filters.py | 131 ++ circe/execution/ibis/standardize.py | 183 +++ circe/execution/ibis_compat.py | 96 +- circe/execution/lower/__init__.py | 3 + circe/execution/lower/common.py | 375 ++++++ circe/execution/lower/condition_era.py | 13 + circe/execution/lower/condition_occurrence.py | 57 + circe/execution/lower/criteria.py | 84 ++ circe/execution/lower/death.py | 32 + circe/execution/lower/device_exposure.py | 53 + circe/execution/lower/dose_era.py | 13 + circe/execution/lower/drug_era.py | 13 + circe/execution/lower/drug_exposure.py | 68 ++ circe/execution/lower/location_region.py | 47 + circe/execution/lower/measurement.py | 73 ++ circe/execution/lower/observation.py | 71 ++ circe/execution/lower/observation_period.py | 13 + circe/execution/lower/payer_plan_period.py | 13 + circe/execution/lower/procedure_occurrence.py | 57 + circe/execution/lower/specimen.py | 58 + circe/execution/lower/visit_detail.py | 65 + circe/execution/lower/visit_occurrence.py | 59 + circe/execution/normalize/__init__.py | 54 + circe/execution/normalize/cohort.py | 168 +++ circe/execution/normalize/collapse.py | 24 + circe/execution/normalize/criteria.py | 493 ++++++++ circe/execution/normalize/end_strategy.py | 37 + circe/execution/normalize/groups.py | 165 +++ circe/execution/normalize/windows.py | 103 ++ circe/execution/options.py | 39 +- circe/execution/plan/__init__.py | 103 ++ circe/execution/plan/cohort.py | 21 + circe/execution/plan/events.py | 179 +++ circe/execution/plan/groups.py | 10 + circe/execution/plan/predicates.py | 19 + circe/execution/plan/schema.py | 49 + circe/execution/typing.py | 28 + tests/execution/_assertions.py | 9 + tests/execution/_domain_cases.py | 46 + tests/execution/test_api_ibis.py | 1077 +++++++++++++++++ tests/execution/test_api_public.py | 373 ++++++ tests/execution/test_compile_contracts.py | 107 ++ tests/execution/test_context_wiring.py | 53 + tests/execution/test_databricks_compat.py | 31 + tests/execution/test_domain_filter_parity.py | 524 ++++++++ .../execution/test_end_strategy_censoring.py | 171 +++ tests/execution/test_error_messages.py | 148 +++ tests/execution/test_groups.py | 275 +++++ tests/execution/test_ibis_compat.py | 79 ++ tests/execution/test_inclusion.py | 155 +++ tests/execution/test_legacy_api_compat.py | 171 +++ tests/execution/test_lower_contracts.py | 43 + tests/execution/test_lowering.py | 220 ++++ tests/execution/test_normalize.py | 222 ++++ tests/execution/test_normalize_contracts.py | 57 + tests/execution/test_operations.py | 62 + tests/execution/test_parity_regressions.py | 216 ++++ tests/execution/test_result_limits.py | 305 +++++ tests/execution/test_scaffolding.py | 30 + .../test_standard_schema_contracts.py | 92 ++ 114 files changed, 10021 insertions(+), 3814 deletions(-) create mode 100644 circe/execution/LIMITATIONS.md create mode 100644 circe/execution/README.md create mode 100644 circe/execution/_dataclass.py create mode 100644 circe/execution/api.py delete mode 100644 circe/execution/build_context.py delete mode 100644 circe/execution/builders/__init__.py delete mode 100644 circe/execution/builders/common.py delete mode 100644 circe/execution/builders/condition_era.py delete mode 100644 circe/execution/builders/condition_occurrence.py delete mode 100644 circe/execution/builders/death.py delete mode 100644 circe/execution/builders/device_exposure.py delete mode 100644 circe/execution/builders/dose_era.py delete mode 100644 circe/execution/builders/drug_era.py delete mode 100644 circe/execution/builders/drug_exposure.py delete mode 100644 circe/execution/builders/groups.py delete mode 100644 circe/execution/builders/measurement.py delete mode 100644 circe/execution/builders/observation.py delete mode 100644 circe/execution/builders/observation_period.py delete mode 100644 circe/execution/builders/payer_plan_period.py delete mode 100644 circe/execution/builders/pipeline.py delete mode 100644 circe/execution/builders/post_processing.py delete mode 100644 circe/execution/builders/procedure_occurrence.py delete mode 100644 circe/execution/builders/registry.py delete mode 100644 circe/execution/builders/specimen.py delete mode 100644 circe/execution/builders/visit_detail.py delete mode 100644 circe/execution/builders/visit_occurrence.py create mode 100644 circe/execution/compat.py delete mode 100644 circe/execution/criteria_compat.py create mode 100644 circe/execution/databricks_compat.py create mode 100644 circe/execution/engine/__init__.py create mode 100644 circe/execution/engine/censoring.py create mode 100644 circe/execution/engine/cohort.py create mode 100644 circe/execution/engine/collapse.py create mode 100644 circe/execution/engine/end_strategy.py create mode 100644 circe/execution/engine/group_demographics.py create mode 100644 circe/execution/engine/group_keys.py create mode 100644 circe/execution/engine/group_operators.py create mode 100644 circe/execution/engine/group_windows.py create mode 100644 circe/execution/engine/groups.py create mode 100644 circe/execution/engine/inclusion.py create mode 100644 circe/execution/engine/limits.py create mode 100644 circe/execution/engine/primary.py create mode 100644 circe/execution/errors.py delete mode 100644 circe/execution/ibis.py create mode 100644 circe/execution/ibis/__init__.py create mode 100644 circe/execution/ibis/codesets.py create mode 100644 circe/execution/ibis/compile_steps.py create mode 100644 circe/execution/ibis/compiler.py create mode 100644 circe/execution/ibis/context.py create mode 100644 circe/execution/ibis/materialize.py create mode 100644 circe/execution/ibis/operations.py create mode 100644 circe/execution/ibis/person_filters.py create mode 100644 circe/execution/ibis/standardize.py create mode 100644 circe/execution/lower/__init__.py create mode 100644 circe/execution/lower/common.py create mode 100644 circe/execution/lower/condition_era.py create mode 100644 circe/execution/lower/condition_occurrence.py create mode 100644 circe/execution/lower/criteria.py create mode 100644 circe/execution/lower/death.py create mode 100644 circe/execution/lower/device_exposure.py create mode 100644 circe/execution/lower/dose_era.py create mode 100644 circe/execution/lower/drug_era.py create mode 100644 circe/execution/lower/drug_exposure.py create mode 100644 circe/execution/lower/location_region.py create mode 100644 circe/execution/lower/measurement.py create mode 100644 circe/execution/lower/observation.py create mode 100644 circe/execution/lower/observation_period.py create mode 100644 circe/execution/lower/payer_plan_period.py create mode 100644 circe/execution/lower/procedure_occurrence.py create mode 100644 circe/execution/lower/specimen.py create mode 100644 circe/execution/lower/visit_detail.py create mode 100644 circe/execution/lower/visit_occurrence.py create mode 100644 circe/execution/normalize/__init__.py create mode 100644 circe/execution/normalize/cohort.py create mode 100644 circe/execution/normalize/collapse.py create mode 100644 circe/execution/normalize/criteria.py create mode 100644 circe/execution/normalize/end_strategy.py create mode 100644 circe/execution/normalize/groups.py create mode 100644 circe/execution/normalize/windows.py create mode 100644 circe/execution/plan/__init__.py create mode 100644 circe/execution/plan/cohort.py create mode 100644 circe/execution/plan/events.py create mode 100644 circe/execution/plan/groups.py create mode 100644 circe/execution/plan/predicates.py create mode 100644 circe/execution/plan/schema.py create mode 100644 circe/execution/typing.py create mode 100644 tests/execution/_assertions.py create mode 100644 tests/execution/_domain_cases.py create mode 100644 tests/execution/test_api_ibis.py create mode 100644 tests/execution/test_api_public.py create mode 100644 tests/execution/test_compile_contracts.py create mode 100644 tests/execution/test_context_wiring.py create mode 100644 tests/execution/test_databricks_compat.py create mode 100644 tests/execution/test_domain_filter_parity.py create mode 100644 tests/execution/test_end_strategy_censoring.py create mode 100644 tests/execution/test_error_messages.py create mode 100644 tests/execution/test_groups.py create mode 100644 tests/execution/test_ibis_compat.py create mode 100644 tests/execution/test_inclusion.py create mode 100644 tests/execution/test_legacy_api_compat.py create mode 100644 tests/execution/test_lower_contracts.py create mode 100644 tests/execution/test_lowering.py create mode 100644 tests/execution/test_normalize.py create mode 100644 tests/execution/test_normalize_contracts.py create mode 100644 tests/execution/test_operations.py create mode 100644 tests/execution/test_parity_regressions.py create mode 100644 tests/execution/test_result_limits.py create mode 100644 tests/execution/test_scaffolding.py create mode 100644 tests/execution/test_standard_schema_contracts.py diff --git a/circe/__init__.py b/circe/__init__.py index 914de487..2b4848a8 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -78,17 +78,15 @@ ) from .api import ( + build_cohort, build_cohort_query, cohort_expression_from_json, cohort_print_friendly, -) -from .execution import ( - ExecutionOptions, - IbisExecutor, - build_ibis, - to_polars, write_cohort, ) + +# Main exports +from .execution import ExecutionOptions, IbisExecutor, build_ibis, to_polars from .io import load_expression from .vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -208,13 +206,14 @@ def get_json_schema() -> dict: # API functions "cohort_expression_from_json", "build_cohort_query", + "build_cohort", + "write_cohort", "cohort_print_friendly", "safe_model_rebuild", - # I/O and experimental execution API + # I/O helpers "load_expression", "ExecutionOptions", "IbisExecutor", "build_ibis", "to_polars", - "write_cohort", ] diff --git a/circe/api.py b/circe/api.py index 06b52370..13533e89 100644 --- a/circe/api.py +++ b/circe/api.py @@ -4,10 +4,12 @@ This module provides a simple R CirceR-style API for working with cohort definitions: - cohort_expression_from_json(): Load cohort expression from JSON string - build_cohort_query(): Generate SQL from cohort expression +- build_cohort(): Build cohort as a relational expression (experimental) +- write_cohort(): Write OHDSI cohort-table rows to a database table - cohort_print_friendly(): Generate Markdown from cohort expression """ -from typing import Optional +from typing import Literal, Optional from .cohortdefinition import ( BuildExpressionQueryOptions, @@ -15,6 +17,7 @@ CohortExpressionQueryBuilder, MarkdownRender, ) +from .execution.typing import IbisBackendLike, Table from .vocabulary.concept import ConceptSet @@ -102,6 +105,126 @@ def build_cohort_query( return builder.build_expression_query(expression, options) +def build_cohort( + expression: CohortExpression, + *, + backend: IbisBackendLike, + cdm_schema: str, + vocabulary_schema: Optional[str] = None, + results_schema: Optional[str] = None, +) -> Table: + """Build a cohort as a relational table expression. + + This uses the experimental Ibis execution engine to compile the cohort + expression into a backend-native relational expression. + + Args: + expression: CohortExpression instance + backend: Ibis backend used to compile the cohort relation + cdm_schema: Schema containing the OMOP CDM tables + vocabulary_schema: Optional schema for vocabulary tables. Defaults to + ``cdm_schema`` when omitted. + results_schema: Optional schema used for result-side table resolution + + Returns: + Ibis table expression representing the cohort result + + Raises: + ExecutionError: If the cohort cannot be normalized, lowered, or + compiled into a relational expression + + Example: + >>> import ibis + >>> backend = ibis.duckdb.connect() + >>> expression = cohort_expression_from_json(json_str) + >>> relation = build_cohort( + ... expression, + ... backend=backend, + ... cdm_schema="cdm", + ... vocabulary_schema="vocab", + ... ) + """ + from .execution import build_cohort as _build_cohort + + return _build_cohort( + expression, + backend=backend, + cdm_schema=cdm_schema, + vocabulary_schema=vocabulary_schema, + results_schema=results_schema, + ) + + +def write_cohort( + expression: CohortExpression, + *, + backend: IbisBackendLike, + cdm_schema: str, + cohort_table: str, + cohort_id: int, + vocabulary_schema: Optional[str] = None, + results_schema: Optional[str] = None, + if_exists: Literal["fail", "replace"] = "fail", +) -> None: + """Build and write an OHDSI cohort table. + + This wraps :func:`build_cohort`, projects the resulting relation into the + standard OHDSI cohort-table shape, and materializes it to a backend table. + Existing rows for other cohort IDs are preserved. + + Args: + expression: CohortExpression instance + backend: Ibis backend used to compile and write the cohort relation + cdm_schema: Schema containing the OMOP CDM tables + cohort_table: Name of the OHDSI cohort table to create or update + cohort_id: Cohort definition identifier written to + ``cohort_definition_id`` + vocabulary_schema: Optional schema for vocabulary tables. Defaults to + ``cdm_schema`` when omitted. + results_schema: Optional schema for the target table + if_exists: Cohort-row policy, either ``"fail"`` or ``"replace"``. + ``"fail"`` raises if rows for ``cohort_id`` already exist. + ``"replace"`` replaces only rows for ``cohort_id``. + + Returns: + None + + Raises: + ExecutionError: If the cohort cannot be built or the target table + cannot be written + + Example: + >>> import ibis + >>> backend = ibis.duckdb.connect() + >>> expression = cohort_expression_from_json(json_str) + >>> write_cohort( + ... expression, + ... backend=backend, + ... cdm_schema="cdm", + ... cohort_table="cohort", + ... cohort_id=1, + ... results_schema="results", + ... if_exists="replace", + ... ) + """ + from .execution import write_cohort as _write_cohort + + _write_cohort( + expression, + backend=backend, + cdm_schema=cdm_schema, + cohort_table=cohort_table, + cohort_id=cohort_id, + vocabulary_schema=vocabulary_schema, + results_schema=results_schema, + if_exists=if_exists, + ) + + +build_cohort_ibis = build_cohort +write_cohort_ibis = write_cohort + + def cohort_print_friendly( expression: CohortExpression, concept_sets: Optional[list[ConceptSet]] = None, diff --git a/circe/execution/LIMITATIONS.md b/circe/execution/LIMITATIONS.md new file mode 100644 index 00000000..696d411b --- /dev/null +++ b/circe/execution/LIMITATIONS.md @@ -0,0 +1,11 @@ +# Ibis Executor Limitations + +The `circe.execution` subsystem is experimental and feature-complete for the +currently implemented semantics. + +Current explicit limitations: + +- `custom_era` end strategy is not implemented. + +The executor raises `UnsupportedFeatureError` when these features are requested, +instead of silently degrading semantics. diff --git a/circe/execution/README.md b/circe/execution/README.md new file mode 100644 index 00000000..2818dc4d --- /dev/null +++ b/circe/execution/README.md @@ -0,0 +1,61 @@ +# Ibis Execution Subsystem + +The `circe.execution` package is an experimental, table-first Ibis executor for +`CohortExpression` models. It runs in parallel with the existing SQL builder. + +## Public Functions + +- `build_cohort(...)` is the canonical expression-building entrypoint. +- `write_cohort(...)` projects the built relation into OHDSI cohort-table shape, + then writes or replaces rows for one `cohort_id` while preserving other cohorts. +- `build_cohort_ibis` and `write_cohort_ibis` are transition aliases. + +## Layered Architecture + +1. `normalize/` +converts public cohort-expression models into frozen internal dataclasses. + +2. `lower/` +maps normalized criteria into `EventPlan` objects and reusable plan steps. + +3. `ibis/` +compiles plan steps into Ibis table expressions. + +4. `engine/` +orchestrates cohort semantics: primary events, criteria groups, inclusion rules, +end strategy, censoring, and collapse. + +## Canonical Event Schema + +All compiled domain event tables are standardized before cohort orchestration. +Canonical columns are defined in `circe/execution/plan/schema.py` and include: + +- `person_id` +- `event_id` +- `start_date` +- `end_date` +- `domain` +- `concept_id` +- `source_concept_id` +- `visit_occurrence_id` +- `criterion_index` +- `criterion_type` +- `source_table` + +## Codeset Resolution Flow + +Codeset expansion is handled by `CachedConceptSetResolver` in +`circe/execution/ibis/codesets.py`. + +Resolution behavior: + +- direct concept inclusion +- descendant expansion via `concept_ancestor` when requested +- mapped concept expansion via `concept_relationship` (`Maps to`) when requested +- exclusion precedence applied after expansion + +The resolver cache is local to an execution context run. + +## Current Limitation + +- `custom_era` end strategy remains unsupported in this executor path. diff --git a/circe/execution/__init__.py b/circe/execution/__init__.py index c0adffbf..d2d28364 100644 --- a/circe/execution/__init__.py +++ b/circe/execution/__init__.py @@ -1,13 +1,33 @@ -"""Experimental backend execution APIs.""" +"""New Ibis execution subsystem. -from .ibis import IbisExecutor, build_ibis, to_polars, write_cohort -from .options import ExecutionOptions, SchemaName +This package is intentionally parallel to the existing SQL builder path and does +not modify cohortdefinition model semantics. +""" + +from .api import build_cohort, build_cohort_ibis, write_cohort, write_cohort_ibis +from .compat import ExecutionOptions, IbisExecutor, build_ibis, to_polars +from .databricks_compat import apply_databricks_post_connect_workaround +from .errors import ( + CompilationError, + ExecutionError, + ExecutionNormalizationError, + UnsupportedCriterionError, + UnsupportedFeatureError, +) __all__ = [ + "build_cohort", + "write_cohort", + "build_cohort_ibis", + "write_cohort_ibis", "ExecutionOptions", - "SchemaName", "IbisExecutor", "build_ibis", "to_polars", - "write_cohort", + "apply_databricks_post_connect_workaround", + "ExecutionError", + "ExecutionNormalizationError", + "UnsupportedCriterionError", + "UnsupportedFeatureError", + "CompilationError", ] diff --git a/circe/execution/_dataclass.py b/circe/execution/_dataclass.py new file mode 100644 index 00000000..0b1c4c71 --- /dev/null +++ b/circe/execution/_dataclass.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, TypeVar + +T = TypeVar("T") + + +def frozen_slots_dataclass(_cls: type[T] | None = None, **kwargs: Any) -> Any: + """Compatibility wrapper for frozen+slots dataclasses. + + `slots=True` is preferred for memory/layout guarantees, but this wrapper keeps + compatibility with older Python runtimes that do not support dataclass slots. + """ + + def wrap(cls: type[T]) -> type[T]: + try: + return dataclass(frozen=True, slots=True, **kwargs)(cls) + except TypeError: + return dataclass(frozen=True, **kwargs)(cls) + + if _cls is None: + return wrap + return wrap(_cls) diff --git a/circe/execution/api.py b/circe/execution/api.py new file mode 100644 index 00000000..24fdaedf --- /dev/null +++ b/circe/execution/api.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from typing import Literal + +from ..cohortdefinition import CohortExpression +from .databricks_compat import maybe_apply_databricks_post_connect_workaround +from .engine.cohort import build_cohort_table +from .errors import ExecutionError +from .ibis.context import make_execution_context +from .ibis.materialize import project_to_ohdsi_cohort_table +from .ibis.operations import ( + cohort_rows_exist, + exclude_cohort_rows, + insert_relation, + read_table, + replace_cohort_rows_transactionally, + supports_transactional_replace, + table_exists, +) +from .normalize.cohort import normalize_cohort +from .typing import IbisBackendLike, Table + + +def build_cohort( + expression: CohortExpression, + *, + backend: IbisBackendLike, + cdm_schema: str, + results_schema: str | None = None, + vocabulary_schema: str | None = None, +) -> Table: + """Normalize, compile, and assemble a cohort relation.""" + maybe_apply_databricks_post_connect_workaround(backend) + + normalized = normalize_cohort(expression) + + ctx = make_execution_context( + backend=backend, + cdm_schema=cdm_schema, + results_schema=results_schema, + vocabulary_schema=vocabulary_schema, + concept_sets=normalized.concept_sets, + ) + + return build_cohort_table(normalized, ctx) + + +def write_relation( + relation: Table, + *, + backend: IbisBackendLike, + target_table: str, + target_schema: str | None = None, + if_exists: Literal["fail", "replace"] = "fail", + temporary: bool = False, +) -> None: + """Materialize a relation to a backend table.""" + if if_exists not in {"fail", "replace"}: + raise ValueError("if_exists must be one of {'fail', 'replace'} for write_relation.") + + maybe_apply_databricks_post_connect_workaround(backend) + + write_kwargs = { + "obj": relation, + "overwrite": if_exists == "replace", + } + if temporary: + write_kwargs["temp"] = True + + try: + if target_schema is not None: + backend.create_table( + target_table, + database=target_schema, + **write_kwargs, + ) + return + + backend.create_table(target_table, **write_kwargs) + except Exception as exc: + schema_label = target_schema if target_schema is not None else "" + raise ExecutionError( + "Ibis executor write error: failed writing relation to " + f"table '{target_table}' in schema '{schema_label}' " + f"(if_exists={if_exists!r}, temporary={temporary})." + ) from exc + + +def write_cohort( + expression: CohortExpression, + *, + backend: IbisBackendLike, + cdm_schema: str, + cohort_table: str, + cohort_id: int, + results_schema: str | None = None, + vocabulary_schema: str | None = None, + if_exists: Literal["fail", "replace"] = "fail", +) -> None: + """Build cohort rows and materialize them with cohort-scoped semantics.""" + if if_exists not in {"fail", "replace"}: + raise ValueError("if_exists must be one of {'fail', 'replace'} for write_cohort.") + + new_rows = build_cohort( + expression, + backend=backend, + cdm_schema=cdm_schema, + results_schema=results_schema, + vocabulary_schema=vocabulary_schema, + ) + new_rows = project_to_ohdsi_cohort_table(new_rows, cohort_id=cohort_id) + + if not table_exists(backend, table_name=cohort_table, schema=results_schema): + write_relation( + new_rows, + backend=backend, + target_table=cohort_table, + target_schema=results_schema, + if_exists="fail", + ) + return + + if if_exists == "fail": + if cohort_rows_exist( + backend, + cohort_table=cohort_table, + results_schema=results_schema, + cohort_id=cohort_id, + ): + raise ExecutionError( + "Ibis executor write error: cohort table " + f"'{cohort_table}' already contains rows for cohort_id={cohort_id}." + ) + insert_relation( + new_rows, + backend=backend, + target_table=cohort_table, + target_schema=results_schema, + ) + return + + if supports_transactional_replace(backend): + replace_cohort_rows_transactionally( + new_rows, + backend=backend, + cohort_table=cohort_table, + results_schema=results_schema, + cohort_id=cohort_id, + ) + return + + existing = read_table( + backend, + table_name=cohort_table, + schema=results_schema, + ) + filtered = exclude_cohort_rows(existing, cohort_id=cohort_id) + relation = filtered.union(new_rows, distinct=False) + write_relation( + relation, + backend=backend, + target_table=cohort_table, + target_schema=results_schema, + if_exists="replace", + ) + + +# Compatibility aliases for transition period. +build_cohort_ibis = build_cohort +write_cohort_ibis = write_cohort diff --git a/circe/execution/build_context.py b/circe/execution/build_context.py deleted file mode 100644 index 48650cd8..00000000 --- a/circe/execution/build_context.py +++ /dev/null @@ -1,579 +0,0 @@ -from __future__ import annotations - -import uuid -import weakref -from collections.abc import Iterable -from dataclasses import dataclass -from functools import reduce -from pathlib import Path -from typing import Callable, Union - -import ibis -import ibis.common.exceptions as ibis_exc -import ibis.expr.types as ir - -from ..vocabulary.concept import ConceptSet -from .ibis_compat import table_from_literal_list - -Database = Union[str, tuple[str, str]] - - -def _qualify(database: Database | None, name: str) -> str: - """Only for statements were constructing outside of Ibis.""" - if database is None: - return name - if isinstance(database, tuple): - return ".".join(database + (name,)) - return f"{database}.{name}" - - -def _table(conn: ibis.BaseBackend, database: Database | None, name: str) -> ir.Table: - return conn.table(name, database=database) - - -def _warn(message: str) -> None: - print(f"Warning: {message}") - - -def _analyze_table(conn: ibis.BaseBackend, *, backend: str | None, qualified_name: str) -> None: - if not backend: - return - if backend in ("postgres", "duckdb"): - conn.raw_sql(f"ANALYZE {qualified_name}") - return - if backend == "databricks": - conn.raw_sql(f"ANALYZE TABLE {qualified_name} COMPUTE STATISTICS") - - -def _drop_table_safely( - conn: ibis.BaseBackend, - *, - name: str, - database: Database | None = None, - warning_label: str, -) -> None: - try: - conn.drop_table(name, database=database, force=True) - except Exception as exc: - _warn(f"could not drop {warning_label}: {exc}") - - -@dataclass(frozen=True) -class CohortBuildOptions: - cdm_schema: str | None = None - vocabulary_schema: str | None = None - result_schema: str | None = None - target_table: str | None = None - cohort_id: int | None = None - generate_stats: bool = False - temp_emulation_schema: str | None = None - profile_dir: str | None = None - capture_sql: bool = False - backend: str | None = None - materialize_stages: bool = True - materialize_codesets: bool = True - - -@dataclass -class CodesetResource: - table: ir.Table - _dropper: Callable[[], None] | None = None - - def cleanup(self): - if self._dropper: - try: - self._dropper() - finally: - self._dropper = None - - -class BuildContext: - """Holds shared state (connection, schemas, compiled codesets) used across builders.""" - - def __init__( - self, - conn: ibis.BaseBackend, - options: CohortBuildOptions, - codeset_resource: CodesetResource | ir.Table, - ): - self._conn = conn - self._options = options - if isinstance(codeset_resource, CodesetResource): - self._codeset_resource = codeset_resource - else: - self._codeset_resource = CodesetResource(table=codeset_resource) - self._codesets = self._codeset_resource.table - self._cleanup_callbacks: list[Callable[[], None]] = [] - self._correlated_cache: dict[str, ir.Table] = {} - self._profile_dir = None - if options.profile_dir: - path = Path(options.profile_dir).resolve() - path.mkdir(parents=True, exist_ok=True) - self._profile_dir = path - self._captured_sql: list[tuple[str, str]] = [] - self._slice_cache: dict[str, ir.Table] = {} - weakref.finalize(self, self.close) - - def _table(self, database: str | None, name: str) -> ir.Table: - try: - return _table(self._conn, database, name) - except ( - ibis_exc.IbisError, - TypeError, - ValueError, - AttributeError, - NotImplementedError, - ): - return self._conn.sql(f"SELECT * FROM {_qualify(database, name)}") - - def table(self, name: str) -> ir.Table: - """Return a CDM table.""" - return self._table(self._options.cdm_schema, name) - - def vocabulary_table(self, name: str) -> ir.Table: - """Return a vocabulary table (concept, concept_ancestor, etc.).""" - schema = self._options.vocabulary_schema or self._options.cdm_schema - return self._table(schema, name) - - def codeset(self, codeset_id: int, *, is_exclusion: bool = False) -> ir.Table: - """Return concepts for the requested codeset. `is_exclusion` is provided for parity with Circe.""" - _ = is_exclusion # placeholder for future differentiated handling - return self._codesets.filter(self._codesets.codeset_id == codeset_id) - - def get_cached_correlated(self, key: str) -> ir.Table | None: - return self._correlated_cache.get(key) - - def cache_correlated(self, key: str, table: ir.Table) -> None: - self._correlated_cache[key] = table - - def materialize( - self, - expr: ir.Table, - *, - label: str, - temp: bool = True, - analyze: bool = True, - ) -> ir.Table: - """ - Materialize an Ibis expression, capturing a unique DuckDB profiling - artifact for this step. - """ - step_id = uuid.uuid4().hex[:8] - table_name = f"_stage_{label}_{step_id}" - backend = self._options.backend - - # "temp emulation" means: create a *real* table in a chosen database/schema. - use_temp_emulation = temp and self._options.temp_emulation_schema is not None - database: Database | None = self._options.temp_emulation_schema if use_temp_emulation else None - temp_flag = False if use_temp_emulation else temp - - # duckdb profiling setup for local dev - profile_filename: Path | None = None - profiling_enabled = False - if backend == "duckdb" and self._profile_dir is not None: - profile_filename = (self._profile_dir / f"ibis_profile_{label}_{step_id}.json").resolve() - try: - escaped = str(profile_filename).replace("'", "''") - self._conn.raw_sql(f"SET profiling_output='{escaped}'") - self._conn.raw_sql("SET enable_profiling='json'") - self._conn.raw_sql("SET profiling_coverage='ALL'") - profiling_enabled = True - except Exception as exc: - _warn(f"could not enable DuckDB profiling for {label}: {exc}") - - try: - self._conn.create_table( - table_name, - obj=expr, - database=database, - temp=temp_flag, - overwrite=True, - ) - if self._options.capture_sql: - self._captured_sql.append((table_name, self._conn.compile(expr))) - finally: - if profiling_enabled: - try: - self._conn.raw_sql("PRAGMA disable_profiling") - except Exception as exc: - _warn(f"could not disable DuckDB profiling for {label}: {exc}") - - if profiling_enabled and profile_filename is not None: - print(f"[Profile Captured]: {profile_filename} (Table: {table_name})") - - if analyze: - qualified = _qualify(database, table_name) - try: - _analyze_table(self._conn, backend=backend, qualified_name=qualified) - except Exception as exc: - _warn(f"could not analyze table {qualified}: {exc}") - - def _drop(): - _drop_table_safely( - self._conn, - name=table_name, - database=database, - warning_label=f"table {table_name} in {database}", - ) - - self.register_cleanup(_drop) - return _table(self._conn, database, table_name) - - def should_materialize_stages(self) -> bool: - return bool(self._options.materialize_stages) - - def maybe_materialize( - self, - expr: ir.Table, - *, - label: str, - temp: bool = True, - analyze: bool = True, - ) -> ir.Table: - if not self.should_materialize_stages(): - return expr - return self.materialize(expr, label=label, temp=temp, analyze=analyze) - - def write_cohort_table( - self, - events: ir.Table, - *, - table_name: str | None = None, - database: Database | None = None, - overwrite: bool = True, - append: bool = False, - ) -> ir.Table: - """ - Persist cohort rows to a results table. - - Output schema matches OHDSI cohort tables: - (cohort_definition_id, subject_id, cohort_start_date, cohort_end_date) - """ - if append and overwrite: - raise ValueError("`append=True` and `overwrite=True` cannot be used together.") - target_table = table_name or self._options.target_table - if not target_table: - raise ValueError("target_table must be set (argument or CohortBuildOptions.target_table)") - target_db = database if database is not None else self._options.result_schema - if target_db is None: - raise ValueError("result_schema must be set (argument or CohortBuildOptions.result_schema)") - - cohort_id = self._options.cohort_id - cohort_id_expr = ( - ibis.literal(int(cohort_id), type="int64") if cohort_id is not None else ibis.null().cast("int64") - ) - - result = events.select( - cohort_id_expr.name("cohort_definition_id"), - events.person_id.cast("int64").name("subject_id"), - events.start_date.cast("date").name("cohort_start_date"), - events.end_date.cast("date").name("cohort_end_date"), - ) - - obj = result - if append: - try: - existing = _table(self._conn, target_db, target_table) - obj = existing.union(result, distinct=False) - except ( - ibis_exc.IbisError, - TypeError, - ValueError, - AttributeError, - NotImplementedError, - ): - obj = result - - self._conn.create_table( - target_table, - obj=obj, - database=target_db, - temp=False, - overwrite=overwrite, - ) - return _table(self._conn, target_db, target_table) - - @property - def codesets(self) -> ir.Table: - return self._codesets - - @property - def conn(self) -> ibis.BaseBackend: - return self._conn - - def options(self) -> CohortBuildOptions: - return self._options - - def captured_sql(self) -> list[tuple[str, str]]: - return list(self._captured_sql) - - def register_cleanup(self, callback: Callable[[], None]): - self._cleanup_callbacks.append(callback) - - def get_or_materialize_slice( - self, - cache_key: str, - expr: ir.Table, - *, - label: str | None = None, - ) -> ir.Table: - """Materialize an expression once and reuse the resulting temp table for later lookups.""" - if not self.should_materialize_stages(): - return expr.view() - cached = self._slice_cache.get(cache_key) - if cached is not None: - return cached - label_hint = label or "slice" - table = self.materialize(expr, label=label_hint, temp=True, analyze=True) - self._slice_cache[cache_key] = table - return table - - def close(self): - if self._codeset_resource is not None: - self._codeset_resource.cleanup() - self._codeset_resource = None # type: ignore[assignment] - while self._cleanup_callbacks: - callback = self._cleanup_callbacks.pop() - try: - callback() - except Exception as exc: - _warn(f"cleanup callback failed: {exc}") - self._captured_sql.clear() - self._slice_cache.clear() - - -def compile_codesets( - conn: ibis.BaseBackend, - concept_sets: list[ConceptSet], - options: CohortBuildOptions, -) -> CodesetResource: - """Rebuild Circe concept set logic as an ibis expression.""" - - vocab_schema = options.vocabulary_schema or options.cdm_schema - concept = _table(conn, vocab_schema, "concept") - concept_ancestor = _table(conn, vocab_schema, "concept_ancestor") - concept_relationship = _table(conn, vocab_schema, "concept_relationship") - - compiled = [] - for concept_set in concept_sets or []: - compiled_expr = _compile_single_codeset(concept, concept_ancestor, concept_relationship, concept_set) - if compiled_expr is not None: - compiled.append(compiled_expr) - - compiled_expr = _empty_codeset_table() if not compiled else _union_all(compiled).distinct() - - if not options.materialize_codesets: - return CodesetResource(table=compiled_expr) - - return _materialize_codesets(conn, compiled_expr, options) - - -def _compile_single_codeset( - concept: ir.Table, - concept_ancestor: ir.Table, - concept_relationship: ir.Table, - concept_set: ConceptSet, -) -> ir.Table | None: - expression = concept_set.expression - if expression is None or not expression.items: - return None - - include_ids: list[int] = [] - include_descendant_ids: list[int] = [] - include_mapped_ids: list[int] = [] - include_mapped_descendant_ids: list[int] = [] - - exclude_ids: list[int] = [] - exclude_descendant_ids: list[int] = [] - exclude_mapped_ids: list[int] = [] - exclude_mapped_descendant_ids: list[int] = [] - - for item in expression.items: - if item.concept is None or item.concept.concept_id is None: - continue - target_include = not bool(item.is_excluded) - include_descendants = bool(item.include_descendants) - include_mapped = bool(item.include_mapped) - concept_id = int(item.concept.concept_id) - - if target_include: - include_ids.append(concept_id) - if include_descendants: - include_descendant_ids.append(concept_id) - if include_mapped: - include_mapped_ids.append(concept_id) - if include_descendants: - include_mapped_descendant_ids.append(concept_id) - else: - exclude_ids.append(concept_id) - if include_descendants: - exclude_descendant_ids.append(concept_id) - if include_mapped: - exclude_mapped_ids.append(concept_id) - if include_descendants: - exclude_mapped_descendant_ids.append(concept_id) - - include_expr = _union_distinct( - [ - _ids_memtable(include_ids), - _descendants(concept, concept_ancestor, include_descendant_ids), - _mapped_concepts( - concept, - concept_ancestor, - concept_relationship, - include_mapped_ids, - include_mapped_descendant_ids, - ), - ] - ) - - if include_expr is None: - return None - - exclude_expr = _union_distinct( - [ - _ids_memtable(exclude_ids), - _descendants(concept, concept_ancestor, exclude_descendant_ids), - _mapped_concepts( - concept, - concept_ancestor, - concept_relationship, - exclude_mapped_ids, - exclude_mapped_descendant_ids, - ), - ] - ) - - if exclude_expr is not None: - include_expr = include_expr.anti_join(exclude_expr, ["concept_id"]) - - codeset_literal = ibis.literal(int(concept_set.id), type="int64") - return include_expr.mutate(codeset_id=codeset_literal)[["codeset_id", "concept_id"]] - - -def _ids_memtable(ids: list[int]) -> ir.Table | None: - if not ids: - return None - return table_from_literal_list(ids, column_name="concept_id", element_type="int64").distinct() - - -def _descendants(concept: ir.Table, concept_ancestor: ir.Table, ancestor_ids: list[int]) -> ir.Table | None: - if not ancestor_ids: - return None - return ( - concept_ancestor.filter(concept_ancestor.ancestor_concept_id.isin(ancestor_ids)) - .join(concept, concept_ancestor.descendant_concept_id == concept.concept_id) - .filter(concept.invalid_reason.isnull()) - .select(concept.concept_id.cast("int64").name("concept_id")) - .distinct() - ) - - -def _mapped_concepts( - concept: ir.Table, - concept_ancestor: ir.Table, - concept_relationship: ir.Table, - concepts_to_map: list[int], - concepts_with_descendants_to_map: list[int], -) -> ir.Table | None: - sources = _union_distinct( - [ - _ids_memtable(concepts_to_map), - _descendants(concept, concept_ancestor, concepts_with_descendants_to_map), - ] - ) - - if sources is None: - return None - - valid_relationships = concept_relationship.filter( - [ - concept_relationship.relationship_id == "Maps to", - concept_relationship.invalid_reason.isnull(), - ] - ) - - return ( - sources.join(valid_relationships, sources.concept_id == valid_relationships.concept_id_2) - .select(valid_relationships.concept_id_1.cast("int64").name("concept_id")) - .distinct() - ) - - -def _empty_codeset_table() -> ir.Table: - empty_concepts = table_from_literal_list([], column_name="concept_id", element_type="int64") - empty_codesets = empty_concepts.mutate( - codeset_id=ibis.null().cast("int64"), - ) - return empty_codesets.select("codeset_id", "concept_id") - - -def _materialize_codesets( - conn: ibis.BaseBackend, - expr: ir.Table, - options: CohortBuildOptions, -) -> CodesetResource: - name = f"_codesets_{uuid.uuid4().hex}" - if options.temp_emulation_schema: - database: Database = options.temp_emulation_schema - conn.create_table( - name, - obj=expr, - database=database, - temp=False, - overwrite=True, - ) - table = _table(conn, database, name) - qualified = _qualify(database, name) - - def _drop(): - _drop_table_safely( - conn, - name=name, - database=database, - warning_label=f"codeset table {name} in {database}", - ) - - else: - conn.create_table( - name, - obj=expr, - temp=True, - overwrite=True, - ) - table = _table(conn, None, name) - qualified = _qualify(None, name) - - def _drop(): - _drop_table_safely( - conn, - name=name, - warning_label=f"codeset temp table {name}", - ) - - backend = options.backend - if backend: - try: - _analyze_table(conn, backend=backend, qualified_name=qualified) - except Exception as exc: - _warn(f"could not analyze codeset table {qualified}: {exc}") - - resource = CodesetResource(table=table, _dropper=_drop) - weakref.finalize(resource, resource.cleanup) - return resource - - -def _union_distinct(tables: Iterable[ir.Table | None]) -> ir.Table | None: - valid_tables = [t for t in tables if t is not None] - if not valid_tables: - return None - - return reduce( - lambda left, right: left.union(right, distinct=True), - valid_tables[1:], - valid_tables[0], - ) - - -def _union_all(tables: list[ir.Table]) -> ir.Table: - return reduce(lambda left, right: left.union(right), tables[1:], tables[0]) diff --git a/circe/execution/builders/__init__.py b/circe/execution/builders/__init__.py deleted file mode 100644 index b625e93a..00000000 --- a/circe/execution/builders/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -from . import ( - condition_era, - condition_occurrence, - death, - device_exposure, - dose_era, - drug_era, - drug_exposure, - measurement, - observation, - observation_period, - payer_plan_period, - procedure_occurrence, - specimen, - visit_detail, - visit_occurrence, -) -from .pipeline import build_primary_events -from .registry import build_events, register - -__all__ = [ - "condition_era", - "condition_occurrence", - "death", - "device_exposure", - "dose_era", - "drug_era", - "drug_exposure", - "measurement", - "observation", - "observation_period", - "payer_plan_period", - "procedure_occurrence", - "specimen", - "visit_detail", - "visit_occurrence", - "build_primary_events", - "build_events", - "register", -] diff --git a/circe/execution/builders/common.py b/circe/execution/builders/common.py deleted file mode 100644 index 59b739e8..00000000 --- a/circe/execution/builders/common.py +++ /dev/null @@ -1,757 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from typing import Any, Callable, cast - -import ibis -import ibis.expr.types as ir -from ibis.expr.api import row_number - -from ...cohortdefinition.core import ( - CollapseSettings, - CollapseType, - ConceptSetSelection, - CustomEraStrategy, - DateOffsetStrategy, - DateRange, - EndStrategy, - NumericRange, - TextFilter, -) -from ...vocabulary.concept import Concept -from ..build_context import BuildContext - -OutputFormatter = Callable[[ir.Table], ir.Table] - - -def _person_subset(ctx: BuildContext, columns: list[str]) -> ir.Table: - person = ctx.table("person") - missing = [col for col in columns if col not in person.columns] - if missing: - raise ValueError(f"Person table missing required columns: {missing}") - return person.select(columns) - - -def standardize_output( - table: ir.Table, - *, - primary_key: str, - start_column: str, - end_column: str, -) -> ir.Table: - """Project and rename columns to the strict builder output contract.""" - start_expr = table[start_column].cast("timestamp") - same_column = end_column == start_column - if same_column: - end_expr = start_expr - needs_offset = ibis.literal(True) - elif end_column in table.columns: - end_raw = table[end_column].cast("timestamp") - end_expr = ibis.coalesce(end_raw, start_expr).cast("timestamp") - needs_offset = end_raw.isnull() - else: - end_expr = start_expr - needs_offset = ibis.literal(True) - one_day = ibis.interval(days=1) - end_expr = ibis.ifelse(needs_offset, cast(Any, end_expr) + one_day, end_expr).cast("timestamp") - visit_expr = ( - table.visit_occurrence_id.cast("int64") - if "visit_occurrence_id" in table.columns - else ibis.null().cast("int64") - ).name("visit_occurrence_id") - return table.select( - table.person_id.cast("int64").name("person_id"), - table[primary_key].cast("int64").name("event_id"), - start_expr.name("start_date"), - end_expr.name("end_date"), - visit_expr, - ) - - -def project_event_columns( - table: ir.Table, - *, - primary_key: str, - start_column: str, - end_column: str, - include_visit_occurrence: bool = False, -) -> ir.Table: - keep = ["person_id", primary_key, start_column] - if end_column in table.columns or include_visit_occurrence and start_column != end_column: - keep.append(end_column) - if include_visit_occurrence and "visit_occurrence_id" in table.columns: - keep.append("visit_occurrence_id") - unique_keep = [col for i, col in enumerate(keep) if col in table.columns and col not in keep[:i]] - return table.select(*(table[col] for col in unique_keep)) - - -def apply_codeset_filter( - table: ir.Table, - concept_column: str, - codeset_id: int | None, - ctx: BuildContext, -) -> ir.Table: - if codeset_id is None: - return table - base_columns = table.columns - left = table.view() - concepts = ctx.codesets.filter(ctx.codesets["codeset_id"] == ibis.literal(codeset_id)).view() - joined = left.join(concepts, [left[concept_column] == concepts["concept_id"]]) - return _project_columns(joined, base_columns) - - -def apply_concept_set_selection( - table: ir.Table, - column: str, - selection: ConceptSetSelection | None, - ctx: BuildContext, -) -> ir.Table: - if selection is None or selection.codeset_id is None: - return table - base_columns = table.columns - left = table.view() - codeset_table = ctx.codesets.filter( - ctx.codesets["codeset_id"] == ibis.literal(selection.codeset_id) - ).view() - if selection.is_exclusion: - return left.anti_join(codeset_table, [left[column] == codeset_table.concept_id]) - joined = left.join(codeset_table, [left[column] == codeset_table.concept_id]) - return _project_columns(joined, base_columns) - - -def coerce_concept_set_selection( - value: object | None, -) -> ConceptSetSelection | None: - if value is None: - return None - if isinstance(value, ConceptSetSelection): - return value - if hasattr(value, "codeset_id"): - return cast(ConceptSetSelection, value) - try: - return ConceptSetSelection(CodesetId=int(cast(Any, value))) - except (TypeError, ValueError) as exc: - raise ValueError(f"Unsupported concept set selection value: {value!r}") from exc - - -def apply_concept_criteria( - table: ir.Table, - *, - column: str, - concepts: Sequence[Concept] | None, - selection: ConceptSetSelection | None, - ctx: BuildContext, - exclude: bool = False, -) -> ir.Table: - table = apply_concept_filters(table, column, concepts, exclude=exclude) - return apply_concept_set_selection(table, column, selection, ctx) - - -def apply_date_range(table: ir.Table, column: str, date_range: DateRange | None) -> ir.Table: - if not date_range: - return table - expr = table[column] - if date_range.op.endswith("bt"): - lower = ibis.literal(date_range.value) - upper = ibis.literal(date_range.extent) - predicate = expr.between(lower, upper) - if date_range.op.startswith("!"): - predicate = ~predicate - else: - comparator = _map_operator(date_range.op) - operand = ibis.literal(date_range.value) - predicate = comparator(expr, operand) - return table.filter(predicate) - - -def apply_numeric_range(table: ir.Table, column, numeric_range: NumericRange | None) -> ir.Table: - if not numeric_range or numeric_range.value is None: - return table - op = numeric_range.op or "eq" - - expr = table[column] if isinstance(column, str) else column - if op.endswith("bt"): - lower = ibis.literal(numeric_range.value) - upper = ibis.literal(numeric_range.extent) - predicate = expr.between(lower, upper) - if op.startswith("!"): - predicate = ~predicate - else: - comparator = _map_operator(op) - operand = ibis.literal(numeric_range.value) - predicate = comparator(expr, operand) - return table.filter(predicate) - - -def apply_text_filter(table: ir.Table, column: str, text_filter: TextFilter | None) -> ir.Table: - if not text_filter or not text_filter.text: - return table - op = text_filter.op or "contains" - negate = op.startswith("!") - core = op[1:] if negate else op - core = core.lower() - prefix = "%" if core in {"endswith", "contains"} else "" - suffix = "%" if core in {"startswith", "contains"} else "" - pattern = f"{prefix}{text_filter.text}{suffix}" - col_expr = cast(ir.StringValue, table[column]) - predicate = col_expr.like(pattern) - if negate: - predicate = ~predicate - return table.filter(predicate) - - -def apply_interval_range( - table: ir.Table, - start_column: str, - end_column: str, - interval_range: NumericRange | None, -) -> ir.Table: - if not interval_range or interval_range.value is None: - return table - - op = (interval_range.op or "gte").lower() - value = int(interval_range.value) - start = cast(Any, table[start_column]) - end = table[end_column] - - def _interval(days: int): - return ibis.interval(days=int(days)) - - if op.endswith("bt"): - if interval_range.extent is None: - raise ValueError("Between operator for interval range requires an extent") - lower = _interval(value) - upper = _interval(int(interval_range.extent)) - predicate = (end >= start + lower) & (end <= start + upper) - if op.startswith("!"): - predicate = ~predicate - return table.filter(predicate) - - target = _interval(value) - if op == "lt": - predicate = end < start + target - elif op == "lte": - predicate = end <= start + target - elif op == "gt": - predicate = end > start + target - elif op == "gte": - predicate = end >= start + target - elif op == "eq": - predicate = (end >= start + target) & (end < start + _interval(value + 1)) - elif op == "!eq": - predicate = ~((end >= start + target) & (end < start + _interval(value + 1))) - else: - raise ValueError(f"Unsupported operator for interval range: {op}") - - return table.filter(predicate) - - -def _map_operator(op: str): - mapping = { - "lt": lambda a, b: a < b, - "lte": lambda a, b: a <= b, - "eq": lambda a, b: a == b, - "!eq": lambda a, b: a != b, - "gt": lambda a, b: a > b, - "gte": lambda a, b: a >= b, - } - if op not in mapping: - raise ValueError(f"Operator {op} not supported") - return mapping[op] - - -def apply_concept_filters( - table: ir.Table, - column: str, - include_concepts: Sequence[Concept] | None, - exclude: bool = False, -) -> ir.Table: - if not include_concepts: - return table - concept_ids = [c.concept_id for c in include_concepts if c.concept_id is not None] - if not concept_ids: - return table - predicate = table[column].isin(cast(Any, concept_ids)) - if exclude: - predicate = ~predicate - return table.filter(predicate) - - -def apply_age_filter( - table: ir.Table, - age_range: NumericRange | None, - ctx: BuildContext, - start_column: str, -) -> ir.Table: - if not age_range: - return table - base_columns = table.columns - person = _person_subset(ctx, ["person_id", "year_of_birth"]) - joined = table.join(person, ["person_id"]) - start_expr = cast(ir.TimestampValue, _ensure_timestamp(joined[start_column])) - age_expr = start_expr.year() - cast(Any, joined.year_of_birth) - joined = joined.mutate(_criteria_age=age_expr) - filtered = apply_numeric_range(joined, "_criteria_age", age_range) - filtered = filtered.drop("_criteria_age") - return _project_columns(filtered, base_columns) - - -def apply_gender_filter( - table: ir.Table, - genders: list[Concept] | None, - gender_selection: ConceptSetSelection | None, - ctx: BuildContext, -) -> ir.Table: - return _apply_person_concept_filter( - table, - person_column="gender_concept_id", - concepts=genders, - selection=gender_selection, - ctx=ctx, - ) - - -def apply_race_filter( - table: ir.Table, - races: list[Concept] | None, - race_selection: ConceptSetSelection | None, - ctx: BuildContext, -) -> ir.Table: - return _apply_person_concept_filter( - table, - person_column="race_concept_id", - concepts=races, - selection=race_selection, - ctx=ctx, - ) - - -def apply_ethnicity_filter( - table: ir.Table, - ethnicities: list[Concept] | None, - ethnicity_selection: ConceptSetSelection | None, - ctx: BuildContext, -) -> ir.Table: - return _apply_person_concept_filter( - table, - person_column="ethnicity_concept_id", - concepts=ethnicities, - selection=ethnicity_selection, - ctx=ctx, - ) - - -def _apply_person_concept_filter( - table: ir.Table, - *, - person_column: str, - concepts: Sequence[Concept] | None, - selection: ConceptSetSelection | None, - ctx: BuildContext, -) -> ir.Table: - if not concepts and not selection: - return table - base_columns = table.columns - person = _person_subset(ctx, ["person_id", person_column]) - joined = table.join(person, ["person_id"]) - joined = apply_concept_criteria( - joined, - column=person_column, - concepts=concepts, - selection=selection, - ctx=ctx, - ) - return _project_columns(joined, base_columns) - - -def apply_observation_window( - events: ir.Table, - observation_window, - ctx: BuildContext, -) -> ir.Table: - if observation_window is None: - return events - observation = ctx.table("observation_period").select( - "person_id", "observation_period_start_date", "observation_period_end_date" - ) - # Use a view to ensure subsequent joins don't mix incompatible relations. - left = events.view() - joined = left.join(observation, ["person_id"]) - prior_days = ibis.interval(days=int(observation_window.prior_days or 0)) - post_days = ibis.interval(days=int(observation_window.post_days or 0)) - start_col = _ensure_timestamp(joined.observation_period_start_date) - end_col = _ensure_timestamp(joined.observation_period_end_date) - start_bound = start_col + cast(Any, prior_days) - end_bound = end_col - cast(Any, post_days) - filtered = joined.filter((joined.start_date >= start_bound) & (joined.start_date <= end_bound)) - base_projection = [filtered[col] for col in events.columns] - base_projection.extend( - filtered[col] - for col in ("observation_period_start_date", "observation_period_end_date") - if col in filtered.columns - ) - return filtered.select(*base_projection) - - -def apply_first_event(table: ir.Table, start_column: str, primary_key: str) -> ir.Table: - window = ibis.window( - group_by=table.person_id, - order_by=[table[start_column], table[primary_key]], - ) - - ranked = table.mutate(_row_num=row_number().over(window)) - filtered = ranked.filter(ranked["_row_num"] == ibis.literal(0)) - keep_columns = [col for col in table.columns if col != "_row_num"] - if keep_columns: - return filtered.select(*(filtered[col] for col in keep_columns)) - return filtered.drop("_row_num") - - -def apply_visit_concept_filters( - table: ir.Table, - visit_types: list[Concept] | None, - visit_selection: ConceptSetSelection | None, - ctx: BuildContext, -) -> ir.Table: - return apply_concept_criteria( - table, - column="visit_concept_id", - concepts=visit_types, - selection=visit_selection, - ctx=ctx, - ) - - -def apply_provider_specialty_filter( - table: ir.Table, - provider_specialties: list[Concept] | None, - provider_specialty_selection: ConceptSetSelection | None, - ctx: BuildContext, - provider_column: str = "provider_id", -) -> ir.Table: - if not provider_specialties and not provider_specialty_selection: - return table - provider = ctx.table("provider") - provider = apply_concept_criteria( - provider, - column="specialty_concept_id", - concepts=provider_specialties, - selection=provider_specialty_selection, - ctx=ctx, - ) - filtered = provider.select(provider.provider_id) - return table.semi_join(filtered, [table[provider_column] == filtered.provider_id]) - - -def apply_care_site_filter( - table: ir.Table, - place_of_service_selection: ConceptSetSelection | None, - ctx: BuildContext, - care_site_column: str = "care_site_id", -) -> ir.Table: - if not place_of_service_selection: - return table - care_site = ctx.table("care_site") - filtered = apply_concept_set_selection( - care_site, "place_of_service_concept_id", place_of_service_selection, ctx - ) - filtered = filtered.select(filtered.care_site_id) - return table.semi_join(filtered, [table[care_site_column] == filtered.care_site_id]) - - -def apply_location_region_filter( - table: ir.Table, - *, - care_site_column: str, - location_codeset_id: int | None, - start_column: str, - end_column: str, - ctx: BuildContext, -) -> ir.Table: - if not location_codeset_id: - return table - base_columns = table.columns - care_site = ctx.table("care_site") - location_history = ctx.table("location_history") - location = ctx.table("location") - joined = table.join(care_site, [table[care_site_column] == care_site.care_site_id]) - start_expr = _ensure_timestamp(joined[start_column]) - end_expr = _ensure_timestamp(joined[end_column]) - lh = location_history - lh_condition = ( - (joined[care_site_column] == lh.entity_id) - & (lh.domain_id == ibis.literal("CARE_SITE")) - & (start_expr >= lh.start_date) - & (end_expr <= ibis.coalesce(lh.end_date, ibis.literal("2099-12-31").cast("date"))) - ) - joined = joined.join(lh, [lh_condition]) - joined = joined.join(location, [joined.location_id == location.location_id]) - codeset = ctx.codesets.filter(ctx.codesets.codeset_id == ibis.literal(location_codeset_id)) - filtered = joined.join(codeset, [location.region_concept_id == codeset.concept_id]) - return _project_columns(filtered, base_columns) - - -def apply_user_defined_period( - table: ir.Table, - start_column: str, - end_column: str, - period, -) -> tuple[ir.Table, str, str]: - if not period: - return table, start_column, end_column - - base_start = table[start_column] - base_end = table[end_column] - additions = {} - new_start = start_column - new_end = end_column - - if getattr(period, "start_date", None): - literal = _literal_like(period.start_date, base_start) - additions["_user_defined_start"] = literal - table = table.filter((base_start <= literal) & (base_end >= literal)) - new_start = "_user_defined_start" - - if getattr(period, "end_date", None): - literal = _literal_like(period.end_date, base_end) - additions["_user_defined_end"] = literal - table = table.filter((base_start <= literal) & (base_end >= literal)) - new_end = "_user_defined_end" - - if additions: - table = table.mutate(**additions) - - return table, new_start, new_end - - -def _literal_like(value, reference): - literal = ibis.literal(value) - dtype = reference.type() - if dtype.is_timestamp(): - return literal.cast("timestamp") - if dtype.is_date(): - return literal.cast("date") - return literal - - -def _ensure_timestamp(expr: ir.Value) -> ir.Value: - dtype = expr.type() - if dtype.is_timestamp(): - return expr - if dtype.is_date(): - return expr.cast("timestamp") - if dtype.is_string(): - return ibis.to_timestamp(expr) - raise ValueError(f"Cannot convert expression of type {dtype} to timestamp") - - -def _cast_like(expr: ir.Value, reference: ir.Value) -> ir.Value: - target_type = reference.type() - if expr.type() == target_type: - return expr - return expr.cast(cast(Any, target_type)) - - -def _project_columns(table: ir.Table, column_names: Sequence[str]) -> ir.Table: - available = [name for name in column_names if name in table.columns] - if not available: - return table - return table.select(*[table[name] for name in available]) - - -def apply_end_strategy( - events: ir.Table, - strategy: EndStrategy | DateOffsetStrategy | CustomEraStrategy | None, - ctx: BuildContext, -) -> ir.Table: - date_offset, custom_era = _resolve_end_strategy_parts(strategy) - if not date_offset and not custom_era: - if "observation_period_end_date" in events.columns: - op_end = _cast_like(_ensure_timestamp(events.observation_period_end_date), events.end_date) - return events.mutate(end_date=op_end) - return events - result = events - if custom_era: - result = _apply_custom_era_strategy(result, custom_era, ctx) - if date_offset: - interval = ibis.interval(days=int(date_offset.offset)) - date_field = str(date_offset.date_field or "StartDate").lower() - anchor = ( - _ensure_timestamp(result.start_date) - if date_field == "startdate" - else _ensure_timestamp(result.end_date) - ) - shifted = anchor + cast(Any, interval) - if "observation_period_end_date" in result.columns: - shifted = ibis.least( - shifted, - _ensure_timestamp(result.observation_period_end_date), - ) - result = result.mutate(end_date=_cast_like(shifted, result.end_date)) - return result - - -def has_end_strategy( - strategy: EndStrategy | DateOffsetStrategy | CustomEraStrategy | None, -) -> bool: - date_offset, custom_era = _resolve_end_strategy_parts(strategy) - return bool(date_offset or custom_era) - - -def _resolve_end_strategy_parts( - strategy: EndStrategy | DateOffsetStrategy | CustomEraStrategy | None, -) -> tuple[DateOffsetStrategy | None, CustomEraStrategy | None]: - if strategy is None: - return None, None - - if isinstance(strategy, DateOffsetStrategy): - return strategy, None - - if isinstance(strategy, CustomEraStrategy): - return None, strategy - - date_offset = getattr(strategy, "date_offset", None) - custom_era = getattr(strategy, "custom_era", None) - - if isinstance(date_offset, dict): - date_offset = DateOffsetStrategy.model_validate(date_offset, strict=False) - if isinstance(custom_era, dict): - custom_era = CustomEraStrategy.model_validate(custom_era, strict=False) - - return date_offset, custom_era - - -def collapse_events(events: ir.Table, settings: CollapseSettings | None) -> ir.Table: - if not settings or settings.collapse_type != CollapseType.ERA: - return events - pad_interval = ibis.interval(days=int(settings.era_pad or 0)) - order_by = [events.start_date, events.end_date, events.event_id] - prev_window = ibis.window( - group_by=events.person_id, - order_by=order_by, - preceding=(None, 1), - ) - extended_end = events.end_date + cast(Any, pad_interval) - prev_max = extended_end.max().over(prev_window) - is_start = ibis.ifelse( - prev_max.notnull() & (prev_max >= events.start_date), - 0, - 1, - ) - annotated = events.mutate( - extended_end=extended_end, - is_start=is_start, - ) - is_start_col = cast(ir.IntegerColumn, annotated["is_start"]) - era_window = ibis.window( - group_by=annotated.person_id, - order_by=[ - annotated.start_date, - ibis.desc(is_start_col), - annotated.end_date, - annotated.event_id, - ], - ) - era_id = is_start_col.cumsum().over(era_window) - grouped = annotated.mutate(_era_id=era_id) - max_end = cast(ir.IntervalScalar, grouped.extended_end.max()) - collapsed = grouped.group_by(grouped.person_id, grouped._era_id).aggregate( - start_date=grouped.start_date.min(), - end_date=(max_end - pad_interval), - visit_occurrence_id=grouped.visit_occurrence_id.max(), - ) - final_window = ibis.window(order_by=[collapsed.person_id, collapsed.start_date, collapsed.end_date]) - collapsed = collapsed.mutate(event_id=(ibis.row_number().over(final_window) + 1)).select( - "person_id", "event_id", "start_date", "end_date", "visit_occurrence_id" - ) - return collapsed - - -def _apply_custom_era_strategy(events: ir.Table, strategy: CustomEraStrategy, ctx: BuildContext) -> ir.Table: - if strategy.drug_codeset_id is None: - raise ValueError("Custom era strategy requires a drug codeset id.") - - persons = events.select(events.person_id).distinct() - codeset = ctx.codesets.filter(ctx.codesets.codeset_id == strategy.drug_codeset_id) - drug_exposure = ctx.table("drug_exposure") - - def _exposure_query(concept_column: str) -> ir.Table: - return ( - drug_exposure.join(persons, ["person_id"]) - .join(codeset, drug_exposure[concept_column] == codeset.concept_id) - .select( - drug_exposure.person_id, - drug_exposure.drug_exposure_start_date.name("drug_exposure_start_date"), - _drug_exposure_end(drug_exposure, strategy).name("drug_exposure_end_date"), - ) - ) - - exposures = _exposure_query("drug_concept_id").union( - _exposure_query("drug_source_concept_id"), distinct=False - ) - - gap = int(strategy.gap_days or 0) - offset = int(strategy.offset or 0) - extend_interval = ibis.interval(days=gap + offset) - - dt = exposures.select( - exposures.person_id, - exposures.drug_exposure_start_date.name("start_date"), - (exposures.drug_exposure_end_date + extend_interval).name("extended_end"), - ).distinct() - - prev_max_window = ibis.window( - group_by=dt.person_id, - order_by=[dt.start_date, dt.extended_end], - preceding=(None, 1), - ) - prev_running_max = dt.extended_end.max().over(prev_max_window) - is_start = ibis.ifelse(prev_running_max.notnull() & (prev_running_max >= dt.start_date), 0, 1) - staged = dt.mutate(is_start=is_start).view() - cumsum_window = ibis.window(group_by=staged.person_id, order_by=[staged.start_date, staged.extended_end]) - group_idx = staged.is_start.cumsum().over(cumsum_window) - annotated = staged.mutate(group_idx=group_idx) - - eras = annotated.group_by(annotated.person_id, annotated.group_idx).aggregate( - era_start=annotated.start_date.min(), - era_end=(annotated.extended_end.max() - ibis.interval(days=gap)), - ) - - join_condition = ( - (events.person_id == eras.person_id) - & (events.start_date >= eras.era_start) - & (events.start_date <= eras.era_end) - ) - joined = events.join(eras, join_condition, how="inner") - if not joined.columns: - return events.limit(0) - supplemental = [ - joined[column] - for column in ("observation_period_start_date", "observation_period_end_date") - if column in joined.columns - ] - return joined.select( - joined.person_id, - joined.event_id, - joined.start_date, - joined.era_end.name("end_date"), - joined.visit_occurrence_id, - *supplemental, - ) - - -def _drug_exposure_end(drug_exposure: ir.Table, strategy: CustomEraStrategy) -> ir.Value: - start = drug_exposure.drug_exposure_start_date - if strategy.days_supply_override is not None: - return start + ibis.interval(days=int(strategy.days_supply_override)) - - end_candidates = [ - drug_exposure.drug_exposure_end_date, - ibis.ifelse( - drug_exposure.days_supply.notnull(), - start + (ibis.interval(days=1) * drug_exposure.days_supply.cast("int64")), - ibis.null(), - ), - start + ibis.interval(days=1), - ] - return ibis.coalesce(*end_candidates) diff --git a/circe/execution/builders/condition_era.py b/circe/execution/builders/condition_era.py deleted file mode 100644 index 359232ce..00000000 --- a/circe/execution/builders/condition_era.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import ConditionEra -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_interval_range, - apply_numeric_range, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("ConditionEra") -def build_condition_era(criteria: ConditionEra, ctx: BuildContext): - table = ctx.table("condition_era") - - table = apply_codeset_filter(table, "condition_concept_id", criteria.codeset_id, ctx) - table = apply_date_range(table, "condition_era_start_date", criteria.era_start_date) - table = apply_date_range(table, "condition_era_end_date", criteria.era_end_date) - table = apply_numeric_range(table, "condition_occurrence_count", criteria.occurrence_count) - table = apply_interval_range( - table, "condition_era_start_date", "condition_era_end_date", criteria.era_length - ) - - if criteria.age_at_start: - table = apply_age_filter(table, criteria.age_at_start, ctx, "condition_era_start_date") - if criteria.age_at_end: - table = apply_age_filter(table, criteria.age_at_end, ctx, "condition_era_end_date") - - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - - if criteria.first: - table = apply_first_event(table, "condition_era_start_date", "condition_era_id") - - events = standardize_output( - table, - primary_key="condition_era_id", - start_column="condition_era_start_date", - end_column="condition_era_end_date", - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/condition_occurrence.py b/circe/execution/builders/condition_occurrence.py deleted file mode 100644 index 73129f26..00000000 --- a/circe/execution/builders/condition_occurrence.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import ConditionOccurrence -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_concept_criteria, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_visit_concept_filters, - coerce_concept_set_selection, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("ConditionOccurrence") -def build_condition_occurrence(criteria: ConditionOccurrence, ctx: BuildContext): - table = ctx.table("condition_occurrence") - - concept_column = criteria.get_concept_id_column() - table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) - if criteria.first: - table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - - table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) - table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) - - table = apply_concept_criteria( - table, - column="condition_type_concept_id", - concepts=criteria.condition_type, - selection=criteria.condition_type_cs, - ctx=ctx, - exclude=bool(criteria.condition_type_exclude), - ) - - table = apply_concept_criteria( - table, - column="condition_status_concept_id", - concepts=getattr(criteria, "condition_status", None), - selection=None, - ctx=ctx, - ) - - if criteria.age: - table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - - source_filter = getattr(criteria, "condition_source_concept", None) - selection = coerce_concept_set_selection(source_filter) - if selection is not None: - table = apply_concept_criteria( - table, - column="condition_source_concept_id", - concepts=None, - selection=selection, - ctx=ctx, - ) - - visit_source = getattr(criteria, "visit_source_concept", None) - needs_visit_filters = bool(criteria.visit_type or criteria.visit_type_cs or visit_source is not None) - if needs_visit_filters: - visit = ctx.table("visit_occurrence").select( - "person_id", - "visit_occurrence_id", - "visit_concept_id", - "visit_source_concept_id", - ) - table = table.join( - visit, - (table.visit_occurrence_id == visit.visit_occurrence_id) & (table.person_id == visit.person_id), - ) - table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) - if visit_source is not None: - table = table.filter(table.visit_source_concept_id == int(visit_source)) - - events = standardize_output( - table, - primary_key=criteria.get_primary_key_column(), - start_column=criteria.get_start_date_column(), - end_column=criteria.get_end_date_column(), - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/death.py b/circe/execution/builders/death.py deleted file mode 100644 index 847398db..00000000 --- a/circe/execution/builders/death.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import ibis - -from ...cohortdefinition.criteria import Death -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_concept_criteria, - apply_date_range, - apply_gender_filter, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("Death") -def build_death(criteria: Death, ctx: BuildContext): - table = ctx.table("death") - - table = apply_codeset_filter(table, "cause_concept_id", criteria.codeset_id, ctx) - - table = apply_date_range(table, "death_date", getattr(criteria, "occurrence_start_date", None)) - - table = apply_concept_criteria( - table, - column="death_type_concept_id", - concepts=criteria.death_type, - selection=criteria.death_type_cs, - ctx=ctx, - exclude=bool(getattr(criteria, "death_type_exclude", False)), - ) - - if getattr(criteria, "death_source_concept", None) is not None: - table = apply_codeset_filter( - table, - "cause_source_concept_id", - int(criteria.death_source_concept), - ctx, - ) - - if criteria.age: - table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - - window = ibis.window(order_by=[table.person_id, table.death_date]) - table = table.mutate(death_event_id=ibis.row_number().over(window)) - - events = standardize_output( - table, - primary_key="death_event_id", - start_column="death_date", - end_column="death_date", - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/device_exposure.py b/circe/execution/builders/device_exposure.py deleted file mode 100644 index ac34fdf0..00000000 --- a/circe/execution/builders/device_exposure.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import DeviceExposure -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_concept_criteria, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_numeric_range, - apply_provider_specialty_filter, - apply_text_filter, - apply_visit_concept_filters, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("DeviceExposure") -def build_device_exposure(criteria: DeviceExposure, ctx: BuildContext): - table = ctx.table("device_exposure") - - concept_column = criteria.get_concept_id_column() - table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) - - table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) - table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) - - table = apply_concept_criteria( - table, - column="device_type_concept_id", - concepts=criteria.device_type, - selection=criteria.device_type_cs, - ctx=ctx, - exclude=bool(criteria.device_type_exclude), - ) - - table = apply_numeric_range(table, "quantity", criteria.quantity) - table = apply_text_filter(table, "unique_device_id", getattr(criteria, "unique_device_id", None)) - - if criteria.age: - table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - table = apply_provider_specialty_filter( - table, - getattr(criteria, "provider_specialty", None), - getattr(criteria, "provider_specialty_cs", None), - ctx, - provider_column="provider_id", - ) - table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) - if criteria.device_source_concept is not None: - table = apply_codeset_filter( - table, - "device_source_concept_id", - criteria.device_source_concept, - ctx, - ) - - if criteria.first: - table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - - events = standardize_output( - table, - primary_key=criteria.get_primary_key_column(), - start_column=criteria.get_start_date_column(), - end_column=criteria.get_end_date_column(), - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/dose_era.py b/circe/execution/builders/dose_era.py deleted file mode 100644 index 6aa0f469..00000000 --- a/circe/execution/builders/dose_era.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import DoseEra -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_concept_criteria, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_interval_range, - apply_numeric_range, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("DoseEra") -def build_dose_era(criteria: DoseEra, ctx: BuildContext): - table = ctx.table("dose_era") - - table = apply_codeset_filter(table, "drug_concept_id", criteria.codeset_id, ctx) - table = apply_date_range(table, "dose_era_start_date", criteria.era_start_date) - table = apply_date_range(table, "dose_era_end_date", criteria.era_end_date) - - table = apply_concept_criteria( - table, - column="unit_concept_id", - concepts=criteria.unit, - selection=criteria.unit_cs, - ctx=ctx, - ) - - table = apply_numeric_range(table, "dose_value", criteria.dose_value) - table = apply_interval_range(table, "dose_era_start_date", "dose_era_end_date", criteria.era_length) - - if criteria.age_at_start: - table = apply_age_filter(table, criteria.age_at_start, ctx, "dose_era_start_date") - if criteria.age_at_end: - table = apply_age_filter(table, criteria.age_at_end, ctx, "dose_era_end_date") - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - - if criteria.first: - table = apply_first_event(table, "dose_era_start_date", "dose_era_id") - - events = standardize_output( - table, - primary_key="dose_era_id", - start_column="dose_era_start_date", - end_column="dose_era_end_date", - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/drug_era.py b/circe/execution/builders/drug_era.py deleted file mode 100644 index f2e99a03..00000000 --- a/circe/execution/builders/drug_era.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import DrugEra -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_interval_range, - apply_numeric_range, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("DrugEra") -def build_drug_era(criteria: DrugEra, ctx: BuildContext): - table = ctx.table("drug_era") - - table = apply_codeset_filter(table, "drug_concept_id", criteria.codeset_id, ctx) - table = apply_date_range(table, "drug_era_start_date", criteria.era_start_date) - table = apply_date_range(table, "drug_era_end_date", criteria.era_end_date) - table = apply_numeric_range(table, "drug_exposure_count", criteria.occurrence_count) - table = apply_numeric_range(table, "gap_days", criteria.gap_days) - table = apply_interval_range(table, "drug_era_start_date", "drug_era_end_date", criteria.era_length) - - if criteria.age_at_start: - table = apply_age_filter(table, criteria.age_at_start, ctx, "drug_era_start_date") - if criteria.age_at_end: - table = apply_age_filter(table, criteria.age_at_end, ctx, "drug_era_end_date") - - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - - if criteria.first: - table = apply_first_event(table, "drug_era_start_date", "drug_era_id") - - events = standardize_output( - table, - primary_key="drug_era_id", - start_column="drug_era_start_date", - end_column="drug_era_end_date", - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/drug_exposure.py b/circe/execution/builders/drug_exposure.py deleted file mode 100644 index 665f9f4c..00000000 --- a/circe/execution/builders/drug_exposure.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import DrugExposure -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_concept_criteria, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_numeric_range, - apply_provider_specialty_filter, - apply_text_filter, - apply_visit_concept_filters, - coerce_concept_set_selection, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("DrugExposure") -def build_drug_exposure(criteria: DrugExposure, ctx: BuildContext): - table = ctx.table("drug_exposure") - - concept_column = criteria.get_concept_id_column() - table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) - if criteria.first: - table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - - table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) - table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) - - table = apply_concept_criteria( - table, - column="drug_type_concept_id", - concepts=criteria.drug_type, - selection=criteria.drug_type_cs, - ctx=ctx, - exclude=bool(getattr(criteria, "drug_type_exclude", False)), - ) - table = apply_concept_criteria( - table, - column="route_concept_id", - concepts=criteria.route_concept, - selection=criteria.route_concept_cs, - ctx=ctx, - ) - table = apply_concept_criteria( - table, - column="dose_unit_concept_id", - concepts=getattr(criteria, "dose_unit", []), - selection=getattr(criteria, "dose_unit_cs", None), - ctx=ctx, - ) - - table = apply_numeric_range(table, "quantity", criteria.quantity) - table = apply_numeric_range(table, "days_supply", criteria.days_supply) - table = apply_numeric_range(table, "refills", criteria.refills) - table = apply_text_filter(table, "stop_reason", getattr(criteria, "stop_reason", None)) - table = apply_text_filter(table, "lot_number", getattr(criteria, "lot_number", None)) - - if criteria.age: - table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - table = apply_provider_specialty_filter( - table, - getattr(criteria, "provider_specialty", None), - getattr(criteria, "provider_specialty_cs", None), - ctx, - provider_column="provider_id", - ) - table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) - - source_filter = getattr(criteria, "drug_source_concept", None) - selection = coerce_concept_set_selection(source_filter) - if selection is not None: - table = apply_concept_criteria( - table, - column="drug_source_concept_id", - concepts=None, - selection=selection, - ctx=ctx, - ) - - events = standardize_output( - table, - primary_key=criteria.get_primary_key_column(), - start_column=criteria.get_start_date_column(), - end_column=criteria.get_end_date_column(), - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/groups.py b/circe/execution/builders/groups.py deleted file mode 100644 index 3bcec09a..00000000 --- a/circe/execution/builders/groups.py +++ /dev/null @@ -1,441 +0,0 @@ -from __future__ import annotations - -from typing import Callable - -import ibis -import ibis.common.exceptions as ibis_exc -import ibis.expr.types as ir - -from ...cohortdefinition.core import ObservationFilter -from ...cohortdefinition.criteria import ( - Criteria, - CriteriaColumn, - CriteriaGroup, - VisitDetail, -) -from ..build_context import BuildContext -from ..criteria_compat import ( - CorrelatedCriteria, - DemoGraphicCriteria, - OccurrenceType, - parse_single_criteria, -) -from .common import ( - apply_age_filter, - apply_date_range, - apply_ethnicity_filter, - apply_gender_filter, - apply_observation_window, - apply_race_filter, -) -from .registry import build_events - - -def apply_criteria_group(events: ir.Table, group: CriteriaGroup | None, ctx: BuildContext) -> ir.Table: - mask = _group_mask(events, group, ctx) - if mask is None: - return events - return events.filter(mask) - - -def _correlated_mask(events: ir.Table, correlated: CorrelatedCriteria, ctx: BuildContext) -> ir.Value: - criteria_model = correlated.criteria - if criteria_model and not isinstance(criteria_model, ir.Expr): - criteria_model = parse_single_criteria(criteria_model) - if criteria_model is None: - return ibis.literal(True) - - count_column_name, count_column_enum = _resolve_count_column(correlated.occurrence) - - base_events = build_events(criteria_model, ctx) - base_events = _attach_count_columns( - base_events, - criteria_model, - ctx, - count_column_name=count_column_name, - count_column_enum=count_column_enum, - ) - requires_corr_end_alignment = _requires_observation_period_end_alignment(correlated) - zero_window: ObservationFilter | None = None - if not correlated.ignore_observation_period: - zero_window = ObservationFilter(prior_days=0, post_days=0) - base_events = apply_observation_window(base_events, zero_window, ctx) - - index_events = events - if not correlated.ignore_observation_period: - missing_observation_bounds = ( - "observation_period_start_date" not in index_events.columns - or "observation_period_end_date" not in index_events.columns - ) - if missing_observation_bounds: - zero_window = zero_window or ObservationFilter(prior_days=0, post_days=0) - index_events = apply_observation_window(index_events, zero_window, ctx) - - select_fields = [ - base_events.person_id, - base_events.event_id.name("_corr_event_id"), - base_events.start_date.name("_corr_start_date"), - base_events.end_date.name("_corr_end_date"), - ] - if "visit_occurrence_id" in base_events.columns: - select_fields.append(base_events.visit_occurrence_id.name("_corr_visit_occurrence_id")) - if count_column_name and count_column_name in base_events.columns: - select_fields.append(base_events[count_column_name]) - - criteria_events = base_events.select(*select_fields) - join_condition = index_events.person_id == criteria_events.person_id - if not correlated.ignore_observation_period: - if "observation_period_start_date" in index_events.columns: - join_condition &= criteria_events._corr_start_date >= index_events.observation_period_start_date - if "observation_period_end_date" in index_events.columns: - join_condition &= criteria_events._corr_start_date <= index_events.observation_period_end_date - if requires_corr_end_alignment: - join_condition &= criteria_events._corr_end_date <= index_events.observation_period_end_date - window_condition = _build_window_condition(index_events, criteria_events, correlated) - if window_condition is not None: - join_condition &= window_condition - - occurrence = correlated.occurrence - occ_type = getattr(occurrence, "type", None) - if isinstance(occ_type, int): - occ_type = OccurrenceType(occurrence.type) - - require_same_visit = bool(correlated.restrict_visit) - if correlated.restrict_visit is None and isinstance(criteria_model, VisitDetail): - require_same_visit = True - - if require_same_visit and ( - "visit_occurrence_id" in index_events.columns - and "_corr_visit_occurrence_id" in criteria_events.columns - ): - join_condition &= ( - index_events.visit_occurrence_id.notnull() - & criteria_events._corr_visit_occurrence_id.notnull() - & (index_events.visit_occurrence_id == criteria_events._corr_visit_occurrence_id) - ) - - joined = index_events.join(criteria_events, join_condition, how="left") - - corr_event_id = joined._corr_event_id - count_expr = corr_event_id - if count_column_name and count_column_name in joined.columns: - count_expr = joined[count_column_name] - match_expr = corr_event_id.notnull() - joined = joined.mutate( - _corr_match_value=ibis.ifelse(match_expr, count_expr, ibis.null()), - ) - - if correlated.occurrence and correlated.occurrence.is_distinct: - aggregator = joined._corr_match_value.nunique() - else: - aggregator = joined._corr_match_value.count() - - aggregated = joined.group_by(joined.person_id, joined.event_id).aggregate(match_count=aggregator) - predicate = _occurrence_predicate(aggregated.match_count, correlated.occurrence) - matching_ids = aggregated.filter(predicate).select("person_id", "event_id").distinct() - return _event_membership_mask(events, matching_ids) - - -def _group_mask(events: ir.Table, group: CriteriaGroup | None, ctx: BuildContext) -> ir.Value | None: - if not group or group.is_empty(): - return None - - masks: list[ir.Value] = [] - for correlated in group.criteria_list or []: - masks.append(_correlated_mask(events, correlated, ctx)) - - for demographic in group.demographic_criteria_list or []: - demo_mask = _demographic_mask(events, demographic, ctx) - if demo_mask is not None: - masks.append(demo_mask) - - for subgroup in group.groups or []: - sub_mask = _group_mask(events, subgroup, ctx) - if sub_mask is not None: - masks.append(sub_mask) - - if not masks: - return None - - group_type = (group.type or "ALL").upper() - if group_type == "ANY": - return _combine_any(masks) - if group_type.startswith("AT_"): - count = group.count - if group_type.endswith("LEAST"): - threshold = count if count is not None else 1 - return _combine_threshold(masks, threshold, at_least=True) - threshold = count if count is not None else 0 - return _combine_threshold(masks, threshold, at_least=False) - return _combine_all(masks) - - -def _combine_all(masks: list[ir.Value]) -> ir.Value: - combined = masks[0] - for mask in masks[1:]: - combined = combined & mask - return combined - - -def _combine_any(masks: list[ir.Value]) -> ir.Value: - combined = masks[0] - for mask in masks[1:]: - combined = combined | mask - return combined - - -def _combine_threshold(masks: list[ir.Value], threshold: int, *, at_least: bool) -> ir.Value: - def _to_int(mask: ir.Value) -> ir.Value: - return ibis.ifelse(mask, ibis.literal(1, type="int64"), ibis.literal(0, type="int64")) - - total = _to_int(masks[0]) - for mask in masks[1:]: - total = total + _to_int(mask) - return total >= threshold if at_least else total <= threshold - - -def _demographic_mask( - events: ir.Table, - demographic: DemoGraphicCriteria, - ctx: BuildContext, -) -> ir.Value | None: - if demographic is None: - return None - - filtered = events - applied = False - if demographic.age: - filtered = apply_age_filter(filtered, demographic.age, ctx, "start_date") - applied = True - if demographic.gender or demographic.gender_cs: - filtered = apply_gender_filter(filtered, demographic.gender, demographic.gender_cs, ctx) - applied = True - if demographic.race or demographic.race_cs: - filtered = apply_race_filter(filtered, demographic.race, demographic.race_cs, ctx) - applied = True - if demographic.ethnicity or demographic.ethnicity_cs: - filtered = apply_ethnicity_filter(filtered, demographic.ethnicity, demographic.ethnicity_cs, ctx) - applied = True - if demographic.occurrence_start_date: - filtered = apply_date_range(filtered, "start_date", demographic.occurrence_start_date) - applied = True - if demographic.occurrence_end_date: - filtered = apply_date_range(filtered, "end_date", demographic.occurrence_end_date) - applied = True - - if not applied: - return None - - filtered_ids = filtered.select(filtered.person_id, filtered.event_id).distinct() - return _event_membership_mask(events, filtered_ids) - - -def _event_membership_mask(events: ir.Table, ids: ir.Table) -> ir.Value: - keys = ids.mutate(_event_key=_event_key_expr(ids)).select("_event_key") - return _event_key_expr(events).isin(keys._event_key) - - -def _event_key_expr(table: ir.Table) -> ir.Value: - return table.person_id.cast("string") + ibis.literal(":") + table.event_id.cast("string") - - -def _occurrence_predicate(count_expr: ir.Value, occurrence) -> ir.Value: - if occurrence is None: - return count_expr > 0 - - occ_type = occurrence.type - if isinstance(occ_type, int): - occ_type = OccurrenceType(occurrence.type) - - if occ_type == OccurrenceType.EXACTLY: - return count_expr == occurrence.count - if occ_type == OccurrenceType.AT_LEAST: - return count_expr >= occurrence.count - if occ_type == OccurrenceType.AT_MOST: - return count_expr <= occurrence.count - return count_expr > 0 - - -def _build_window_condition( - index_events: ir.Table, - correlated_events: ir.Table, - correlated: CorrelatedCriteria, -) -> ir.Value: - cond = ibis.literal(True) - - if correlated.start_window: - correlated_start = _correlated_window_value( - correlated_events, - correlated.start_window.use_event_end, - default="start", - ) - lower = _apply_endpoint_anchor( - index_events, - correlated.start_window.start, - correlated.start_window.use_index_end, - ) - upper = _apply_endpoint_anchor( - index_events, - correlated.start_window.end, - correlated.start_window.use_index_end, - ) - if lower is not None: - cond &= correlated_start >= lower - if upper is not None: - cond &= correlated_start <= upper - - if correlated.end_window: - lower = _apply_endpoint_anchor( - index_events, - correlated.end_window.start, - correlated.end_window.use_index_end, - default_to_index_end=False, - ) - upper = _apply_endpoint_anchor( - index_events, - correlated.end_window.end, - correlated.end_window.use_index_end, - default_to_index_end=False, - ) - correlated_end = _correlated_window_value( - correlated_events, - correlated.end_window.use_event_end, - default="end", - ) - if lower is not None: - cond &= correlated_end >= lower - if upper is not None: - cond &= correlated_end <= upper - - return cond - - -def _apply_endpoint_anchor( - events: ir.Table, - endpoint, - use_index_end: bool | None, - *, - default_to_index_end: bool = False, -): - anchor = ( - events.end_date - if (use_index_end or (use_index_end is None and default_to_index_end)) - else events.start_date - ) - if not endpoint or endpoint.days is None: - return None - days = ibis.interval(days=int(endpoint.days)) - coeff = endpoint.coeff if endpoint.coeff is not None else 1 - return anchor + days * coeff - - -def _correlated_window_value( - correlated_events: ir.Table, - use_event_end: bool | None, - *, - default: str, -) -> ir.Value: - if use_event_end is True: - return correlated_events._corr_end_date - if use_event_end is False: - return correlated_events._corr_start_date - if default == "end": - return correlated_events._corr_end_date - return correlated_events._corr_start_date - - -_COUNT_COLUMN_MAPPING: dict[CriteriaColumn, str] = { - CriteriaColumn.START_DATE: "_corr_start_date", - CriteriaColumn.END_DATE: "_corr_end_date", - CriteriaColumn.VISIT_ID: "_corr_visit_occurrence_id", - CriteriaColumn.DOMAIN_CONCEPT: "_corr_domain_concept_id", - CriteriaColumn.DOMAIN_SOURCE_CONCEPT: "_corr_domain_source_concept_id", -} - - -_COUNT_COLUMN_SOURCES: dict[CriteriaColumn, Callable[[Criteria], str]] = { - CriteriaColumn.DOMAIN_CONCEPT: lambda criteria: criteria.get_concept_id_column(), - CriteriaColumn.DOMAIN_SOURCE_CONCEPT: lambda criteria: _source_concept_column(criteria), -} - - -def _resolve_count_column(occurrence): - if occurrence is None or occurrence.count_column is None: - return None, None - column = occurrence.count_column - enum_value: CriteriaColumn | None = None - if isinstance(column, CriteriaColumn): - enum_value = column - else: - value = str(column) - if value.upper() in CriteriaColumn.__members__: - enum_value = CriteriaColumn[value.upper()] - else: - lower = value.lower() - for member in CriteriaColumn: - if member.value == lower: - enum_value = member - break - if enum_value is None: - return None, None - return _COUNT_COLUMN_MAPPING.get(enum_value), enum_value - - -def _source_concept_column(criteria) -> str: - prefix = criteria.snake_case_class_name().split("_")[0] - return f"{prefix}_source_concept_id" - - -def _attach_count_columns( - events: ir.Table, - criteria_model, - ctx: BuildContext, - *, - count_column_name: str | None, - count_column_enum: CriteriaColumn | None, -) -> ir.Table: - if not count_column_name or not count_column_enum: - return events - source_getter = _COUNT_COLUMN_SOURCES.get(count_column_enum) - if source_getter is None: - return events - source_column = source_getter(criteria_model) - if source_column is None: - return events - table_name = criteria_model.snake_case_class_name() - try: - domain_table = ctx.table(table_name) - except ( - ibis_exc.IbisError, - TypeError, - ValueError, - AttributeError, - NotImplementedError, - ): - return events - if source_column not in domain_table.columns: - return events - primary_key = criteria_model.get_primary_key_column() - if primary_key not in domain_table.columns: - return events - lookup = domain_table.select( - domain_table[primary_key].name("_corr_join_key"), - domain_table[source_column].name(count_column_name), - ) - augmented = events.join(lookup, events.event_id == lookup._corr_join_key, how="left") - base_columns = events.columns - projection = [augmented[name] for name in base_columns if name in augmented.columns] - projection.append(augmented[count_column_name]) - return augmented.select(*projection) - - -def _requires_observation_period_end_alignment(correlated: CorrelatedCriteria) -> bool: - if correlated.start_window and correlated.start_window.use_event_end: - return True - if correlated.end_window and correlated.end_window.use_event_end: - return True - occurrence = correlated.occurrence - if occurrence and occurrence.count_column is not None: - resolved, _ = _resolve_count_column(occurrence) - return resolved == "_corr_end_date" - return False diff --git a/circe/execution/builders/measurement.py b/circe/execution/builders/measurement.py deleted file mode 100644 index 2659b519..00000000 --- a/circe/execution/builders/measurement.py +++ /dev/null @@ -1,200 +0,0 @@ -from __future__ import annotations - -import ibis - -from ...cohortdefinition.criteria import Measurement -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_concept_criteria, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_numeric_range, - apply_provider_specialty_filter, - apply_visit_concept_filters, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("Measurement") -def build_measurement(criteria: Measurement, ctx: BuildContext): - table = ctx.table("measurement") - concept_column = criteria.get_concept_id_column() - table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) - if criteria.first: - table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - - table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) - table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) - - table = apply_concept_criteria( - table, - column="measurement_type_concept_id", - concepts=criteria.measurement_type, - selection=criteria.measurement_type_cs, - ctx=ctx, - exclude=bool(criteria.measurement_type_exclude), - ) - - table = apply_concept_criteria( - table, - column="operator_concept_id", - concepts=getattr(criteria, "operator_concept", None), - selection=getattr(criteria, "operator_concept_cs", None), - ctx=ctx, - ) - - value_column = "value_as_number" - if criteria.unit: - table = apply_concept_criteria( - table, - column="unit_concept_id", - concepts=criteria.unit, - selection=None, - ctx=ctx, - ) - table, value_column = _maybe_normalize_units(table, criteria.unit, criteria.value_as_number) - table = apply_concept_criteria( - table, - column="unit_concept_id", - concepts=None, - selection=criteria.unit_cs, - ctx=ctx, - ) - - table = apply_concept_criteria( - table, - column="value_as_concept_id", - concepts=criteria.value_as_concept, - selection=criteria.value_as_concept_cs, - ctx=ctx, - ) - - table = apply_numeric_range(table, value_column, criteria.value_as_number) - table = apply_numeric_range(table, "range_low", criteria.range_low) - table = apply_numeric_range(table, "range_high", criteria.range_high) - if getattr(criteria, "range_low_ratio", None): - denom = ibis.ifelse(table.range_low == 0, ibis.null(), table.range_low) - ratio = (table.value_as_number / denom).name("_range_low_ratio") - table = table.mutate(_range_low_ratio=ratio) - table = apply_numeric_range(table, "_range_low_ratio", criteria.range_low_ratio) - if getattr(criteria, "range_high_ratio", None): - denom = ibis.ifelse(table.range_high == 0, ibis.null(), table.range_high) - ratio = (table.value_as_number / denom).name("_range_high_ratio") - table = table.mutate(_range_high_ratio=ratio) - table = apply_numeric_range(table, "_range_high_ratio", criteria.range_high_ratio) - - if getattr(criteria, "abnormal", None): - abnormal_predicate = ( - (table.value_as_number < table.range_low) - | (table.value_as_number > table.range_high) - | table.value_as_concept_id.isin([4155142, 4155143]) - ) - table = table.filter(abnormal_predicate) - - if criteria.age: - table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - table = apply_provider_specialty_filter( - table, - getattr(criteria, "provider_specialty", None), - getattr(criteria, "provider_specialty_cs", None), - ctx, - provider_column="provider_id", - ) - table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) - if criteria.measurement_source_concept is not None: - table = apply_codeset_filter( - table, - "measurement_source_concept_id", - criteria.measurement_source_concept, - ctx, - ) - - events = standardize_output( - table, - primary_key=criteria.get_primary_key_column(), - start_column=criteria.get_start_date_column(), - end_column=criteria.get_end_date_column(), - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) - - -def _maybe_normalize_units(table, units, value_range): - """ - Best-effort unit normalization for numeric comparisons. - - Circe generally relies on unit-specific criteria rows (separate thresholds per unit scale). - Normalizing in that situation breaks parity (e.g. neutrophil counts expressed as 10..1500 cells/uL). - - Strategy: - - Always normalize mass to kilograms (pounds -> kg). - - For cell counts, only normalize when the numeric range appears to be in the canonical 10^9/L scale. - Heuristic: upper bound <= 100. - """ - unit_ids = [concept.concept_id for concept in units if concept.concept_id is not None] - if not unit_ids: - return table, "value_as_number" - if not all(unit_id in _UNIT_NORMALIZATION for unit_id in unit_ids): - return table, "value_as_number" - groups = {_UNIT_NORMALIZATION[unit_id][0] for unit_id in unit_ids} - if len(groups) != 1: - return table, "value_as_number" - - group = next(iter(groups)) - if group == "mass_kg": - should_normalize = True - elif group == "count_10e9_per_l": - should_normalize = _range_looks_like_canonical_cell_count(value_range) - else: - should_normalize = False - - if not should_normalize: - return table, "value_as_number" - - multiplier = _unit_multiplier_expr(table.unit_concept_id, unit_ids) - normalized = (table.value_as_number * multiplier).name("_normalized_value") - table = table.mutate(_normalized_value=normalized) - return table, "_normalized_value" - - -def _range_looks_like_canonical_cell_count(value_range) -> bool: - if value_range is None or value_range.value is None: - return False - op = (value_range.op or "eq").lower() - upper = float(value_range.value) - if op.endswith("bt") and value_range.extent is not None: - upper = max(upper, float(value_range.extent)) - # Canonical 10^9/L scale is typically << 100; high thresholds indicate raw unit ranges. - return upper <= 100.0 - - -def _unit_multiplier_expr(unit_column, unit_ids): - multiplier_expr = ibis.literal(1.0) - for unit_id in unit_ids: - multiplier = _UNIT_NORMALIZATION[unit_id][1] - multiplier_expr = ibis.ifelse( - unit_column == ibis.literal(unit_id), - ibis.literal(multiplier), - multiplier_expr, - ) - return multiplier_expr - - -_UNIT_NORMALIZATION = { - # Mass - 9529: ("mass_kg", 1.0), # kilogram - 3195625: ("mass_kg", 0.45359237), # pound - # Cell counts per liter (expressed in 10^9/L) - 9444: ("count_10e9_per_l", 1.0), # billion per liter - 44777588: ("count_10e9_per_l", 1.0), - 8848: ("count_10e9_per_l", 1.0), # thousand per microliter - 8816: ("count_10e9_per_l", 1.0), # million per milliliter - 8961: ("count_10e9_per_l", 1.0), # thousand per cubic millimeter - 8784: ("count_10e9_per_l", 0.001), # cells per microliter - 8647: ("count_10e9_per_l", 0.001), # per microliter -} diff --git a/circe/execution/builders/observation.py b/circe/execution/builders/observation.py deleted file mode 100644 index 93dff5bc..00000000 --- a/circe/execution/builders/observation.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import Observation -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_concept_criteria, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_numeric_range, - apply_provider_specialty_filter, - apply_text_filter, - apply_visit_concept_filters, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("Observation") -def build_observation(criteria: Observation, ctx: BuildContext): - table = ctx.table("observation") - table = apply_codeset_filter(table, criteria.get_concept_id_column(), criteria.codeset_id, ctx) - - table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) - table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) - - table = apply_concept_criteria( - table, - column="observation_type_concept_id", - concepts=criteria.observation_type, - selection=criteria.observation_type_cs, - ctx=ctx, - exclude=bool(criteria.observation_type_exclude), - ) - - table = apply_concept_criteria( - table, - column="qualifier_concept_id", - concepts=criteria.qualifier, - selection=criteria.qualifier_cs, - ctx=ctx, - ) - - table = apply_concept_criteria( - table, - column="unit_concept_id", - concepts=criteria.unit, - selection=criteria.unit_cs, - ctx=ctx, - ) - - table = apply_concept_criteria( - table, - column="value_as_concept_id", - concepts=criteria.value_as_concept, - selection=criteria.value_as_concept_cs, - ctx=ctx, - ) - - table = apply_numeric_range(table, "value_as_number", criteria.value_as_number) - table = apply_text_filter(table, "value_as_string", criteria.value_as_string) - - if criteria.age: - table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - table = apply_provider_specialty_filter( - table, - getattr(criteria, "provider_specialty", None), - getattr(criteria, "provider_specialty_cs", None), - ctx, - provider_column="provider_id", - ) - table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) - if criteria.observation_source_concept is not None: - table = apply_codeset_filter( - table, - "observation_source_concept_id", - criteria.observation_source_concept, - ctx, - ) - - if criteria.first: - table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - - events = standardize_output( - table, - primary_key=criteria.get_primary_key_column(), - start_column=criteria.get_start_date_column(), - end_column=criteria.get_end_date_column(), - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/observation_period.py b/circe/execution/builders/observation_period.py deleted file mode 100644 index e5e396f2..00000000 --- a/circe/execution/builders/observation_period.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import ObservationPeriod -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_concept_criteria, - apply_date_range, - apply_first_event, - apply_interval_range, - apply_user_defined_period, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("ObservationPeriod") -def build_observation_period(criteria: ObservationPeriod, ctx: BuildContext): - table = ctx.table("observation_period") - - table = apply_date_range(table, "observation_period_start_date", criteria.period_start_date) - table = apply_date_range(table, "observation_period_end_date", criteria.period_end_date) - - table = apply_concept_criteria( - table, - column="period_type_concept_id", - concepts=criteria.period_type, - selection=criteria.period_type_cs, - ctx=ctx, - ) - - table = apply_interval_range( - table, - "observation_period_start_date", - "observation_period_end_date", - criteria.period_length, - ) - - if criteria.age_at_start: - table = apply_age_filter(table, criteria.age_at_start, ctx, "observation_period_start_date") - if criteria.age_at_end: - table = apply_age_filter(table, criteria.age_at_end, ctx, "observation_period_end_date") - - table, start_column, end_column = apply_user_defined_period( - table, - "observation_period_start_date", - "observation_period_end_date", - criteria.user_defined_period, - ) - - if criteria.first: - table = apply_first_event(table, start_column, "observation_period_id") - - events = standardize_output( - table, - primary_key="observation_period_id", - start_column=start_column, - end_column=end_column, - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/payer_plan_period.py b/circe/execution/builders/payer_plan_period.py deleted file mode 100644 index 766160fd..00000000 --- a/circe/execution/builders/payer_plan_period.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import PayerPlanPeriod -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_interval_range, - apply_user_defined_period, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("PayerPlanPeriod") -def build_payer_plan_period(criteria: PayerPlanPeriod, ctx: BuildContext): - table = ctx.table("payer_plan_period") - - table = apply_date_range(table, "payer_plan_period_start_date", criteria.period_start_date) - table = apply_date_range(table, "payer_plan_period_end_date", criteria.period_end_date) - - table = apply_interval_range( - table, - "payer_plan_period_start_date", - "payer_plan_period_end_date", - criteria.period_length, - ) - - if criteria.age_at_start: - table = apply_age_filter(table, criteria.age_at_start, ctx, "payer_plan_period_start_date") - if criteria.age_at_end: - table = apply_age_filter(table, criteria.age_at_end, ctx, "payer_plan_period_end_date") - - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - - table = apply_codeset_filter(table, "payer_concept_id", criteria.payer_concept, ctx) - table = apply_codeset_filter(table, "plan_concept_id", criteria.plan_concept, ctx) - table = apply_codeset_filter(table, "sponsor_concept_id", criteria.sponsor_concept, ctx) - table = apply_codeset_filter(table, "stop_reason_concept_id", criteria.stop_reason_concept, ctx) - table = apply_codeset_filter(table, "payer_source_concept_id", criteria.payer_source_concept, ctx) - table = apply_codeset_filter(table, "plan_source_concept_id", criteria.plan_source_concept, ctx) - table = apply_codeset_filter(table, "sponsor_source_concept_id", criteria.sponsor_source_concept, ctx) - table = apply_codeset_filter( - table, "stop_reason_source_concept_id", criteria.stop_reason_source_concept, ctx - ) - - table, start_column, end_column = apply_user_defined_period( - table, - "payer_plan_period_start_date", - "payer_plan_period_end_date", - criteria.user_defined_period, - ) - - if criteria.first: - table = apply_first_event(table, start_column, "payer_plan_period_id") - - events = standardize_output( - table, - primary_key="payer_plan_period_id", - start_column=start_column, - end_column=end_column, - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/pipeline.py b/circe/execution/builders/pipeline.py deleted file mode 100644 index 8b0b3b4d..00000000 --- a/circe/execution/builders/pipeline.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import annotations - -import ibis -import ibis.common.exceptions as ibis_exc -import ibis.expr.types as ir -import polars as pl - -from ...cohortdefinition import CohortExpression -from ..build_context import BuildContext -from .common import ( - apply_end_strategy, - apply_observation_window, - collapse_events, - has_end_strategy, -) -from .groups import apply_criteria_group -from .post_processing import apply_censor_window, apply_censoring, apply_inclusion_rules -from .registry import build_events - -OUTPUT_SCHEMA = { - "person_id": pl.Int64, - "event_id": pl.Int64, - "start_date": pl.Datetime, - "end_date": pl.Datetime, - "visit_occurrence_id": pl.Int64, -} - - -def build_primary_events(expression: CohortExpression, ctx: BuildContext): - def _maybe_materialize(table: ir.Table, label: str) -> ir.Table: - return ctx.maybe_materialize(table, label=label, analyze=True) - - primary = expression.primary_criteria - if primary is None or not primary.criteria_list: - return None - event_tables: list[ir.Table] = [] - for criteria in primary.criteria_list: - table = build_events(criteria, ctx) - if table is None: - continue - event_tables.append(table) - if not event_tables: - return None - if ctx.should_materialize_stages(): - materialized: list[ir.Table] = [] - for idx, table in enumerate(event_tables, start=1): - materialized.append(ctx.maybe_materialize(table, label=f"primary_src_{idx}", analyze=True)) - event_tables = materialized - events = event_tables[0] - for table in event_tables[1:]: - events = events.union(table, distinct=False) - events = events.mutate(_source_event_id=events.event_id) - events = apply_observation_window(events, primary.observation_window, ctx) - events = _assign_primary_event_ids(events) - if _should_limit(primary.primary_limit): - events = _apply_result_limit(events, primary.primary_limit) - - events = ctx.maybe_materialize(events, label="primary_events", analyze=True) - - # Short-circuit the remainder of the pipeline when no primary events exist. - if ctx.should_materialize_stages(): - try: - primary_count = events.count().execute() - except (ibis_exc.IbisError, RuntimeError, ValueError, TypeError): - primary_count = None - if primary_count == 0: - events = _drop_aux_columns(events) - return events.limit(0) - - events = apply_criteria_group(events, expression.additional_criteria, ctx) - if expression.additional_criteria: - events = ctx.maybe_materialize(events, label="additional_criteria", analyze=True) - - events = apply_inclusion_rules(events, expression.inclusion_rules, ctx) - if expression.inclusion_rules: - events = ctx.maybe_materialize(events, label="inclusion", analyze=True) - # Circe ignores QualifiedLimit, so we do the same to preserve parity. - if _should_limit(expression.expression_limit): - events = _apply_result_limit(events, expression.expression_limit) - events = apply_end_strategy(events, expression.end_strategy, ctx) - if has_end_strategy(expression.end_strategy): - events = _maybe_materialize(events, label="strategy_ends") - - # Censoring should cut the cohort end date, so apply it after end strategy. - events = apply_censoring(events, expression.censoring_criteria, ctx) - if expression.censoring_criteria: - events = ctx.maybe_materialize(events, label="censoring", analyze=True) - events = apply_censor_window(events, expression.censor_window, ctx) - events = _drop_aux_columns(events) - events = collapse_events(events, expression.collapse_settings) - if expression.collapse_settings and expression.collapse_settings.collapse_type: - events = _maybe_materialize(events, label="final_cohort") - return events - - -def build_primary_events_polars(expression: CohortExpression, ctx: BuildContext) -> pl.DataFrame: - events = build_primary_events(expression, ctx) - if events is None: - return pl.DataFrame(schema=OUTPUT_SCHEMA) - return events.to_polars() - - -def _assign_primary_event_ids(events): - if "_source_event_id" not in events.columns: - events = events.mutate(_source_event_id=events.event_id) - order = [events.person_id, events.start_date, events._source_event_id] - person_window = ibis.window(group_by=events.person_id, order_by=order[1:]) - person_rank = ibis.row_number().over(person_window) - events = events.mutate( - # Keep event ids unique *within* a person to avoid global sorts/shuffles. - # Most downstream logic keys by (person_id, event_id). - event_id=(person_rank + 1), - _person_ordinal=(person_rank + 1), - ) - supplemental = [ - events[column] - for column in ("observation_period_start_date", "observation_period_end_date") - if column in events.columns - ] - return events.select( - events.person_id, - events.event_id, - events.start_date, - events.end_date, - events.visit_occurrence_id, - events._source_event_id, - events._person_ordinal, - *supplemental, - ) - - -def _apply_result_limit(events: ir.Table, limit) -> ir.Table: - if not limit or (limit.type or "ALL").lower() == "all": - return events - - order_by = [events.start_date] - if "event_id" in events.columns: - order_by.append(events.event_id) - - w = ibis.window(group_by=events.person_id, order_by=order_by) - - helper = "__mitos_rn__" - - ranked = events.mutate(**{helper: ibis.row_number().over(w)}) - limited = ranked.filter(ranked[helper] == 0) - - return limited.select([limited[c] for c in events.columns]) - - -def _drop_aux_columns(events: ir.Table) -> ir.Table: - drop_cols = [ - col - for col in ( - "_source_event_id", - "_person_ordinal", - "observation_period_start_date", - "observation_period_end_date", - "_result_row", - ) - if col in events.columns - ] - if drop_cols: - events = events.drop(*drop_cols) - return events - - -def _should_limit(limit) -> bool: - return bool(limit and (limit.type or "all").lower() != "all") diff --git a/circe/execution/builders/post_processing.py b/circe/execution/builders/post_processing.py deleted file mode 100644 index d95bbc0f..00000000 --- a/circe/execution/builders/post_processing.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -import ibis -import ibis.expr.types as ir - -from ...cohortdefinition.criteria import Criteria, InclusionRule -from ..build_context import BuildContext -from .groups import apply_criteria_group -from .registry import build_events - - -def apply_additional_criteria(events: ir.Table, group, ctx: BuildContext) -> ir.Table: - return apply_criteria_group(events, group, ctx) - - -def apply_inclusion_rules(events: ir.Table, rules: list[InclusionRule], ctx: BuildContext) -> ir.Table: - if not rules: - return events - - base_events = events.select(events.person_id, events.event_id) - bit_hits = [] - used_bits: list[int] = [] - for idx, rule in enumerate(rules): - rule_events = apply_criteria_group(events, rule.expression, ctx) - if rule_events is None: - continue - bit_value = 1 << idx - used_bits.append(bit_value) - bit_hits.append( - rule_events.select( - rule_events.person_id, - rule_events.event_id, - ibis.literal(bit_value, type="int64").name("_rule_bit"), - ).distinct() - ) - if not bit_hits: - return events - - union_hits = bit_hits[0] - for table in bit_hits[1:]: - union_hits = union_hits.union(table, distinct=False) - - union_hits = ctx.maybe_materialize(union_hits, label="inclusion_hits", analyze=True) - - mask = union_hits.group_by(union_hits.person_id, union_hits.event_id).aggregate( - # Postgres returns NUMERIC for SUM(BIGINT), which breaks bitwise ops. - # Ibis also infers SUM(int64) -> int64 and may optimize away an int64 cast, - # so we force an intermediate cast to keep the SQL-level cast. - _rule_mask=union_hits._rule_bit.sum().cast("decimal(38,0)").cast("int64") - ) - target_mask = sum(used_bits) - target_literal = ibis.literal(target_mask, type="int64") - mask = mask.filter((mask._rule_mask & target_literal) == target_literal) - - filtered_ids = base_events.inner_join(mask, ["person_id", "event_id"]) - return events.inner_join(filtered_ids, ["person_id", "event_id"]).select(events.columns) - - -def apply_censoring(events: ir.Table, criteria_list: list[Criteria], ctx: BuildContext) -> ir.Table: - if not criteria_list: - return events - censor_tables = [build_events(criteria, ctx) for criteria in criteria_list if criteria] - if not censor_tables: - return events - censor_events = censor_tables[0] - for table in censor_tables[1:]: - censor_events = censor_events.union(table) - - censor_events = censor_events.select( - censor_events.person_id, - censor_events.start_date.name("censor_start"), - ) - joined = events.join( - censor_events, - (events.person_id == censor_events.person_id) & (censor_events.censor_start >= events.start_date), - how="left", - ) - min_censor = joined.group_by(joined.person_id, joined.event_id).aggregate( - censor_date=joined.censor_start.min() - ) - event_columns = events.columns - events = events.left_join( - min_censor, - (events.person_id == min_censor.person_id) & (events.event_id == min_censor.event_id), - ) - events = events.select(*event_columns, min_censor.censor_date) - events = events.mutate( - end_date=ibis.ifelse( - events.censor_date.notnull() & (events.censor_date < events.end_date), - events.censor_date, - events.end_date, - ) - ).select(*event_columns) - return events - - -def apply_censor_window(events: ir.Table, window, ctx: BuildContext) -> ir.Table: - if not window: - return events - if window.start_date: - events = events.filter(events.start_date >= ibis.timestamp(window.start_date)) - if window.end_date: - events = events.filter(events.end_date <= ibis.timestamp(window.end_date)) - return events diff --git a/circe/execution/builders/procedure_occurrence.py b/circe/execution/builders/procedure_occurrence.py deleted file mode 100644 index f09c1d92..00000000 --- a/circe/execution/builders/procedure_occurrence.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import ProcedureOccurrence -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_concept_criteria, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_numeric_range, - apply_provider_specialty_filter, - apply_visit_concept_filters, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("ProcedureOccurrence") -def build_procedure_occurrence(criteria: ProcedureOccurrence, ctx: BuildContext): - table = ctx.table("procedure_occurrence") - - concept_column = criteria.get_concept_id_column() - table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) - if criteria.first: - table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - - table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) - table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) - - table = apply_concept_criteria( - table, - column="procedure_type_concept_id", - concepts=criteria.procedure_type, - selection=criteria.procedure_type_cs, - ctx=ctx, - exclude=bool(criteria.procedure_type_exclude), - ) - - table = apply_concept_criteria( - table, - column="modifier_concept_id", - concepts=criteria.modifier, - selection=criteria.modifier_cs, - ctx=ctx, - ) - - table = apply_numeric_range(table, "quantity", criteria.quantity) - - if criteria.age: - table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - table = apply_provider_specialty_filter( - table, - getattr(criteria, "provider_specialty", None), - getattr(criteria, "provider_specialty_cs", None), - ctx, - provider_column="provider_id", - ) - table = apply_visit_concept_filters(table, criteria.visit_type, criteria.visit_type_cs, ctx) - - if criteria.procedure_source_concept is not None: - table = apply_codeset_filter( - table, "procedure_source_concept_id", criteria.procedure_source_concept, ctx - ) - - events = standardize_output( - table, - primary_key=criteria.get_primary_key_column(), - start_column=criteria.get_start_date_column(), - end_column=criteria.get_end_date_column(), - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/registry.py b/circe/execution/builders/registry.py deleted file mode 100644 index 68a0a633..00000000 --- a/circe/execution/builders/registry.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -import hashlib -from collections.abc import Callable - -import ibis.expr.types as ir - -from ...cohortdefinition.criteria import Criteria -from ..build_context import BuildContext - -_REGISTRY: dict[str, Callable[[Criteria, BuildContext], ir.Table]] = {} - - -def register(criteria_name: str): - def decorator(func: Callable[[Criteria, BuildContext], ir.Table]): - _REGISTRY[criteria_name] = func - return func - - return decorator - - -def get_builder(criteria: Criteria): - name = criteria.__class__.__name__ - try: - return _REGISTRY[name] - except KeyError as exc: - raise ValueError(f"No builder registered for criteria {name}") from exc - - -def build_events(criteria: Criteria, ctx: BuildContext) -> ir.Table: - builder = get_builder(criteria) - table = builder(criteria, ctx) - cache_key, label = _criteria_cache_key(criteria) - return ctx.get_or_materialize_slice(cache_key, table, label=label) - - -def _criteria_cache_key(criteria: Criteria) -> tuple[str, str]: - payload = criteria.model_dump_json( - by_alias=True, - exclude_defaults=False, - exclude_none=False, - ) - raw_key = f"{criteria.__class__.__name__}:{payload}" - digest = hashlib.sha1(raw_key.encode("utf-8")).hexdigest()[:8] - label = f"{criteria.__class__.__name__.lower()}_{digest}" - return raw_key, label diff --git a/circe/execution/builders/specimen.py b/circe/execution/builders/specimen.py deleted file mode 100644 index 4bb7c9bc..00000000 --- a/circe/execution/builders/specimen.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import Specimen -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_concept_criteria, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_numeric_range, - apply_text_filter, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("Specimen") -def build_specimen(criteria: Specimen, ctx: BuildContext): - table = ctx.table("specimen") - - table = apply_codeset_filter(table, "specimen_concept_id", criteria.codeset_id, ctx) - table = apply_date_range(table, "specimen_date", criteria.occurrence_start_date) - - table = apply_concept_criteria( - table, - column="specimen_type_concept_id", - concepts=criteria.specimen_type, - selection=criteria.specimen_type_cs, - ctx=ctx, - exclude=bool(criteria.specimen_type_exclude), - ) - - table = apply_numeric_range(table, "quantity", criteria.quantity) - - table = apply_concept_criteria( - table, - column="unit_concept_id", - concepts=criteria.unit, - selection=criteria.unit_cs, - ctx=ctx, - ) - - table = apply_concept_criteria( - table, - column="anatomic_site_concept_id", - concepts=criteria.anatomic_site, - selection=criteria.anatomic_site_cs, - ctx=ctx, - ) - - table = apply_concept_criteria( - table, - column="disease_status_concept_id", - concepts=criteria.disease_status, - selection=criteria.disease_status_cs, - ctx=ctx, - ) - - table = apply_text_filter(table, "specimen_source_id", criteria.source_id) - if criteria.specimen_source_concept is not None: - table = apply_codeset_filter( - table, - "specimen_source_concept_id", - criteria.specimen_source_concept, - ctx, - ) - - if criteria.age: - table = apply_age_filter(table, criteria.age, ctx, "specimen_date") - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - - if criteria.first: - table = apply_first_event(table, "specimen_date", "specimen_id") - - events = standardize_output( - table, - primary_key="specimen_id", - start_column="specimen_date", - end_column="specimen_date", - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/visit_detail.py b/circe/execution/builders/visit_detail.py deleted file mode 100644 index 5e8b075a..00000000 --- a/circe/execution/builders/visit_detail.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import VisitDetail -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_care_site_filter, - apply_codeset_filter, - apply_concept_set_selection, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_interval_range, - apply_location_region_filter, - apply_provider_specialty_filter, - project_event_columns, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("VisitDetail") -def build_visit_detail(criteria: VisitDetail, ctx: BuildContext): - table = ctx.table("visit_detail") - - table = apply_codeset_filter(table, "visit_detail_concept_id", criteria.codeset_id, ctx) - if criteria.first: - table = apply_first_event(table, "visit_detail_start_date", "visit_detail_id") - table = apply_date_range(table, "visit_detail_start_date", criteria.visit_detail_start_date) - table = apply_date_range(table, "visit_detail_end_date", criteria.visit_detail_end_date) - table = apply_concept_set_selection( - table, "visit_detail_type_concept_id", criteria.visit_detail_type_cs, ctx - ) - if criteria.visit_detail_source_concept is not None: - table = apply_codeset_filter( - table, - "visit_detail_source_concept_id", - criteria.visit_detail_source_concept, - ctx, - ) - table = apply_interval_range( - table, - "visit_detail_start_date", - "visit_detail_end_date", - criteria.visit_detail_length, - ) - - if criteria.age: - table = apply_age_filter(table, criteria.age, ctx, "visit_detail_end_date") - table = apply_gender_filter(table, [], criteria.gender_cs, ctx) - table = apply_provider_specialty_filter( - table, - None, - criteria.provider_specialty_cs, - ctx, - ) - table = apply_care_site_filter(table, criteria.place_of_service_cs, ctx) - table = apply_location_region_filter( - table, - care_site_column="care_site_id", - location_codeset_id=criteria.place_of_service_location, - start_column="visit_detail_start_date", - end_column="visit_detail_end_date", - ctx=ctx, - ) - - table = project_event_columns( - table, - primary_key="visit_detail_id", - start_column="visit_detail_start_date", - end_column="visit_detail_end_date", - include_visit_occurrence=True, - ) - - events = standardize_output( - table, - primary_key="visit_detail_id", - start_column="visit_detail_start_date", - end_column="visit_detail_end_date", - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/builders/visit_occurrence.py b/circe/execution/builders/visit_occurrence.py deleted file mode 100644 index 1f2e8eff..00000000 --- a/circe/execution/builders/visit_occurrence.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from ...cohortdefinition.criteria import VisitOccurrence -from ..build_context import BuildContext -from .common import ( - apply_age_filter, - apply_codeset_filter, - apply_concept_criteria, - apply_date_range, - apply_first_event, - apply_gender_filter, - apply_numeric_range, - apply_provider_specialty_filter, - project_event_columns, - standardize_output, -) -from .groups import apply_criteria_group -from .registry import register - - -@register("VisitOccurrence") -def build_visit_occurrence(criteria: VisitOccurrence, ctx: BuildContext): - table = ctx.table("visit_occurrence") - - concept_column = criteria.get_concept_id_column() - table = apply_codeset_filter(table, concept_column, criteria.codeset_id, ctx) - - table = apply_date_range(table, criteria.get_start_date_column(), criteria.occurrence_start_date) - table = apply_date_range(table, criteria.get_end_date_column(), criteria.occurrence_end_date) - - table = apply_concept_criteria( - table, - column="visit_type_concept_id", - concepts=criteria.visit_type, - selection=criteria.visit_type_cs, - ctx=ctx, - exclude=bool(criteria.visit_type_exclude), - ) - - table = apply_provider_specialty_filter( - table, - criteria.provider_specialty, - criteria.provider_specialty_cs, - ctx, - ) - table = apply_concept_criteria( - table, - column="place_of_service_concept_id", - concepts=criteria.place_of_service, - selection=criteria.place_of_service_cs, - ctx=ctx, - ) - if criteria.visit_length: - table = apply_numeric_range(table, "visit_length", criteria.visit_length) - - if criteria.age: - table = apply_age_filter(table, criteria.age, ctx, criteria.get_start_date_column()) - table = apply_gender_filter(table, criteria.gender, criteria.gender_cs, ctx) - - if criteria.visit_source_concept is not None: - table = apply_codeset_filter(table, "visit_source_concept_id", criteria.visit_source_concept, ctx) - - if criteria.first: - table = apply_first_event(table, criteria.get_start_date_column(), criteria.get_primary_key_column()) - - table = project_event_columns( - table, - primary_key=criteria.get_primary_key_column(), - start_column=criteria.get_start_date_column(), - end_column=criteria.get_end_date_column(), - include_visit_occurrence=True, - ) - - events = standardize_output( - table, - primary_key=criteria.get_primary_key_column(), - start_column=criteria.get_start_date_column(), - end_column=criteria.get_end_date_column(), - ) - return apply_criteria_group(events, criteria.correlated_criteria, ctx) diff --git a/circe/execution/compat.py b/circe/execution/compat.py new file mode 100644 index 00000000..31e78df4 --- /dev/null +++ b/circe/execution/compat.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Mapping, Tuple, Union + +from .errors import ExecutionError +from .ibis.materialize import project_to_ohdsi_cohort_table +from .ibis.operations import table_exists + +if TYPE_CHECKING: + import pandas as pd + import polars as pl + + from ..cohortdefinition import CohortExpression + + +SchemaName = Union[str, Tuple[str, str]] +ExpressionInput = Union["CohortExpression", Mapping[str, Any], str, Path] + + +@dataclass(frozen=True) +class ExecutionOptions: + """Legacy execution options preserved as compatibility wrappers.""" + + cdm_schema: SchemaName | None = None + vocabulary_schema: SchemaName | None = None + result_schema: SchemaName | None = None + + cohort_id: int | None = None + + materialize_stages: bool = False + materialize_codesets: bool = True + temp_emulation_schema: SchemaName | None = None + + capture_sql: bool = False + profile_dir: str | None = None + + +def schema_to_str(schema: SchemaName | None) -> str | None: + """Normalize schema names to a string representation.""" + if schema is None: + return None + if isinstance(schema, tuple): + return ".".join(schema) + return schema + + +class IbisExecutor: + """Legacy object API preserved as a thin wrapper over the new executor.""" + + def __init__(self, conn: Any, options: ExecutionOptions | None = None): + self._conn = conn + self._options = options or ExecutionOptions() + + @property + def conn(self) -> Any: + return self._conn + + @property + def options(self) -> ExecutionOptions: + return self._options + + def build(self, expression: ExpressionInput) -> Any: + from ..io import load_expression + from .api import build_cohort as _build_cohort + + cohort_expression = load_expression(expression) + return _build_cohort( + cohort_expression, + backend=self._conn, + cdm_schema=schema_to_str(self._options.cdm_schema), + vocabulary_schema=schema_to_str(self._options.vocabulary_schema), + results_schema=schema_to_str(self._options.result_schema), + ) + + def to_polars(self, expression: ExpressionInput) -> pl.DataFrame: + table = self.build(expression) + if not hasattr(table, "to_polars"): + raise RuntimeError("The returned ibis table does not support to_polars() on this backend.") + return table.to_polars() + + def to_pandas(self, expression: ExpressionInput) -> pd.DataFrame: + table = self.build(expression) + if not hasattr(table, "to_pandas"): + raise RuntimeError("The returned ibis table does not support to_pandas() on this backend.") + return table.to_pandas() + + def write( + self, + expression: ExpressionInput, + *, + table: str, + schema: SchemaName | None = None, + overwrite: bool = True, + append: bool = False, + cohort_id: int | None = None, + ) -> Any: + from ..io import load_expression + from .api import build_cohort as _build_cohort + from .api import write_relation as _write_relation + + if append and overwrite: + raise ValueError("`append=True` and `overwrite=True` cannot be used together.") + + effective_cohort_id = cohort_id if cohort_id is not None else self._options.cohort_id + if effective_cohort_id is None: + raise ExecutionError( + "Ibis executor write error: cohort_id is required when writing OHDSI cohort-table rows." + ) + target_schema = schema_to_str(schema) or schema_to_str(self._options.result_schema) + relation = _build_cohort( + load_expression(expression), + backend=self._conn, + cdm_schema=schema_to_str(self._options.cdm_schema), + vocabulary_schema=schema_to_str(self._options.vocabulary_schema), + results_schema=target_schema, + ) + relation = project_to_ohdsi_cohort_table( + relation, + cohort_id=effective_cohort_id, + ) + + if append and table_exists(self._conn, table_name=table, schema=target_schema): + try: + if target_schema is not None: + existing = self._conn.table(table, database=target_schema) + else: + existing = self._conn.table(table) + relation = existing.union(relation, distinct=False) + except Exception as exc: + raise ExecutionError( + f"Ibis executor write error: failed reading existing table '{table}' for append." + ) from exc + + _write_relation( + relation, + backend=self._conn, + target_table=table, + target_schema=target_schema, + if_exists="replace" if overwrite or append else "fail", + temporary=False, + ) + + try: + if target_schema is not None: + return self._conn.table(table, database=target_schema) + return self._conn.table(table) + except Exception as exc: + raise ExecutionError(f"Ibis executor write error: failed to read back table '{table}'.") from exc + + def captured_sql(self) -> list[tuple[str, str]]: + return [] + + def close(self) -> None: + return None + + def __enter__(self) -> IbisExecutor: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + +def build_ibis( + expression: ExpressionInput, + conn: Any, + options: ExecutionOptions | None = None, +) -> Any: + with IbisExecutor(conn, options) as executor: + return executor.build(expression) + + +def to_polars( + expression: ExpressionInput, + conn: Any, + options: ExecutionOptions | None = None, +) -> pl.DataFrame: + with IbisExecutor(conn, options) as executor: + return executor.to_polars(expression) + + +def write_cohort( + expression: ExpressionInput, + conn: Any, + *, + table: str, + schema: SchemaName | None = None, + overwrite: bool = True, + append: bool = False, + cohort_id: int | None = None, + options: ExecutionOptions | None = None, +) -> Any: + with IbisExecutor(conn, options) as executor: + return executor.write( + expression, + table=table, + schema=schema, + overwrite=overwrite, + append=append, + cohort_id=cohort_id, + ) diff --git a/circe/execution/criteria_compat.py b/circe/execution/criteria_compat.py deleted file mode 100644 index fb52f2b6..00000000 --- a/circe/execution/criteria_compat.py +++ /dev/null @@ -1,203 +0,0 @@ -from __future__ import annotations - -from enum import IntEnum -from typing import Any - -from ..cohortdefinition.criteria import ( - ConditionEra, - ConditionOccurrence, - CorelatedCriteria, - Criteria, - Death, - DemographicCriteria, - DeviceExposure, - DoseEra, - DrugEra, - DrugExposure, - Measurement, - Observation, - ObservationPeriod, - PayerPlanPeriod, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, -) - -CorrelatedCriteria = CorelatedCriteria -DemoGraphicCriteria = DemographicCriteria - - -class OccurrenceType(IntEnum): - EXACTLY = 0 - AT_MOST = 1 - AT_LEAST = 2 - - -_CONCEPT_ID_OVERRIDES: dict[str, str] = { - "Death": "cause_concept_id", - "DoseEra": "drug_concept_id", - "VisitDetail": "visit_detail_concept_id", -} - -_PRIMARY_KEY_OVERRIDES: dict[str, str] = { - "Death": "person_id", -} - -_START_DATE_OVERRIDES: dict[str, str] = { - "ConditionEra": "condition_era_start_date", - "DrugExposure": "drug_exposure_start_date", - "Measurement": "measurement_date", - "Observation": "observation_date", - "DeviceExposure": "device_exposure_start_date", - "ProcedureOccurrence": "procedure_date", - "DrugEra": "drug_era_start_date", - "DoseEra": "dose_era_start_date", - "ObservationPeriod": "observation_period_start_date", - "Specimen": "specimen_date", - "Death": "death_date", - "VisitDetail": "visit_detail_start_date", - "PayerPlanPeriod": "payer_plan_period_start_date", -} - -_END_DATE_OVERRIDES: dict[str, str] = { - "ConditionEra": "condition_era_end_date", - "DrugExposure": "drug_exposure_end_date", - "Measurement": "measurement_date", - "Observation": "observation_date", - "DeviceExposure": "device_exposure_end_date", - "ProcedureOccurrence": "procedure_date", - "DrugEra": "drug_era_end_date", - "DoseEra": "dose_era_end_date", - "ObservationPeriod": "observation_period_end_date", - "Specimen": "specimen_date", - "Death": "death_date", - "VisitDetail": "visit_detail_end_date", - "PayerPlanPeriod": "payer_plan_period_end_date", -} - - -def _to_snake_case(name: str) -> str: - output: list[str] = [] - for idx, char in enumerate(name): - if char.isupper() and idx > 0: - output.append("_") - output.append(char.lower()) - return "".join(output) - - -def _snake_case_class_name(cls: type[Criteria]) -> str: - return _to_snake_case(cls.__name__) - - -def _get_concept_id_column(self: Criteria) -> str: - cls_name = self.__class__.__name__ - overridden = _CONCEPT_ID_OVERRIDES.get(cls_name) - if overridden: - return overridden - table_name = self.snake_case_class_name() - return f"{table_name.split('_')[0]}_concept_id" - - -def _get_primary_key_column(self: Criteria) -> str: - cls_name = self.__class__.__name__ - overridden = _PRIMARY_KEY_OVERRIDES.get(cls_name) - if overridden: - return overridden - return f"{self.snake_case_class_name()}_id" - - -def _get_start_date_column(self: Criteria) -> str: - cls_name = self.__class__.__name__ - overridden = _START_DATE_OVERRIDES.get(cls_name) - if overridden: - return overridden - return f"{self.snake_case_class_name().split('_')[0]}_start_date" - - -def _get_end_date_column(self: Criteria) -> str: - cls_name = self.__class__.__name__ - overridden = _END_DATE_OVERRIDES.get(cls_name) - if overridden: - return overridden - return f"{self.snake_case_class_name().split('_')[0]}_end_date" - - -def ensure_criteria_compat() -> None: - if getattr(Criteria, "_execution_compat_patched", False): - return - - Criteria.snake_case_class_name = classmethod(_snake_case_class_name) - Criteria.get_concept_id_column = _get_concept_id_column - Criteria.get_primary_key_column = _get_primary_key_column - Criteria.get_start_date_column = _get_start_date_column - Criteria.get_end_date_column = _get_end_date_column - Criteria._execution_compat_patched = True - - -CRITERIA_TYPE_MAP: dict[str, type[Criteria]] = { - "ConditionOccurrence": ConditionOccurrence, - "ConditionEra": ConditionEra, - "VisitOccurrence": VisitOccurrence, - "DrugExposure": DrugExposure, - "DrugEra": DrugEra, - "DoseEra": DoseEra, - "ObservationPeriod": ObservationPeriod, - "Measurement": Measurement, - "Observation": Observation, - "Specimen": Specimen, - "DeviceExposure": DeviceExposure, - "ProcedureOccurrence": ProcedureOccurrence, - "Death": Death, - "VisitDetail": VisitDetail, - "PayerPlanPeriod": PayerPlanPeriod, -} -CRITERIA_TYPE_MAP_CASEFOLD: dict[str, type[Criteria]] = { - name.casefold(): model for name, model in CRITERIA_TYPE_MAP.items() -} - - -def parse_single_criteria(criteria_dict: Any) -> Criteria: - if isinstance(criteria_dict, Criteria): - return criteria_dict - - if not isinstance(criteria_dict, dict): - raise ValueError("Criteria wrapper must be an object.") - - if len(criteria_dict) != 1: - raise ValueError("Criteria wrapper must contain exactly one criteria type key.") - - criteria_type, criteria_data = next(iter(criteria_dict.items())) - model_cls = CRITERIA_TYPE_MAP.get(criteria_type) - if model_cls is None and isinstance(criteria_type, str): - model_cls = CRITERIA_TYPE_MAP_CASEFOLD.get(criteria_type.casefold()) - if model_cls is None: - raise ValueError(f"Unsupported criteria type: {criteria_type}") - - if criteria_data is None: - criteria_data = {} - - if not isinstance(criteria_data, dict): - raise ValueError(f"Criteria payload for {criteria_type} must be an object.") - - return model_cls.model_validate(criteria_data, strict=False) - - -def parse_criteria_list(criteria_list_data: Any) -> list[Criteria]: - if criteria_list_data is None: - return [] - - if not isinstance(criteria_list_data, list): - raise ValueError("Criteria list must be a list.") - - criteria_instances: list[Criteria] = [] - for idx, criteria_dict in enumerate(criteria_list_data): - try: - parsed = parse_single_criteria(criteria_dict) - except ValueError as exc: - raise ValueError(f"Invalid criteria wrapper at index {idx}: {exc}") from exc - criteria_instances.append(parsed) - return criteria_instances - - -ensure_criteria_compat() diff --git a/circe/execution/databricks_compat.py b/circe/execution/databricks_compat.py new file mode 100644 index 00000000..20bff94d --- /dev/null +++ b/circe/execution/databricks_compat.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import functools +import inspect + +ISSUE_REFERENCE = "https://github.com/ibis-project/ibis/issues/11598" +_PATCH_FLAG = "_circe_databricks_post_connect_patched" + + +def _databricks_backend_class(): + try: + import ibis.backends.databricks as databricks_backend + except Exception: + return None + return getattr(databricks_backend, "Backend", None) + + +def _post_connect_needs_workaround(post_connect) -> bool: + try: + source = inspect.getsource(post_connect).lower() + except (OSError, TypeError): + return True + return "create volume if not exists" in source and "memtable" in source + + +def _is_memtable_volume_error(exc: Exception) -> bool: + message = str(exc).lower() + if "create volume if not exists" in message: + return True + return bool("memtable" in message and "volume" in message) + + +def _backend_looks_like_databricks(backend) -> bool: + backend_name = getattr(backend, "name", None) + if isinstance(backend_name, str) and backend_name.lower() == "databricks": + return True + class_name = backend.__class__.__name__.lower() + return "databricks" in class_name + + +def apply_databricks_post_connect_workaround(*, backend_cls=None) -> bool: + """ + Patch Databricks backend `_post_connect` for Ibis issue #11598. + + Some Ibis Databricks versions call `CREATE VOLUME IF NOT EXISTS ...` during + `_post_connect` for memtable support and can fail in read-only/locked-down + schemas. This workaround suppresses only that known failure mode and should + be removed once upstream behavior is fixed. + + Activation note: + This helper should be applied lazily by the execution path when a + Databricks backend is actually used. + """ + backend_cls = _databricks_backend_class() if backend_cls is None else backend_cls + if backend_cls is None: + return False + + post_connect = getattr(backend_cls, "_post_connect", None) + if not callable(post_connect): + return False + + if getattr(backend_cls, _PATCH_FLAG, False): + return True + + if not _post_connect_needs_workaround(post_connect): + return False + + @functools.wraps(post_connect) + def _patched_post_connect(self, *args, **kwargs): + try: + return post_connect(self, *args, **kwargs) + except Exception as exc: + if _is_memtable_volume_error(exc): + return None + raise + + backend_cls._post_connect = _patched_post_connect + setattr(backend_cls, _PATCH_FLAG, True) + return True + + +def maybe_apply_databricks_post_connect_workaround(backend) -> bool: + """Apply the workaround only for Databricks-like backends.""" + if not _backend_looks_like_databricks(backend): + return False + return apply_databricks_post_connect_workaround(backend_cls=backend.__class__) + + +__all__ = [ + "ISSUE_REFERENCE", + "apply_databricks_post_connect_workaround", + "maybe_apply_databricks_post_connect_workaround", +] diff --git a/circe/execution/engine/__init__.py b/circe/execution/engine/__init__.py new file mode 100644 index 00000000..dd3411cc --- /dev/null +++ b/circe/execution/engine/__init__.py @@ -0,0 +1,19 @@ +from .censoring import apply_censoring +from .cohort import build_cohort_table +from .collapse import collapse_events +from .end_strategy import apply_end_strategy +from .groups import apply_additional_criteria +from .inclusion import apply_inclusion_rules +from .limits import apply_result_limit +from .primary import build_primary_events + +__all__ = [ + "build_cohort_table", + "build_primary_events", + "apply_additional_criteria", + "apply_inclusion_rules", + "apply_end_strategy", + "apply_censoring", + "collapse_events", + "apply_result_limit", +] diff --git a/circe/execution/engine/censoring.py b/circe/execution/engine/censoring.py new file mode 100644 index 00000000..ce21f626 --- /dev/null +++ b/circe/execution/engine/censoring.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import ibis + +from ..ibis.compiler import compile_event_plan +from ..lower.criteria import lower_criterion +from ..plan.schema import END_DATE, PERSON_ID +from .end_strategy import attach_observation_bounds + + +def _union_all(tables): + current = tables[0] + for table in tables[1:]: + current = current.union(table, distinct=False) + return current + + +def _compile_censor_events(criteria, ctx): + compiled = [] + for index, criterion in enumerate(criteria): + plan = lower_criterion(criterion, criterion_index=10_000 + index) + table = compile_event_plan(plan, ctx) + compiled.append( + table.select( + table.person_id.cast("int64").name(PERSON_ID), + table.start_date.cast("date").name("censor_start_date"), + ) + ) + if not compiled: + return None + return _union_all(compiled) + + +def apply_censoring(events, criteria, window, ctx): + del window # Censor-window clipping is applied in collapse/finalization stage. + + if not criteria: + return events + + censor_events = _compile_censor_events(criteria, ctx) + if censor_events is None: + return events + + with_bounds = attach_observation_bounds(events, ctx) + + joined = with_bounds.join( + censor_events, + predicates=[with_bounds.person_id == censor_events.person_id], + ) + valid = joined.filter( + (joined.censor_start_date >= joined.start_date) & (joined.censor_start_date <= joined.op_end_date) + ) + censor_min = valid.group_by(valid.person_id, valid.event_id).aggregate( + censor_end_date=valid.censor_start_date.min() + ) + + merged = with_bounds.left_join( + censor_min, + predicates=[ + (with_bounds.person_id == censor_min.person_id) & (with_bounds.event_id == censor_min.event_id) + ], + ) + + new_end = ibis.coalesce( + ibis.least(merged.end_date, merged.censor_end_date), + merged.end_date, + ) + projected = merged.mutate(_new_end_date=new_end) + + return projected.select( + *[ + projected[c] if c != END_DATE else projected._new_end_date.cast("date").name(END_DATE) + for c in events.columns + ] + ) diff --git a/circe/execution/engine/cohort.py b/circe/execution/engine/cohort.py new file mode 100644 index 00000000..7f34652a --- /dev/null +++ b/circe/execution/engine/cohort.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from ..ibis.context import ExecutionContext +from ..lower.criteria import lower_criterion +from ..normalize.cohort import NormalizedCohort +from ..plan.cohort import CohortPlan, PrimaryEventInput +from ..typing import Table +from .censoring import apply_censoring +from .collapse import collapse_events +from .end_strategy import apply_end_strategy +from .groups import apply_additional_criteria +from .inclusion import apply_inclusion_rules +from .limits import apply_result_limit +from .primary import build_primary_events + + +def build_cohort_table(normalized: NormalizedCohort, ctx: ExecutionContext) -> Table: + primary_plans = tuple( + PrimaryEventInput( + event_plan=lower_criterion(criterion, criterion_index=index), + correlated_criteria=criterion.correlated_criteria, + ) + for index, criterion in enumerate(normalized.primary.criteria) + ) + cohort_plan = CohortPlan( + primary_event_plans=primary_plans, + observation_window=normalized.primary.observation_window, + primary_limit_type=normalized.primary.primary_limit_type, + qualified_limit_type=normalized.result_limits.qualified_limit_type, + expression_limit_type=normalized.result_limits.expression_limit_type, + ) + primary_events = build_primary_events(cohort_plan, ctx) + qualified_events = apply_additional_criteria(primary_events, normalized.additional_criteria, ctx) + if normalized.additional_criteria is not None and not normalized.additional_criteria.is_empty(): + qualified_events = apply_result_limit( + qualified_events, + cohort_plan.qualified_limit_type, + ) + included_events = apply_inclusion_rules(qualified_events, normalized.inclusion_rules, ctx) + included_events = apply_result_limit( + included_events, + cohort_plan.expression_limit_type, + ) + ended_events = apply_end_strategy(included_events, normalized.end_strategy, ctx) + censored_events = apply_censoring( + ended_events, + normalized.censoring_criteria, + normalized.censor_window, + ctx, + ) + return collapse_events( + censored_events, + normalized.collapse_settings, + normalized.censor_window, + ) diff --git a/circe/execution/engine/collapse.py b/circe/execution/engine/collapse.py new file mode 100644 index 00000000..aa31ddcb --- /dev/null +++ b/circe/execution/engine/collapse.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import ibis + +from ..plan.schema import END_DATE, PERSON_ID, START_DATE + + +def _apply_censor_window(events, censor_window): + if censor_window is None: + return events + + start_expr = events.start_date + end_expr = events.end_date + + if censor_window.start_date: + start_bound = ibis.literal(censor_window.start_date).cast("date") + start_expr = ibis.greatest(events.start_date, start_bound) + + if censor_window.end_date: + end_bound = ibis.literal(censor_window.end_date).cast("date") + end_expr = ibis.least(events.end_date, end_bound) + + clipped = events.mutate(start_date=start_expr, end_date=end_expr) + return clipped.filter(clipped.start_date <= clipped.end_date) + + +def _collapse_era(intervals, era_pad: int): + padded = intervals.mutate(_padded_end_date=(intervals.end_date + ibis.interval(days=int(era_pad)))) + + ordering = [padded.start_date] + ordered_window = ibis.window(group_by=padded.person_id, order_by=ordering) + with_cummax = padded.mutate(_cummax_padded_end=padded._padded_end_date.max().over(ordered_window)) + with_prev = with_cummax.mutate( + _prev_max_padded_end=with_cummax._cummax_padded_end.lag().over(ordered_window) + ) + marked = with_prev.mutate( + _is_new_group=ibis.ifelse( + with_prev._prev_max_padded_end.isnull() | (with_prev._prev_max_padded_end < with_prev.start_date), + ibis.literal(1, type="int64"), + ibis.literal(0, type="int64"), + ) + ) + + group_index = marked._is_new_group.sum().over(ordered_window) + grouped = marked.mutate(_group_idx=group_index) + + collapsed = grouped.group_by(grouped.person_id, grouped._group_idx).aggregate( + start_date=grouped.start_date.min(), + _max_padded_end=grouped._padded_end_date.max(), + ) + return collapsed.select( + collapsed.person_id.cast("int64").name(PERSON_ID), + collapsed.start_date.cast("date").name(START_DATE), + (collapsed._max_padded_end - ibis.interval(days=int(era_pad))).cast("date").name(END_DATE), + ) + + +def collapse_events(events, collapse_settings, censor_window): + if collapse_settings is None: + return _apply_censor_window(events, censor_window) + + collapse_type = (collapse_settings.collapse_type or "era").lower() + if collapse_type == "no_collapse": + return _apply_censor_window(events, censor_window) + + intervals = events.select( + events.person_id.cast("int64").name(PERSON_ID), + events.start_date.cast("date").name(START_DATE), + events.end_date.cast("date").name(END_DATE), + ) + intervals = _apply_censor_window(intervals, censor_window) + return _collapse_era(intervals, collapse_settings.era_pad) diff --git a/circe/execution/engine/end_strategy.py b/circe/execution/engine/end_strategy.py new file mode 100644 index 00000000..a099985b --- /dev/null +++ b/circe/execution/engine/end_strategy.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import ibis + +from ..errors import UnsupportedFeatureError +from ..plan.schema import END_DATE, PERSON_ID, START_DATE + + +def attach_observation_bounds(events, ctx): + observation_period = ctx.table("observation_period").select( + PERSON_ID, + "observation_period_start_date", + "observation_period_end_date", + ) + joined = events.join( + observation_period, + (events[PERSON_ID] == observation_period[PERSON_ID]) + & (events[START_DATE] >= observation_period.observation_period_start_date.cast("date")) + & (events[START_DATE] <= observation_period.observation_period_end_date.cast("date")), + ) + return joined.select( + *[joined[c] for c in events.columns], + observation_period.observation_period_start_date.cast("date").name("op_start_date"), + observation_period.observation_period_end_date.cast("date").name("op_end_date"), + ).distinct() + + +def _apply_date_offset_strategy(with_bounds, strategy): + offset = int(strategy.payload.get("offset", 0)) + date_field = str(strategy.payload.get("date_field", START_DATE)).lower() + + if date_field in {"startdate", START_DATE}: + base_date = with_bounds[START_DATE] + elif date_field in {"enddate", END_DATE}: + base_date = with_bounds[END_DATE] + else: + raise UnsupportedFeatureError( + f"Ibis executor end-strategy error: unsupported date_offset date field {date_field!r}." + ) + + candidate = base_date + ibis.interval(days=offset) + return ibis.least(candidate, with_bounds.op_end_date) + + +def _replace_end_date(events, with_bounds, new_end_expr): + projected = with_bounds.mutate(_new_end_date=new_end_expr) + selected = projected.select( + *[ + projected[c] if c != END_DATE else projected._new_end_date.cast("date").name(END_DATE) + for c in events.columns + ] + ) + return selected + + +def apply_end_strategy(events, strategy, ctx): + with_bounds = attach_observation_bounds(events, ctx) + + if strategy is None: + return _replace_end_date(events, with_bounds, with_bounds.op_end_date) + + if strategy.kind == "date_offset": + end_date_expr = _apply_date_offset_strategy(with_bounds, strategy) + return _replace_end_date(events, with_bounds, end_date_expr) + + if strategy.kind == "custom_era": + raise UnsupportedFeatureError("Ibis executor end-strategy error: custom_era is not supported.") + + # Fallback: preserve default semantics of op_end_date clipping. + return _replace_end_date(events, with_bounds, with_bounds.op_end_date) diff --git a/circe/execution/engine/group_demographics.py b/circe/execution/engine/group_demographics.py new file mode 100644 index 00000000..bc5920aa --- /dev/null +++ b/circe/execution/engine/group_demographics.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import ibis + +from ..errors import UnsupportedFeatureError +from ..ibis.context import ExecutionContext +from ..normalize.groups import NormalizedDemographicCriteria +from ..plan.schema import EVENT_ID, PERSON_ID +from ..typing import Table + + +def _apply_numeric_predicate(expr, predicate): + op = (predicate.op or "eq").lower() + value = predicate.value + extent = predicate.extent + + if value is None: + return ibis.literal(True) + + if op in {"eq", "="}: + return expr == value + if op in {"neq", "!=", "ne"}: + return expr != value + if op in {"gt", ">"}: + return expr > value + if op in {"gte", ">="}: + return expr >= value + if op in {"lt", "<"}: + return expr < value + if op in {"lte", "<="}: + return expr <= value + if op in {"bt", "between"}: + if extent is None: + raise UnsupportedFeatureError( + "Ibis executor group evaluation error: demographic numeric range " + "'between' requires an extent value." + ) + lower = min(value, extent) + upper = max(value, extent) + return (expr >= lower) & (expr <= upper) + raise UnsupportedFeatureError( + f"Ibis executor group evaluation error: unsupported demographic numeric range op {predicate.op!r}." + ) + + +def _apply_date_predicate(expr, predicate): + op = (predicate.op or "eq").lower() + value = predicate.value + extent = predicate.extent + + if value is None: + return ibis.literal(True) + + value_expr = ibis.literal(value).cast("date") + date_expr = expr.cast("date") + if op in {"eq", "="}: + return date_expr == value_expr + if op in {"neq", "!=", "ne"}: + return date_expr != value_expr + if op in {"gt", ">"}: + return date_expr > value_expr + if op in {"gte", ">="}: + return date_expr >= value_expr + if op in {"lt", "<"}: + return date_expr < value_expr + if op in {"lte", "<="}: + return date_expr <= value_expr + if op in {"bt", "between"}: + if extent is None: + raise UnsupportedFeatureError( + "Ibis executor group evaluation error: demographic date range " + "'between' requires an extent value." + ) + extent_expr = ibis.literal(extent).cast("date") + lower = ibis.least(value_expr, extent_expr) + upper = ibis.greatest(value_expr, extent_expr) + return (date_expr >= lower) & (date_expr <= upper) + raise UnsupportedFeatureError( + f"Ibis executor group evaluation error: unsupported demographic date range op {predicate.op!r}." + ) + + +def _demographic_concept_ids( + *, + explicit_ids: tuple[int, ...], + codeset_id: int | None, + ctx: ExecutionContext, +) -> tuple[int, ...]: + all_ids = list(explicit_ids) + if codeset_id is not None: + for concept_id in ctx.concept_ids_for_codeset(codeset_id): + if concept_id not in all_ids: + all_ids.append(concept_id) + return tuple(all_ids) + + +def demographic_match_keys( + index_events: Table, + demographic: NormalizedDemographicCriteria, + ctx: ExecutionContext, +) -> Table: + person_table = ctx.table("person") + person = person_table.select( + person_table.person_id.name("p_person_id"), + "year_of_birth", + "gender_concept_id", + "race_concept_id", + "ethnicity_concept_id", + ) + joined = index_events.join(person, index_events.person_id == person.p_person_id) + + predicates = [ibis.literal(True)] + if demographic.age is not None: + event_date = joined.start_date.cast("date") + age_years = event_date.year() - joined.year_of_birth + predicates.append(_apply_numeric_predicate(age_years, demographic.age)) + + gender_ids = _demographic_concept_ids( + explicit_ids=demographic.gender_concept_ids, + codeset_id=demographic.gender_codeset_id, + ctx=ctx, + ) + if gender_ids: + predicates.append(joined.gender_concept_id.isin(gender_ids)) + + race_ids = _demographic_concept_ids( + explicit_ids=demographic.race_concept_ids, + codeset_id=demographic.race_codeset_id, + ctx=ctx, + ) + if race_ids: + predicates.append(joined.race_concept_id.isin(race_ids)) + + ethnicity_ids = _demographic_concept_ids( + explicit_ids=demographic.ethnicity_concept_ids, + codeset_id=demographic.ethnicity_codeset_id, + ctx=ctx, + ) + if ethnicity_ids: + predicates.append(joined.ethnicity_concept_id.isin(ethnicity_ids)) + + if demographic.occurrence_start_date is not None: + predicates.append( + _apply_date_predicate( + joined.start_date, + demographic.occurrence_start_date, + ) + ) + if demographic.occurrence_end_date is not None: + predicates.append( + _apply_date_predicate( + joined.end_date, + demographic.occurrence_end_date, + ) + ) + + predicate = predicates[0] + for part in predicates[1:]: + predicate = predicate & part + + matched = joined.filter(predicate) + return matched.select( + matched.person_id.name(PERSON_ID), + matched.event_id.name(EVENT_ID), + ).distinct() diff --git a/circe/execution/engine/group_keys.py b/circe/execution/engine/group_keys.py new file mode 100644 index 00000000..4de323d6 --- /dev/null +++ b/circe/execution/engine/group_keys.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from ..plan.schema import EVENT_ID, PERSON_ID +from ..typing import Table + + +def union_all(tables: list[Table]) -> Table: + current = tables[0] + for table in tables[1:]: + current = current.union(table, distinct=False) + return current + + +def event_keys(events: Table) -> Table: + return events.select( + events.person_id.cast("int64").name(PERSON_ID), + events.event_id.cast("int64").name(EVENT_ID), + ).distinct() diff --git a/circe/execution/engine/group_operators.py b/circe/execution/engine/group_operators.py new file mode 100644 index 00000000..391d61ce --- /dev/null +++ b/circe/execution/engine/group_operators.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import ibis + +from ..errors import UnsupportedFeatureError +from ..ibis.compiler import compile_event_plan +from ..ibis.context import ExecutionContext +from ..lower.criteria import lower_criterion +from ..normalize.groups import NormalizedCorrelatedCriteria +from ..plan.schema import ( + CONCEPT_ID, + DAYS_SUPPLY, + DURATION, + END_DATE, + EVENT_ID, + GAP_DAYS, + OCCURRENCE_COUNT, + PERSON_ID, + QUANTITY, + RANGE_HIGH, + RANGE_LOW, + REFILLS, + SOURCE_CONCEPT_ID, + START_DATE, + UNIT_CONCEPT_ID, + VALUE_AS_NUMBER, + VISIT_DETAIL_ID, + VISIT_OCCURRENCE_ID, +) +from ..typing import Table +from .group_keys import event_keys +from .group_windows import apply_window_constraints + + +def resolve_distinct_count_column(count_column: str | None) -> str: + if count_column is None: + return f"a_{CONCEPT_ID}" + + normalized = count_column.lower() + mapping = { + "domain_concept_id": f"a_{CONCEPT_ID}", + "domain_source_concept_id": f"a_{SOURCE_CONCEPT_ID}", + VISIT_OCCURRENCE_ID: f"a_{VISIT_OCCURRENCE_ID}", + "visit_id": f"a_{VISIT_OCCURRENCE_ID}", + "visit_detail_id": f"a_{VISIT_DETAIL_ID}", + START_DATE: f"a_{START_DATE}", + END_DATE: f"a_{END_DATE}", + "duration": f"a_{DURATION}", + "quantity": f"a_{QUANTITY}", + "days_supply": f"a_{DAYS_SUPPLY}", + "refills": f"a_{REFILLS}", + "range_low": f"a_{RANGE_LOW}", + "range_high": f"a_{RANGE_HIGH}", + "value_as_number": f"a_{VALUE_AS_NUMBER}", + "unit_concept_id": f"a_{UNIT_CONCEPT_ID}", + "occurrence_count": f"a_{OCCURRENCE_COUNT}", + "gap_days": f"a_{GAP_DAYS}", + } + if normalized in mapping: + return mapping[normalized] + + raise UnsupportedFeatureError( + "Ibis executor group evaluation error: unsupported distinct count column " + f"{count_column!r} for correlated criteria." + ) + + +def occurrence_predicate(match_count_expr, occurrence_type: int, occurrence_count: int): + if occurrence_type == 0: + return match_count_expr == occurrence_count + if occurrence_type == 1: + return match_count_expr <= occurrence_count + if occurrence_type == 2: + return match_count_expr >= occurrence_count + raise UnsupportedFeatureError( + f"Ibis executor group evaluation error: unsupported correlated occurrence type {occurrence_type}." + ) + + +def group_predicate(match_count_expr, mode: str, count: int | None, child_count: int): + normalized_mode = (mode or "ALL").upper() + if normalized_mode == "ALL": + return match_count_expr == child_count + if normalized_mode == "ANY": + return match_count_expr > 0 + if normalized_mode == "AT_LEAST": + threshold = 0 if count is None else int(count) + return match_count_expr >= threshold + if normalized_mode == "AT_MOST": + threshold = 0 if count is None else int(count) + return match_count_expr <= threshold + raise UnsupportedFeatureError( + f"Ibis executor group evaluation error: unsupported criteria group mode {mode!r}." + ) + + +def _compile_correlated_events( + correlated: NormalizedCorrelatedCriteria, + *, + criterion_index: int, + ctx: ExecutionContext, +) -> Table: + event_plan = lower_criterion(correlated.criterion, criterion_index=criterion_index) + return compile_event_plan(event_plan, ctx) + + +def correlated_match_keys( + index_events: Table, + correlated: NormalizedCorrelatedCriteria, + *, + criterion_index: int, + ctx: ExecutionContext, +) -> Table: + correlated_events = _compile_correlated_events( + correlated, + criterion_index=criterion_index, + ctx=ctx, + ) + + p = index_events.select( + index_events[PERSON_ID].name("p_person_id"), + index_events[EVENT_ID].name("p_event_id"), + index_events[START_DATE].name("p_start_date"), + index_events[END_DATE].name("p_end_date"), + index_events[VISIT_OCCURRENCE_ID].name("p_visit_occurrence_id"), + index_events.op_start_date.name("p_op_start_date"), + index_events.op_end_date.name("p_op_end_date"), + ) + a = correlated_events.select( + correlated_events[PERSON_ID].name("a_person_id"), + correlated_events[EVENT_ID].name("a_event_id"), + correlated_events[START_DATE].name("a_start_date"), + correlated_events[END_DATE].name("a_end_date"), + correlated_events[VISIT_OCCURRENCE_ID].name("a_visit_occurrence_id"), + correlated_events[VISIT_DETAIL_ID].name("a_visit_detail_id"), + correlated_events[CONCEPT_ID].name("a_concept_id"), + correlated_events[SOURCE_CONCEPT_ID].name("a_source_concept_id"), + correlated_events[QUANTITY].name("a_quantity"), + correlated_events[DAYS_SUPPLY].name("a_days_supply"), + correlated_events[REFILLS].name("a_refills"), + correlated_events[RANGE_LOW].name("a_range_low"), + correlated_events[RANGE_HIGH].name("a_range_high"), + correlated_events[VALUE_AS_NUMBER].name("a_value_as_number"), + correlated_events[UNIT_CONCEPT_ID].name("a_unit_concept_id"), + correlated_events[OCCURRENCE_COUNT].name("a_occurrence_count"), + correlated_events[GAP_DAYS].name("a_gap_days"), + correlated_events[DURATION].name("a_duration"), + ) + + joined = p.join( + a, + predicates=[p.p_person_id == a.a_person_id], + ) + constrained = apply_window_constraints(joined, correlated) + + if correlated.occurrence_is_distinct: + distinct_col = resolve_distinct_count_column(correlated.occurrence_count_column) + counts = constrained.group_by( + constrained.p_person_id, + constrained.p_event_id, + ).aggregate(match_count=constrained[distinct_col].nunique()) + else: + counts = constrained.group_by( + constrained.p_person_id, + constrained.p_event_id, + ).aggregate(match_count=constrained.a_event_id.count()) + + keys = event_keys(index_events) + joined_counts = keys.left_join( + counts, + predicates=[(keys.person_id == counts.p_person_id) & (keys.event_id == counts.p_event_id)], + ) + counted = joined_counts.mutate(match_count=ibis.coalesce(joined_counts.match_count, ibis.literal(0))) + + predicate = occurrence_predicate( + counted.match_count, + int(correlated.occurrence_type), + int(correlated.occurrence_count), + ) + return counted.filter(predicate).select( + counted.person_id.name(PERSON_ID), + counted.event_id.name(EVENT_ID), + ) diff --git a/circe/execution/engine/group_windows.py b/circe/execution/engine/group_windows.py new file mode 100644 index 00000000..e0b126df --- /dev/null +++ b/circe/execution/engine/group_windows.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import ibis + +from ..ibis.context import ExecutionContext +from ..normalize.groups import NormalizedCorrelatedCriteria +from ..normalize.windows import NormalizedWindow, NormalizedWindowBound +from ..plan.schema import END_DATE, EVENT_ID, PERSON_ID, START_DATE, VISIT_OCCURRENCE_ID +from ..typing import Table + + +def attach_observation_period(events: Table, ctx: ExecutionContext) -> Table: + observation_period = ctx.table("observation_period").select( + PERSON_ID, + "observation_period_start_date", + "observation_period_end_date", + ) + + joined = events.join( + observation_period, + (events[PERSON_ID] == observation_period[PERSON_ID]) + & (events[START_DATE] >= observation_period.observation_period_start_date.cast("date")) + & (events[START_DATE] <= observation_period.observation_period_end_date.cast("date")), + ) + + return joined.select( + events[PERSON_ID].name(PERSON_ID), + events[EVENT_ID].name(EVENT_ID), + events[START_DATE].name(START_DATE), + events[END_DATE].name(END_DATE), + events[VISIT_OCCURRENCE_ID].name(VISIT_OCCURRENCE_ID), + observation_period.observation_period_start_date.cast("date").name("op_start_date"), + observation_period.observation_period_end_date.cast("date").name("op_end_date"), + ).distinct() + + +def window_bound_expression( + bound: NormalizedWindowBound | None, + *, + index_anchor_expr, + use_observation_period: bool, + op_start_expr, + op_end_expr, +): + if bound is None: + return None + + if bound.days is not None: + return index_anchor_expr + ibis.interval(days=int(bound.coeff) * int(bound.days)) + + if not use_observation_period: + return None + + return op_start_expr if int(bound.coeff) == -1 else op_end_expr + + +def apply_window_constraints(joined, correlated: NormalizedCorrelatedCriteria): + predicate = joined.a_person_id == joined.p_person_id + + if not correlated.ignore_observation_period: + predicate = predicate & (joined.a_start_date >= joined.p_op_start_date) + predicate = predicate & (joined.a_start_date <= joined.p_op_end_date) + + start_window: NormalizedWindow | None = correlated.start_window + if start_window is not None: + start_index_anchor = joined.p_end_date if bool(start_window.use_index_end) else joined.p_start_date + start_event_date = ( + joined.a_end_date + if (start_window.use_event_end is not None and start_window.use_event_end) + else joined.a_start_date + ) + + start_lower = window_bound_expression( + start_window.start, + index_anchor_expr=start_index_anchor, + use_observation_period=(not correlated.ignore_observation_period), + op_start_expr=joined.p_op_start_date, + op_end_expr=joined.p_op_end_date, + ) + if start_lower is not None: + predicate = predicate & (start_event_date >= start_lower) + + start_upper = window_bound_expression( + start_window.end, + index_anchor_expr=start_index_anchor, + use_observation_period=(not correlated.ignore_observation_period), + op_start_expr=joined.p_op_start_date, + op_end_expr=joined.p_op_end_date, + ) + if start_upper is not None: + predicate = predicate & (start_event_date <= start_upper) + + end_window: NormalizedWindow | None = correlated.end_window + if end_window is not None: + end_index_anchor = joined.p_end_date if bool(end_window.use_index_end) else joined.p_start_date + end_event_date = ( + joined.a_end_date + if (end_window.use_event_end is None or end_window.use_event_end) + else joined.a_start_date + ) + + end_lower = window_bound_expression( + end_window.start, + index_anchor_expr=end_index_anchor, + use_observation_period=(not correlated.ignore_observation_period), + op_start_expr=joined.p_op_start_date, + op_end_expr=joined.p_op_end_date, + ) + if end_lower is not None: + predicate = predicate & (end_event_date >= end_lower) + + end_upper = window_bound_expression( + end_window.end, + index_anchor_expr=end_index_anchor, + use_observation_period=(not correlated.ignore_observation_period), + op_start_expr=joined.p_op_start_date, + op_end_expr=joined.p_op_end_date, + ) + if end_upper is not None: + predicate = predicate & (end_event_date <= end_upper) + + if correlated.restrict_visit: + predicate = predicate & (joined.a_visit_occurrence_id == joined.p_visit_occurrence_id) + + return joined.filter(predicate) diff --git a/circe/execution/engine/groups.py b/circe/execution/engine/groups.py new file mode 100644 index 00000000..d630df29 --- /dev/null +++ b/circe/execution/engine/groups.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import ibis + +from ..ibis.context import ExecutionContext +from ..normalize.groups import NormalizedCriteriaGroup +from ..plan.schema import EVENT_ID, PERSON_ID +from ..typing import Table +from .group_demographics import demographic_match_keys +from .group_keys import event_keys, union_all +from .group_operators import correlated_match_keys, group_predicate +from .group_windows import attach_observation_period + + +def _evaluate_group( + index_events: Table, + group: NormalizedCriteriaGroup, + ctx: ExecutionContext, +) -> Table: + keys = event_keys(index_events) + + if group.is_empty(): + return keys + + child_results: list[Table] = [] + index_id = 0 + + for correlated in group.criteria: + correlated_matches = correlated_match_keys( + index_events, + correlated, + criterion_index=index_id, + ctx=ctx, + ) + child_results.append(correlated_matches.mutate(index_id=ibis.literal(index_id, type="int64"))) + index_id += 1 + + for demographic in group.demographics: + demographic_matches = demographic_match_keys(index_events, demographic, ctx) + child_results.append(demographic_matches.mutate(index_id=ibis.literal(index_id, type="int64"))) + index_id += 1 + + for child_group in group.groups: + child_group_matches = _evaluate_group(index_events, child_group, ctx) + child_results.append(child_group_matches.mutate(index_id=ibis.literal(index_id, type="int64"))) + index_id += 1 + + if not child_results: + return keys + + unioned = union_all(child_results) + group_counts = unioned.group_by(unioned.person_id, unioned.event_id).aggregate( + matched_children=unioned.index_id.nunique() + ) + + joined_counts = keys.left_join( + group_counts, + predicates=[(keys.person_id == group_counts.person_id) & (keys.event_id == group_counts.event_id)], + ) + counted = joined_counts.mutate( + matched_children=ibis.coalesce(joined_counts.matched_children, ibis.literal(0)) + ) + + predicate = group_predicate( + counted.matched_children, + group.mode, + group.count, + index_id, + ) + return counted.filter(predicate).select( + counted.person_id.name(PERSON_ID), + counted.event_id.name(EVENT_ID), + ) + + +def apply_additional_criteria( + events: Table, + group: NormalizedCriteriaGroup | None, + ctx: ExecutionContext, +) -> Table: + if group is None or group.is_empty(): + return events + + index_events = attach_observation_period(events, ctx) + matched_keys = _evaluate_group(index_events, group, ctx) + + filtered = events.join( + matched_keys, + predicates=[ + (events.person_id == matched_keys.person_id) & (events.event_id == matched_keys.event_id) + ], + ) + return filtered.select(*[filtered[c] for c in events.columns]) diff --git a/circe/execution/engine/inclusion.py b/circe/execution/engine/inclusion.py new file mode 100644 index 00000000..7e957842 --- /dev/null +++ b/circe/execution/engine/inclusion.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from ..normalize.groups import NormalizedInclusionRule +from .groups import apply_additional_criteria + + +def apply_inclusion_rules( + events, + inclusion_rules: tuple[NormalizedInclusionRule, ...], + ctx, +): + if not inclusion_rules: + return events + + included = events + for rule in inclusion_rules: + included = apply_additional_criteria(included, rule.expression, ctx) + return included diff --git a/circe/execution/engine/limits.py b/circe/execution/engine/limits.py new file mode 100644 index 00000000..e8d3f936 --- /dev/null +++ b/circe/execution/engine/limits.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import ibis + +from ..plan.schema import DOMAIN, EVENT_ID, PERSON_ID, START_DATE + + +def apply_result_limit(events, limit_type: str): + normalized = (limit_type or "all").lower() + if normalized in {"all", ""}: + return events + + descending = normalized == "last" + order_by = [events[START_DATE], events[EVENT_ID]] + if DOMAIN in events.columns: + order_by.append(events[DOMAIN]) + + if descending: + order_by = [expr.desc() for expr in order_by] + + window = ibis.window( + group_by=events[PERSON_ID], + order_by=order_by, + ) + ranked = events.mutate(_limit_rn=ibis.row_number().over(window)) + return ranked.filter(ranked._limit_rn == 0).drop("_limit_rn") diff --git a/circe/execution/engine/primary.py b/circe/execution/engine/primary.py new file mode 100644 index 00000000..07f0ec41 --- /dev/null +++ b/circe/execution/engine/primary.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import ibis + +from ..errors import ExecutionNormalizationError +from ..ibis.compiler import compile_event_plan +from ..ibis.context import ExecutionContext +from ..normalize.windows import NormalizedObservationWindow +from ..plan.cohort import CohortPlan +from ..plan.schema import DOMAIN, EVENT_ID, PERSON_ID, START_DATE +from ..typing import Table +from .groups import apply_additional_criteria +from .limits import apply_result_limit + + +def _union_all(tables): + current = tables[0] + for table in tables[1:]: + current = current.union(table, distinct=False) + return current + + +def _assign_primary_event_ids(events): + ordering = [events[START_DATE], events[EVENT_ID], events[DOMAIN]] + person_window = ibis.window(group_by=events[PERSON_ID], order_by=ordering) + ranked = events.mutate(_primary_rn=ibis.row_number().over(person_window)) + return ranked.mutate(**{EVENT_ID: ranked._primary_rn + 1}).drop("_primary_rn") + + +def _apply_observation_window( + events, + ctx: ExecutionContext, + window: NormalizedObservationWindow, +): + observation_period = ctx.table("observation_period").select( + PERSON_ID, + "observation_period_start_date", + "observation_period_end_date", + ) + joined = events.join( + observation_period, + events[PERSON_ID] == observation_period[PERSON_ID], + ) + lower = joined.observation_period_start_date + ibis.interval(days=window.prior_days) + upper = joined.observation_period_end_date - ibis.interval(days=window.post_days) + filtered = joined.filter((joined[START_DATE] >= lower) & (joined[START_DATE] <= upper)) + return filtered.select(*[filtered[c] for c in events.columns]) + + +def build_primary_events(plan: CohortPlan, ctx: ExecutionContext) -> Table: + if not plan.primary_event_plans: + raise ExecutionNormalizationError( + "Ibis executor primary build error: no primary criteria were lowered to executable plans." + ) + + compiled = [] + for primary in plan.primary_event_plans: + events = compile_event_plan(primary.event_plan, ctx) + events = apply_additional_criteria(events, primary.correlated_criteria, ctx) + compiled.append(events) + + events = _union_all(compiled) + events = _assign_primary_event_ids(events) + + if plan.observation_window is not None: + events = _apply_observation_window(events, ctx, plan.observation_window) + + events = apply_result_limit(events, plan.primary_limit_type) + return events diff --git a/circe/execution/errors.py b/circe/execution/errors.py new file mode 100644 index 00000000..ab2ddba6 --- /dev/null +++ b/circe/execution/errors.py @@ -0,0 +1,21 @@ +from __future__ import annotations + + +class ExecutionError(RuntimeError): + """Base execution subsystem error.""" + + +class ExecutionNormalizationError(ExecutionError): + """Raised when expression normalization fails structurally.""" + + +class UnsupportedCriterionError(ExecutionError): + """Raised when a criterion type is unsupported by the executor.""" + + +class UnsupportedFeatureError(ExecutionError): + """Raised when requested executor semantics are unsupported.""" + + +class CompilationError(ExecutionError): + """Raised when lowering/compilation to Ibis cannot proceed.""" diff --git a/circe/execution/ibis.py b/circe/execution/ibis.py deleted file mode 100644 index e0d3d2e7..00000000 --- a/circe/execution/ibis.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Experimental ibis execution API.""" - -from __future__ import annotations - -from dataclasses import replace -from typing import TYPE_CHECKING, Any - -from ..io import ExpressionInput, load_expression -from .options import ExecutionOptions, SchemaName, schema_to_str - -if TYPE_CHECKING: - import pandas as pd - import polars as pl - - -class IbisExecutor: - """Execute cohort expressions against an ibis backend. - - Notes: - - This API is experimental. - - `build()` returns an ibis table expression (lazy relation). - - Materialization happens in `to_polars()` / `to_pandas()` / `write()`. - """ - - def __init__(self, conn: Any, options: ExecutionOptions | None = None): - self._conn = conn - self._options = options or ExecutionOptions() - self._open_contexts: list[Any] = [] - - @property - def conn(self) -> Any: - return self._conn - - @property - def options(self) -> ExecutionOptions: - return self._options - - def build(self, expression: ExpressionInput) -> Any: - """Build a lazy ibis relation for the final cohort rows.""" - cohort_expression = load_expression(expression) - self.close() - return self._build_native(cohort_expression) - - def to_polars(self, expression: ExpressionInput) -> pl.DataFrame: - """Execute cohort expression and collect to Polars.""" - table = self.build(expression) - if not hasattr(table, "to_polars"): - raise RuntimeError("The returned ibis table does not support to_polars() on this backend.") - return table.to_polars() - - def to_pandas(self, expression: ExpressionInput) -> pd.DataFrame: - """Execute cohort expression and collect to pandas.""" - table = self.build(expression) - if not hasattr(table, "to_pandas"): - raise RuntimeError("The returned ibis table does not support to_pandas() on this backend.") - return table.to_pandas() - - def write( - self, - expression: ExpressionInput, - *, - table: str, - schema: SchemaName | None = None, - overwrite: bool = True, - append: bool = False, - cohort_id: int | None = None, - ) -> Any: - """Persist cohort rows to a cohort table and return a backend table handle.""" - if append and overwrite: - raise ValueError("`append=True` and `overwrite=True` cannot be used together.") - cohort_expression = load_expression(expression) - self.close() - events, ctx = self._build_with_context_native(cohort_expression, cohort_id_override=cohort_id) - self._open_contexts.append(ctx) - return ctx.write_cohort_table( - events, - table_name=table, - database=schema_to_str(schema) or schema_to_str(self._options.result_schema), - overwrite=overwrite, - append=append, - ) - - def captured_sql(self) -> list[tuple[str, str]]: - """Return captured staged SQL snippets when capture_sql is enabled.""" - captured: list[tuple[str, str]] = [] - for ctx in self._open_contexts: - if hasattr(ctx, "captured_sql"): - captured.extend(ctx.captured_sql()) - return captured - - def close(self) -> None: - """Release temporary resources held by execution contexts.""" - while self._open_contexts: - ctx = self._open_contexts.pop() - try: - ctx.close() - except Exception as exc: - print(f"Warning: failed to close execution context: {exc}") - - def __enter__(self) -> IbisExecutor: - return self - - def __exit__(self, exc_type, exc, tb) -> None: - self.close() - - def _build_native(self, cohort_expression: Any) -> Any: - events, ctx = self._build_with_context_native(cohort_expression) - self._open_contexts.append(ctx) - return events - - def _build_with_context_native( - self, - cohort_expression: Any, - cohort_id_override: int | None = None, - ) -> Any: - try: - from .build_context import ( - BuildContext, - CohortBuildOptions, - compile_codesets, - ) - from .builders.pipeline import build_primary_events - except ModuleNotFoundError as exc: - raise RuntimeError( - "Ibis execution requires optional dependencies. " - "Install `ohdsi-circe-python-alpha[ibis]` plus a backend extra, " - "for example `[ibis-duckdb]`." - ) from exc - - backend = self._infer_backend_name(self._conn) - options = CohortBuildOptions( - cdm_schema=schema_to_str(self._options.cdm_schema), - vocabulary_schema=schema_to_str(self._options.vocabulary_schema), - result_schema=schema_to_str(self._options.result_schema), - cohort_id=(cohort_id_override if cohort_id_override is not None else self._options.cohort_id), - materialize_stages=self._options.materialize_stages, - materialize_codesets=self._options.materialize_codesets, - temp_emulation_schema=schema_to_str(self._options.temp_emulation_schema), - profile_dir=self._options.profile_dir, - capture_sql=self._options.capture_sql, - backend=backend, - ) - resource = compile_codesets(self._conn, cohort_expression.concept_sets or [], options) - ctx = BuildContext(self._conn, options, resource) - events = build_primary_events(cohort_expression, ctx) - if events is None: - raise RuntimeError("No primary events were generated for the supplied cohort expression.") - return events, ctx - - @staticmethod - def _infer_backend_name(conn: Any) -> str | None: - backend_name = getattr(conn, "name", None) - if isinstance(backend_name, str) and backend_name: - return backend_name.lower() - class_name = conn.__class__.__name__.lower() - if "duckdb" in class_name: - return "duckdb" - if "postgres" in class_name: - return "postgres" - if "databricks" in class_name: - return "databricks" - return None - - -def build_ibis( - expression: ExpressionInput, - conn: Any, - options: ExecutionOptions | None = None, -) -> Any: - """Convenience wrapper for IbisExecutor.build().""" - with IbisExecutor(conn, options) as executor: - return executor.build(expression) - - -def to_polars( - expression: ExpressionInput, - conn: Any, - options: ExecutionOptions | None = None, -) -> pl.DataFrame: - """Convenience wrapper for IbisExecutor.to_polars().""" - with IbisExecutor(conn, options) as executor: - return executor.to_polars(expression) - - -def write_cohort( - expression: ExpressionInput, - conn: Any, - *, - table: str, - schema: SchemaName | None = None, - overwrite: bool = True, - append: bool = False, - cohort_id: int | None = None, - options: ExecutionOptions | None = None, -) -> Any: - """Convenience wrapper for IbisExecutor.write().""" - effective_options = options - if cohort_id is not None: - effective_options = replace(options or ExecutionOptions(), cohort_id=cohort_id) - - with IbisExecutor(conn, effective_options) as executor: - return executor.write( - expression, - table=table, - schema=schema, - overwrite=overwrite, - append=append, - cohort_id=cohort_id, - ) diff --git a/circe/execution/ibis/__init__.py b/circe/execution/ibis/__init__.py new file mode 100644 index 00000000..b1381b6b --- /dev/null +++ b/circe/execution/ibis/__init__.py @@ -0,0 +1,27 @@ +from ..compat import ( + ExecutionOptions, + IbisExecutor, + SchemaName, + build_ibis, + schema_to_str, + to_polars, + write_cohort, +) +from ..plan.schema import STANDARD_EVENT_COLUMNS +from .compiler import compile_event_plan +from .context import ExecutionContext +from .standardize import standardize_event_table + +__all__ = [ + "ExecutionContext", + "ExecutionOptions", + "IbisExecutor", + "SchemaName", + "build_ibis", + "compile_event_plan", + "STANDARD_EVENT_COLUMNS", + "schema_to_str", + "standardize_event_table", + "to_polars", + "write_cohort", +] diff --git a/circe/execution/ibis/codesets.py b/circe/execution/ibis/codesets.py new file mode 100644 index 00000000..b0ec27fa --- /dev/null +++ b/circe/execution/ibis/codesets.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import Any, Callable, Mapping + +from ..errors import CompilationError +from ..normalize.cohort import NormalizedConceptSet, NormalizedConceptSetItem +from ..plan.schema import CONCEPT_ID +from ..typing import Table + +TableGetter = Callable[[str, str | None], Table] + + +class CachedConceptSetResolver: + """Resolve concept sets to concrete concept IDs using vocabulary tables.""" + + def __init__( + self, + *, + table_getter: TableGetter, + vocabulary_schema: str | None, + concept_sets: Mapping[int, NormalizedConceptSet], + ) -> None: + self._table_getter = table_getter + self._vocabulary_schema = vocabulary_schema + self._concept_sets = concept_sets + self._cache: dict[int, tuple[int, ...]] = {} + + def resolve_codeset(self, codeset_id: int) -> tuple[int, ...]: + normalized_id = int(codeset_id) + if normalized_id in self._cache: + return self._cache[normalized_id] + + concept_set = self._concept_sets.get(normalized_id) + if concept_set is None or not concept_set.items: + return () + + include_ids: set[int] = set() + exclude_ids: set[int] = set() + for item in concept_set.items: + expanded = self._expand_item(item) + if item.is_excluded: + exclude_ids.update(expanded) + else: + include_ids.update(expanded) + + resolved = tuple(sorted(include_ids - exclude_ids)) + self._cache[normalized_id] = resolved + return resolved + + def _expand_item(self, item: NormalizedConceptSetItem) -> set[int]: + base_ids: set[int] = {int(item.concept_id)} + if item.include_descendants: + base_ids.update(self._descendant_ids(base_ids)) + + expanded = set(base_ids) + if item.include_mapped: + expanded.update(self._mapped_ids(base_ids)) + return expanded + + def _vocabulary_table(self, table_name: str): + try: + return self._table_getter(table_name, self._vocabulary_schema) + except Exception as exc: # pragma: no cover - backend specific error types + raise CompilationError( + f"Ibis executor compilation error: failed to access vocabulary table '{table_name}'." + ) from exc + + def _descendant_ids(self, ancestor_ids: set[int]) -> set[int]: + if not ancestor_ids: + return set() + + concept = self._vocabulary_table("concept") + concept_ancestor = self._vocabulary_table("concept_ancestor") + query = ( + concept_ancestor.join( + concept, + concept_ancestor.descendant_concept_id == concept.concept_id, + ) + .filter(concept_ancestor.ancestor_concept_id.isin(tuple(ancestor_ids))) + .filter(concept.invalid_reason.isnull()) + .select(concept_ancestor.descendant_concept_id.name(CONCEPT_ID)) + .distinct() + ) + return self._execute_concept_id_query(query) + + def _mapped_ids(self, input_ids: set[int]) -> set[int]: + if not input_ids: + return set() + + concept_relationship = self._vocabulary_table("concept_relationship") + query = ( + concept_relationship.filter(concept_relationship.concept_id_2.isin(tuple(input_ids))) + .filter(concept_relationship.relationship_id == "Maps to") + .filter(concept_relationship.invalid_reason.isnull()) + .select(concept_relationship.concept_id_1.name(CONCEPT_ID)) + .distinct() + ) + return self._execute_concept_id_query(query) + + def _execute_concept_id_query(self, query) -> set[int]: + try: + rows = query.execute() + except Exception as exc: # pragma: no cover - backend specific error types + raise CompilationError( + "Ibis executor compilation error: failed executing concept-set expansion query." + ) from exc + + values: list[Any] + if hasattr(rows, "columns"): # pandas DataFrame + values = rows[CONCEPT_ID].tolist() if CONCEPT_ID in rows.columns else rows.iloc[:, 0].tolist() + elif isinstance(rows, (list, tuple, set)): + values = list(rows) + else: + values = [rows] + + output: set[int] = set() + for value in values: + if value is None: + continue + output.add(int(value)) + return output diff --git a/circe/execution/ibis/compile_steps.py b/circe/execution/ibis/compile_steps.py new file mode 100644 index 00000000..0c6ad844 --- /dev/null +++ b/circe/execution/ibis/compile_steps.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +import ibis + +from ..errors import CompilationError, UnsupportedFeatureError +from ..plan.events import ( + ApplyDateAdjustment, + FilterByCareSite, + FilterByCareSiteLocationRegion, + FilterByCodeset, + FilterByConceptSet, + FilterByDateRange, + FilterByNumericRange, + FilterByPersonAge, + FilterByPersonEthnicity, + FilterByPersonGender, + FilterByPersonRace, + FilterByProviderSpecialty, + FilterByText, + FilterByVisit, + JoinLocationRegion, + KeepFirstPerPerson, + RestrictToCorrelatedWindow, + StandardizeEventShape, +) +from ..plan.predicates import DateRangePredicate, NumericRangePredicate +from ..plan.schema import END_DATE, PERSON_ID, START_DATE +from .context import ExecutionContext +from .person_filters import ( + apply_person_age_filter, + apply_person_ethnicity_filter, + apply_person_gender_filter, + apply_person_race_filter, +) +from .standardize import standardize_event_table + + +def _apply_numeric_predicate(expr, predicate: NumericRangePredicate): + op = (predicate.op or "eq").lower() + value = predicate.value + extent = predicate.extent + + if value is None: + return ibis.literal(True) + + if op in {"eq", "="}: + return expr == value + if op in {"neq", "!=", "ne"}: + return expr != value + if op in {"gt", ">"}: + return expr > value + if op in {"gte", ">="}: + return expr >= value + if op in {"lt", "<"}: + return expr < value + if op in {"lte", "<="}: + return expr <= value + if op in {"bt", "between"}: + if extent is None: + raise CompilationError( + "Ibis executor compilation error: numeric range 'between' requires an extent value." + ) + lower = min(value, extent) + upper = max(value, extent) + return (expr >= lower) & (expr <= upper) + + raise CompilationError(f"Ibis executor compilation error: unsupported numeric range op {predicate.op!r}.") + + +def _apply_date_predicate(expr, predicate: DateRangePredicate): + op = (predicate.op or "eq").lower() + value = predicate.value + extent = predicate.extent + + if value is None: + return ibis.literal(True) + + value_expr = ibis.literal(value).cast("date") + + if op in {"eq", "="}: + return expr.cast("date") == value_expr + if op in {"neq", "!=", "ne"}: + return expr.cast("date") != value_expr + if op in {"gt", ">"}: + return expr.cast("date") > value_expr + if op in {"gte", ">="}: + return expr.cast("date") >= value_expr + if op in {"lt", "<"}: + return expr.cast("date") < value_expr + if op in {"lte", "<="}: + return expr.cast("date") <= value_expr + if op in {"bt", "between"}: + if extent is None: + raise CompilationError( + "Ibis executor compilation error: date range 'between' requires an extent value." + ) + extent_expr = ibis.literal(extent).cast("date") + lower = ibis.least(value_expr, extent_expr) + upper = ibis.greatest(value_expr, extent_expr) + return (expr.cast("date") >= lower) & (expr.cast("date") <= upper) + + raise CompilationError(f"Ibis executor compilation error: unsupported date range op {predicate.op!r}.") + + +def _resolve_concept_ids( + *, + direct_ids: tuple[int, ...], + codeset_id: int | None, + ctx: ExecutionContext, +) -> tuple[int, ...]: + all_ids = list(direct_ids) + if codeset_id is not None: + for cid in ctx.concept_ids_for_codeset(codeset_id): + if cid not in all_ids: + all_ids.append(cid) + return tuple(all_ids) + + +def _select_original_columns(table, joined): + return joined.select(*[joined[c] for c in table.columns]) + + +def _filter_visit_concepts(table, ctx: ExecutionContext, *, step: FilterByVisit): + visit = ctx.table("visit_occurrence") + visit_lookup = visit.select( + visit.visit_occurrence_id.name("_visit_occurrence_id"), + visit.person_id.name("_visit_person_id"), + visit.visit_concept_id.name("_visit_concept_id"), + ) + joined = table.join( + visit_lookup, + predicates=[ + table[step.visit_occurrence_column] == visit_lookup._visit_occurrence_id, + table[PERSON_ID] == visit_lookup._visit_person_id, + ], + ) + concept_ids = _resolve_concept_ids( + direct_ids=step.concept_ids, + codeset_id=step.codeset_id, + ctx=ctx, + ) + predicate = joined._visit_concept_id.isin(concept_ids) + filtered = joined.filter(~predicate if step.exclude else predicate) + return _select_original_columns(table, filtered) + + +def _filter_provider_specialty( + table, + ctx: ExecutionContext, + *, + step: FilterByProviderSpecialty, +): + provider = ctx.table("provider") + provider_lookup = provider.select( + provider.provider_id.name("_provider_id"), + provider.specialty_concept_id.name("_specialty_concept_id"), + ) + joined = table.join( + provider_lookup, + predicates=[table[step.provider_id_column] == provider_lookup._provider_id], + ) + concept_ids = _resolve_concept_ids( + direct_ids=step.concept_ids, + codeset_id=step.codeset_id, + ctx=ctx, + ) + predicate = joined._specialty_concept_id.isin(concept_ids) + filtered = joined.filter(~predicate if step.exclude else predicate) + return _select_original_columns(table, filtered) + + +def _filter_care_site(table, ctx: ExecutionContext, *, step: FilterByCareSite): + care_site = ctx.table("care_site") + care_site_lookup = care_site.select( + care_site.care_site_id.name("_care_site_id"), + care_site.place_of_service_concept_id.name("_place_of_service_concept_id"), + ) + joined = table.join( + care_site_lookup, + predicates=[table[step.care_site_id_column] == care_site_lookup._care_site_id], + ) + concept_ids = _resolve_concept_ids( + direct_ids=step.concept_ids, + codeset_id=step.codeset_id, + ctx=ctx, + ) + predicate = joined._place_of_service_concept_id.isin(concept_ids) + filtered = joined.filter(~predicate if step.exclude else predicate) + return _select_original_columns(table, filtered) + + +def _filter_care_site_location_region( + table, + ctx: ExecutionContext, + *, + step: FilterByCareSiteLocationRegion, +): + region_ids = ctx.concept_ids_for_codeset(step.codeset_id) + if not region_ids: + return table.limit(0) + + location_history = ctx.table("location_history") + history_lookup = location_history.select( + location_history.entity_id.name("_care_site_id"), + location_history.location_id.name("_history_location_id"), + location_history.domain_id.name("_history_domain_id"), + location_history.start_date.name("_history_start_date"), + location_history.end_date.name("_history_end_date"), + ) + joined_history = table.join( + history_lookup, + predicates=[table[step.care_site_id_column] == history_lookup._care_site_id], + ) + history_end = ibis.coalesce( + joined_history._history_end_date.cast("date"), + ibis.literal("2099-12-31").cast("date"), + ) + joined_history = joined_history.filter( + (joined_history._history_domain_id == "CARE_SITE") + & ( + joined_history[step.start_date_column].cast("date") + >= joined_history._history_start_date.cast("date") + ) + & (joined_history[step.end_date_column].cast("date") <= history_end) + ) + + location = ctx.table("location") + location_lookup = location.select( + location.location_id.name("_location_id"), + location.region_concept_id.name("_region_concept_id"), + ) + joined = joined_history.join( + location_lookup, + predicates=[joined_history._history_location_id == location_lookup._location_id], + ) + filtered = joined.filter(joined._region_concept_id.isin(region_ids)) + return _select_original_columns(table, filtered) + + +def apply_step(step, *, table, source, ctx: ExecutionContext): + if isinstance(step, JoinLocationRegion): + location = ctx.table("location").select( + "location_id", + step.region_column, + ) + joined = table.join( + location, + predicates=[table[step.location_id_column] == location.location_id], + ) + return joined.select( + *[joined[c] for c in table.columns], + location[step.region_column].name(step.region_column), + ) + + if isinstance(step, FilterByCodeset): + concept_ids = ctx.concept_ids_for_codeset(step.codeset_id) + if not concept_ids: + return table if step.exclude else table.limit(0) + predicate = table[step.column].isin(concept_ids) + return table.filter(~predicate if step.exclude else predicate) + + if isinstance(step, FilterByConceptSet): + if not step.concept_ids: + return table if step.exclude else table.limit(0) + predicate = table[step.column].isin(step.concept_ids) + return table.filter(~predicate if step.exclude else predicate) + + if isinstance(step, FilterByVisit): + return _filter_visit_concepts(table, ctx, step=step) + + if isinstance(step, FilterByProviderSpecialty): + return _filter_provider_specialty(table, ctx, step=step) + + if isinstance(step, FilterByCareSite): + return _filter_care_site(table, ctx, step=step) + + if isinstance(step, FilterByCareSiteLocationRegion): + return _filter_care_site_location_region(table, ctx, step=step) + + if isinstance(step, FilterByDateRange): + return table.filter(_apply_date_predicate(table[step.column], step.predicate)) + + if isinstance(step, FilterByNumericRange): + return table.filter(_apply_numeric_predicate(table[step.column], step.predicate)) + + if isinstance(step, FilterByText): + op = (step.op or "eq").lower() + if step.text is None: + return table + if op in {"eq", "="}: + return table.filter(table[step.column] == step.text) + if op in {"neq", "!=", "ne"}: + return table.filter(table[step.column] != step.text) + if op in {"contains", "like"}: + return table.filter(table[step.column].contains(step.text)) + raise CompilationError(f"Ibis executor compilation error: unsupported text filter op {step.op!r}.") + + if isinstance(step, FilterByPersonAge): + return apply_person_age_filter( + table, + ctx, + date_column=step.date_column, + predicate=step.predicate, + ) + + if isinstance(step, FilterByPersonGender): + return apply_person_gender_filter( + table, + ctx, + concept_ids=step.concept_ids, + codeset_id=step.codeset_id, + ) + + if isinstance(step, FilterByPersonRace): + return apply_person_race_filter( + table, + ctx, + concept_ids=step.concept_ids, + codeset_id=step.codeset_id, + ) + + if isinstance(step, FilterByPersonEthnicity): + return apply_person_ethnicity_filter( + table, + ctx, + concept_ids=step.concept_ids, + codeset_id=step.codeset_id, + ) + + if isinstance(step, KeepFirstPerPerson): + order_by = [table[c] for c in step.order_by if c in table.columns] + window = ibis.window(group_by=table[PERSON_ID], order_by=order_by) + ranked = table.mutate(_exec_rn=ibis.row_number().over(window)) + return ranked.filter(ranked._exec_rn == 0).drop("_exec_rn") + + if isinstance(step, ApplyDateAdjustment): + start_anchor = table[START_DATE] if step.start_with == START_DATE else table[END_DATE] + end_anchor = table[START_DATE] if step.end_with == START_DATE else table[END_DATE] + return table.mutate( + **{ + START_DATE: start_anchor + ibis.interval(days=step.start_offset_days), + END_DATE: end_anchor + ibis.interval(days=step.end_offset_days), + } + ) + + if isinstance(step, RestrictToCorrelatedWindow): + raise UnsupportedFeatureError( + "Ibis executor compilation error: RestrictToCorrelatedWindow step is not implemented." + ) + + if isinstance(step, StandardizeEventShape): + return standardize_event_table( + table, + source=source, + criterion_type=step.criterion_type, + criterion_index=step.criterion_index, + start_offset_days=step.start_offset_days, + end_offset_days=step.end_offset_days, + start_with=step.start_with, + end_with=step.end_with, + ) + + raise CompilationError( + f"Ibis executor compilation error: unsupported plan step {step.__class__.__name__}." + ) diff --git a/circe/execution/ibis/compiler.py b/circe/execution/ibis/compiler.py new file mode 100644 index 00000000..daf9a400 --- /dev/null +++ b/circe/execution/ibis/compiler.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from ..plan.events import EventPlan +from ..typing import Table +from .compile_steps import apply_step +from .context import ExecutionContext + + +def compile_event_plan(plan: EventPlan, ctx: ExecutionContext) -> Table: + table = ctx.table(plan.source.table_name) + for step in plan.steps: + table = apply_step(step, table=table, source=plan.source, ctx=ctx) + return table diff --git a/circe/execution/ibis/context.py b/circe/execution/ibis/context.py new file mode 100644 index 00000000..57dab8e4 --- /dev/null +++ b/circe/execution/ibis/context.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from .._dataclass import frozen_slots_dataclass +from ..normalize.cohort import NormalizedConceptSet +from ..typing import IbisBackendLike, Table +from .codesets import CachedConceptSetResolver + + +def _table_with_schema_fallback( + backend: IbisBackendLike, + table_name: str, + schema: str | None, +) -> Table: + try: + if schema is not None: + return backend.table(table_name, database=schema) + except TypeError: + pass + return backend.table(table_name) + + +@frozen_slots_dataclass +class ExecutionContext: + backend: IbisBackendLike + cdm_schema: str + results_schema: str | None + vocabulary_schema: str | None + codeset_resolver: CachedConceptSetResolver + + def table(self, table_name: str) -> Table: + return self._table_from_schema(table_name, self.cdm_schema) + + def vocabulary_table(self, table_name: str) -> Table: + return self._table_from_schema( + table_name, + self.vocabulary_schema or self.cdm_schema, + ) + + def _table_from_schema(self, table_name: str, schema: str | None) -> Table: + return _table_with_schema_fallback(self.backend, table_name, schema) + + def concept_ids_for_codeset(self, codeset_id: int) -> tuple[int, ...]: + return self.codeset_resolver.resolve_codeset(codeset_id) + + +def make_execution_context( + *, + backend: IbisBackendLike, + cdm_schema: str, + concept_sets: Mapping[int, NormalizedConceptSet], + results_schema: str | None = None, + vocabulary_schema: str | None = None, +) -> ExecutionContext: + """Construct an executor context from API-level wiring arguments.""" + vocabulary_schema = vocabulary_schema or cdm_schema + + def _table_getter(table_name: str, schema: str | None) -> Table: + return _table_with_schema_fallback(backend, table_name, schema) + + resolver = CachedConceptSetResolver( + table_getter=_table_getter, + vocabulary_schema=vocabulary_schema, + concept_sets=concept_sets, + ) + return ExecutionContext( + backend=backend, + cdm_schema=cdm_schema, + results_schema=results_schema, + vocabulary_schema=vocabulary_schema, + codeset_resolver=resolver, + ) diff --git a/circe/execution/ibis/materialize.py b/circe/execution/ibis/materialize.py new file mode 100644 index 00000000..f0ef0207 --- /dev/null +++ b/circe/execution/ibis/materialize.py @@ -0,0 +1,16 @@ +from __future__ import annotations + + +def project_to_ohdsi_cohort_table(relation, *, cohort_id: int | None): + """Project a generic cohort relation into OHDSI cohort-table shape.""" + import ibis + + cohort_id_expr = ( + ibis.literal(int(cohort_id), type="int64") if cohort_id is not None else ibis.null().cast("int64") + ) + return relation.select( + cohort_id_expr.name("cohort_definition_id"), + relation.person_id.cast("int64").name("subject_id"), + relation.start_date.cast("date").name("cohort_start_date"), + relation.end_date.cast("date").name("cohort_end_date"), + ) diff --git a/circe/execution/ibis/operations.py b/circe/execution/ibis/operations.py new file mode 100644 index 00000000..cb34c58b --- /dev/null +++ b/circe/execution/ibis/operations.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import sqlglot as sg +import sqlglot.expressions as sge + +from ..errors import ExecutionError +from ..typing import IbisBackendLike + + +def table_exists( + backend: IbisBackendLike, + *, + table_name: str, + schema: str | None, +) -> bool: + """Return whether a backend table exists.""" + list_tables = getattr(backend, "list_tables", None) + if callable(list_tables): + if schema is not None: + try: + return table_name in list_tables(database=schema) + except TypeError: + return table_name in list_tables() + return table_name in list_tables() + + try: + read_table(backend, table_name=table_name, schema=schema) + except Exception: + return False + return True + + +def read_table( + backend: IbisBackendLike, + *, + table_name: str, + schema: str | None, +): + """Read a backend table as an Ibis relation.""" + if schema is not None: + return backend.table(table_name, database=schema) + return backend.table(table_name) + + +def cohort_rows_exist( + backend: IbisBackendLike, + *, + cohort_table: str, + results_schema: str | None, + cohort_id: int, +) -> bool: + """Return whether a cohort table already contains rows for a cohort id.""" + import ibis + + try: + table = read_table(backend, table_name=cohort_table, schema=results_schema) + cohort_id_expr = ibis.literal(int(cohort_id), type="int64") + matching = table.filter(table.cohort_definition_id.cast("int64") == cohort_id_expr) + return len(matching.limit(1).execute()) > 0 + except Exception as exc: + raise ExecutionError( + f"Ibis executor write error: failed checking existing rows for cohort_id={cohort_id}." + ) from exc + + +def delete_cohort_rows( + backend: IbisBackendLike, + *, + cohort_table: str, + results_schema: str | None, + cohort_id: int, +) -> None: + """Delete existing cohort-table rows for a single cohort id.""" + raw_sql = getattr(backend, "raw_sql", None) + if not callable(raw_sql): + raise ExecutionError( + "Ibis executor write error: backend does not support raw_sql for cohort-table deletes." + ) + + catalog, database = _catalog_db_tuple(backend, results_schema) + quoted = getattr(getattr(backend, "compiler", None), "quoted", False) + statement = sge.delete(sg.table(cohort_table, db=database, catalog=catalog, quoted=quoted)).where( + sg.column("cohort_definition_id", quoted=quoted).eq(sge.convert(int(cohort_id))) + ) + + try: + raw_sql(statement) + except Exception as exc: + raise ExecutionError( + "Ibis executor write error: failed deleting existing cohort rows from " + f"'{cohort_table}' for cohort_id={cohort_id}." + ) from exc + + +def supports_transactional_replace(backend: IbisBackendLike) -> bool: + """Return whether cohort-scoped delete+insert can run transactionally.""" + return getattr(backend, "name", None) in {"duckdb", "postgres"} + + +def replace_cohort_rows_transactionally( + relation, + *, + backend: IbisBackendLike, + cohort_table: str, + results_schema: str | None, + cohort_id: int, +) -> None: + """Replace one cohort's rows atomically using delete+insert when supported.""" + if not supports_transactional_replace(backend): + raise ExecutionError( + "Ibis executor write error: backend does not support transactional cohort-table replace." + ) + + _run_transaction_control(backend, "BEGIN") + try: + delete_cohort_rows( + backend, + cohort_table=cohort_table, + results_schema=results_schema, + cohort_id=cohort_id, + ) + insert_relation( + relation, + backend=backend, + target_table=cohort_table, + target_schema=results_schema, + ) + except Exception: + _run_transaction_control(backend, "ROLLBACK") + raise + else: + _run_transaction_control(backend, "COMMIT") + + +def exclude_cohort_rows(table, *, cohort_id: int): + """Filter an existing cohort table to all cohort ids except one.""" + import ibis + + cohort_id_expr = ibis.literal(int(cohort_id), type="int64") + try: + return table.filter(table.cohort_definition_id.cast("int64") != cohort_id_expr) + except Exception as exc: + raise ExecutionError( + f"Ibis executor write error: failed removing existing rows for cohort_id={cohort_id}." + ) from exc + + +def insert_relation( + relation, + *, + backend: IbisBackendLike, + target_table: str, + target_schema: str | None, +) -> None: + """Insert an Ibis relation into an existing backend table.""" + insert = getattr(backend, "insert", None) + if not callable(insert): + raise ExecutionError( + "Ibis executor write error: backend does not support insert for cohort-table writes." + ) + + try: + insert(target_table, relation, database=target_schema, overwrite=False) + except Exception as exc: + schema_label = target_schema if target_schema is not None else "" + raise ExecutionError( + "Ibis executor write error: failed inserting relation into " + f"table '{target_table}' in schema '{schema_label}'." + ) from exc + + +def _run_transaction_control(backend: IbisBackendLike, statement: str) -> None: + raw_sql = getattr(backend, "raw_sql", None) + if not callable(raw_sql): + raise ExecutionError( + "Ibis executor write error: backend does not support raw_sql for transactional cohort writes." + ) + + try: + raw_sql(statement) + except Exception as exc: + raise ExecutionError( + f"Ibis executor write error: failed executing transaction statement {statement!r}." + ) from exc + + +def _catalog_db_tuple(backend: IbisBackendLike, schema: str | None) -> tuple[str | None, str | None]: + if schema is None: + return None, None + + to_sqlglot_table = getattr(backend, "_to_sqlglot_table", None) + to_catalog_db_tuple = getattr(backend, "_to_catalog_db_tuple", None) + if callable(to_sqlglot_table) and callable(to_catalog_db_tuple): + try: + return to_catalog_db_tuple(to_sqlglot_table(schema)) + except Exception: + pass + + return None, schema diff --git a/circe/execution/ibis/person_filters.py b/circe/execution/ibis/person_filters.py new file mode 100644 index 00000000..b46bd998 --- /dev/null +++ b/circe/execution/ibis/person_filters.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import ibis + +from ..errors import CompilationError +from ..plan.predicates import NumericRangePredicate +from ..plan.schema import PERSON_ID +from .context import ExecutionContext + + +def _apply_numeric_predicate(expr, predicate: NumericRangePredicate): + op = (predicate.op or "eq").lower() + value = predicate.value + extent = predicate.extent + + if value is None: + return ibis.literal(True) + + if op in {"eq", "="}: + return expr == value + if op in {"neq", "!=", "ne"}: + return expr != value + if op in {"gt", ">"}: + return expr > value + if op in {"gte", ">="}: + return expr >= value + if op in {"lt", "<"}: + return expr < value + if op in {"lte", "<="}: + return expr <= value + if op in {"bt", "between"}: + if extent is None: + raise CompilationError( + "Ibis executor compilation error: person numeric range 'between' requires an extent value." + ) + lower = min(value, extent) + upper = max(value, extent) + return (expr >= lower) & (expr <= upper) + + raise CompilationError( + f"Ibis executor compilation error: unsupported person numeric range op {predicate.op!r}." + ) + + +def apply_person_age_filter(table, ctx: ExecutionContext, *, date_column: str, predicate): + person = ctx.table("person").select( + PERSON_ID, + "year_of_birth", + ) + joined = table.join(person, table[PERSON_ID] == person[PERSON_ID]) + event_date = joined[date_column].cast("date") + age_years = event_date.year() - joined.year_of_birth + filtered = joined.filter(_apply_numeric_predicate(age_years, predicate)) + return filtered.select(*[filtered[c] for c in table.columns]) + + +def apply_person_gender_filter( + table, + ctx: ExecutionContext, + *, + concept_ids: tuple[int, ...], + codeset_id: int | None, +): + all_ids = list(concept_ids) + if codeset_id is not None: + for cid in ctx.concept_ids_for_codeset(codeset_id): + if cid not in all_ids: + all_ids.append(cid) + + if not all_ids: + return table + + person = ctx.table("person").select(PERSON_ID, "gender_concept_id") + joined = table.join(person, table[PERSON_ID] == person[PERSON_ID]) + filtered = joined.filter(joined.gender_concept_id.isin(all_ids)) + return filtered.select(*[filtered[c] for c in table.columns]) + + +def _apply_person_concept_filter( + table, + ctx: ExecutionContext, + *, + person_column: str, + concept_ids: tuple[int, ...], + codeset_id: int | None, +): + all_ids = list(concept_ids) + if codeset_id is not None: + for cid in ctx.concept_ids_for_codeset(codeset_id): + if cid not in all_ids: + all_ids.append(cid) + + if not all_ids: + return table + + person = ctx.table("person").select(PERSON_ID, person_column) + joined = table.join(person, table[PERSON_ID] == person[PERSON_ID]) + filtered = joined.filter(joined[person_column].isin(all_ids)) + return filtered.select(*[filtered[c] for c in table.columns]) + + +def apply_person_race_filter( + table, + ctx: ExecutionContext, + *, + concept_ids: tuple[int, ...], + codeset_id: int | None, +): + return _apply_person_concept_filter( + table, + ctx, + person_column="race_concept_id", + concept_ids=concept_ids, + codeset_id=codeset_id, + ) + + +def apply_person_ethnicity_filter( + table, + ctx: ExecutionContext, + *, + concept_ids: tuple[int, ...], + codeset_id: int | None, +): + return _apply_person_concept_filter( + table, + ctx, + person_column="ethnicity_concept_id", + concept_ids=concept_ids, + codeset_id=codeset_id, + ) diff --git a/circe/execution/ibis/standardize.py b/circe/execution/ibis/standardize.py new file mode 100644 index 00000000..a04435c3 --- /dev/null +++ b/circe/execution/ibis/standardize.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import ibis + +from ..plan.events import EventSource +from ..plan.schema import ( + CONCEPT_ID, + CRITERION_INDEX, + CRITERION_TYPE, + DAYS_SUPPLY, + DOMAIN, + DURATION, + END_DATE, + EVENT_ID, + GAP_DAYS, + OCCURRENCE_COUNT, + PERSON_ID, + QUANTITY, + RANGE_HIGH, + RANGE_LOW, + REFILLS, + SOURCE_CONCEPT_ID, + SOURCE_TABLE, + START_DATE, + UNIT_CONCEPT_ID, + VALUE_AS_NUMBER, + VISIT_DETAIL_ID, + VISIT_OCCURRENCE_ID, +) + + +def _typed_optional_column(table, column_name: str | None, dtype: str): + if column_name and column_name in table.columns: + return table[column_name].cast(dtype) + return ibis.null().cast(dtype) + + +def _base_start_expr(table, *, source: EventSource): + return table[source.start_date_column].cast("date") + + +def _base_end_expr(table, *, source: EventSource, start_expr): + raw_end_expr = _typed_optional_column(table, source.end_date_column, "date") + + if source.table_name == "condition_occurrence": + return ibis.coalesce(raw_end_expr, start_expr + ibis.interval(days=1)) + if source.table_name == "drug_exposure": + days_supply_expr = _typed_optional_column(table, "days_supply", "int64") + supply_end_expr = start_expr + days_supply_expr.as_interval("D") + return ibis.coalesce(raw_end_expr, supply_end_expr, start_expr + ibis.interval(days=1)) + if source.table_name == "device_exposure": + return ibis.coalesce(raw_end_expr, start_expr + ibis.interval(days=1)) + if source.table_name in {"procedure_occurrence", "measurement", "observation", "death"}: + return start_expr + ibis.interval(days=1) + if source.table_name == "specimen": + return start_expr + + return raw_end_expr + + +def _adjust_dates( + start_expr, + end_expr, + *, + start_offset_days: int, + end_offset_days: int, + start_with: str, + end_with: str, +): + start_anchor = start_expr if start_with == START_DATE else end_expr + end_anchor = start_expr if end_with == START_DATE else end_expr + adjusted_start = start_anchor + ibis.interval(days=int(start_offset_days)) + adjusted_end = end_anchor + ibis.interval(days=int(end_offset_days)) + return adjusted_start, adjusted_end + + +def _duration_expr(*, source: EventSource, start_expr, end_expr): + if source.table_name in {"measurement", "observation"}: + return ibis.null().cast("int64") + if source.table_name in {"death", "specimen"}: + return ibis.literal(1, type="int64") + return end_expr.delta(start_expr, unit="day").cast("int64") + + +def _supplemental_exprs(table, *, source: EventSource, start_expr, end_expr) -> dict[str, object]: + value_as_number_expr = _typed_optional_column(table, "value_as_number", "float64") + if source.table_name == "dose_era" and "dose_value" in table.columns: + value_as_number_expr = table["dose_value"].cast("float64") + + unit_concept_expr = _typed_optional_column(table, "unit_concept_id", "int64") + if source.table_name == "drug_exposure" and "dose_unit_concept_id" in table.columns: + unit_concept_expr = table["dose_unit_concept_id"].cast("int64") + + occurrence_count_expr = ibis.null().cast("int64") + if "occurrence_count" in table.columns: + occurrence_count_expr = table["occurrence_count"].cast("int64") + elif "condition_occurrence_count" in table.columns: + occurrence_count_expr = table["condition_occurrence_count"].cast("int64") + elif "drug_exposure_count" in table.columns: + occurrence_count_expr = table["drug_exposure_count"].cast("int64") + + return { + QUANTITY: _typed_optional_column(table, "quantity", "float64"), + DAYS_SUPPLY: _typed_optional_column(table, "days_supply", "float64"), + REFILLS: _typed_optional_column(table, "refills", "float64"), + RANGE_LOW: _typed_optional_column(table, "range_low", "float64"), + RANGE_HIGH: _typed_optional_column(table, "range_high", "float64"), + VALUE_AS_NUMBER: value_as_number_expr, + UNIT_CONCEPT_ID: unit_concept_expr, + VISIT_DETAIL_ID: _typed_optional_column(table, "visit_detail_id", "int64"), + OCCURRENCE_COUNT: occurrence_count_expr, + GAP_DAYS: _typed_optional_column(table, "gap_days", "int64"), + DURATION: _duration_expr(source=source, start_expr=start_expr, end_expr=end_expr), + } + + +def standardize_event_table( + table, + *, + source: EventSource, + criterion_type: str, + criterion_index: int, + start_offset_days: int = 0, + end_offset_days: int = 0, + start_with: str = START_DATE, + end_with: str = END_DATE, +): + base_start_expr = _base_start_expr(table, source=source) + base_end_expr = _base_end_expr(table, source=source, start_expr=base_start_expr) + start_expr, end_expr = _adjust_dates( + base_start_expr, + base_end_expr, + start_offset_days=start_offset_days, + end_offset_days=end_offset_days, + start_with=start_with, + end_with=end_with, + ) + + concept_expr = ibis.null().cast("int64") + if source.concept_column and source.concept_column in table.columns: + concept_expr = table[source.concept_column].cast("int64") + if source.table_name == "death": + concept_expr = ibis.coalesce(concept_expr, ibis.literal(0, type="int64")) + + source_concept_expr = ibis.null().cast("int64") + if source.source_concept_column and source.source_concept_column in table.columns: + source_concept_expr = table[source.source_concept_column].cast("int64") + + visit_occ_expr = ibis.null().cast("int64") + if source.visit_occurrence_column and source.visit_occurrence_column in table.columns: + visit_occ_expr = table[source.visit_occurrence_column].cast("int64") + + supplemental_exprs = _supplemental_exprs( + table, + source=source, + start_expr=start_expr, + end_expr=end_expr, + ) + standardized = table.select( + table[source.person_id_column].cast("int64").name(PERSON_ID), + table[source.event_id_column].cast("int64").name(EVENT_ID), + start_expr.name(START_DATE), + end_expr.name(END_DATE), + ibis.literal(source.domain).name(DOMAIN), + concept_expr.name(CONCEPT_ID), + source_concept_expr.name(SOURCE_CONCEPT_ID), + visit_occ_expr.name(VISIT_OCCURRENCE_ID), + supplemental_exprs[VISIT_DETAIL_ID].name(VISIT_DETAIL_ID), + supplemental_exprs[QUANTITY].name(QUANTITY), + supplemental_exprs[DAYS_SUPPLY].name(DAYS_SUPPLY), + supplemental_exprs[REFILLS].name(REFILLS), + supplemental_exprs[RANGE_LOW].name(RANGE_LOW), + supplemental_exprs[RANGE_HIGH].name(RANGE_HIGH), + supplemental_exprs[VALUE_AS_NUMBER].name(VALUE_AS_NUMBER), + supplemental_exprs[UNIT_CONCEPT_ID].name(UNIT_CONCEPT_ID), + supplemental_exprs[OCCURRENCE_COUNT].name(OCCURRENCE_COUNT), + supplemental_exprs[GAP_DAYS].name(GAP_DAYS), + supplemental_exprs[DURATION].name(DURATION), + ibis.literal(int(criterion_index), type="int64").name(CRITERION_INDEX), + ibis.literal(criterion_type).name(CRITERION_TYPE), + ibis.literal(source.table_name).name(SOURCE_TABLE), + ) + return standardized diff --git a/circe/execution/ibis_compat.py b/circe/execution/ibis_compat.py index d5f215f9..037dce6c 100644 --- a/circe/execution/ibis_compat.py +++ b/circe/execution/ibis_compat.py @@ -1,36 +1,49 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Mapping, Sequence +from typing import Any import ibis import ibis.expr.operations as ops -import ibis.expr.types as ir from ibis.common.collections import FrozenOrderedDict +from .typing import IbisBackendLike, Table -def table_from_literal_list( - values: Iterable[int], + +def _is_nullish(value: Any) -> bool: + if value is None: + return True + try: + return value != value + except Exception: + return False + + +def _typed_literal(value: Any, *, dtype: str): + if _is_nullish(value): + return ibis.null().cast(dtype) + return ibis.literal(value).cast(dtype) + + +def literal_column_relation( + values: Iterable[Any], *, column_name: str, - element_type: str = "int64", -) -> ir.Table: - """ - Build a 1-column table from a Python list without using `ibis.memtable`. - - This avoids Databricks' memtable upload machinery (which depends on a writable - Unity Catalog volume) while still producing a pure Ibis expression. - """ + dtype: str, + backend: IbisBackendLike | None = None, +) -> Table: + """Build a 1-column relation from Python literals without `ibis.memtable(...)`.""" + _ = backend values_list = list(values) if not values_list: dummy = ops.DummyTable( - values=FrozenOrderedDict({column_name: ibis.null().cast(element_type).op()}) + values=FrozenOrderedDict({column_name: ibis.null().cast(dtype).op()}) ).to_expr() return dummy.select(dummy[column_name]).filter(ibis.literal(False)) - array_type = f"array<{element_type}>" - arr = ibis.literal(values_list, type=array_type) - - dummy = ops.DummyTable(values=FrozenOrderedDict({"__values__": arr.op()})).to_expr() + array_type = f"array<{dtype}>" + literal_array = ibis.literal(values_list, type=array_type) + dummy = ops.DummyTable(values=FrozenOrderedDict({"__values__": literal_array.op()})).to_expr() unnested = ops.TableUnnest( dummy.op(), dummy["__values__"].op(), @@ -39,3 +52,52 @@ def table_from_literal_list( False, ).to_expr() return unnested.select(unnested[column_name]) + + +def _single_row_relation( + row: Mapping[str, Any], + *, + schema: Mapping[str, str], +) -> Table: + return ops.DummyTable( + values=FrozenOrderedDict( + {column: _typed_literal(row.get(column), dtype=dtype).op() for column, dtype in schema.items()} + ) + ).to_expr() + + +def literal_rows_relation( + rows: Sequence[Mapping[str, Any]], + *, + schema: Mapping[str, str], + backend: IbisBackendLike | None = None, +) -> Table: + """Build a typed relation from row dictionaries without `ibis.memtable(...)`.""" + _ = backend + if not schema: + raise ValueError("literal_rows_relation requires a non-empty schema.") + + if not rows: + empty_row = _single_row_relation( + dict.fromkeys(schema), + schema=schema, + ) + return empty_row.filter(ibis.literal(False)) + + relation: Table = _single_row_relation(rows[0], schema=schema) + for row in rows[1:]: + relation = relation.union(_single_row_relation(row, schema=schema), distinct=False) + return relation + + +def table_from_literal_list( + values: Iterable[int], + *, + column_name: str, + element_type: str = "int64", +) -> Table: + """Backward-compatible wrapper over `literal_column_relation`.""" + return literal_column_relation(values, column_name=column_name, dtype=element_type) + + +__all__ = ["literal_column_relation", "literal_rows_relation", "table_from_literal_list"] diff --git a/circe/execution/lower/__init__.py b/circe/execution/lower/__init__.py new file mode 100644 index 00000000..95d11a79 --- /dev/null +++ b/circe/execution/lower/__init__.py @@ -0,0 +1,3 @@ +from .criteria import LOWERERS, lower_criterion + +__all__ = ["LOWERERS", "lower_criterion"] diff --git a/circe/execution/lower/common.py b/circe/execution/lower/common.py new file mode 100644 index 00000000..3b474989 --- /dev/null +++ b/circe/execution/lower/common.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +from ...cohortdefinition.core import ConceptSetSelection, NumericRange, TextFilter +from ...vocabulary.concept import Concept +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import ( + EventPlan, + EventSource, + FilterByCareSite, + FilterByCareSiteLocationRegion, + FilterByCodeset, + FilterByConceptSet, + FilterByDateRange, + FilterByNumericRange, + FilterByPersonAge, + FilterByPersonEthnicity, + FilterByPersonGender, + FilterByPersonRace, + FilterByProviderSpecialty, + FilterByText, + FilterByVisit, + KeepFirstPerPerson, + PlanStep, + StandardizeEventShape, +) +from ..plan.predicates import DateRangePredicate, NumericRangePredicate +from ..plan.schema import DURATION, END_DATE, START_DATE + + +def lower_common_steps(criterion: NormalizedCriterion) -> list[PlanStep]: + steps: list[PlanStep] = [] + + if criterion.codeset_id is not None and criterion.concept_column is not None: + steps.append( + FilterByCodeset( + column=criterion.concept_column, + codeset_id=int(criterion.codeset_id), + ) + ) + + if criterion.person_filters.gender_concept_ids or criterion.person_filters.gender_codeset_id is not None: + steps.append( + FilterByPersonGender( + concept_ids=criterion.person_filters.gender_concept_ids, + codeset_id=criterion.person_filters.gender_codeset_id, + ) + ) + + if criterion.person_filters.race_concept_ids or criterion.person_filters.race_codeset_id is not None: + steps.append( + FilterByPersonRace( + concept_ids=criterion.person_filters.race_concept_ids, + codeset_id=criterion.person_filters.race_codeset_id, + ) + ) + + if ( + criterion.person_filters.ethnicity_concept_ids + or criterion.person_filters.ethnicity_codeset_id is not None + ): + steps.append( + FilterByPersonEthnicity( + concept_ids=criterion.person_filters.ethnicity_concept_ids, + codeset_id=criterion.person_filters.ethnicity_codeset_id, + ) + ) + + if criterion.first: + steps.append( + KeepFirstPerPerson( + order_by=(criterion.start_date_column, criterion.event_id_column), + ) + ) + + return steps + + +def concept_ids(values: list[Concept] | None) -> tuple[int, ...]: + if not values: + return () + output: list[int] = [] + for concept in values: + if concept is None or concept.concept_id is None: + continue + cid = int(concept.concept_id) + if cid not in output: + output.append(cid) + return tuple(output) + + +def append_numeric_filter( + steps: list[PlanStep], + *, + column: str, + value: NumericRange | None, +) -> None: + if value is None: + return + steps.append( + FilterByNumericRange( + column=column, + predicate=NumericRangePredicate( + op=value.op, + value=value.value, + extent=value.extent, + ), + ) + ) + + +def append_text_filter( + steps: list[PlanStep], + *, + column: str, + value: TextFilter | None, +) -> None: + if value is None: + return + steps.append( + FilterByText( + column=column, + op=value.op, + text=value.text, + ) + ) + + +def append_concept_filters( + steps: list[PlanStep], + *, + column: str, + concepts: list[Concept] | None = None, + codeset_selection: ConceptSetSelection | None = None, + exclude: bool = False, +) -> None: + ids = concept_ids(concepts) + if ids: + steps.append( + FilterByConceptSet( + column=column, + concept_ids=ids, + exclude=bool(exclude), + ) + ) + + if codeset_selection and codeset_selection.codeset_id is not None: + steps.append( + FilterByCodeset( + column=column, + codeset_id=int(codeset_selection.codeset_id), + exclude=bool(codeset_selection.is_exclusion) or bool(exclude), + ) + ) + + +def append_visit_filters( + steps: list[PlanStep], + *, + visit_occurrence_column: str, + concepts: list[Concept] | None = None, + codeset_selection: ConceptSetSelection | None = None, + exclude: bool = False, +) -> None: + ids = concept_ids(concepts) + if ids: + steps.append( + FilterByVisit( + visit_occurrence_column=visit_occurrence_column, + concept_ids=ids, + exclude=bool(exclude), + ) + ) + + if codeset_selection and codeset_selection.codeset_id is not None: + steps.append( + FilterByVisit( + visit_occurrence_column=visit_occurrence_column, + codeset_id=int(codeset_selection.codeset_id), + exclude=bool(codeset_selection.is_exclusion), + ) + ) + + +def append_provider_specialty_filters( + steps: list[PlanStep], + *, + provider_id_column: str = "provider_id", + concepts: list[Concept] | None = None, + codeset_selection: ConceptSetSelection | None = None, +) -> None: + ids = concept_ids(concepts) + if ids: + steps.append( + FilterByProviderSpecialty( + provider_id_column=provider_id_column, + concept_ids=ids, + ) + ) + + if codeset_selection and codeset_selection.codeset_id is not None: + steps.append( + FilterByProviderSpecialty( + provider_id_column=provider_id_column, + codeset_id=int(codeset_selection.codeset_id), + exclude=bool(codeset_selection.is_exclusion), + ) + ) + + +def append_care_site_filters( + steps: list[PlanStep], + *, + care_site_id_column: str = "care_site_id", + concepts: list[Concept] | None = None, + codeset_selection: ConceptSetSelection | None = None, +) -> None: + ids = concept_ids(concepts) + if ids: + steps.append( + FilterByCareSite( + care_site_id_column=care_site_id_column, + concept_ids=ids, + ) + ) + + if codeset_selection and codeset_selection.codeset_id is not None: + steps.append( + FilterByCareSite( + care_site_id_column=care_site_id_column, + codeset_id=int(codeset_selection.codeset_id), + exclude=bool(codeset_selection.is_exclusion), + ) + ) + + +def append_care_site_location_region_filter( + steps: list[PlanStep], + *, + care_site_id_column: str = "care_site_id", + start_date_column: str, + end_date_column: str, + codeset_id: int | None, +) -> None: + if codeset_id is None: + return + steps.append( + FilterByCareSiteLocationRegion( + care_site_id_column=care_site_id_column, + start_date_column=start_date_column, + end_date_column=end_date_column, + codeset_id=int(codeset_id), + ) + ) + + +def append_post_standardization_common_steps( + criterion: NormalizedCriterion, + *, + steps: list[PlanStep], +) -> None: + if criterion.person_filters.age is not None: + steps.append( + FilterByPersonAge( + date_column=START_DATE, + predicate=NumericRangePredicate( + op=criterion.person_filters.age.op, + value=criterion.person_filters.age.value, + extent=criterion.person_filters.age.extent, + ), + ) + ) + + if criterion.occurrence_start_date is not None: + steps.append( + FilterByDateRange( + column=START_DATE, + predicate=DateRangePredicate( + op=criterion.occurrence_start_date.op, + value=criterion.occurrence_start_date.value, + extent=criterion.occurrence_start_date.extent, + ), + ) + ) + + if criterion.occurrence_end_date is not None: + steps.append( + FilterByDateRange( + column=END_DATE, + predicate=DateRangePredicate( + op=criterion.occurrence_end_date.op, + value=criterion.occurrence_end_date.value, + extent=criterion.occurrence_end_date.extent, + ), + ) + ) + + +def append_duration_filter( + steps: list[PlanStep], + *, + value: NumericRange | None, +) -> None: + append_numeric_filter(steps, column=DURATION, value=value) + + +def build_standard_domain_plan( + criterion: NormalizedCriterion, + *, + criterion_index: int, + steps: list[PlanStep], + post_standardize_steps: list[PlanStep] | None = None, +) -> EventPlan: + plan_steps = list(steps) + date_adjustment = getattr(criterion.raw_criteria, "date_adjustment", None) + start_with = START_DATE + end_with = END_DATE + start_offset_days = 0 + end_offset_days = 0 + if date_adjustment is not None: + start_with = ( + date_adjustment.start_with.value + if getattr(date_adjustment, "start_with", None) is not None + else START_DATE + ) + end_with = ( + date_adjustment.end_with.value + if getattr(date_adjustment, "end_with", None) is not None + else END_DATE + ) + start_offset_days = int(date_adjustment.start_offset) + end_offset_days = int(date_adjustment.end_offset) + + plan_steps.append( + StandardizeEventShape( + criterion_type=criterion.criterion_type, + criterion_index=criterion_index, + start_offset_days=start_offset_days, + end_offset_days=end_offset_days, + start_with=start_with, + end_with=end_with, + ) + ) + + standard_post_steps = list(post_standardize_steps or []) + append_post_standardization_common_steps(criterion, steps=standard_post_steps) + plan_steps.extend(standard_post_steps) + + return EventPlan( + source=EventSource( + table_name=criterion.source_table, + domain=criterion.domain, + event_id_column=criterion.event_id_column, + start_date_column=criterion.start_date_column, + end_date_column=criterion.end_date_column, + concept_column=criterion.concept_column, + source_concept_column=criterion.source_concept_column, + visit_occurrence_column=criterion.visit_occurrence_column, + ), + criterion_type=criterion.criterion_type, + criterion_index=criterion_index, + steps=tuple(plan_steps), + ) + + +def lower_standard_domain_plan( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + steps = lower_common_steps(criterion) + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + ) diff --git a/circe/execution/lower/condition_era.py b/circe/execution/lower/condition_era.py new file mode 100644 index 00000000..a6e2f9d0 --- /dev/null +++ b/circe/execution/lower/condition_era.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import lower_standard_domain_plan + + +def lower_condition_era( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + return lower_standard_domain_plan(criterion, criterion_index=criterion_index) diff --git a/circe/execution/lower/condition_occurrence.py b/circe/execution/lower/condition_occurrence.py new file mode 100644 index 00000000..8d11c2dc --- /dev/null +++ b/circe/execution/lower/condition_occurrence.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import ConditionOccurrence +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import ( + append_concept_filters, + append_provider_specialty_filters, + append_text_filter, + append_visit_filters, + build_standard_domain_plan, + lower_common_steps, +) + + +def lower_condition_occurrence( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, ConditionOccurrence): + raise TypeError("lower_condition_occurrence requires ConditionOccurrence criteria") + + steps = lower_common_steps(criterion) + + append_concept_filters( + steps, + column="condition_type_concept_id", + concepts=raw.condition_type, + codeset_selection=raw.condition_type_cs, + exclude=bool(raw.condition_type_exclude), + ) + append_text_filter(steps, column="stop_reason", value=raw.stop_reason) + append_provider_specialty_filters( + steps, + concepts=raw.provider_specialty, + codeset_selection=raw.provider_specialty_cs, + ) + append_visit_filters( + steps, + visit_occurrence_column="visit_occurrence_id", + concepts=raw.visit_type, + codeset_selection=raw.visit_type_cs, + ) + append_concept_filters( + steps, + column="condition_status_concept_id", + concepts=raw.condition_status, + codeset_selection=raw.condition_status_cs, + ) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + ) diff --git a/circe/execution/lower/criteria.py b/circe/execution/lower/criteria.py new file mode 100644 index 00000000..90171f78 --- /dev/null +++ b/circe/execution/lower/criteria.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Protocol + +from ...cohortdefinition.criteria import ( + ConditionEra, + ConditionOccurrence, + Criteria, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, +) +from ..errors import UnsupportedCriterionError +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .condition_era import lower_condition_era +from .condition_occurrence import lower_condition_occurrence +from .death import lower_death +from .device_exposure import lower_device_exposure +from .dose_era import lower_dose_era +from .drug_era import lower_drug_era +from .drug_exposure import lower_drug_exposure +from .location_region import lower_location_region +from .measurement import lower_measurement +from .observation import lower_observation +from .observation_period import lower_observation_period +from .payer_plan_period import lower_payer_plan_period +from .procedure_occurrence import lower_procedure_occurrence +from .specimen import lower_specimen +from .visit_detail import lower_visit_detail +from .visit_occurrence import lower_visit_occurrence + + +class LowerFn(Protocol): + def __call__( + self, + criterion: NormalizedCriterion, + *, + criterion_index: int, + ) -> EventPlan: ... + + +LOWERERS: dict[type[Criteria], LowerFn] = { + ConditionOccurrence: lower_condition_occurrence, + DrugExposure: lower_drug_exposure, + VisitOccurrence: lower_visit_occurrence, + Measurement: lower_measurement, + ProcedureOccurrence: lower_procedure_occurrence, + Observation: lower_observation, + VisitDetail: lower_visit_detail, + DeviceExposure: lower_device_exposure, + Specimen: lower_specimen, + Death: lower_death, + ObservationPeriod: lower_observation_period, + PayerPlanPeriod: lower_payer_plan_period, + ConditionEra: lower_condition_era, + DrugEra: lower_drug_era, + DoseEra: lower_dose_era, + LocationRegion: lower_location_region, +} + + +def lower_criterion( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + lowerer = LOWERERS.get(type(criterion.raw_criteria)) + if lowerer is not None: + return lowerer(criterion, criterion_index=criterion_index) + raise UnsupportedCriterionError( + f"Ibis executor lowering error: no lowerer registered for {criterion.criterion_type}." + ) diff --git a/circe/execution/lower/death.py b/circe/execution/lower/death.py new file mode 100644 index 00000000..0c95c21d --- /dev/null +++ b/circe/execution/lower/death.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import Death +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import append_concept_filters, build_standard_domain_plan, lower_common_steps + + +def lower_death( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, Death): + raise TypeError("lower_death requires Death criteria") + + steps = lower_common_steps(criterion) + + append_concept_filters( + steps, + column="death_type_concept_id", + concepts=raw.death_type, + codeset_selection=raw.death_type_cs, + exclude=bool(raw.death_type_exclude), + ) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + ) diff --git a/circe/execution/lower/device_exposure.py b/circe/execution/lower/device_exposure.py new file mode 100644 index 00000000..c5c2e19d --- /dev/null +++ b/circe/execution/lower/device_exposure.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import DeviceExposure +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import ( + append_concept_filters, + append_numeric_filter, + append_provider_specialty_filters, + append_text_filter, + append_visit_filters, + build_standard_domain_plan, + lower_common_steps, +) + + +def lower_device_exposure( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, DeviceExposure): + raise TypeError("lower_device_exposure requires DeviceExposure criteria") + + steps = lower_common_steps(criterion) + + append_concept_filters( + steps, + column="device_type_concept_id", + concepts=raw.device_type, + codeset_selection=raw.device_type_cs, + exclude=bool(raw.device_type_exclude), + ) + append_text_filter(steps, column="unique_device_id", value=raw.unique_device_id) + append_numeric_filter(steps, column="quantity", value=raw.quantity) + append_provider_specialty_filters( + steps, + concepts=raw.provider_specialty, + codeset_selection=raw.provider_specialty_cs, + ) + append_visit_filters( + steps, + visit_occurrence_column="visit_occurrence_id", + concepts=raw.visit_type, + codeset_selection=raw.visit_type_cs, + ) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + ) diff --git a/circe/execution/lower/dose_era.py b/circe/execution/lower/dose_era.py new file mode 100644 index 00000000..cae6b94d --- /dev/null +++ b/circe/execution/lower/dose_era.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import lower_standard_domain_plan + + +def lower_dose_era( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + return lower_standard_domain_plan(criterion, criterion_index=criterion_index) diff --git a/circe/execution/lower/drug_era.py b/circe/execution/lower/drug_era.py new file mode 100644 index 00000000..4ffcc7af --- /dev/null +++ b/circe/execution/lower/drug_era.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import lower_standard_domain_plan + + +def lower_drug_era( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + return lower_standard_domain_plan(criterion, criterion_index=criterion_index) diff --git a/circe/execution/lower/drug_exposure.py b/circe/execution/lower/drug_exposure.py new file mode 100644 index 00000000..cdbbf93b --- /dev/null +++ b/circe/execution/lower/drug_exposure.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import DrugExposure +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import ( + append_concept_filters, + append_numeric_filter, + append_provider_specialty_filters, + append_text_filter, + append_visit_filters, + build_standard_domain_plan, + lower_common_steps, +) + + +def lower_drug_exposure( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, DrugExposure): + raise TypeError("lower_drug_exposure requires DrugExposure criteria") + + steps = lower_common_steps(criterion) + + append_concept_filters( + steps, + column="drug_type_concept_id", + concepts=raw.drug_type, + codeset_selection=raw.drug_type_cs, + exclude=bool(raw.drug_type_exclude), + ) + append_text_filter(steps, column="stop_reason", value=raw.stop_reason) + append_concept_filters( + steps, + column="route_concept_id", + concepts=raw.route_concept, + codeset_selection=raw.route_concept_cs, + ) + append_concept_filters( + steps, + column="dose_unit_concept_id", + concepts=raw.dose_unit, + codeset_selection=raw.dose_unit_cs, + ) + append_text_filter(steps, column="lot_number", value=raw.lot_number) + append_numeric_filter(steps, column="refills", value=raw.refills) + append_numeric_filter(steps, column="quantity", value=raw.quantity) + append_numeric_filter(steps, column="days_supply", value=raw.days_supply) + append_provider_specialty_filters( + steps, + concepts=raw.provider_specialty, + codeset_selection=raw.provider_specialty_cs, + ) + append_visit_filters( + steps, + visit_occurrence_column="visit_occurrence_id", + concepts=raw.visit_type, + codeset_selection=raw.visit_type_cs, + ) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + ) diff --git a/circe/execution/lower/location_region.py b/circe/execution/lower/location_region.py new file mode 100644 index 00000000..21afdfae --- /dev/null +++ b/circe/execution/lower/location_region.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import ( + EventPlan, + EventSource, + FilterByCodeset, + FilterByText, + JoinLocationRegion, + StandardizeEventShape, +) + + +def lower_location_region( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + steps = [ + FilterByText(column="domain_id", op="eq", text="PERSON"), + JoinLocationRegion(location_id_column="location_id", region_column="region_concept_id"), + ] + if criterion.codeset_id is not None: + steps.append(FilterByCodeset(column="region_concept_id", codeset_id=int(criterion.codeset_id))) + steps.append( + StandardizeEventShape( + criterion_type=criterion.criterion_type, + criterion_index=criterion_index, + ) + ) + + return EventPlan( + source=EventSource( + table_name=criterion.source_table, + domain=criterion.domain, + event_id_column=criterion.event_id_column, + start_date_column=criterion.start_date_column, + end_date_column=criterion.end_date_column, + person_id_column="entity_id", + concept_column=criterion.concept_column, + source_concept_column=criterion.source_concept_column, + visit_occurrence_column=criterion.visit_occurrence_column, + ), + criterion_type=criterion.criterion_type, + criterion_index=criterion_index, + steps=tuple(steps), + ) diff --git a/circe/execution/lower/measurement.py b/circe/execution/lower/measurement.py new file mode 100644 index 00000000..d2491945 --- /dev/null +++ b/circe/execution/lower/measurement.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import Measurement +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import ( + append_concept_filters, + append_numeric_filter, + append_provider_specialty_filters, + append_text_filter, + append_visit_filters, + build_standard_domain_plan, + lower_common_steps, +) + + +def lower_measurement( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, Measurement): + raise TypeError("lower_measurement requires Measurement criteria") + + steps = lower_common_steps(criterion) + + append_concept_filters( + steps, + column="measurement_type_concept_id", + concepts=raw.measurement_type, + codeset_selection=raw.measurement_type_cs, + exclude=bool(raw.measurement_type_exclude), + ) + append_concept_filters( + steps, + column="operator_concept_id", + concepts=raw.operator, + codeset_selection=raw.operator_cs, + ) + append_numeric_filter(steps, column="value_as_number", value=raw.value_as_number) + append_text_filter(steps, column="value_as_string", value=raw.value_as_string) + append_concept_filters( + steps, + column="value_as_concept_id", + concepts=raw.value_as_concept, + codeset_selection=raw.value_as_concept_cs, + ) + append_concept_filters( + steps, + column="unit_concept_id", + concepts=raw.unit, + codeset_selection=raw.unit_cs, + ) + append_numeric_filter(steps, column="range_low", value=raw.range_low) + append_numeric_filter(steps, column="range_high", value=raw.range_high) + append_provider_specialty_filters( + steps, + concepts=raw.provider_specialty, + codeset_selection=raw.provider_specialty_cs, + ) + append_visit_filters( + steps, + visit_occurrence_column="visit_occurrence_id", + concepts=raw.visit_type, + codeset_selection=raw.visit_type_cs, + ) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + ) diff --git a/circe/execution/lower/observation.py b/circe/execution/lower/observation.py new file mode 100644 index 00000000..7ff85c21 --- /dev/null +++ b/circe/execution/lower/observation.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import Observation +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import ( + append_concept_filters, + append_numeric_filter, + append_provider_specialty_filters, + append_text_filter, + append_visit_filters, + build_standard_domain_plan, + lower_common_steps, +) + + +def lower_observation( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, Observation): + raise TypeError("lower_observation requires Observation criteria") + + steps = lower_common_steps(criterion) + + append_concept_filters( + steps, + column="observation_type_concept_id", + concepts=raw.observation_type, + codeset_selection=raw.observation_type_cs, + exclude=bool(raw.observation_type_exclude), + ) + append_numeric_filter(steps, column="value_as_number", value=raw.value_as_number) + append_text_filter(steps, column="value_as_string", value=raw.value_as_string) + append_concept_filters( + steps, + column="value_as_concept_id", + concepts=raw.value_as_concept, + codeset_selection=raw.value_as_concept_cs, + ) + append_concept_filters( + steps, + column="unit_concept_id", + concepts=raw.unit, + codeset_selection=raw.unit_cs, + ) + append_concept_filters( + steps, + column="qualifier_concept_id", + concepts=raw.qualifier, + codeset_selection=raw.qualifier_cs, + ) + append_provider_specialty_filters( + steps, + concepts=raw.provider_specialty, + codeset_selection=raw.provider_specialty_cs, + ) + append_visit_filters( + steps, + visit_occurrence_column="visit_occurrence_id", + concepts=raw.visit_type, + codeset_selection=raw.visit_type_cs, + ) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + ) diff --git a/circe/execution/lower/observation_period.py b/circe/execution/lower/observation_period.py new file mode 100644 index 00000000..ef2b8a90 --- /dev/null +++ b/circe/execution/lower/observation_period.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import lower_standard_domain_plan + + +def lower_observation_period( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + return lower_standard_domain_plan(criterion, criterion_index=criterion_index) diff --git a/circe/execution/lower/payer_plan_period.py b/circe/execution/lower/payer_plan_period.py new file mode 100644 index 00000000..3a08d1ea --- /dev/null +++ b/circe/execution/lower/payer_plan_period.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import lower_standard_domain_plan + + +def lower_payer_plan_period( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + return lower_standard_domain_plan(criterion, criterion_index=criterion_index) diff --git a/circe/execution/lower/procedure_occurrence.py b/circe/execution/lower/procedure_occurrence.py new file mode 100644 index 00000000..caded791 --- /dev/null +++ b/circe/execution/lower/procedure_occurrence.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import ProcedureOccurrence +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import ( + append_concept_filters, + append_numeric_filter, + append_provider_specialty_filters, + append_visit_filters, + build_standard_domain_plan, + lower_common_steps, +) + + +def lower_procedure_occurrence( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, ProcedureOccurrence): + raise TypeError("lower_procedure_occurrence requires ProcedureOccurrence criteria") + + steps = lower_common_steps(criterion) + + append_concept_filters( + steps, + column="procedure_type_concept_id", + concepts=raw.procedure_type, + codeset_selection=raw.procedure_type_cs, + exclude=bool(raw.procedure_type_exclude), + ) + append_concept_filters( + steps, + column="modifier_concept_id", + concepts=raw.modifier, + codeset_selection=raw.modifier_cs, + ) + append_numeric_filter(steps, column="quantity", value=raw.quantity) + append_provider_specialty_filters( + steps, + concepts=raw.provider_specialty, + codeset_selection=raw.provider_specialty_cs, + ) + append_visit_filters( + steps, + visit_occurrence_column="visit_occurrence_id", + concepts=raw.visit_type, + codeset_selection=raw.visit_type_cs, + ) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + ) diff --git a/circe/execution/lower/specimen.py b/circe/execution/lower/specimen.py new file mode 100644 index 00000000..02710630 --- /dev/null +++ b/circe/execution/lower/specimen.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import Specimen +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan +from .common import ( + append_concept_filters, + append_numeric_filter, + append_text_filter, + build_standard_domain_plan, + lower_common_steps, +) + + +def lower_specimen( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, Specimen): + raise TypeError("lower_specimen requires Specimen criteria") + + steps = lower_common_steps(criterion) + + append_concept_filters( + steps, + column="specimen_type_concept_id", + concepts=raw.specimen_type, + codeset_selection=raw.specimen_type_cs, + exclude=bool(raw.specimen_type_exclude), + ) + append_numeric_filter(steps, column="quantity", value=raw.quantity) + append_concept_filters( + steps, + column="unit_concept_id", + concepts=raw.unit, + codeset_selection=raw.unit_cs, + ) + append_concept_filters( + steps, + column="anatomic_site_concept_id", + concepts=raw.anatomic_site, + codeset_selection=raw.anatomic_site_cs, + ) + append_concept_filters( + steps, + column="disease_status_concept_id", + concepts=raw.disease_status, + codeset_selection=raw.disease_status_cs, + ) + append_text_filter(steps, column="specimen_source_id", value=raw.source_id) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + ) diff --git a/circe/execution/lower/visit_detail.py b/circe/execution/lower/visit_detail.py new file mode 100644 index 00000000..73985026 --- /dev/null +++ b/circe/execution/lower/visit_detail.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import VisitDetail +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan, PlanStep +from .common import ( + append_care_site_filters, + append_care_site_location_region_filter, + append_concept_filters, + append_duration_filter, + append_provider_specialty_filters, + build_standard_domain_plan, + lower_common_steps, +) + + +def lower_visit_detail( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, VisitDetail): + raise TypeError("lower_visit_detail requires VisitDetail criteria") + + steps = lower_common_steps(criterion) + post_standardize_steps: list[PlanStep] = [] + + append_concept_filters( + steps, + column="visit_detail_type_concept_id", + concepts=raw.visit_detail_type, + codeset_selection=raw.visit_detail_type_cs, + exclude=bool(raw.visit_detail_type_exclude), + ) + append_concept_filters( + steps, + column="discharge_to_concept_id", + concepts=raw.discharge_to, + codeset_selection=raw.discharge_to_cs, + ) + append_provider_specialty_filters( + steps, + concepts=raw.provider_specialty, + codeset_selection=raw.provider_specialty_cs, + ) + append_care_site_filters( + steps, + concepts=raw.place_of_service, + codeset_selection=raw.place_of_service_cs, + ) + append_care_site_location_region_filter( + steps, + start_date_column=criterion.start_date_column, + end_date_column=criterion.end_date_column, + codeset_id=raw.place_of_service_location, + ) + append_duration_filter(post_standardize_steps, value=raw.visit_detail_length) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + post_standardize_steps=post_standardize_steps, + ) diff --git a/circe/execution/lower/visit_occurrence.py b/circe/execution/lower/visit_occurrence.py new file mode 100644 index 00000000..ef7e9d9e --- /dev/null +++ b/circe/execution/lower/visit_occurrence.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import VisitOccurrence +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan, PlanStep +from .common import ( + append_care_site_filters, + append_care_site_location_region_filter, + append_concept_filters, + append_duration_filter, + append_provider_specialty_filters, + build_standard_domain_plan, + lower_common_steps, +) + + +def lower_visit_occurrence( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, VisitOccurrence): + raise TypeError("lower_visit_occurrence requires VisitOccurrence criteria") + + steps = lower_common_steps(criterion) + post_standardize_steps: list[PlanStep] = [] + + append_concept_filters( + steps, + column="visit_type_concept_id", + concepts=raw.visit_type, + codeset_selection=raw.visit_type_cs, + exclude=bool(raw.visit_type_exclude), + ) + append_provider_specialty_filters( + steps, + concepts=raw.provider_specialty, + codeset_selection=raw.provider_specialty_cs, + ) + append_care_site_filters( + steps, + concepts=raw.place_of_service, + codeset_selection=raw.place_of_service_cs, + ) + append_care_site_location_region_filter( + steps, + start_date_column=criterion.start_date_column, + end_date_column=criterion.end_date_column, + codeset_id=raw.place_of_service_location, + ) + append_duration_filter(post_standardize_steps, value=raw.visit_length) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + post_standardize_steps=post_standardize_steps, + ) diff --git a/circe/execution/normalize/__init__.py b/circe/execution/normalize/__init__.py new file mode 100644 index 00000000..f5ec8a35 --- /dev/null +++ b/circe/execution/normalize/__init__.py @@ -0,0 +1,54 @@ +from .cohort import ( + NormalizedCohort, + NormalizedConceptSet, + NormalizedConceptSetItem, + NormalizedPrimaryCriteria, + normalize_cohort, +) +from .collapse import NormalizedCollapseSettings, normalize_collapse_settings +from .criteria import NormalizedCriterion, NormalizedPersonFilters, normalize_criterion +from .end_strategy import NormalizedEndStrategy +from .groups import ( + NormalizedCorrelatedCriteria, + NormalizedCriteriaGroup, + NormalizedDemographicCriteria, + NormalizedInclusionRule, + normalize_criteria_group, + normalize_inclusion_rule, +) +from .windows import ( + NormalizedDateRange, + NormalizedNumericRange, + NormalizedObservationWindow, + NormalizedPeriod, + NormalizedWindow, + NormalizedWindowBound, + normalize_period, +) + +__all__ = [ + "normalize_cohort", + "normalize_criterion", + "normalize_collapse_settings", + "normalize_period", + "NormalizedCohort", + "NormalizedConceptSet", + "NormalizedConceptSetItem", + "NormalizedPrimaryCriteria", + "NormalizedCollapseSettings", + "NormalizedCriterion", + "NormalizedPersonFilters", + "NormalizedEndStrategy", + "NormalizedCorrelatedCriteria", + "NormalizedCriteriaGroup", + "NormalizedDemographicCriteria", + "NormalizedInclusionRule", + "normalize_criteria_group", + "normalize_inclusion_rule", + "NormalizedDateRange", + "NormalizedNumericRange", + "NormalizedObservationWindow", + "NormalizedPeriod", + "NormalizedWindow", + "NormalizedWindowBound", +] diff --git a/circe/execution/normalize/cohort.py b/circe/execution/normalize/cohort.py new file mode 100644 index 00000000..5838fe64 --- /dev/null +++ b/circe/execution/normalize/cohort.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from ...cohortdefinition import CohortExpression +from ...vocabulary.concept import ConceptSet +from .._dataclass import frozen_slots_dataclass +from ..errors import ExecutionNormalizationError, UnsupportedFeatureError +from .collapse import NormalizedCollapseSettings, normalize_collapse_settings +from .criteria import NormalizedCriterion, normalize_criterion +from .end_strategy import NormalizedEndStrategy, normalize_end_strategy +from .groups import ( + NormalizedCriteriaGroup, + NormalizedInclusionRule, + normalize_criteria_group, + normalize_inclusion_rule, +) +from .windows import ( + NormalizedObservationWindow, + NormalizedPeriod, + normalize_observation_window, + normalize_period, +) + + +@frozen_slots_dataclass +class NormalizedPrimaryCriteria: + criteria: tuple[NormalizedCriterion, ...] + observation_window: NormalizedObservationWindow | None + primary_limit_type: str + + +@frozen_slots_dataclass +class NormalizedResultLimits: + qualified_limit_type: str + expression_limit_type: str + + +@frozen_slots_dataclass +class NormalizedConceptSetItem: + concept_id: int + is_excluded: bool + include_descendants: bool + include_mapped: bool + + +@frozen_slots_dataclass +class NormalizedConceptSet: + set_id: int + items: tuple[NormalizedConceptSetItem, ...] + + +@frozen_slots_dataclass +class NormalizedCohort: + title: str | None + concept_sets: dict[int, NormalizedConceptSet] + primary: NormalizedPrimaryCriteria + result_limits: NormalizedResultLimits + additional_criteria: NormalizedCriteriaGroup | None + inclusion_rules: tuple[NormalizedInclusionRule, ...] + censoring_criteria: tuple[NormalizedCriterion, ...] + censor_window: NormalizedPeriod | None + collapse_settings: NormalizedCollapseSettings | None + end_strategy: NormalizedEndStrategy | None + + +def _normalized_item( + *, + concept_id: int, + is_excluded: bool, + include_descendants: bool, + include_mapped: bool, +) -> NormalizedConceptSetItem: + return NormalizedConceptSetItem( + concept_id=int(concept_id), + is_excluded=bool(is_excluded), + include_descendants=bool(include_descendants), + include_mapped=bool(include_mapped), + ) + + +def _extract_codesets(concept_sets: list[ConceptSet]) -> dict[int, NormalizedConceptSet]: + output: dict[int, NormalizedConceptSet] = {} + + for concept_set in concept_sets or []: + if concept_set is None or concept_set.id is None: + continue + set_id = int(concept_set.id) + expression = concept_set.expression + if not expression: + continue + + items: list[NormalizedConceptSetItem] = [] + + if expression.concept is not None and expression.concept.concept_id is not None: + items.append( + _normalized_item( + concept_id=int(expression.concept.concept_id), + is_excluded=bool(expression.is_excluded), + include_descendants=bool(expression.include_descendants), + include_mapped=bool(expression.include_mapped), + ) + ) + + for item in expression.items or []: + if item is None: + continue + if item.concept is None or item.concept.concept_id is None: + continue + items.append( + _normalized_item( + concept_id=int(item.concept.concept_id), + is_excluded=bool(item.is_excluded), + include_descendants=bool(item.include_descendants), + include_mapped=bool(item.include_mapped), + ) + ) + + output[set_id] = NormalizedConceptSet( + set_id=set_id, + items=tuple(items), + ) + + return output + + +def normalize_cohort( + expression: CohortExpression, +) -> NormalizedCohort: + primary = expression.primary_criteria + if primary is None or not primary.criteria_list: + raise ExecutionNormalizationError( + "Ibis executor normalization error: CohortExpression must contain at least one primary criterion." + ) + + normalized_criteria = tuple(normalize_criterion(criteria) for criteria in primary.criteria_list) + normalized_primary = NormalizedPrimaryCriteria( + criteria=normalized_criteria, + observation_window=normalize_observation_window(primary.observation_window), + primary_limit_type=( + (primary.primary_limit.type if primary.primary_limit else "all") or "all" + ).lower(), + ) + normalized_limits = NormalizedResultLimits( + qualified_limit_type=( + (expression.qualified_limit.type if expression.qualified_limit else "all") or "all" + ).lower(), + expression_limit_type=( + (expression.expression_limit.type if expression.expression_limit else "all") or "all" + ).lower(), + ) + + normalized_end_strategy = normalize_end_strategy(expression.end_strategy) + if normalized_end_strategy is not None and normalized_end_strategy.kind == "custom_era": + raise UnsupportedFeatureError( + "Ibis executor normalization error: custom_era end strategy is not supported." + ) + + return NormalizedCohort( + title=expression.title, + concept_sets=_extract_codesets(expression.concept_sets), + primary=normalized_primary, + result_limits=normalized_limits, + additional_criteria=normalize_criteria_group(expression.additional_criteria), + inclusion_rules=tuple(normalize_inclusion_rule(rule) for rule in expression.inclusion_rules), + censoring_criteria=tuple(normalize_criterion(criteria) for criteria in expression.censoring_criteria), + censor_window=normalize_period(expression.censor_window), + collapse_settings=normalize_collapse_settings(expression.collapse_settings), + end_strategy=normalized_end_strategy, + ) diff --git a/circe/execution/normalize/collapse.py b/circe/execution/normalize/collapse.py new file mode 100644 index 00000000..1ecd18c0 --- /dev/null +++ b/circe/execution/normalize/collapse.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from ...cohortdefinition.core import CollapseSettings +from .._dataclass import frozen_slots_dataclass + + +@frozen_slots_dataclass +class NormalizedCollapseSettings: + era_pad: int + collapse_type: str + + +def normalize_collapse_settings( + value: CollapseSettings | None, +) -> NormalizedCollapseSettings | None: + if value is None: + return None + collapse_type = "era" + if value.collapse_type is not None: + collapse_type = str(value.collapse_type).lower() + return NormalizedCollapseSettings( + era_pad=int(value.era_pad), + collapse_type=collapse_type, + ) diff --git a/circe/execution/normalize/criteria.py b/circe/execution/normalize/criteria.py new file mode 100644 index 00000000..f0af5979 --- /dev/null +++ b/circe/execution/normalize/criteria.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING + +from ...cohortdefinition.criteria import ( + ConditionEra, + ConditionOccurrence, + Criteria, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, +) +from ...vocabulary.concept import Concept +from .._dataclass import frozen_slots_dataclass +from ..errors import UnsupportedCriterionError +from .windows import ( + NormalizedDateRange, + NormalizedNumericRange, + normalize_date_range, + normalize_numeric_range, +) + +if TYPE_CHECKING: + from .groups import NormalizedCriteriaGroup + + +@frozen_slots_dataclass +class NormalizedPersonFilters: + age: NormalizedNumericRange | None = None + gender_concept_ids: tuple[int, ...] = () + gender_codeset_id: int | None = None + race_concept_ids: tuple[int, ...] = () + race_codeset_id: int | None = None + ethnicity_concept_ids: tuple[int, ...] = () + ethnicity_codeset_id: int | None = None + + +@frozen_slots_dataclass +class NormalizedCriterion: + raw_criteria: Criteria + criterion_type: str + domain: str + source_table: str + event_id_column: str + start_date_column: str + end_date_column: str + concept_column: str | None + source_concept_column: str | None + visit_occurrence_column: str | None + codeset_id: int | None + first: bool + occurrence_start_date: NormalizedDateRange | None + occurrence_end_date: NormalizedDateRange | None + person_filters: NormalizedPersonFilters + correlated_criteria: NormalizedCriteriaGroup | None = None + + +def _concept_ids(values: list[Concept] | None) -> tuple[int, ...]: + if not values: + return () + output: list[int] = [] + for concept in values: + if concept is None or concept.concept_id is None: + continue + cid = int(concept.concept_id) + if cid not in output: + output.append(cid) + return tuple(output) + + +def _person_filters_from_criterion(criteria: Criteria) -> NormalizedPersonFilters: + return NormalizedPersonFilters( + age=normalize_numeric_range(getattr(criteria, "age", None)), + gender_concept_ids=_concept_ids(getattr(criteria, "gender", None)), + gender_codeset_id=( + int(criteria.gender_cs.codeset_id) + if getattr(criteria, "gender_cs", None) and criteria.gender_cs.codeset_id is not None + else None + ), + race_concept_ids=_concept_ids(getattr(criteria, "race", None)), + race_codeset_id=( + int(criteria.race_cs.codeset_id) + if getattr(criteria, "race_cs", None) and criteria.race_cs.codeset_id is not None + else None + ), + ethnicity_concept_ids=_concept_ids(getattr(criteria, "ethnicity", None)), + ethnicity_codeset_id=( + int(criteria.ethnicity_cs.codeset_id) + if getattr(criteria, "ethnicity_cs", None) and criteria.ethnicity_cs.codeset_id is not None + else None + ), + ) + + +def _build_normalized_criterion( + *, + criteria: Criteria, + criterion_type: str, + domain: str, + source_table: str, + event_id_column: str, + start_date_column: str, + end_date_column: str, + concept_column: str | None, + source_concept_column: str | None, + visit_occurrence_column: str | None, + codeset_id: int | None, + first: bool, + occurrence_start_date: NormalizedDateRange | None, + occurrence_end_date: NormalizedDateRange | None, +) -> NormalizedCriterion: + return NormalizedCriterion( + raw_criteria=criteria, + criterion_type=criterion_type, + domain=domain, + source_table=source_table, + event_id_column=event_id_column, + start_date_column=start_date_column, + end_date_column=end_date_column, + concept_column=concept_column, + source_concept_column=source_concept_column, + visit_occurrence_column=visit_occurrence_column, + codeset_id=codeset_id, + first=first, + occurrence_start_date=occurrence_start_date, + occurrence_end_date=occurrence_end_date, + person_filters=_person_filters_from_criterion(criteria), + ) + + +def _normalize_condition_occurrence(criteria: ConditionOccurrence) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="ConditionOccurrence", + domain="condition_occurrence", + source_table="condition_occurrence", + event_id_column="condition_occurrence_id", + start_date_column="condition_start_date", + end_date_column="condition_end_date", + concept_column="condition_concept_id", + source_concept_column="condition_source_concept_id", + visit_occurrence_column="visit_occurrence_id", + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.occurrence_start_date), + occurrence_end_date=normalize_date_range(criteria.occurrence_end_date), + ) + + +def _normalize_drug_exposure(criteria: DrugExposure) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="DrugExposure", + domain="drug_exposure", + source_table="drug_exposure", + event_id_column="drug_exposure_id", + start_date_column="drug_exposure_start_date", + end_date_column="drug_exposure_end_date", + concept_column="drug_concept_id", + source_concept_column="drug_source_concept_id", + visit_occurrence_column="visit_occurrence_id", + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.occurrence_start_date), + occurrence_end_date=normalize_date_range(criteria.occurrence_end_date), + ) + + +def _normalize_visit_occurrence(criteria: VisitOccurrence) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="VisitOccurrence", + domain="visit_occurrence", + source_table="visit_occurrence", + event_id_column="visit_occurrence_id", + start_date_column="visit_start_date", + end_date_column="visit_end_date", + concept_column="visit_concept_id", + source_concept_column="visit_source_concept_id", + visit_occurrence_column="visit_occurrence_id", + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.occurrence_start_date), + occurrence_end_date=normalize_date_range(criteria.occurrence_end_date), + ) + + +def _normalize_measurement(criteria: Measurement) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="Measurement", + domain="measurement", + source_table="measurement", + event_id_column="measurement_id", + start_date_column="measurement_date", + end_date_column="measurement_date", + concept_column="measurement_concept_id", + source_concept_column="measurement_source_concept_id", + visit_occurrence_column="visit_occurrence_id", + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.occurrence_start_date), + occurrence_end_date=normalize_date_range(criteria.occurrence_end_date), + ) + + +def _normalize_procedure_occurrence( + criteria: ProcedureOccurrence, +) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="ProcedureOccurrence", + domain="procedure_occurrence", + source_table="procedure_occurrence", + event_id_column="procedure_occurrence_id", + start_date_column="procedure_date", + end_date_column="procedure_date", + concept_column="procedure_concept_id", + source_concept_column="procedure_source_concept_id", + visit_occurrence_column="visit_occurrence_id", + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.occurrence_start_date), + occurrence_end_date=normalize_date_range(criteria.occurrence_end_date), + ) + + +def _normalize_observation(criteria: Observation) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="Observation", + domain="observation", + source_table="observation", + event_id_column="observation_id", + start_date_column="observation_date", + end_date_column="observation_date", + concept_column="observation_concept_id", + source_concept_column="observation_source_concept_id", + visit_occurrence_column="visit_occurrence_id", + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.occurrence_start_date), + occurrence_end_date=normalize_date_range(criteria.occurrence_end_date), + ) + + +def _normalize_visit_detail(criteria: VisitDetail) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="VisitDetail", + domain="visit_detail", + source_table="visit_detail", + event_id_column="visit_detail_id", + start_date_column="visit_detail_start_date", + end_date_column="visit_detail_end_date", + concept_column="visit_detail_concept_id", + source_concept_column="visit_detail_source_concept_id", + visit_occurrence_column="visit_occurrence_id", + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.visit_detail_start_date), + occurrence_end_date=normalize_date_range(criteria.visit_detail_end_date), + ) + + +def _normalize_device_exposure(criteria: DeviceExposure) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="DeviceExposure", + domain="device_exposure", + source_table="device_exposure", + event_id_column="device_exposure_id", + start_date_column="device_exposure_start_date", + end_date_column="device_exposure_end_date", + concept_column="device_concept_id", + source_concept_column="device_source_concept_id", + visit_occurrence_column="visit_occurrence_id", + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.occurrence_start_date), + occurrence_end_date=normalize_date_range(criteria.occurrence_end_date), + ) + + +def _normalize_specimen(criteria: Specimen) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="Specimen", + domain="specimen", + source_table="specimen", + event_id_column="specimen_id", + start_date_column="specimen_date", + end_date_column="specimen_date", + concept_column="specimen_concept_id", + source_concept_column="specimen_source_concept_id", + visit_occurrence_column="visit_occurrence_id", + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.occurrence_start_date), + occurrence_end_date=normalize_date_range(criteria.occurrence_end_date), + ) + + +def _normalize_death(criteria: Death) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="Death", + domain="death", + source_table="death", + event_id_column="person_id", + start_date_column="death_date", + end_date_column="death_date", + concept_column="cause_concept_id", + source_concept_column="cause_source_concept_id", + visit_occurrence_column=None, + codeset_id=criteria.codeset_id, + first=False, + occurrence_start_date=normalize_date_range(criteria.occurrence_start_date), + occurrence_end_date=None, + ) + + +def _normalize_observation_period(criteria: ObservationPeriod) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="ObservationPeriod", + domain="observation_period", + source_table="observation_period", + event_id_column="observation_period_id", + start_date_column="observation_period_start_date", + end_date_column="observation_period_end_date", + concept_column="period_type_concept_id", + source_concept_column=None, + visit_occurrence_column=None, + codeset_id=None, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.period_start_date), + occurrence_end_date=normalize_date_range(criteria.period_end_date), + ) + + +def _normalize_payer_plan_period(criteria: PayerPlanPeriod) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="PayerPlanPeriod", + domain="payer_plan_period", + source_table="payer_plan_period", + event_id_column="payer_plan_period_id", + start_date_column="payer_plan_period_start_date", + end_date_column="payer_plan_period_end_date", + concept_column="payer_concept_id", + source_concept_column="payer_source_concept_id", + visit_occurrence_column=None, + codeset_id=None, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.period_start_date), + occurrence_end_date=normalize_date_range(criteria.period_end_date), + ) + + +def _normalize_condition_era(criteria: ConditionEra) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="ConditionEra", + domain="condition_era", + source_table="condition_era", + event_id_column="condition_era_id", + start_date_column="condition_era_start_date", + end_date_column="condition_era_end_date", + concept_column="condition_concept_id", + source_concept_column=None, + visit_occurrence_column=None, + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.era_start_date), + occurrence_end_date=normalize_date_range(criteria.era_end_date), + ) + + +def _normalize_drug_era(criteria: DrugEra) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="DrugEra", + domain="drug_era", + source_table="drug_era", + event_id_column="drug_era_id", + start_date_column="drug_era_start_date", + end_date_column="drug_era_end_date", + concept_column="drug_concept_id", + source_concept_column=None, + visit_occurrence_column=None, + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.era_start_date), + occurrence_end_date=normalize_date_range(criteria.era_end_date), + ) + + +def _normalize_dose_era(criteria: DoseEra) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="DoseEra", + domain="dose_era", + source_table="dose_era", + event_id_column="dose_era_id", + start_date_column="dose_era_start_date", + end_date_column="dose_era_end_date", + concept_column="drug_concept_id", + source_concept_column=None, + visit_occurrence_column=None, + codeset_id=criteria.codeset_id, + first=bool(criteria.first), + occurrence_start_date=normalize_date_range(criteria.era_start_date), + occurrence_end_date=normalize_date_range(criteria.era_end_date), + ) + + +def _normalize_location_region(criteria: LocationRegion) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="LocationRegion", + domain="location_region", + source_table="location_history", + event_id_column="location_id", + start_date_column="start_date", + end_date_column="end_date", + concept_column="region_concept_id", + source_concept_column=None, + visit_occurrence_column=None, + codeset_id=criteria.codeset_id, + first=False, + occurrence_start_date=None, + occurrence_end_date=None, + ) + + +def normalize_criterion(criteria: Criteria) -> NormalizedCriterion: + if isinstance(criteria, ConditionOccurrence): + normalized = _normalize_condition_occurrence(criteria) + elif isinstance(criteria, DrugExposure): + normalized = _normalize_drug_exposure(criteria) + elif isinstance(criteria, VisitOccurrence): + normalized = _normalize_visit_occurrence(criteria) + elif isinstance(criteria, Measurement): + normalized = _normalize_measurement(criteria) + elif isinstance(criteria, ProcedureOccurrence): + normalized = _normalize_procedure_occurrence(criteria) + elif isinstance(criteria, Observation): + normalized = _normalize_observation(criteria) + elif isinstance(criteria, VisitDetail): + normalized = _normalize_visit_detail(criteria) + elif isinstance(criteria, DeviceExposure): + normalized = _normalize_device_exposure(criteria) + elif isinstance(criteria, Specimen): + normalized = _normalize_specimen(criteria) + elif isinstance(criteria, Death): + normalized = _normalize_death(criteria) + elif isinstance(criteria, ObservationPeriod): + normalized = _normalize_observation_period(criteria) + elif isinstance(criteria, PayerPlanPeriod): + normalized = _normalize_payer_plan_period(criteria) + elif isinstance(criteria, ConditionEra): + normalized = _normalize_condition_era(criteria) + elif isinstance(criteria, DrugEra): + normalized = _normalize_drug_era(criteria) + elif isinstance(criteria, DoseEra): + normalized = _normalize_dose_era(criteria) + elif isinstance(criteria, LocationRegion): + normalized = _normalize_location_region(criteria) + else: + raise UnsupportedCriterionError( + f"Ibis executor normalization error: unsupported criterion type {criteria.__class__.__name__}." + ) + + if criteria.correlated_criteria is not None and not criteria.correlated_criteria.is_empty(): + from .groups import normalize_criteria_group + + normalized_group = normalize_criteria_group(criteria.correlated_criteria) + normalized = replace(normalized, correlated_criteria=normalized_group) + + return normalized diff --git a/circe/execution/normalize/end_strategy.py b/circe/execution/normalize/end_strategy.py new file mode 100644 index 00000000..62ff666b --- /dev/null +++ b/circe/execution/normalize/end_strategy.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any + +from ...cohortdefinition.core import CustomEraStrategy, DateOffsetStrategy, EndStrategy +from .._dataclass import frozen_slots_dataclass + + +@frozen_slots_dataclass +class NormalizedEndStrategy: + kind: str + payload: dict[str, Any] + + +def normalize_end_strategy( + value: EndStrategy | DateOffsetStrategy | CustomEraStrategy | None, +) -> NormalizedEndStrategy | None: + if value is None: + return None + if isinstance(value, DateOffsetStrategy): + return NormalizedEndStrategy( + kind="date_offset", + payload={ + "offset": int(value.offset), + "date_field": str(value.date_field), + }, + ) + if isinstance(value, CustomEraStrategy): + return NormalizedEndStrategy( + kind="custom_era", + payload={ + "drug_codeset_id": value.drug_codeset_id, + "offset": int(value.offset), + "gap_days": int(value.gap_days), + }, + ) + return NormalizedEndStrategy(kind="end_strategy", payload={}) diff --git a/circe/execution/normalize/groups.py b/circe/execution/normalize/groups.py new file mode 100644 index 00000000..23c8d55d --- /dev/null +++ b/circe/execution/normalize/groups.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from ...cohortdefinition.criteria import ( + CorelatedCriteria, + CriteriaGroup, + DemographicCriteria, + InclusionRule, + Occurrence, +) +from ...vocabulary.concept import Concept +from .._dataclass import frozen_slots_dataclass +from .criteria import NormalizedCriterion, normalize_criterion +from .windows import ( + NormalizedDateRange, + NormalizedNumericRange, + NormalizedWindow, + normalize_date_range, + normalize_numeric_range, + normalize_window, +) + + +@frozen_slots_dataclass +class NormalizedDemographicCriteria: + age: NormalizedNumericRange | None = None + gender_codeset_id: int | None = None + gender_concept_ids: tuple[int, ...] = () + race_codeset_id: int | None = None + race_concept_ids: tuple[int, ...] = () + ethnicity_codeset_id: int | None = None + ethnicity_concept_ids: tuple[int, ...] = () + occurrence_start_date: NormalizedDateRange | None = None + occurrence_end_date: NormalizedDateRange | None = None + + +@frozen_slots_dataclass +class NormalizedCorrelatedCriteria: + criterion: NormalizedCriterion + occurrence_type: int + occurrence_count: int + occurrence_is_distinct: bool + occurrence_count_column: str | None + start_window: NormalizedWindow | None + end_window: NormalizedWindow | None + restrict_visit: bool + ignore_observation_period: bool + + +@frozen_slots_dataclass +class NormalizedCriteriaGroup: + mode: str + count: int | None = None + criteria: tuple[NormalizedCorrelatedCriteria, ...] = () + groups: tuple[NormalizedCriteriaGroup, ...] = () + demographics: tuple[NormalizedDemographicCriteria, ...] = () + + def is_empty(self) -> bool: + return not self.criteria and not self.groups and not self.demographics + + +@frozen_slots_dataclass +class NormalizedInclusionRule: + name: str | None + description: str | None + expression: NormalizedCriteriaGroup | None + + +def _concept_ids(values: list[Concept] | None) -> tuple[int, ...]: + if not values: + return () + output: list[int] = [] + for concept in values: + if concept is None or concept.concept_id is None: + continue + cid = int(concept.concept_id) + if cid not in output: + output.append(cid) + return tuple(output) + + +def _normalize_demographic( + demographic: DemographicCriteria, +) -> NormalizedDemographicCriteria: + return NormalizedDemographicCriteria( + age=normalize_numeric_range(demographic.age), + gender_codeset_id=( + int(demographic.gender_cs.codeset_id) + if demographic.gender_cs and demographic.gender_cs.codeset_id is not None + else None + ), + gender_concept_ids=_concept_ids(demographic.gender), + race_codeset_id=( + int(demographic.race_cs.codeset_id) + if demographic.race_cs and demographic.race_cs.codeset_id is not None + else None + ), + race_concept_ids=_concept_ids(demographic.race), + ethnicity_codeset_id=( + int(demographic.ethnicity_cs.codeset_id) + if demographic.ethnicity_cs and demographic.ethnicity_cs.codeset_id is not None + else None + ), + ethnicity_concept_ids=_concept_ids(demographic.ethnicity), + occurrence_start_date=normalize_date_range(demographic.occurrence_start_date), + occurrence_end_date=normalize_date_range(demographic.occurrence_end_date), + ) + + +def _normalize_correlated_criteria( + correlated: CorelatedCriteria, +) -> NormalizedCorrelatedCriteria: + occurrence = correlated.occurrence or Occurrence( + type=Occurrence._AT_LEAST, + count=1, + is_distinct=False, + ) + + count_column = None + if occurrence.count_column is not None: + count_column = occurrence.count_column.value + + return NormalizedCorrelatedCriteria( + criterion=normalize_criterion(correlated.criteria), + occurrence_type=int(occurrence.type), + occurrence_count=int(occurrence.count), + occurrence_is_distinct=bool(occurrence.is_distinct), + occurrence_count_column=count_column, + start_window=normalize_window(correlated.start_window), + end_window=normalize_window(correlated.end_window), + restrict_visit=bool(correlated.restrict_visit), + ignore_observation_period=bool(correlated.ignore_observation_period), + ) + + +def normalize_criteria_group( + group: CriteriaGroup | None, +) -> NormalizedCriteriaGroup | None: + if group is None: + return None + + normalized_children: list[NormalizedCriteriaGroup] = [] + for child in group.groups or []: + normalized_child = normalize_criteria_group(child) + if normalized_child is not None: + normalized_children.append(normalized_child) + + return NormalizedCriteriaGroup( + mode=((group.type or "ALL").upper()), + count=(int(group.count) if group.count is not None else None), + criteria=tuple( + _normalize_correlated_criteria(correlated) for correlated in (group.criteria_list or []) + ), + groups=tuple(normalized_children), + demographics=tuple( + _normalize_demographic(demographic) for demographic in (group.demographic_criteria_list or []) + ), + ) + + +def normalize_inclusion_rule(rule: InclusionRule) -> NormalizedInclusionRule: + return NormalizedInclusionRule( + name=rule.name, + description=rule.description, + expression=normalize_criteria_group(rule.expression), + ) diff --git a/circe/execution/normalize/windows.py b/circe/execution/normalize/windows.py new file mode 100644 index 00000000..ab87aa8e --- /dev/null +++ b/circe/execution/normalize/windows.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Any + +from ...cohortdefinition.core import ( + DateRange, + NumericRange, + ObservationFilter, + Period, + Window, + WindowBound, +) +from .._dataclass import frozen_slots_dataclass + + +@frozen_slots_dataclass +class NormalizedDateRange: + op: str | None + value: Any + extent: Any + + +@frozen_slots_dataclass +class NormalizedNumericRange: + op: str | None + value: float | int | None + extent: float | int | None + + +@frozen_slots_dataclass +class NormalizedObservationWindow: + prior_days: int + post_days: int + + +@frozen_slots_dataclass +class NormalizedPeriod: + start_date: str | None + end_date: str | None + + +@frozen_slots_dataclass +class NormalizedWindowBound: + coeff: int + days: int | None + + +@frozen_slots_dataclass +class NormalizedWindow: + start: NormalizedWindowBound | None + end: NormalizedWindowBound | None + use_event_end: bool | None + use_index_end: bool | None + + +def normalize_date_range(value: DateRange | None) -> NormalizedDateRange | None: + if value is None: + return None + return NormalizedDateRange(op=value.op, value=value.value, extent=value.extent) + + +def normalize_numeric_range( + value: NumericRange | None, +) -> NormalizedNumericRange | None: + if value is None: + return None + return NormalizedNumericRange(op=value.op, value=value.value, extent=value.extent) + + +def normalize_observation_window( + value: ObservationFilter | None, +) -> NormalizedObservationWindow | None: + if value is None: + return None + return NormalizedObservationWindow( + prior_days=int(value.prior_days), + post_days=int(value.post_days), + ) + + +def normalize_period(value: Period | None) -> NormalizedPeriod | None: + if value is None: + return None + return NormalizedPeriod(start_date=value.start_date, end_date=value.end_date) + + +def normalize_window_bound( + value: WindowBound | None, +) -> NormalizedWindowBound | None: + if value is None: + return None + return NormalizedWindowBound(coeff=int(value.coeff), days=value.days) + + +def normalize_window(value: Window | None) -> NormalizedWindow | None: + if value is None: + return None + return NormalizedWindow( + start=normalize_window_bound(value.start), + end=normalize_window_bound(value.end), + use_event_end=value.use_event_end, + use_index_end=value.use_index_end, + ) diff --git a/circe/execution/options.py b/circe/execution/options.py index b88f1a6f..9b479827 100644 --- a/circe/execution/options.py +++ b/circe/execution/options.py @@ -1,38 +1,3 @@ -"""Execution options for backend-native cohort execution.""" +from .compat import ExecutionOptions, SchemaName, schema_to_str -from __future__ import annotations - -from dataclasses import dataclass -from typing import Union - -SchemaName = Union[str, tuple[str, str]] - - -@dataclass(frozen=True) -class ExecutionOptions: - """Runtime options for backend execution via ibis. - - This API is experimental and may evolve while execution parity is built out. - """ - - cdm_schema: SchemaName | None = None - vocabulary_schema: SchemaName | None = None - result_schema: SchemaName | None = None - - cohort_id: int | None = None - - materialize_stages: bool = False - materialize_codesets: bool = True - temp_emulation_schema: SchemaName | None = None - - capture_sql: bool = False - profile_dir: str | None = None - - -def schema_to_str(schema: SchemaName | None) -> str | None: - """Normalize schema names to a string representation.""" - if schema is None: - return None - if isinstance(schema, tuple): - return ".".join(schema) - return schema +__all__ = ["ExecutionOptions", "SchemaName", "schema_to_str"] diff --git a/circe/execution/plan/__init__.py b/circe/execution/plan/__init__.py new file mode 100644 index 00000000..72d5ac2b --- /dev/null +++ b/circe/execution/plan/__init__.py @@ -0,0 +1,103 @@ +from .cohort import CohortPlan, PrimaryEventInput +from .events import ( + ApplyDateAdjustment, + EventPlan, + EventSource, + FilterByCareSite, + FilterByCareSiteLocationRegion, + FilterByCodeset, + FilterByConceptSet, + FilterByDateRange, + FilterByNumericRange, + FilterByPersonAge, + FilterByPersonEthnicity, + FilterByPersonGender, + FilterByPersonRace, + FilterByProviderSpecialty, + FilterByText, + FilterByVisit, + FilterByVisitDetail, + JoinLocationRegion, + KeepFirstPerPerson, + RestrictToCorrelatedWindow, + StandardizeEventShape, +) +from .groups import GroupPredicate +from .predicates import DateRangePredicate, NumericRangePredicate +from .schema import ( + CONCEPT_ID, + CRITERION_INDEX, + CRITERION_TYPE, + DAYS_SUPPLY, + DOMAIN, + DURATION, + END_DATE, + EVENT_ID, + GAP_DAYS, + OCCURRENCE_COUNT, + PERSON_ID, + QUANTITY, + RANGE_HIGH, + RANGE_LOW, + REFILLS, + SOURCE_CONCEPT_ID, + SOURCE_TABLE, + STANDARD_EVENT_COLUMNS, + START_DATE, + UNIT_CONCEPT_ID, + VALUE_AS_NUMBER, + VISIT_DETAIL_ID, + VISIT_OCCURRENCE_ID, +) + +__all__ = [ + "CohortPlan", + "PrimaryEventInput", + "EventPlan", + "EventSource", + "GroupPredicate", + "DateRangePredicate", + "NumericRangePredicate", + "PERSON_ID", + "EVENT_ID", + "START_DATE", + "END_DATE", + "VISIT_OCCURRENCE_ID", + "DOMAIN", + "CONCEPT_ID", + "SOURCE_CONCEPT_ID", + "CRITERION_INDEX", + "CRITERION_TYPE", + "QUANTITY", + "DAYS_SUPPLY", + "REFILLS", + "RANGE_LOW", + "RANGE_HIGH", + "VALUE_AS_NUMBER", + "UNIT_CONCEPT_ID", + "VISIT_DETAIL_ID", + "OCCURRENCE_COUNT", + "GAP_DAYS", + "DURATION", + "SOURCE_TABLE", + "STANDARD_EVENT_COLUMNS", + "FilterByCareSite", + "FilterByCareSiteLocationRegion", + "FilterByCodeset", + "FilterByConceptSet", + "FilterByDateRange", + "FilterByNumericRange", + "FilterByText", + "FilterByVisit", + "FilterByVisitDetail", + "JoinLocationRegion", + "FilterByProviderSpecialty", + "FilterByPersonAge", + "FilterByPersonGender", + "FilterByPersonRace", + "FilterByPersonEthnicity", + "KeepFirstPerPerson", + "ApplyDateAdjustment", + "RestrictToCorrelatedWindow", + "StandardizeEventShape", +] diff --git a/circe/execution/plan/cohort.py b/circe/execution/plan/cohort.py new file mode 100644 index 00000000..0e3a922a --- /dev/null +++ b/circe/execution/plan/cohort.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from .._dataclass import frozen_slots_dataclass +from ..normalize.groups import NormalizedCriteriaGroup +from ..normalize.windows import NormalizedObservationWindow +from .events import EventPlan + + +@frozen_slots_dataclass +class PrimaryEventInput: + event_plan: EventPlan + correlated_criteria: NormalizedCriteriaGroup | None = None + + +@frozen_slots_dataclass +class CohortPlan: + primary_event_plans: tuple[PrimaryEventInput, ...] + observation_window: NormalizedObservationWindow | None + primary_limit_type: str + qualified_limit_type: str + expression_limit_type: str diff --git a/circe/execution/plan/events.py b/circe/execution/plan/events.py new file mode 100644 index 00000000..99652fd7 --- /dev/null +++ b/circe/execution/plan/events.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from typing import Any, Union + +from .._dataclass import frozen_slots_dataclass +from .predicates import DateRangePredicate, NumericRangePredicate +from .schema import PERSON_ID + + +@frozen_slots_dataclass +class EventSource: + table_name: str + domain: str + event_id_column: str + start_date_column: str + end_date_column: str + person_id_column: str = PERSON_ID + concept_column: str | None = None + source_concept_column: str | None = None + visit_occurrence_column: str | None = None + + +@frozen_slots_dataclass +class FilterByCodeset: + column: str + codeset_id: int + exclude: bool = False + + +@frozen_slots_dataclass +class FilterByConceptSet: + column: str + concept_ids: tuple[int, ...] + exclude: bool = False + + +@frozen_slots_dataclass +class FilterByDateRange: + column: str + predicate: DateRangePredicate + + +@frozen_slots_dataclass +class FilterByNumericRange: + column: str + predicate: NumericRangePredicate + + +@frozen_slots_dataclass +class FilterByText: + column: str + op: str | None + text: str | None + + +@frozen_slots_dataclass +class JoinLocationRegion: + location_id_column: str = "location_id" + region_column: str = "region_concept_id" + + +@frozen_slots_dataclass +class FilterByVisit: + visit_occurrence_column: str = "visit_occurrence_id" + concept_ids: tuple[int, ...] = () + codeset_id: int | None = None + exclude: bool = False + + +@frozen_slots_dataclass +class FilterByVisitDetail: + visit_detail_codeset_id: int | None = None + + +@frozen_slots_dataclass +class FilterByProviderSpecialty: + provider_id_column: str = "provider_id" + concept_ids: tuple[int, ...] = () + codeset_id: int | None = None + exclude: bool = False + + +@frozen_slots_dataclass +class FilterByCareSite: + care_site_id_column: str = "care_site_id" + concept_ids: tuple[int, ...] = () + codeset_id: int | None = None + exclude: bool = False + + +@frozen_slots_dataclass +class FilterByCareSiteLocationRegion: + care_site_id_column: str = "care_site_id" + start_date_column: str = "start_date" + end_date_column: str = "end_date" + codeset_id: int = 0 + + +@frozen_slots_dataclass +class FilterByPersonAge: + date_column: str + predicate: NumericRangePredicate + + +@frozen_slots_dataclass +class FilterByPersonGender: + concept_ids: tuple[int, ...] = () + codeset_id: int | None = None + + +@frozen_slots_dataclass +class FilterByPersonRace: + concept_ids: tuple[int, ...] = () + codeset_id: int | None = None + + +@frozen_slots_dataclass +class FilterByPersonEthnicity: + concept_ids: tuple[int, ...] = () + codeset_id: int | None = None + + +@frozen_slots_dataclass +class KeepFirstPerPerson: + order_by: tuple[str, ...] + + +@frozen_slots_dataclass +class ApplyDateAdjustment: + start_offset_days: int + end_offset_days: int + start_with: str = "start_date" + end_with: str = "end_date" + + +@frozen_slots_dataclass +class RestrictToCorrelatedWindow: + payload: dict[str, Any] + + +@frozen_slots_dataclass +class StandardizeEventShape: + criterion_type: str + criterion_index: int + start_offset_days: int = 0 + end_offset_days: int = 0 + start_with: str = "start_date" + end_with: str = "end_date" + + +PlanStep = Union[ + FilterByCodeset, + FilterByConceptSet, + FilterByDateRange, + FilterByNumericRange, + FilterByText, + JoinLocationRegion, + FilterByVisit, + FilterByVisitDetail, + FilterByProviderSpecialty, + FilterByCareSite, + FilterByCareSiteLocationRegion, + FilterByPersonAge, + FilterByPersonGender, + FilterByPersonRace, + FilterByPersonEthnicity, + KeepFirstPerPerson, + ApplyDateAdjustment, + RestrictToCorrelatedWindow, + StandardizeEventShape, +] + + +@frozen_slots_dataclass +class EventPlan: + source: EventSource + criterion_type: str + criterion_index: int + steps: tuple[PlanStep, ...] diff --git a/circe/execution/plan/groups.py b/circe/execution/plan/groups.py new file mode 100644 index 00000000..c6ba3958 --- /dev/null +++ b/circe/execution/plan/groups.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from .._dataclass import frozen_slots_dataclass + + +@frozen_slots_dataclass +class GroupPredicate: + mode: str + count: int | None = None + children: tuple[GroupPredicate, ...] = () diff --git a/circe/execution/plan/predicates.py b/circe/execution/plan/predicates.py new file mode 100644 index 00000000..cbfca913 --- /dev/null +++ b/circe/execution/plan/predicates.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Any + +from .._dataclass import frozen_slots_dataclass + + +@frozen_slots_dataclass +class DateRangePredicate: + op: str | None + value: Any + extent: Any + + +@frozen_slots_dataclass +class NumericRangePredicate: + op: str | None + value: float | int | None + extent: float | int | None diff --git a/circe/execution/plan/schema.py b/circe/execution/plan/schema.py new file mode 100644 index 00000000..061815f0 --- /dev/null +++ b/circe/execution/plan/schema.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +PERSON_ID = "person_id" +EVENT_ID = "event_id" +START_DATE = "start_date" +END_DATE = "end_date" +VISIT_OCCURRENCE_ID = "visit_occurrence_id" +VISIT_DETAIL_ID = "visit_detail_id" +DOMAIN = "domain" +CONCEPT_ID = "concept_id" +SOURCE_CONCEPT_ID = "source_concept_id" +QUANTITY = "quantity" +DAYS_SUPPLY = "days_supply" +REFILLS = "refills" +RANGE_LOW = "range_low" +RANGE_HIGH = "range_high" +VALUE_AS_NUMBER = "value_as_number" +UNIT_CONCEPT_ID = "unit_concept_id" +OCCURRENCE_COUNT = "occurrence_count" +GAP_DAYS = "gap_days" +DURATION = "duration" +CRITERION_INDEX = "criterion_index" +CRITERION_TYPE = "criterion_type" +SOURCE_TABLE = "source_table" + +STANDARD_EVENT_COLUMNS = ( + PERSON_ID, + EVENT_ID, + START_DATE, + END_DATE, + DOMAIN, + CONCEPT_ID, + SOURCE_CONCEPT_ID, + VISIT_OCCURRENCE_ID, + VISIT_DETAIL_ID, + QUANTITY, + DAYS_SUPPLY, + REFILLS, + RANGE_LOW, + RANGE_HIGH, + VALUE_AS_NUMBER, + UNIT_CONCEPT_ID, + OCCURRENCE_COUNT, + GAP_DAYS, + DURATION, + CRITERION_INDEX, + CRITERION_TYPE, + SOURCE_TABLE, +) diff --git a/circe/execution/typing.py b/circe/execution/typing.py new file mode 100644 index 00000000..4d9ead7a --- /dev/null +++ b/circe/execution/typing.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, TypeAlias + +if TYPE_CHECKING: + from ibis.expr.types import Table as IbisTable + + Table: TypeAlias = IbisTable +else: # pragma: no cover - typing-only fallback when ibis is not installed + Table: TypeAlias = Any + + +class IbisBackendLike(Protocol): + """Minimal backend surface required by the Ibis executor.""" + + def table(self, name: str, database: str | None = None) -> Table: ... + + def create_table( + self, + name: str, + /, + obj: Any = None, + *, + schema: Any | None = None, + database: str | None = None, + temp: bool = False, + overwrite: bool = False, + ) -> Any: ... diff --git a/tests/execution/_assertions.py b/tests/execution/_assertions.py new file mode 100644 index 00000000..cd16efda --- /dev/null +++ b/tests/execution/_assertions.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from circe.execution.plan.schema import STANDARD_EVENT_COLUMNS + + +def assert_standard_event_columns(columns) -> None: + """Assert a table-like object exposes the canonical standard event schema.""" + normalized = tuple(columns) + assert normalized == STANDARD_EVENT_COLUMNS diff --git a/tests/execution/_domain_cases.py b/tests/execution/_domain_cases.py new file mode 100644 index 00000000..c508140d --- /dev/null +++ b/tests/execution/_domain_cases.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import Callable + +from circe.cohortdefinition import ( + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, +) + +CriteriaFactory = Callable[[], object] + + +def domain_criteria_cases() -> list[tuple[str, CriteriaFactory, int | None]]: + """Domain criteria factories + default concept id used for codeset filters.""" + return [ + ("condition_occurrence", lambda: ConditionOccurrence(codeset_id=1), 111), + ("drug_exposure", lambda: DrugExposure(codeset_id=1), 222), + ("visit_occurrence", lambda: VisitOccurrence(codeset_id=1), 333), + ("measurement", lambda: Measurement(codeset_id=1), 444), + ("procedure_occurrence", lambda: ProcedureOccurrence(codeset_id=1), 555), + ("observation", lambda: Observation(codeset_id=1), 666), + ("visit_detail", lambda: VisitDetail(codeset_id=1), 777), + ("device_exposure", lambda: DeviceExposure(codeset_id=1), 888), + ("specimen", lambda: Specimen(codeset_id=1), 999), + ("death", lambda: Death(codeset_id=1), 1001), + ("observation_period", lambda: ObservationPeriod(), None), + ("payer_plan_period", lambda: PayerPlanPeriod(), None), + ("condition_era", lambda: ConditionEra(codeset_id=1), 1201), + ("drug_era", lambda: DrugEra(codeset_id=1), 1301), + ("dose_era", lambda: DoseEra(codeset_id=1), 1401), + ("location_history", lambda: LocationRegion(codeset_id=1), 15151), + ] diff --git a/tests/execution/test_api_ibis.py b/tests/execution/test_api_ibis.py new file mode 100644 index 00000000..0a4e77ef --- /dev/null +++ b/tests/execution/test_api_ibis.py @@ -0,0 +1,1077 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort_ibis +from circe.cohortdefinition import ( + CohortExpression, + ConditionEra, + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + Occurrence, + PayerPlanPeriod, + PrimaryCriteria, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, +) +from circe.cohortdefinition.core import CustomEraStrategy, NumericRange +from circe.execution.errors import UnsupportedFeatureError +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _make_concept_set(set_id: int, concept_id: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), + ) + + +def _seed_common_tables(conn, ibis): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2], + "year_of_birth": [1980, 2015], + "gender_concept_id": [8507, 8507], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1, 2], + "observation_period_id": [10, 11], + "observation_period_start_date": ["2019-01-01", "2019-01-01"], + "observation_period_end_date": ["2021-12-31", "2021-12-31"], + } + ), + overwrite=True, + ) + + +def _seed_vocabulary_tables(conn, ibis): + conn.create_table( + "concept", + obj=ibis.memtable( + { + "concept_id": [100, 101, 102, 200, 201], + "invalid_reason": [None, None, "D", None, None], + } + ), + overwrite=True, + ) + conn.create_table( + "concept_ancestor", + obj=ibis.memtable( + { + "ancestor_concept_id": [100, 100], + "descendant_concept_id": [101, 102], + } + ), + overwrite=True, + ) + conn.create_table( + "concept_relationship", + obj=ibis.memtable( + { + "concept_id_1": [200, 201], + "concept_id_2": [100, 101], + "relationship_id": ["Maps to", "Maps to"], + "invalid_reason": [None, "D"], + } + ), + overwrite=True, + ) + + +def test_build_cohort_ibis_condition_occurrence(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 2], + "condition_occurrence_id": [100, 101, 102], + "condition_concept_id": [111, 111, 999], + "condition_start_date": ["2020-01-01", "2020-02-01", "2020-01-05"], + "condition_end_date": ["2020-01-02", "2020-02-02", "2020-01-06"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + ConditionOccurrence( + codeset_id=1, + first=True, + age=NumericRange(op="gte", value=18), + ) + ] + ), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.columns) >= { + "person_id", + "event_id", + "start_date", + "end_date", + "domain", + "criterion_type", + } + assert set(result.person_id) == {1} + assert len(result) == 1 + + +def test_build_cohort_ibis_condition_occurrence_with_race_and_ethnicity_filters(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2], + "year_of_birth": [1980, 1980], + "gender_concept_id": [8507, 8507], + "race_concept_id": [8527, 8516], + "ethnicity_concept_id": [38003564, 38003563], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "condition_occurrence_id": [150, 151], + "condition_concept_id": [111, 111], + "condition_start_date": ["2020-01-01", "2020-01-01"], + "condition_end_date": ["2020-01-01", "2020-01-01"], + } + ), + overwrite=True, + ) + + criteria = ConditionOccurrence(codeset_id=1) + criteria.__dict__["race"] = [Concept(conceptId=8527)] + criteria.__dict__["ethnicity"] = [Concept(conceptId=38003564)] + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[criteria]), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + + +def test_build_cohort_ibis_applies_criterion_local_correlated_criteria(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 2], + "condition_occurrence_id": [160, 161, 260], + "condition_concept_id": [111, 222, 111], + "condition_start_date": ["2020-01-01", "2020-01-03", "2020-01-01"], + "condition_end_date": ["2020-01-01", "2020-01-03", "2020-01-01"], + "visit_occurrence_id": [10, 10, 20], + } + ), + overwrite=True, + ) + + criteria = ConditionOccurrence( + codeset_id=1, + correlated_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=2), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ) + ], + ), + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111), _make_concept_set(2, 222)], + primary_criteria=PrimaryCriteria(criteria_list=[criteria]), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + + +def test_build_cohort_ibis_concept_set_resolves_descendants_and_mapped(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + _seed_vocabulary_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1, 1, 1, 2], + "condition_occurrence_id": [1000, 1001, 1002, 1003, 1004, 1005], + "condition_concept_id": [100, 101, 102, 200, 201, 999], + "condition_start_date": [ + "2020-01-01", + "2020-01-02", + "2020-01-03", + "2020-01-04", + "2020-01-05", + "2020-01-01", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-02", + "2020-01-03", + "2020-01-04", + "2020-01-05", + "2020-01-01", + ], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, + expression=ConceptSetExpression( + items=[ + ConceptSetItem( + concept=Concept(conceptId=100), + includeDescendants=True, + includeMapped=True, + ), + ConceptSetItem( + concept=Concept(conceptId=101), + isExcluded=True, + includeMapped=True, + ), + ] + ), + ) + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + assert set(result.concept_id) == {100, 200} + + +def test_build_cohort_ibis_uses_vocabulary_schema_option_for_expansion(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + conn.raw_sql("CREATE SCHEMA vocab") + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1], + "condition_occurrence_id": [2000, 2001], + "condition_concept_id": [100, 101], + "condition_start_date": ["2020-01-01", "2020-01-02"], + "condition_end_date": ["2020-01-01", "2020-01-02"], + } + ), + overwrite=True, + ) + conn.create_table( + "concept", + obj=ibis.memtable({"concept_id": [100, 101, 102], "invalid_reason": [None, None, "D"]}), + database="vocab", + overwrite=True, + ) + conn.create_table( + "concept_ancestor", + obj=ibis.memtable({"ancestor_concept_id": [100], "descendant_concept_id": [101]}), + database="vocab", + overwrite=True, + ) + conn.create_table( + "concept_relationship", + obj=ibis.memtable( + { + "concept_id_1": [9999, 9998], + "concept_id_2": [100, 101], + "relationship_id": ["Maps to", "Maps to"], + "invalid_reason": [None, "D"], + } + ), + database="vocab", + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, + expression=ConceptSetExpression( + items=[ + ConceptSetItem( + concept=Concept(conceptId=100), + includeDescendants=True, + ) + ] + ), + ) + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + ) + + result = build_cohort_ibis( + expression, + backend=conn, + cdm_schema="main", + vocabulary_schema="vocab", + ).execute() + assert set(result.concept_id) == {100, 101} + + +def test_build_cohort_ibis_drug_exposure(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [1, 2], + "drug_exposure_id": [200, 201], + "drug_concept_id": [222, 999], + "drug_exposure_start_date": ["2020-03-01", "2020-03-01"], + "drug_exposure_end_date": ["2020-03-02", "2020-03-02"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(2, 222)], + primary_criteria=PrimaryCriteria(criteria_list=[DrugExposure(codeset_id=2)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "drug_exposure") + + +def test_build_cohort_ibis_visit_occurrence(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "visit_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "visit_occurrence_id": [300, 301], + "visit_concept_id": [333, 999], + "visit_start_date": ["2020-05-01", "2020-05-01"], + "visit_end_date": ["2020-05-02", "2020-05-02"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(3, 333)], + primary_criteria=PrimaryCriteria(criteria_list=[VisitOccurrence(codeset_id=3)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "visit_occurrence") + + +def test_build_cohort_ibis_measurement(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "measurement", + obj=ibis.memtable( + { + "person_id": [1, 2], + "measurement_id": [400, 401], + "measurement_concept_id": [444, 999], + "measurement_date": ["2020-06-01", "2020-06-01"], + "visit_occurrence_id": [10, 11], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(4, 444)], + primary_criteria=PrimaryCriteria(criteria_list=[Measurement(codeset_id=4)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "measurement") + + +def test_build_cohort_ibis_measurement_with_value_and_unit_filters(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "measurement", + obj=ibis.memtable( + { + "person_id": [1, 2], + "measurement_id": [410, 411], + "measurement_concept_id": [444, 444], + "measurement_date": ["2020-06-01", "2020-06-01"], + "visit_occurrence_id": [10, 11], + "value_as_number": [5.0, 15.0], + "unit_concept_id": [9001, 9002], + "value_as_concept_id": [7001, 7002], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(4, 444)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + Measurement( + codeset_id=4, + value_as_number=NumericRange(op="gte", value=10), + unit=[Concept(conceptId=9002)], + value_as_concept=[Concept(conceptId=7002)], + ) + ] + ), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {2} + assert all(result.domain == "measurement") + + +def test_build_cohort_ibis_procedure_occurrence(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "procedure_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "procedure_occurrence_id": [500, 501], + "procedure_concept_id": [555, 999], + "procedure_date": ["2020-07-01", "2020-07-01"], + "visit_occurrence_id": [10, 11], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(5, 555)], + primary_criteria=PrimaryCriteria(criteria_list=[ProcedureOccurrence(codeset_id=5)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "procedure_occurrence") + + +def test_build_cohort_ibis_procedure_occurrence_with_domain_filters(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "procedure_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "procedure_occurrence_id": [510, 511], + "procedure_concept_id": [555, 555], + "procedure_date": ["2020-07-01", "2020-07-01"], + "visit_occurrence_id": [10, 11], + "procedure_type_concept_id": [901, 902], + "modifier_concept_id": [1001, 1002], + "quantity": [1, 5], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(5, 555)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + ProcedureOccurrence( + codeset_id=5, + procedure_type=[Concept(conceptId=902)], + quantity=NumericRange(op="gte", value=5), + ) + ] + ), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {2} + assert all(result.domain == "procedure_occurrence") + + +def test_build_cohort_ibis_observation(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "observation", + obj=ibis.memtable( + { + "person_id": [1, 2], + "observation_id": [600, 601], + "observation_concept_id": [666, 999], + "observation_date": ["2020-08-01", "2020-08-01"], + "visit_occurrence_id": [10, 11], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(6, 666)], + primary_criteria=PrimaryCriteria(criteria_list=[Observation(codeset_id=6)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "observation") + + +def test_build_cohort_ibis_observation_with_domain_filters(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "observation", + obj=ibis.memtable( + { + "person_id": [1, 2], + "observation_id": [610, 611], + "observation_concept_id": [666, 666], + "observation_date": ["2020-08-01", "2020-08-01"], + "visit_occurrence_id": [10, 11], + "observation_type_concept_id": [2001, 2002], + "value_as_number": [1.0, 20.0], + "value_as_string": ["low", "high"], + "value_as_concept_id": [3001, 3002], + "unit_concept_id": [4001, 4002], + "qualifier_concept_id": [5001, 5002], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(6, 666)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + Observation( + codeset_id=6, + observation_type=[Concept(conceptId=2002)], + value_as_number=NumericRange(op="gte", value=10), + value_as_concept=[Concept(conceptId=3002)], + unit=[Concept(conceptId=4002)], + ) + ] + ), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {2} + assert all(result.domain == "observation") + + +def test_build_cohort_ibis_visit_detail(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "visit_detail", + obj=ibis.memtable( + { + "person_id": [1, 2], + "visit_detail_id": [700, 701], + "visit_detail_concept_id": [777, 999], + "visit_detail_start_date": ["2020-09-01", "2020-09-01"], + "visit_detail_end_date": ["2020-09-02", "2020-09-02"], + "visit_occurrence_id": [10, 11], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(7, 777)], + primary_criteria=PrimaryCriteria(criteria_list=[VisitDetail(codeset_id=7)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "visit_detail") + + +def test_build_cohort_ibis_visit_detail_with_domain_filters(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "visit_detail", + obj=ibis.memtable( + { + "person_id": [1, 2], + "visit_detail_id": [710, 711], + "visit_detail_concept_id": [777, 777], + "visit_detail_start_date": ["2020-09-01", "2020-09-01"], + "visit_detail_end_date": ["2020-09-02", "2020-09-02"], + "visit_occurrence_id": [10, 11], + "visit_detail_type_concept_id": [6001, 6002], + "discharge_to_concept_id": [7001, 7002], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(7, 777)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + VisitDetail( + codeset_id=7, + visit_detail_type=[Concept(conceptId=6002)], + discharge_to=[Concept(conceptId=7002)], + ) + ] + ), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {2} + assert all(result.domain == "visit_detail") + + +def test_build_cohort_ibis_device_exposure(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "device_exposure", + obj=ibis.memtable( + { + "person_id": [1, 2], + "device_exposure_id": [800, 801], + "device_concept_id": [888, 999], + "device_exposure_start_date": ["2020-10-01", "2020-10-01"], + "device_exposure_end_date": ["2020-10-02", "2020-10-02"], + "visit_occurrence_id": [10, 11], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(8, 888)], + primary_criteria=PrimaryCriteria(criteria_list=[DeviceExposure(codeset_id=8)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "device_exposure") + + +def test_build_cohort_ibis_specimen(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "specimen", + obj=ibis.memtable( + { + "person_id": [1, 2], + "specimen_id": [900, 901], + "specimen_concept_id": [9990, 9991], + "specimen_date": ["2020-11-01", "2020-11-01"], + "visit_occurrence_id": [10, 11], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(9, 9990)], + primary_criteria=PrimaryCriteria(criteria_list=[Specimen(codeset_id=9)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "specimen") + + +def test_build_cohort_ibis_death(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "death", + obj=ibis.memtable( + { + "person_id": [1, 2], + "cause_concept_id": [10001, 10002], + "cause_source_concept_id": [20001, 20002], + "death_date": ["2020-12-01", "2020-12-01"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(10, 10001)], + primary_criteria=PrimaryCriteria(criteria_list=[Death(codeset_id=10)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "death") + + +def test_build_cohort_ibis_observation_period(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + + expression = CohortExpression( + primary_criteria=PrimaryCriteria(criteria_list=[ObservationPeriod()]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1, 2} + assert all(result.domain == "observation_period") + + +def test_build_cohort_ibis_payer_plan_period(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "payer_plan_period", + obj=ibis.memtable( + { + "person_id": [1], + "payer_plan_period_id": [1100], + "payer_concept_id": [12345], + "payer_source_concept_id": [54321], + "payer_plan_period_start_date": ["2020-01-01"], + "payer_plan_period_end_date": ["2020-12-31"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + primary_criteria=PrimaryCriteria(criteria_list=[PayerPlanPeriod()]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "payer_plan_period") + + +def test_build_cohort_ibis_condition_era(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_era", + obj=ibis.memtable( + { + "person_id": [1, 2], + "condition_era_id": [1200, 1201], + "condition_concept_id": [12121, 99999], + "condition_era_start_date": ["2020-01-01", "2020-01-01"], + "condition_era_end_date": ["2020-02-01", "2020-02-01"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(11, 12121)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionEra(codeset_id=11)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "condition_era") + + +def test_build_cohort_ibis_drug_era(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "drug_era", + obj=ibis.memtable( + { + "person_id": [1, 2], + "drug_era_id": [1300, 1301], + "drug_concept_id": [13131, 99999], + "drug_era_start_date": ["2020-03-01", "2020-03-01"], + "drug_era_end_date": ["2020-04-01", "2020-04-01"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(12, 13131)], + primary_criteria=PrimaryCriteria(criteria_list=[DrugEra(codeset_id=12)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "drug_era") + + +def test_build_cohort_ibis_dose_era(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "dose_era", + obj=ibis.memtable( + { + "person_id": [1, 2], + "dose_era_id": [1400, 1401], + "drug_concept_id": [14141, 99999], + "dose_era_start_date": ["2020-05-01", "2020-05-01"], + "dose_era_end_date": ["2020-06-01", "2020-06-01"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(13, 14141)], + primary_criteria=PrimaryCriteria(criteria_list=[DoseEra(codeset_id=13)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "dose_era") + + +def test_build_cohort_ibis_location_region(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "location", + obj=ibis.memtable( + { + "location_id": [10, 20], + "region_concept_id": [15151, 99999], + } + ), + overwrite=True, + ) + conn.create_table( + "location_history", + obj=ibis.memtable( + { + "entity_id": [1, 2], + "location_id": [10, 20], + "start_date": ["2020-01-01", "2020-01-01"], + "end_date": ["2020-12-31", "2020-12-31"], + "domain_id": ["PERSON", "PERSON"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(14, 15151)], + primary_criteria=PrimaryCriteria(criteria_list=[LocationRegion(codeset_id=14)]), + ) + + table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "location_region") + + +def test_build_cohort_ibis_location_region_keeps_repeated_location_history_rows(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "location", + obj=ibis.memtable( + { + "location_id": [10], + "region_concept_id": [15151], + } + ), + overwrite=True, + ) + conn.create_table( + "location_history", + obj=ibis.memtable( + { + "entity_id": [1, 1], + "location_id": [10, 10], + "start_date": ["2020-01-01", "2020-02-01"], + "end_date": ["2020-01-31", "2020-02-28"], + "domain_id": ["PERSON", "PERSON"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(14, 15151)], + primary_criteria=PrimaryCriteria(criteria_list=[LocationRegion(codeset_id=14)]), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + + assert len(result) == 2 + assert set(result.person_id) == {1} + assert sorted(result.start_date.astype(str).tolist()) == ["2020-01-01", "2020-02-01"] + + +def test_build_cohort_ibis_rejects_unsupported_features(): + expression = CohortExpression( + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence()]), + end_strategy=CustomEraStrategy(drug_codeset_id=1, gap_days=30, offset=0), + ) + with pytest.raises(UnsupportedFeatureError, match="custom_era"): + _ = build_cohort_ibis(expression, backend=object(), cdm_schema="main") diff --git a/tests/execution/test_api_public.py b/tests/execution/test_api_public.py new file mode 100644 index 00000000..5bdba43a --- /dev/null +++ b/tests/execution/test_api_public.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import pytest + +import circe.api as api +from circe.api import ( + build_cohort, + build_cohort_ibis, + write_cohort, + write_cohort_ibis, +) +from circe.cohortdefinition import CohortExpression, ConditionOccurrence, PrimaryCriteria +from circe.execution.api import write_relation +from circe.execution.errors import ExecutionError +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _make_concept_set(set_id: int, concept_id: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), + ) + + +def _expression() -> CohortExpression: + return CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + ) + + +def _seed_tables(conn, ibis): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2], + "year_of_birth": [1980, 1982], + "gender_concept_id": [8507, 8507], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1, 2], + "observation_period_id": [10, 11], + "observation_period_start_date": ["2019-01-01", "2019-01-01"], + "observation_period_end_date": ["2021-12-31", "2021-12-31"], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "condition_occurrence_id": [100, 101], + "condition_concept_id": [111, 111], + "condition_start_date": ["2020-01-01", "2020-01-02"], + "condition_end_date": ["2020-01-01", "2020-01-02"], + } + ), + overwrite=True, + ) + + +def test_public_aliases_resolve_to_canonical_functions(): + assert hasattr(api, "build_cohort") + assert hasattr(api, "write_cohort") + assert hasattr(api, "build_cohort_query") + assert hasattr(api, "build_cohort_ibis") + assert hasattr(api, "write_cohort_ibis") + assert build_cohort_ibis is build_cohort + assert write_cohort_ibis is write_cohort + + +def test_build_cohort_returns_relation_and_alias_works(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_tables(conn, ibis) + + expression = _expression() + + relation = build_cohort(expression, backend=conn, cdm_schema="main") + alias_relation = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + + assert hasattr(relation, "execute") + assert len(relation.execute()) == len(alias_relation.execute()) + + +def test_write_cohort_writes_result_table(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_tables(conn, ibis) + + write_cohort( + _expression(), + backend=conn, + cdm_schema="main", + cohort_table="cohort_out", + cohort_id=42, + if_exists="replace", + ) + result = conn.table("cohort_out").execute() + assert len(result) == 2 + assert list(result.columns) == [ + "cohort_definition_id", + "subject_id", + "cohort_start_date", + "cohort_end_date", + ] + assert set(result.cohort_definition_id) == {42} + + +def test_write_cohort_if_exists_fail_raises(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_tables(conn, ibis) + + write_cohort( + _expression(), + backend=conn, + cdm_schema="main", + cohort_table="cohort_out", + cohort_id=42, + if_exists="fail", + ) + with pytest.raises(ExecutionError, match="already contains rows for cohort_id=42"): + write_cohort( + _expression(), + backend=conn, + cdm_schema="main", + cohort_table="cohort_out", + cohort_id=42, + if_exists="fail", + ) + + +def test_write_cohort_if_exists_replace_overwrites(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_tables(conn, ibis) + expression = _expression() + + write_cohort( + expression, + backend=conn, + cdm_schema="main", + cohort_table="cohort_out", + cohort_id=10, + if_exists="replace", + ) + first = conn.table("cohort_out").execute() + assert len(first) == 2 + assert set(first.cohort_definition_id) == {10} + + write_cohort( + expression, + backend=conn, + cdm_schema="main", + cohort_table="cohort_out", + cohort_id=20, + if_exists="replace", + ) + combined = conn.table("cohort_out").execute() + assert len(combined) == 4 + assert set(combined.cohort_definition_id) == {10, 20} + + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": ["2020-01-01"], + "condition_end_date": ["2020-01-01"], + } + ), + overwrite=True, + ) + write_cohort( + expression, + backend=conn, + cdm_schema="main", + cohort_table="cohort_out", + cohort_id=10, + if_exists="replace", + ) + replaced = conn.table("cohort_out").execute() + replaced_10 = replaced[replaced.cohort_definition_id == 10] + replaced_20 = replaced[replaced.cohort_definition_id == 20] + assert set(replaced_10.subject_id) == {1} + assert set(replaced_20.subject_id) == {1, 2} + + +def test_write_cohort_respects_results_schema_and_alias(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_tables(conn, ibis) + + write_cohort( + _expression(), + backend=conn, + cdm_schema="main", + results_schema="main", + cohort_table="cohort_schema", + cohort_id=7, + if_exists="replace", + ) + assert len(conn.table("cohort_schema", database="main").execute()) == 2 + + write_cohort_ibis( + _expression(), + backend=conn, + cdm_schema="main", + cohort_table="cohort_alias", + cohort_id=8, + if_exists="replace", + results_schema="main", + ) + alias_result = conn.table("cohort_alias", database="main").execute() + assert len(alias_result) == 2 + assert set(alias_result.cohort_definition_id) == {8} + + +def test_expression_first_build_modify_then_write_relation(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_tables(conn, ibis) + + relation = build_cohort(_expression(), backend=conn, cdm_schema="main") + modified = relation.filter(relation.person_id == 1) + + write_relation( + modified, + backend=conn, + target_table="cohort_filtered", + target_schema="main", + if_exists="replace", + ) + result = conn.table("cohort_filtered", database="main").execute() + assert set(result.person_id) == {1} + + +def test_write_cohort_rejects_invalid_if_exists(): + with pytest.raises(ValueError, match="if_exists must be one of"): + write_cohort( + _expression(), + backend=object(), + cdm_schema="main", + cohort_table="cohort_out", + cohort_id=1, + if_exists="append", + ) + + +def test_write_cohort_replace_uses_delete_then_insert(monkeypatch: pytest.MonkeyPatch): + import circe.execution.api as execution_api + + events: list[tuple[str, object]] = [] + + monkeypatch.setattr(execution_api, "build_cohort", lambda *args, **kwargs: object()) + monkeypatch.setattr( + execution_api, "project_to_ohdsi_cohort_table", lambda relation, *, cohort_id: relation + ) + monkeypatch.setattr(execution_api, "table_exists", lambda *args, **kwargs: True) + monkeypatch.setattr(execution_api, "supports_transactional_replace", lambda *args, **kwargs: True) + monkeypatch.setattr( + execution_api, + "replace_cohort_rows_transactionally", + lambda relation, *, backend, cohort_table, results_schema=None, cohort_id: events.append( + ("replace", cohort_table, results_schema, cohort_id) + ), + ) + monkeypatch.setattr( + execution_api, + "write_relation", + lambda *args, **kwargs: events.append(("create", kwargs["target_table"])), + ) + + write_cohort( + _expression(), + backend=object(), + cdm_schema="main", + results_schema="results", + cohort_table="cohort_out", + cohort_id=9, + if_exists="replace", + ) + + assert events == [("replace", "cohort_out", "results", 9)] + + +def test_write_cohort_replace_falls_back_to_safe_rewrite(monkeypatch: pytest.MonkeyPatch): + import circe.execution.api as execution_api + + events: list[tuple[str, object]] = [] + existing = object() + + class _Filtered: + def union(self, relation, distinct=False): + events.append(("union", distinct)) + return "merged" + + filtered = _Filtered() + + monkeypatch.setattr(execution_api, "build_cohort", lambda *args, **kwargs: object()) + monkeypatch.setattr( + execution_api, "project_to_ohdsi_cohort_table", lambda relation, *, cohort_id: relation + ) + monkeypatch.setattr(execution_api, "table_exists", lambda *args, **kwargs: True) + monkeypatch.setattr(execution_api, "supports_transactional_replace", lambda *args, **kwargs: False) + monkeypatch.setattr(execution_api, "read_table", lambda *args, **kwargs: existing) + monkeypatch.setattr( + execution_api, + "exclude_cohort_rows", + lambda relation, *, cohort_id: events.append(("filter", cohort_id)) or filtered, + ) + monkeypatch.setattr( + execution_api, + "write_relation", + lambda relation, *, backend, target_table, target_schema=None, if_exists="fail", temporary=False: ( + events.append(("write", relation, target_table, target_schema, if_exists)) + ), + ) + + write_cohort( + _expression(), + backend=object(), + cdm_schema="main", + results_schema="results", + cohort_table="cohort_out", + cohort_id=9, + if_exists="replace", + ) + + assert events == [ + ("filter", 9), + ("union", False), + ("write", "merged", "cohort_out", "results", "replace"), + ] + + +def test_write_relation_type_error_is_reported_as_generic_write_failure(): + class _Backend: + def create_table(self, name, **kwargs): + raise TypeError("boom") + + with pytest.raises(ExecutionError, match="failed writing relation to table 'cohort_out'"): + write_relation( + object(), + backend=_Backend(), + target_table="cohort_out", + target_schema="main", + if_exists="replace", + ) diff --git a/tests/execution/test_compile_contracts.py b/tests/execution/test_compile_contracts.py new file mode 100644 index 00000000..841497e3 --- /dev/null +++ b/tests/execution/test_compile_contracts.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import pytest + +from circe.cohortdefinition import CohortExpression, PrimaryCriteria +from circe.execution.ibis.compiler import compile_event_plan +from circe.execution.ibis.context import make_execution_context +from circe.execution.lower.criteria import lower_criterion +from circe.execution.normalize.cohort import normalize_cohort +from circe.execution.plan.schema import STANDARD_EVENT_COLUMNS +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem +from tests.execution._domain_cases import domain_criteria_cases + + +def _seed_common_tables(conn, ibis): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1], + "year_of_birth": [1980], + "gender_concept_id": [8507], + "race_concept_id": [8527], + "ethnicity_concept_id": [38003564], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1], + "observation_period_id": [10], + "observation_period_start_date": ["2019-01-01"], + "observation_period_end_date": ["2022-12-31"], + } + ), + overwrite=True, + ) + + +@pytest.mark.parametrize(("source_table", "factory", "concept_id"), domain_criteria_cases()) +def test_compile_contract_emits_standard_schema(source_table, factory, concept_id): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + + criteria = factory() + concept_sets = [] + if concept_id is not None: + concept_sets = [ + ConceptSet( + id=1, + expression=ConceptSetExpression( + items=[ConceptSetItem(concept=Concept(conceptId=concept_id))] + ), + ) + ] + + expression = CohortExpression( + concept_sets=concept_sets, + primary_criteria=PrimaryCriteria(criteria_list=[criteria]), + ) + normalized = normalize_cohort(expression) + normalized_criterion = normalized.primary.criteria[0] + plan = lower_criterion(normalized_criterion, criterion_index=0) + + source_data = { + plan.source.person_id_column: [1], + plan.source.event_id_column: [101], + plan.source.start_date_column: ["2020-01-01"], + plan.source.end_date_column: ["2020-01-01"], + } + if plan.source.visit_occurrence_column and plan.source.visit_occurrence_column not in source_data: + source_data[plan.source.visit_occurrence_column] = [10] + if ( + plan.source.concept_column + and plan.source.concept_column not in source_data + and source_table != "location_history" + ): + source_data[plan.source.concept_column] = [concept_id or 0] + if plan.source.source_concept_column and plan.source.source_concept_column not in source_data: + source_data[plan.source.source_concept_column] = [concept_id or 0] + if source_table == "location_history": + source_data["domain_id"] = ["PERSON"] + source_data["location_id"] = [10] + conn.create_table( + "location", + obj=ibis.memtable({"location_id": [10], "region_concept_id": [concept_id]}), + overwrite=True, + ) + + conn.create_table(source_table, obj=ibis.memtable(source_data), overwrite=True) + + ctx = make_execution_context( + backend=conn, + cdm_schema="main", + results_schema=None, + concept_sets=normalized.concept_sets, + ) + + result = compile_event_plan(plan, ctx).execute() + assert tuple(result.columns) == STANDARD_EVENT_COLUMNS + assert len(result) == 1 diff --git a/tests/execution/test_context_wiring.py b/tests/execution/test_context_wiring.py new file mode 100644 index 00000000..83da0e44 --- /dev/null +++ b/tests/execution/test_context_wiring.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from circe.execution.ibis.codesets import CachedConceptSetResolver +from circe.execution.ibis.context import ExecutionContext, make_execution_context + + +class _BackendWithSchemaSupport: + def __init__(self): + self.calls: list[tuple[str, str | None]] = [] + + def table(self, name: str, database: str | None = None): + self.calls.append((name, database)) + return (name, database) + + +class _BackendWithoutSchemaSupport: + def __init__(self): + self.calls: list[tuple[str, str | None]] = [] + + def table(self, name: str, database: str | None = None): + self.calls.append((name, database)) + if database is not None: + raise TypeError("database kwarg not supported") + return (name, None) + + +def test_make_execution_context_uses_cdm_schema_as_vocabulary_fallback(): + backend = _BackendWithSchemaSupport() + ctx = make_execution_context( + backend=backend, + cdm_schema="cdm", + concept_sets={}, + ) + + assert isinstance(ctx, ExecutionContext) + assert ctx.vocabulary_schema == "cdm" + assert isinstance(ctx.codeset_resolver, CachedConceptSetResolver) + assert ctx.table("person") == ("person", "cdm") + assert ctx.concept_ids_for_codeset(999) == () + + +def test_make_execution_context_honors_vocabulary_schema_option_and_backend_fallback(): + backend = _BackendWithoutSchemaSupport() + ctx = make_execution_context( + backend=backend, + cdm_schema="cdm", + concept_sets={}, + vocabulary_schema="vocab", + ) + + assert ctx.vocabulary_schema == "vocab" + assert ctx.vocabulary_table("concept") == ("concept", None) + assert backend.calls == [("concept", "vocab"), ("concept", None)] diff --git a/tests/execution/test_databricks_compat.py b/tests/execution/test_databricks_compat.py new file mode 100644 index 00000000..d32975cd --- /dev/null +++ b/tests/execution/test_databricks_compat.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import pytest + +from circe.execution.databricks_compat import apply_databricks_post_connect_workaround + + +def test_databricks_post_connect_workaround_swallows_memtable_volume_error(): + class FakeDatabricksBackend: + def _post_connect(self): + raise RuntimeError("CREATE VOLUME IF NOT EXISTS my_catalog.my_schema.memtable") + + patched = apply_databricks_post_connect_workaround(backend_cls=FakeDatabricksBackend) + assert patched is True + + backend = FakeDatabricksBackend() + assert backend._post_connect() is None + + +def test_databricks_post_connect_workaround_keeps_non_volume_errors(): + class FakeDatabricksBackend: + def _post_connect(self): + _ = "CREATE VOLUME IF NOT EXISTS my_catalog.my_schema.memtable" + raise RuntimeError("different setup error") + + patched = apply_databricks_post_connect_workaround(backend_cls=FakeDatabricksBackend) + assert patched is True + + backend = FakeDatabricksBackend() + with pytest.raises(RuntimeError, match="different setup error"): + backend._post_connect() diff --git a/tests/execution/test_domain_filter_parity.py b/tests/execution/test_domain_filter_parity.py new file mode 100644 index 00000000..c05f91c8 --- /dev/null +++ b/tests/execution/test_domain_filter_parity.py @@ -0,0 +1,524 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort_ibis +from circe.cohortdefinition import ( + CohortExpression, + ConditionOccurrence, + Death, + DeviceExposure, + DrugExposure, + Measurement, + PrimaryCriteria, + Specimen, + VisitDetail, + VisitOccurrence, +) +from circe.cohortdefinition.core import ConceptSetSelection, DateAdjustment, NumericRange +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem +from tests.execution.test_api_ibis import _seed_common_tables + + +def _make_concept_set(set_id: int, concept_id: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), + ) + + +def test_condition_occurrence_applies_related_filters_and_date_adjustment(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "provider", + obj=ibis.memtable( + { + "provider_id": [1, 2], + "specialty_concept_id": [8001, 8002], + } + ), + overwrite=True, + ) + conn.create_table( + "visit_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "visit_occurrence_id": [10, 11], + "visit_concept_id": [7001, 7002], + "visit_start_date": ["2020-01-01", "2020-01-01"], + "visit_end_date": ["2020-01-03", "2020-01-03"], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "condition_occurrence_id": [100, 101], + "condition_concept_id": [111, 111], + "condition_start_date": ["2020-01-01", "2020-01-01"], + "condition_end_date": [None, "2020-01-03"], + "visit_occurrence_id": [10, 11], + "provider_id": [1, 2], + "condition_type_concept_id": [9001, 9002], + "condition_status_concept_id": [9101, 9102], + "stop_reason": ["keep me", "drop me"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + ConditionOccurrence( + codeset_id=1, + condition_type=[Concept(conceptId=9001)], + condition_status=[Concept(conceptId=9101)], + stop_reason={"op": "contains", "text": "keep"}, + provider_specialty=[Concept(conceptId=8001)], + visit_type=[Concept(conceptId=7001)], + occurrence_start_date={"op": "gte", "value": "2020-01-02"}, + occurrence_end_date={"op": "gte", "value": "2020-01-04"}, + date_adjustment=DateAdjustment(start_offset=1, end_offset=2), + ) + ] + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert list(result.person_id) == [1] + assert result.iloc[0].start_date.date().isoformat() == "2020-01-02" + + +def test_drug_exposure_applies_domain_filters_and_end_date_fallback(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "provider", + obj=ibis.memtable({"provider_id": [1, 2], "specialty_concept_id": [8001, 8002]}), + overwrite=True, + ) + conn.create_table( + "visit_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "visit_occurrence_id": [10, 11], + "visit_concept_id": [7001, 7002], + "visit_start_date": ["2020-03-01", "2020-03-01"], + "visit_end_date": ["2020-03-02", "2020-03-02"], + } + ), + overwrite=True, + ) + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [1, 2], + "drug_exposure_id": [200, 201], + "drug_concept_id": [222, 222], + "drug_exposure_start_date": ["2020-03-01", "2020-03-01"], + "drug_exposure_end_date": [None, "2020-03-02"], + "visit_occurrence_id": [10, 11], + "provider_id": [1, 2], + "drug_type_concept_id": [3001, 3002], + "route_concept_id": [4001, 4002], + "dose_unit_concept_id": [5001, 5002], + "lot_number": ["A-LOT", "B-LOT"], + "quantity": [10.0, 1.0], + "days_supply": [5, 1], + "refills": [2, 0], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(2, 222)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + DrugExposure( + codeset_id=2, + drug_type=[Concept(conceptId=3001)], + route_concept=[Concept(conceptId=4001)], + dose_unit=[Concept(conceptId=5001)], + lot_number={"op": "contains", "text": "A-"}, + quantity=NumericRange(op="gte", value=10), + days_supply=NumericRange(op="gte", value=5), + refills=NumericRange(op="gte", value=2), + provider_specialty=[Concept(conceptId=8001)], + visit_type=[Concept(conceptId=7001)], + occurrence_end_date={"op": "gte", "value": "2020-03-06"}, + ) + ] + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert list(result.person_id) == [1] + + +def test_visit_occurrence_applies_care_site_provider_location_and_duration_filters(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "provider", + obj=ibis.memtable({"provider_id": [1, 2], "specialty_concept_id": [8001, 8002]}), + overwrite=True, + ) + conn.create_table( + "care_site", + obj=ibis.memtable( + { + "care_site_id": [100, 101], + "place_of_service_concept_id": [9001, 9002], + } + ), + overwrite=True, + ) + conn.create_table( + "location_history", + obj=ibis.memtable( + { + "entity_id": [100, 101], + "domain_id": ["CARE_SITE", "CARE_SITE"], + "location_id": [500, 501], + "start_date": ["2020-01-01", "2020-01-01"], + "end_date": [None, "2020-12-31"], + } + ), + overwrite=True, + ) + conn.create_table( + "location", + obj=ibis.memtable({"location_id": [500, 501], "region_concept_id": [6001, 6002]}), + overwrite=True, + ) + conn.create_table( + "visit_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "visit_occurrence_id": [300, 301], + "visit_concept_id": [333, 333], + "visit_start_date": ["2020-05-01", "2020-05-01"], + "visit_end_date": ["2020-05-03", "2020-05-02"], + "visit_type_concept_id": [7001, 7002], + "provider_id": [1, 2], + "care_site_id": [100, 101], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(3, 333), _make_concept_set(31, 6001)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + VisitOccurrence( + codeset_id=3, + visit_type=[Concept(conceptId=7001)], + visit_length=NumericRange(op="gte", value=2), + provider_specialty=[Concept(conceptId=8001)], + place_of_service=[Concept(conceptId=9001)], + place_of_service_location=31, + ) + ] + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert list(result.person_id) == [1] + + +def test_device_exposure_applies_domain_filters_and_end_date_fallback(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "provider", + obj=ibis.memtable({"provider_id": [1, 2], "specialty_concept_id": [8001, 8002]}), + overwrite=True, + ) + conn.create_table( + "visit_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "visit_occurrence_id": [10, 11], + "visit_concept_id": [7001, 7002], + "visit_start_date": ["2020-10-01", "2020-10-01"], + "visit_end_date": ["2020-10-02", "2020-10-02"], + } + ), + overwrite=True, + ) + conn.create_table( + "device_exposure", + obj=ibis.memtable( + { + "person_id": [1, 2], + "device_exposure_id": [800, 801], + "device_concept_id": [888, 888], + "device_exposure_start_date": ["2020-10-01", "2020-10-01"], + "device_exposure_end_date": [None, "2020-10-02"], + "visit_occurrence_id": [10, 11], + "provider_id": [1, 2], + "device_type_concept_id": [3001, 3002], + "unique_device_id": ["abc-123", "xyz-999"], + "quantity": [5, 1], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(8, 888)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + DeviceExposure( + codeset_id=8, + device_type=[Concept(conceptId=3001)], + unique_device_id={"op": "contains", "text": "abc"}, + quantity=NumericRange(op="gte", value=5), + provider_specialty=[Concept(conceptId=8001)], + visit_type=[Concept(conceptId=7001)], + occurrence_end_date={"op": "gte", "value": "2020-10-02"}, + ) + ] + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert list(result.person_id) == [1] + + +def test_specimen_applies_domain_filters(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "specimen", + obj=ibis.memtable( + { + "person_id": [1, 2], + "specimen_id": [900, 901], + "specimen_concept_id": [9990, 9990], + "specimen_date": ["2020-11-01", "2020-11-01"], + "visit_occurrence_id": [10, 11], + "specimen_type_concept_id": [1001, 1002], + "quantity": [5.0, 1.0], + "unit_concept_id": [2001, 2002], + "anatomic_site_concept_id": [3001, 3002], + "disease_status_concept_id": [4001, 4002], + "specimen_source_id": ["keep-source", "drop-source"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(9, 9990)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + Specimen( + codeset_id=9, + specimen_type=[Concept(conceptId=1001)], + quantity=NumericRange(op="gte", value=5), + unit=[Concept(conceptId=2001)], + anatomic_site=[Concept(conceptId=3001)], + disease_status=[Concept(conceptId=4001)], + source_id={"op": "contains", "text": "keep"}, + ) + ] + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert list(result.person_id) == [1] + + +def test_death_applies_death_type_and_derived_end_date(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "death", + obj=ibis.memtable( + { + "person_id": [1, 2], + "cause_concept_id": [10001, 10001], + "cause_source_concept_id": [20001, 20002], + "death_type_concept_id": [3001, 3002], + "death_date": ["2020-12-01", "2020-12-01"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(10, 10001)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + Death( + codeset_id=10, + death_type=[Concept(conceptId=3001)], + occurrence_end_date={"op": "gte", "value": "2020-12-02"}, + ) + ] + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert list(result.person_id) == [1] + + +def test_measurement_and_visit_detail_apply_shared_related_filters(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "provider", + obj=ibis.memtable({"provider_id": [1, 2], "specialty_concept_id": [8001, 8002]}), + overwrite=True, + ) + conn.create_table( + "visit_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "visit_occurrence_id": [10, 11], + "visit_concept_id": [7001, 7002], + "visit_start_date": ["2020-06-01", "2020-06-01"], + "visit_end_date": ["2020-06-02", "2020-06-02"], + } + ), + overwrite=True, + ) + conn.create_table( + "care_site", + obj=ibis.memtable( + { + "care_site_id": [100, 101], + "place_of_service_concept_id": [9001, 9002], + } + ), + overwrite=True, + ) + conn.create_table( + "location_history", + obj=ibis.memtable( + { + "entity_id": [100, 101], + "domain_id": ["CARE_SITE", "CARE_SITE"], + "location_id": [500, 501], + "start_date": ["2020-01-01", "2020-01-01"], + "end_date": [None, "2020-12-31"], + } + ), + overwrite=True, + ) + conn.create_table( + "location", + obj=ibis.memtable({"location_id": [500, 501], "region_concept_id": [6001, 6002]}), + overwrite=True, + ) + conn.create_table( + "measurement", + obj=ibis.memtable( + { + "person_id": [1, 2], + "measurement_id": [400, 401], + "measurement_concept_id": [444, 444], + "measurement_date": ["2020-06-01", "2020-06-01"], + "visit_occurrence_id": [10, 11], + "provider_id": [1, 2], + } + ), + overwrite=True, + ) + conn.create_table( + "visit_detail", + obj=ibis.memtable( + { + "person_id": [1, 2], + "visit_detail_id": [710, 711], + "visit_detail_concept_id": [777, 777], + "visit_detail_start_date": ["2020-09-01", "2020-09-01"], + "visit_detail_end_date": ["2020-09-03", "2020-09-02"], + "visit_occurrence_id": [10, 11], + "provider_id": [1, 2], + "care_site_id": [100, 101], + } + ), + overwrite=True, + ) + + measurement_expression = CohortExpression( + concept_sets=[_make_concept_set(4, 444)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + Measurement( + codeset_id=4, + provider_specialty=[Concept(conceptId=8001)], + visit_type=[Concept(conceptId=7001)], + ) + ] + ), + ) + measurement_result = build_cohort_ibis( + measurement_expression, + backend=conn, + cdm_schema="main", + ).execute() + assert list(measurement_result.person_id) == [1] + + visit_detail_expression = CohortExpression( + concept_sets=[ + _make_concept_set(7, 777), + _make_concept_set(21, 8001), + _make_concept_set(22, 9001), + _make_concept_set(23, 6001), + ], + primary_criteria=PrimaryCriteria( + criteria_list=[ + VisitDetail( + codeset_id=7, + provider_specialty_cs=ConceptSetSelection(codeset_id=21, is_exclusion=False), + place_of_service_cs=ConceptSetSelection(codeset_id=22, is_exclusion=False), + place_of_service_location=23, + visit_detail_length=NumericRange(op="gte", value=2), + ) + ] + ), + ) + visit_detail_result = build_cohort_ibis( + visit_detail_expression, + backend=conn, + cdm_schema="main", + ).execute() + assert list(visit_detail_result.person_id) == [1] diff --git a/tests/execution/test_end_strategy_censoring.py b/tests/execution/test_end_strategy_censoring.py new file mode 100644 index 00000000..bfdb7406 --- /dev/null +++ b/tests/execution/test_end_strategy_censoring.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort_ibis +from circe.cohortdefinition import CohortExpression, ConditionOccurrence, PrimaryCriteria +from circe.cohortdefinition.core import CollapseSettings, DateOffsetStrategy, Period +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _make_concept_set(set_id: int, concept_id: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), + ) + + +def _seed_common_tables(conn, ibis): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1], + "year_of_birth": [1980], + "gender_concept_id": [8507], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1], + "observation_period_id": [10], + "observation_period_start_date": ["2019-01-01"], + "observation_period_end_date": ["2021-12-31"], + } + ), + overwrite=True, + ) + + +def test_date_offset_end_strategy_applies_to_end_date(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": ["2020-01-01"], + "condition_end_date": ["2020-01-01"], + "visit_occurrence_id": [10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=DateOffsetStrategy(offset=30, date_field="start_date"), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert str(result.iloc[0]["end_date"])[:10] == "2020-01-31" + + +def test_censoring_criteria_clips_end_date(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1], + "condition_occurrence_id": [100, 101], + "condition_concept_id": [111, 222], + "condition_start_date": ["2020-01-01", "2020-01-10"], + "condition_end_date": ["2020-01-01", "2020-01-10"], + "visit_occurrence_id": [10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111), _make_concept_set(2, 222)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + censoring_criteria=[ConditionOccurrence(codeset_id=2)], + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert str(result.iloc[0]["end_date"])[:10] == "2020-01-10" + + +def test_censor_window_clips_start_and_end_dates(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": ["2020-01-01"], + "condition_end_date": ["2020-01-01"], + "visit_occurrence_id": [10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=DateOffsetStrategy(offset=40, date_field="start_date"), + censor_window=Period(start_date="2020-01-05", end_date="2020-01-20"), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert str(result.iloc[0]["start_date"])[:10] == "2020-01-05" + assert str(result.iloc[0]["end_date"])[:10] == "2020-01-20" + + +def test_collapse_settings_era_merges_intervals(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1], + "condition_occurrence_id": [100, 101], + "condition_concept_id": [111, 111], + "condition_start_date": ["2020-01-01", "2020-01-03"], + "condition_end_date": ["2020-01-01", "2020-01-03"], + "visit_occurrence_id": [10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=DateOffsetStrategy(offset=0, date_field="start_date"), + collapse_settings=CollapseSettings(era_pad=2), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.columns) == {"person_id", "start_date", "end_date"} + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-01-01" + assert str(result.iloc[0]["end_date"])[:10] == "2020-01-03" diff --git a/tests/execution/test_error_messages.py b/tests/execution/test_error_messages.py new file mode 100644 index 00000000..6f7efb7a --- /dev/null +++ b/tests/execution/test_error_messages.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort_ibis +from circe.cohortdefinition import ( + CohortExpression, + ConditionOccurrence, + CorelatedCriteria, + Criteria, + CriteriaGroup, + DemographicCriteria, + Measurement, + Occurrence, + PrimaryCriteria, +) +from circe.cohortdefinition.core import CustomEraStrategy, NumericRange +from circe.execution.errors import CompilationError, UnsupportedCriterionError, UnsupportedFeatureError +from circe.execution.normalize.criteria import normalize_criterion +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _seed_common_tables(conn, ibis): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1], + "year_of_birth": [1980], + "gender_concept_id": [8507], + "race_concept_id": [8527], + "ethnicity_concept_id": [38003564], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1], + "observation_period_id": [10], + "observation_period_start_date": ["2019-01-01"], + "observation_period_end_date": ["2022-12-31"], + } + ), + overwrite=True, + ) + + +def _concept_set(set_id: int, concept_id: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), + ) + + +def test_error_message_for_custom_era_end_strategy(): + expression = CohortExpression( + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence()]), + end_strategy=CustomEraStrategy(drug_codeset_id=1, gap_days=30, offset=0), + ) + + with pytest.raises(UnsupportedFeatureError, match="custom_era end strategy"): + _ = build_cohort_ibis(expression, backend=object(), cdm_schema="main") + + +def test_error_message_for_unsupported_criterion_type(): + with pytest.raises( + UnsupportedCriterionError, + match="normalization error: unsupported criterion type Criteria", + ): + _ = normalize_criterion(Criteria()) + + +def test_error_message_for_unsupported_numeric_op_during_compilation(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "measurement", + obj=ibis.memtable( + { + "person_id": [1], + "measurement_id": [100], + "measurement_concept_id": [444], + "measurement_date": ["2020-01-01"], + "visit_occurrence_id": [10], + "value_as_number": [5.0], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_concept_set(1, 444)], + primary_criteria=PrimaryCriteria( + criteria_list=[Measurement(codeset_id=1, value_as_number=NumericRange(op="nope", value=1))] + ), + ) + + with pytest.raises(CompilationError, match="compilation error: unsupported numeric range op"): + _ = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + + +def test_error_message_for_unsupported_demographic_numeric_op(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1], + "condition_occurrence_id": [100, 101], + "condition_concept_id": [111, 222], + "condition_start_date": ["2020-01-01", "2020-01-03"], + "condition_end_date": ["2020-01-01", "2020-01-03"], + "visit_occurrence_id": [10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_concept_set(1, 111), _concept_set(2, 222)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=2), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ) + ], + demographic_criteria_list=[DemographicCriteria(age=NumericRange(op="invalid", value=18))], + ), + ) + + with pytest.raises( + UnsupportedFeatureError, + match="group evaluation error: unsupported demographic numeric range op", + ): + _ = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() diff --git a/tests/execution/test_groups.py b/tests/execution/test_groups.py new file mode 100644 index 00000000..01737b9a --- /dev/null +++ b/tests/execution/test_groups.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort_ibis +from circe.cohortdefinition import ( + CohortExpression, + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + DemographicCriteria, + Occurrence, + PrimaryCriteria, + Window, + WindowBound, +) +from circe.cohortdefinition.core import DateRange, NumericRange +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _make_concept_set(set_id: int, concept_id: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), + ) + + +def _seed_common_tables(conn, ibis, *, persons=(1, 2, 3)): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": list(persons), + "year_of_birth": [1980 for _ in persons], + "gender_concept_id": [8507 for _ in persons], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": list(persons), + "observation_period_id": [10 + idx for idx, _ in enumerate(persons)], + "observation_period_start_date": ["2019-01-01" for _ in persons], + "observation_period_end_date": ["2022-12-31" for _ in persons], + } + ), + overwrite=True, + ) + + +def test_additional_criteria_all_filters_primary_events(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2)) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 2], + "condition_occurrence_id": [100, 101, 102], + "condition_concept_id": [111, 222, 111], + "condition_start_date": ["2020-01-01", "2020-01-02", "2020-01-01"], + "condition_end_date": ["2020-01-01", "2020-01-02", "2020-01-01"], + "visit_occurrence_id": [10, 10, 20], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111), _make_concept_set(2, 222)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=2), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ) + ], + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + + +@pytest.mark.parametrize( + ("group_type", "count", "expected_persons"), + [ + ("ANY", None, {1, 2, 3}), + ("ALL", None, {3}), + ("AT_LEAST", 2, {3}), + ("AT_MOST", 1, {1, 2}), + ], +) +def test_additional_group_operators(group_type, count, expected_persons): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2, 3)) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 2, 2, 3, 3, 3], + "condition_occurrence_id": [100, 101, 200, 201, 300, 301, 302], + "condition_concept_id": [111, 222, 111, 333, 111, 222, 333], + "condition_start_date": [ + "2020-01-01", + "2020-01-02", + "2020-01-01", + "2020-01-02", + "2020-01-01", + "2020-01-02", + "2020-01-03", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-02", + "2020-01-01", + "2020-01-02", + "2020-01-01", + "2020-01-02", + "2020-01-03", + ], + "visit_occurrence_id": [10, 10, 20, 20, 30, 30, 30], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + _make_concept_set(3, 333), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type=group_type, + count=count, + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=2), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ), + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=3), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ), + ], + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == expected_persons + + +def test_correlated_criteria_respects_restrict_visit_and_start_window(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2)) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 2, 2], + "condition_occurrence_id": [100, 101, 200, 201], + "condition_concept_id": [111, 222, 111, 222], + "condition_start_date": [ + "2020-01-01", + "2020-01-06", + "2020-01-01", + "2020-01-10", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-06", + "2020-01-01", + "2020-01-10", + ], + "visit_occurrence_id": [10, 10, 20, 21], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111), _make_concept_set(2, 222)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=2), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + restrict_visit=True, + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=7), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + # Person 1 matches (same visit, +5 days). Person 2 fails (different visit and +9 days). + assert set(result.person_id) == {1} + + +def test_additional_demographic_criteria_groups_filter_primary_events(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2, 3)) + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "year_of_birth": [1980, 1980, 2010], + "gender_concept_id": [8507, 8507, 8507], + "race_concept_id": [8527, 8516, 8527], + "ethnicity_concept_id": [38003564, 38003564, 38003563], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "condition_occurrence_id": [100, 200, 300], + "condition_concept_id": [111, 111, 111], + "condition_start_date": ["2020-01-03", "2020-01-03", "2020-01-03"], + "condition_end_date": ["2020-01-03", "2020-01-03", "2020-01-03"], + "visit_occurrence_id": [10, 20, 30], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + demographic_criteria_list=[ + DemographicCriteria( + age=NumericRange(op="gte", value=18), + gender=[Concept(conceptId=8507)], + race=[Concept(conceptId=8527)], + ethnicity=[Concept(conceptId=38003564)], + occurrence_start_date=DateRange(op="gte", value="2020-01-02"), + ) + ], + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} diff --git a/tests/execution/test_ibis_compat.py b/tests/execution/test_ibis_compat.py new file mode 100644 index 00000000..8f71ee68 --- /dev/null +++ b/tests/execution/test_ibis_compat.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import pytest + +from circe.execution.ibis_compat import literal_column_relation, literal_rows_relation + + +def test_literal_column_relation_round_trips_values(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + relation = literal_column_relation( + [3, 1, 2], + column_name="value", + dtype="int64", + backend=conn, + ) + result = relation.execute() + + assert sorted(result["value"].tolist()) == [1, 2, 3] + + +def test_literal_column_relation_empty_preserves_schema(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + relation = literal_column_relation( + [], + column_name="value", + dtype="int64", + backend=conn, + ) + result = relation.execute() + + assert list(result.columns) == ["value"] + assert len(result) == 0 + + +def test_literal_rows_relation_round_trips_typed_rows(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + relation = literal_rows_relation( + [ + {"cohort_id": 1, "cohort_name": "A", "is_subset": False}, + {"cohort_id": 2, "cohort_name": None, "is_subset": True}, + ], + schema={ + "cohort_id": "int64", + "cohort_name": "string", + "is_subset": "boolean", + }, + backend=conn, + ) + result = relation.execute().sort_values("cohort_id").reset_index(drop=True) + + assert list(result["cohort_id"]) == [1, 2] + assert result.loc[0, "cohort_name"] == "A" + assert result.loc[1, "cohort_name"] is None or result["cohort_name"].isna().iloc[1] + assert list(result["is_subset"]) == [False, True] + + +def test_literal_rows_relation_empty_relation(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + relation = literal_rows_relation( + [], + schema={"cohort_id": "int64", "status": "string"}, + backend=conn, + ) + result = relation.execute() + + assert list(result.columns) == ["cohort_id", "status"] + assert len(result) == 0 diff --git a/tests/execution/test_inclusion.py b/tests/execution/test_inclusion.py new file mode 100644 index 00000000..36c64777 --- /dev/null +++ b/tests/execution/test_inclusion.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort_ibis +from circe.cohortdefinition import ( + CohortExpression, + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + InclusionRule, + Occurrence, + PrimaryCriteria, +) +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _make_concept_set(set_id: int, concept_id: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), + ) + + +def _seed_common_tables(conn, ibis, *, persons=(1, 2, 3)): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": list(persons), + "year_of_birth": [1980 for _ in persons], + "gender_concept_id": [8507 for _ in persons], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": list(persons), + "observation_period_id": [10 + idx for idx, _ in enumerate(persons)], + "observation_period_start_date": ["2019-01-01" for _ in persons], + "observation_period_end_date": ["2022-12-31" for _ in persons], + } + ), + overwrite=True, + ) + + +def test_inclusion_rules_require_all_rules_to_match(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2, 3)) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 2, 2, 3, 3, 3], + "condition_occurrence_id": [100, 101, 200, 201, 300, 301, 302], + "condition_concept_id": [111, 222, 111, 333, 111, 222, 333], + "condition_start_date": [ + "2020-01-01", + "2020-01-02", + "2020-01-01", + "2020-01-02", + "2020-01-01", + "2020-01-02", + "2020-01-03", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-02", + "2020-01-01", + "2020-01-02", + "2020-01-01", + "2020-01-02", + "2020-01-03", + ], + "visit_occurrence_id": [10, 10, 20, 20, 30, 30, 30], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + _make_concept_set(3, 333), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + inclusion_rules=[ + InclusionRule( + name="rule-1", + expression=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=2), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ) + ], + ), + ), + InclusionRule( + name="rule-2", + expression=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=3), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ) + ], + ), + ), + ], + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {3} + + +def test_inclusion_rule_without_expression_is_noop(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2)) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "condition_occurrence_id": [100, 200], + "condition_concept_id": [111, 111], + "condition_start_date": ["2020-01-01", "2020-01-01"], + "condition_end_date": ["2020-01-01", "2020-01-01"], + "visit_occurrence_id": [10, 20], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + inclusion_rules=[InclusionRule(name="empty", expression=None)], + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1, 2} diff --git a/tests/execution/test_legacy_api_compat.py b/tests/execution/test_legacy_api_compat.py new file mode 100644 index 00000000..47cdfc89 --- /dev/null +++ b/tests/execution/test_legacy_api_compat.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import pytest + +from circe.cohortdefinition import CohortExpression, ConditionOccurrence, PrimaryCriteria +from circe.execution import ExecutionOptions, IbisExecutor, build_ibis, to_polars +from circe.execution.compat import write_cohort as legacy_write_cohort +from circe.execution.errors import ExecutionError +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _make_concept_set(set_id: int, concept_id: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), + ) + + +def _expression() -> CohortExpression: + return CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + ) + + +def _seed_tables(conn, ibis) -> None: + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2], + "year_of_birth": [1980, 1982], + "gender_concept_id": [8507, 8507], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1, 2], + "observation_period_id": [10, 11], + "observation_period_start_date": ["2019-01-01", "2019-01-01"], + "observation_period_end_date": ["2021-12-31", "2021-12-31"], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2], + "condition_occurrence_id": [100, 101], + "condition_concept_id": [111, 111], + "condition_start_date": ["2020-01-01", "2020-01-02"], + "condition_end_date": ["2020-01-01", "2020-01-02"], + } + ), + overwrite=True, + ) + + +def test_legacy_build_helpers_return_relations_and_polars(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_tables(conn, ibis) + options = ExecutionOptions(cdm_schema="main") + + relation = build_ibis(_expression(), conn, options) + frame = to_polars(_expression(), conn, options) + + assert hasattr(relation, "execute") + assert len(relation.execute()) == 2 + assert frame.height == 2 + + +def test_legacy_executor_build_matches_function_wrapper(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_tables(conn, ibis) + options = ExecutionOptions(cdm_schema="main") + + executor = IbisExecutor(conn, options) + via_executor = executor.build(_expression()).execute() + via_function = build_ibis(_expression(), conn, options).execute() + + assert len(via_executor) == len(via_function) == 2 + assert set(via_executor.person_id) == {1, 2} + assert executor.captured_sql() == [] + + +def test_legacy_write_cohort_projects_ohdsi_columns(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_tables(conn, ibis) + + legacy_write_cohort( + _expression(), + conn, + table="cohort_legacy", + schema="main", + overwrite=True, + cohort_id=77, + options=ExecutionOptions(cdm_schema="main"), + ) + + result = conn.table("cohort_legacy", database="main").execute() + + assert list(result.columns) == [ + "cohort_definition_id", + "subject_id", + "cohort_start_date", + "cohort_end_date", + ] + assert set(result["cohort_definition_id"]) == {77} + assert set(result["subject_id"]) == {1, 2} + + +def test_legacy_executor_write_uses_options_cohort_id_default(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_tables(conn, ibis) + + executor = IbisExecutor( + conn, + ExecutionOptions(cdm_schema="main", result_schema="main", cohort_id=91), + ) + executor.write(_expression(), table="cohort_from_executor", overwrite=True) + + result = conn.table("cohort_from_executor", database="main").execute() + assert set(result["cohort_definition_id"]) == {91} + + +def test_legacy_executor_write_requires_cohort_id(): + executor = IbisExecutor(object(), ExecutionOptions()) + + with pytest.raises(ExecutionError, match="cohort_id is required"): + executor.write(_expression(), table="cohort_out", overwrite=True) + + +def test_legacy_append_raises_if_existing_table_cannot_be_read(monkeypatch: pytest.MonkeyPatch): + import circe.execution.api as execution_api + import circe.execution.compat as compat_module + + class _AppendBackend: + def list_tables(self, database=None): + return ["cohort_out"] + + def table(self, name, database=None): + raise RuntimeError("boom") + + monkeypatch.setattr(execution_api, "build_cohort", lambda *args, **kwargs: object()) + monkeypatch.setattr(compat_module, "project_to_ohdsi_cohort_table", lambda relation, cohort_id: relation) + + executor = IbisExecutor( + _AppendBackend(), + ExecutionOptions(cdm_schema="main", result_schema="main", cohort_id=7), + ) + + with pytest.raises(ExecutionError, match="failed reading existing table 'cohort_out' for append"): + executor.write(_expression(), table="cohort_out", append=True, overwrite=False) diff --git a/tests/execution/test_lower_contracts.py b/tests/execution/test_lower_contracts.py new file mode 100644 index 00000000..576de634 --- /dev/null +++ b/tests/execution/test_lower_contracts.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import pytest + +from circe.cohortdefinition import ConditionOccurrence +from circe.execution.lower.criteria import lower_criterion +from circe.execution.normalize.criteria import normalize_criterion +from circe.execution.plan.events import ( + FilterByCodeset, + FilterByPersonEthnicity, + FilterByPersonRace, + StandardizeEventShape, +) +from circe.vocabulary import Concept +from tests.execution._domain_cases import domain_criteria_cases + + +@pytest.mark.parametrize(("source_table", "factory", "concept_id"), domain_criteria_cases()) +def test_lower_contract_emits_source_and_standardization( + source_table, + factory, + concept_id, +): + criteria = factory() + normalized = normalize_criterion(criteria) + plan = lower_criterion(normalized, criterion_index=17) + + assert plan.source.table_name == source_table + assert plan.criterion_type == criteria.__class__.__name__ + assert any(isinstance(step, StandardizeEventShape) for step in plan.steps) + + has_codeset_step = any(isinstance(step, FilterByCodeset) for step in plan.steps) + assert has_codeset_step is (concept_id is not None) + + +def test_lower_contract_emits_person_race_and_ethnicity_steps_when_present(): + criteria = ConditionOccurrence(codeset_id=1) + criteria.__dict__["race"] = [Concept(conceptId=8527)] + criteria.__dict__["ethnicity"] = [Concept(conceptId=38003564)] + + plan = lower_criterion(normalize_criterion(criteria), criterion_index=18) + assert any(isinstance(step, FilterByPersonRace) for step in plan.steps) + assert any(isinstance(step, FilterByPersonEthnicity) for step in plan.steps) diff --git a/tests/execution/test_lowering.py b/tests/execution/test_lowering.py new file mode 100644 index 00000000..8e29a57a --- /dev/null +++ b/tests/execution/test_lowering.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import pytest + +from circe.cohortdefinition import ( + ConditionEra, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + PayerPlanPeriod, + ProcedureOccurrence, + Specimen, + VisitDetail, + VisitOccurrence, +) +from circe.execution.lower.criteria import lower_criterion +from circe.execution.normalize.criteria import normalize_criterion +from circe.execution.plan.events import ( + FilterByCareSite, + FilterByCareSiteLocationRegion, + FilterByCodeset, + FilterByConceptSet, + FilterByDateRange, + FilterByNumericRange, + FilterByPersonEthnicity, + FilterByPersonRace, + FilterByProviderSpecialty, + FilterByText, + FilterByVisit, + KeepFirstPerPerson, + StandardizeEventShape, +) +from circe.execution.plan.schema import DURATION, START_DATE +from circe.vocabulary import Concept + + +def test_lowering_condition_occurrence_emits_expected_steps(): + normalized = normalize_criterion( + ConditionOccurrence( + codeset_id=1, + first=True, + ) + ) + + plan = lower_criterion(normalized, criterion_index=3) + + assert plan.source.table_name == "condition_occurrence" + assert plan.source.concept_column == "condition_concept_id" + assert any(isinstance(step, FilterByCodeset) for step in plan.steps) + assert any(isinstance(step, KeepFirstPerPerson) for step in plan.steps) + standardize = [step for step in plan.steps if isinstance(step, StandardizeEventShape)] + assert len(standardize) == 1 + assert standardize[0].criterion_index == 3 + + +def test_lowering_measurement_emits_domain_specific_filter_steps(): + normalized = normalize_criterion( + Measurement( + codeset_id=1, + value_as_number={"op": "gte", "value": 10}, + unit=[{"conceptId": 9002}], + value_as_concept=[{"conceptId": 7002}], + ) + ) + + plan = lower_criterion(normalized, criterion_index=5) + + assert any(isinstance(step, FilterByNumericRange) for step in plan.steps) + # unit + value_as_concept should emit concept filters in addition to codeset filter + concept_steps = [step for step in plan.steps if isinstance(step, FilterByConceptSet)] + assert len(concept_steps) >= 2 + + +def test_lowering_observation_procedure_visit_detail_emit_domain_filters(): + observation_plan = lower_criterion( + normalize_criterion( + Observation( + codeset_id=1, + observation_type=[Concept(conceptId=1001)], + value_as_number={"op": "gte", "value": 2}, + value_as_string={"op": "contains", "text": "abc"}, + ) + ), + criterion_index=6, + ) + assert any(isinstance(step, FilterByConceptSet) for step in observation_plan.steps) + assert any(isinstance(step, FilterByNumericRange) for step in observation_plan.steps) + assert any(isinstance(step, FilterByText) for step in observation_plan.steps) + + procedure_plan = lower_criterion( + normalize_criterion( + ProcedureOccurrence( + codeset_id=1, + procedure_type=[Concept(conceptId=2001)], + quantity={"op": "gte", "value": 1}, + ) + ), + criterion_index=7, + ) + assert any(isinstance(step, FilterByConceptSet) for step in procedure_plan.steps) + assert any(isinstance(step, FilterByNumericRange) for step in procedure_plan.steps) + + visit_detail_plan = lower_criterion( + normalize_criterion( + VisitDetail( + codeset_id=1, + visit_detail_type=[Concept(conceptId=3001)], + discharge_to=[Concept(conceptId=3002)], + ) + ), + criterion_index=8, + ) + concept_steps = [s for s in visit_detail_plan.steps if isinstance(s, FilterByConceptSet)] + assert len(concept_steps) >= 2 + + +@pytest.mark.parametrize( + ("criteria", "table_name", "concept_column", "expects_codeset_step"), + [ + (Measurement(codeset_id=1), "measurement", "measurement_concept_id", True), + ( + ProcedureOccurrence(codeset_id=1), + "procedure_occurrence", + "procedure_concept_id", + True, + ), + (Observation(codeset_id=1), "observation", "observation_concept_id", True), + (VisitDetail(codeset_id=1), "visit_detail", "visit_detail_concept_id", True), + (DeviceExposure(codeset_id=1), "device_exposure", "device_concept_id", True), + (Specimen(codeset_id=1), "specimen", "specimen_concept_id", True), + (Death(codeset_id=1), "death", "cause_concept_id", True), + (ObservationPeriod(), "observation_period", "period_type_concept_id", False), + (PayerPlanPeriod(), "payer_plan_period", "payer_concept_id", False), + (ConditionEra(codeset_id=1), "condition_era", "condition_concept_id", True), + (DrugEra(codeset_id=1), "drug_era", "drug_concept_id", True), + (DoseEra(codeset_id=1), "dose_era", "drug_concept_id", True), + (LocationRegion(codeset_id=1), "location_history", "region_concept_id", True), + ], +) +def test_lowering_new_domains_emit_standardized_plans( + criteria, + table_name, + concept_column, + expects_codeset_step, +): + normalized = normalize_criterion(criteria) + plan = lower_criterion(normalized, criterion_index=4) + + assert plan.source.table_name == table_name + assert plan.source.concept_column == concept_column + assert any(isinstance(step, FilterByCodeset) for step in plan.steps) is expects_codeset_step + standardize = [step for step in plan.steps if isinstance(step, StandardizeEventShape)] + assert len(standardize) == 1 + + +def test_lowering_emits_race_and_ethnicity_person_filters(): + criteria = ConditionOccurrence(codeset_id=1) + criteria.__dict__["race"] = [Concept(conceptId=8527)] + criteria.__dict__["ethnicity"] = [Concept(conceptId=38003564)] + + plan = lower_criterion(normalize_criterion(criteria), criterion_index=9) + assert any(isinstance(step, FilterByPersonRace) for step in plan.steps) + assert any(isinstance(step, FilterByPersonEthnicity) for step in plan.steps) + + +def test_lowering_condition_occurrence_emits_related_filters_and_post_standardized_dates(): + normalized = normalize_criterion( + ConditionOccurrence( + codeset_id=1, + occurrence_start_date={"op": "gte", "value": "2020-01-02"}, + condition_type=[{"conceptId": 1001}], + provider_specialty=[{"conceptId": 2001}], + visit_type=[{"conceptId": 3001}], + date_adjustment={ + "startOffset": 1, + "endOffset": 2, + }, + ) + ) + + plan = lower_criterion(normalized, criterion_index=10) + + assert any(isinstance(step, FilterByConceptSet) for step in plan.steps) + assert any(isinstance(step, FilterByProviderSpecialty) for step in plan.steps) + assert any(isinstance(step, FilterByVisit) for step in plan.steps) + date_steps = [step for step in plan.steps if isinstance(step, FilterByDateRange)] + assert len(date_steps) == 1 + assert date_steps[0].column == START_DATE + standardize = next(step for step in plan.steps if isinstance(step, StandardizeEventShape)) + assert standardize.start_offset_days == 1 + assert standardize.end_offset_days == 2 + + +def test_lowering_visit_occurrence_emits_care_site_and_duration_filters(): + normalized = normalize_criterion( + VisitOccurrence( + codeset_id=1, + visit_type=[{"conceptId": 1001}], + visit_length={"op": "gte", "value": 2}, + provider_specialty=[{"conceptId": 2001}], + place_of_service=[{"conceptId": 3001}], + place_of_service_location=4, + ) + ) + + plan = lower_criterion(normalized, criterion_index=11) + + assert any(isinstance(step, FilterByProviderSpecialty) for step in plan.steps) + assert any(isinstance(step, FilterByCareSite) for step in plan.steps) + assert any(isinstance(step, FilterByCareSiteLocationRegion) for step in plan.steps) + duration_steps = [ + step for step in plan.steps if isinstance(step, FilterByNumericRange) and step.column == DURATION + ] + assert len(duration_steps) == 1 diff --git a/tests/execution/test_normalize.py b/tests/execution/test_normalize.py new file mode 100644 index 00000000..bd69e13b --- /dev/null +++ b/tests/execution/test_normalize.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from circe.cohortdefinition import ( + CohortExpression, + ConditionEra, + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + Death, + DeviceExposure, + DoseEra, + DrugEra, + InclusionRule, + LocationRegion, + Measurement, + Observation, + ObservationPeriod, + Occurrence, + PayerPlanPeriod, + PrimaryCriteria, + ProcedureOccurrence, + Specimen, + VisitDetail, +) +from circe.cohortdefinition.core import ConceptSetSelection, NumericRange +from circe.execution.normalize.cohort import normalize_cohort +from circe.execution.normalize.criteria import normalize_criterion +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _concept_set(set_id: int, include: int, exclude: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression( + items=[ + ConceptSetItem(concept=Concept(conceptId=include), isExcluded=False), + ConceptSetItem(concept=Concept(conceptId=exclude), isExcluded=True), + ] + ), + ) + + +def test_normalize_cohort_extracts_codesets_and_keeps_expression_immutable(): + expression = CohortExpression( + title="Normalize Test", + concept_sets=[_concept_set(1, include=111, exclude=999)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + ConditionOccurrence( + codeset_id=1, + first=True, + age=NumericRange(op="gte", value=18), + ) + ] + ), + ) + before = expression.model_dump_json(by_alias=True, exclude_none=False) + + normalized = normalize_cohort(expression) + + after = expression.model_dump_json(by_alias=True, exclude_none=False) + assert before == after + assert normalized.title == "Normalize Test" + assert 1 in normalized.concept_sets + assert tuple(item.concept_id for item in normalized.concept_sets[1].items) == ( + 111, + 999, + ) + assert len(normalized.primary.criteria) == 1 + criterion = normalized.primary.criteria[0] + assert criterion.criterion_type == "ConditionOccurrence" + assert criterion.codeset_id == 1 + assert criterion.first is True + assert criterion.person_filters.age is not None + + +def test_normalize_cohort_additional_criteria_group(): + expression = CohortExpression( + concept_sets=[_concept_set(1, include=111, exclude=999)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ANY", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=1), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ) + ], + ), + ) + + normalized = normalize_cohort(expression) + assert normalized.additional_criteria is not None + assert normalized.additional_criteria.mode == "ANY" + assert len(normalized.additional_criteria.criteria) == 1 + + +def test_normalize_cohort_inclusion_rules(): + expression = CohortExpression( + concept_sets=[_concept_set(1, include=111, exclude=999)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + inclusion_rules=[ + InclusionRule( + name="rule-1", + expression=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=1), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ) + ], + ), + ) + ], + ) + + normalized = normalize_cohort(expression) + assert len(normalized.inclusion_rules) == 1 + assert normalized.inclusion_rules[0].name == "rule-1" + assert normalized.inclusion_rules[0].expression is not None + + +def test_normalize_new_domains(): + cases = [ + (Measurement(codeset_id=1), "measurement"), + (ProcedureOccurrence(codeset_id=1), "procedure_occurrence"), + (Observation(codeset_id=1), "observation"), + (VisitDetail(codeset_id=1), "visit_detail"), + (DeviceExposure(codeset_id=1), "device_exposure"), + (Specimen(codeset_id=1), "specimen"), + (Death(codeset_id=1), "death"), + (ObservationPeriod(), "observation_period"), + (PayerPlanPeriod(), "payer_plan_period"), + (ConditionEra(codeset_id=1), "condition_era"), + (DrugEra(codeset_id=1), "drug_era"), + (DoseEra(codeset_id=1), "dose_era"), + (LocationRegion(codeset_id=1), "location_history"), + ] + for criteria, expected_table in cases: + normalized = normalize_criterion(criteria) + assert normalized.source_table == expected_table + + +def test_normalize_cohort_preserves_concept_set_item_expansion_flags(): + expression = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, + expression=ConceptSetExpression( + items=[ + ConceptSetItem( + concept=Concept(conceptId=111), + includeDescendants=True, + ) + ] + ), + ) + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + ) + + normalized = normalize_cohort(expression) + assert normalized.concept_sets[1].items[0].include_descendants is True + + +def test_normalize_cohort_preserves_expression_level_concept_set_flags(): + expression = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, + expression=ConceptSetExpression( + concept=Concept(conceptId=111), + includeMapped=True, + ), + ) + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + ) + + normalized = normalize_cohort(expression) + normalized_item = normalized.concept_sets[1].items[0] + assert normalized_item.concept_id == 111 + assert normalized_item.include_mapped is True + assert normalized_item.is_excluded is False + + +def test_normalize_criterion_preserves_criterion_local_correlated_criteria(): + criteria = ConditionOccurrence( + codeset_id=1, + correlated_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=1), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ) + ], + ), + ) + + normalized = normalize_criterion(criteria) + assert normalized.correlated_criteria is not None + assert normalized.correlated_criteria.mode == "ALL" + assert len(normalized.correlated_criteria.criteria) == 1 + + +def test_normalize_criterion_includes_race_and_ethnicity_person_filters(): + criteria = ConditionOccurrence(codeset_id=1) + criteria.__dict__["race"] = [Concept(conceptId=8527)] + criteria.__dict__["race_cs"] = ConceptSetSelection(codeset_id=2, is_exclusion=False) + criteria.__dict__["ethnicity"] = [Concept(conceptId=38003564)] + criteria.__dict__["ethnicity_cs"] = ConceptSetSelection( + codeset_id=3, + is_exclusion=False, + ) + + normalized = normalize_criterion(criteria) + assert normalized.person_filters.race_concept_ids == (8527,) + assert normalized.person_filters.race_codeset_id == 2 + assert normalized.person_filters.ethnicity_concept_ids == (38003564,) + assert normalized.person_filters.ethnicity_codeset_id == 3 diff --git a/tests/execution/test_normalize_contracts.py b/tests/execution/test_normalize_contracts.py new file mode 100644 index 00000000..569c2a6e --- /dev/null +++ b/tests/execution/test_normalize_contracts.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import pytest + +from circe.cohortdefinition import CohortExpression, PrimaryCriteria +from circe.execution.normalize.cohort import normalize_cohort +from circe.execution.normalize.criteria import normalize_criterion +from circe.execution.normalize.groups import NormalizedCriteriaGroup +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem +from tests.execution._domain_cases import domain_criteria_cases + + +@pytest.mark.parametrize(("source_table", "factory", "_"), domain_criteria_cases()) +def test_normalize_criterion_contract(source_table, factory, _): + criteria = factory() + normalized = normalize_criterion(criteria) + + assert normalized.criterion_type == criteria.__class__.__name__ + assert normalized.source_table == source_table + assert normalized.domain + assert normalized.event_id_column + assert normalized.start_date_column + assert normalized.end_date_column + + +@pytest.mark.parametrize(("source_table", "factory", "concept_id"), domain_criteria_cases()) +def test_normalize_cohort_does_not_mutate_public_expression( + source_table, + factory, + concept_id, +): + del source_table + + criteria = factory() + concept_sets = [] + if concept_id is not None: + concept_sets = [ + ConceptSet( + id=1, + expression=ConceptSetExpression( + items=[ConceptSetItem(concept=Concept(conceptId=concept_id))] + ), + ) + ] + + expression = CohortExpression( + concept_sets=concept_sets, + primary_criteria=PrimaryCriteria(criteria_list=[criteria]), + ) + before = expression.model_dump_json(by_alias=True, exclude_none=False) + + normalized = normalize_cohort(expression) + after = expression.model_dump_json(by_alias=True, exclude_none=False) + + assert before == after + assert len(normalized.primary.criteria) == 1 + assert isinstance(normalized.additional_criteria, (type(None), NormalizedCriteriaGroup)) diff --git a/tests/execution/test_operations.py b/tests/execution/test_operations.py new file mode 100644 index 00000000..a4ea117e --- /dev/null +++ b/tests/execution/test_operations.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from circe.execution.errors import ExecutionError +from circe.execution.ibis.operations import replace_cohort_rows_transactionally + + +class _Backend: + name = "duckdb" + compiler = SimpleNamespace(quoted=False) + + def __init__(self, *, fail_insert: bool = False): + self.fail_insert = fail_insert + self.events: list[tuple[str, object]] = [] + + def raw_sql(self, query): + sql = query.sql("duckdb") if hasattr(query, "sql") else query + self.events.append(("sql", sql)) + + def insert(self, name, obj, *, database=None, overwrite=False): + self.events.append(("insert", name, database, overwrite)) + if self.fail_insert: + raise RuntimeError("boom") + + +def test_replace_cohort_rows_transactionally_commits_on_success(): + backend = _Backend() + + replace_cohort_rows_transactionally( + object(), + backend=backend, + cohort_table="cohort_out", + results_schema="main", + cohort_id=5, + ) + + assert backend.events[0] == ("sql", "BEGIN") + assert backend.events[1][0] == "sql" + assert "DELETE FROM main.cohort_out WHERE cohort_definition_id = 5" in backend.events[1][1] + assert backend.events[2] == ("insert", "cohort_out", "main", False) + assert backend.events[3] == ("sql", "COMMIT") + + +def test_replace_cohort_rows_transactionally_rolls_back_on_insert_failure(): + backend = _Backend(fail_insert=True) + + with pytest.raises(ExecutionError, match="failed inserting relation into table 'cohort_out'"): + replace_cohort_rows_transactionally( + object(), + backend=backend, + cohort_table="cohort_out", + results_schema="main", + cohort_id=5, + ) + + assert backend.events[0] == ("sql", "BEGIN") + assert backend.events[1][0] == "sql" + assert backend.events[2] == ("insert", "cohort_out", "main", False) + assert backend.events[3] == ("sql", "ROLLBACK") diff --git a/tests/execution/test_parity_regressions.py b/tests/execution/test_parity_regressions.py new file mode 100644 index 00000000..087c11bc --- /dev/null +++ b/tests/execution/test_parity_regressions.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort_ibis +from circe.cohortdefinition import ( + CohortExpression, + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + DemographicCriteria, + Occurrence, + PrimaryCriteria, +) +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _seed_common_tables(conn, ibis, *, persons): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": list(persons), + "year_of_birth": [1980 for _ in persons], + "gender_concept_id": [8507 for _ in persons], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": list(persons), + "observation_period_id": [10 + idx for idx, _ in enumerate(persons)], + "observation_period_start_date": ["2019-01-01" for _ in persons], + "observation_period_end_date": ["2022-12-31" for _ in persons], + } + ), + overwrite=True, + ) + + +def test_parity_concept_set_expansion_with_exclusions(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2)) + conn.create_table( + "concept", + obj=ibis.memtable( + { + "concept_id": [100, 101, 102, 200, 201], + "invalid_reason": [None, None, "D", None, None], + } + ), + overwrite=True, + ) + conn.create_table( + "concept_ancestor", + obj=ibis.memtable( + { + "ancestor_concept_id": [100, 100], + "descendant_concept_id": [101, 102], + } + ), + overwrite=True, + ) + conn.create_table( + "concept_relationship", + obj=ibis.memtable( + { + "concept_id_1": [200, 201], + "concept_id_2": [100, 101], + "relationship_id": ["Maps to", "Maps to"], + "invalid_reason": [None, "D"], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1, 2], + "condition_occurrence_id": [1000, 1001, 1002, 1003], + "condition_concept_id": [100, 101, 200, 999], + "condition_start_date": [ + "2020-01-01", + "2020-01-02", + "2020-01-03", + "2020-01-01", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-02", + "2020-01-03", + "2020-01-01", + ], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, + expression=ConceptSetExpression( + items=[ + ConceptSetItem( + concept=Concept(conceptId=100), + includeDescendants=True, + includeMapped=True, + ), + ConceptSetItem( + concept=Concept(conceptId=101), + isExcluded=True, + includeMapped=True, + ), + ] + ), + ) + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + assert set(result.concept_id) == {100, 200} + + +def test_parity_primary_correlated_and_demographic_group_combination(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2, 3)) + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "year_of_birth": [1980, 1980, 1980], + "gender_concept_id": [8507, 8507, 8507], + "race_concept_id": [8527, 8527, 8516], + "ethnicity_concept_id": [38003564, 38003564, 38003564], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 2, 3, 3], + "condition_occurrence_id": [10, 11, 20, 30, 31], + "condition_concept_id": [111, 222, 111, 111, 222], + "condition_start_date": [ + "2020-01-01", + "2020-01-03", + "2020-01-01", + "2020-01-01", + "2020-01-03", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-03", + "2020-01-01", + "2020-01-01", + "2020-01-03", + ], + "visit_occurrence_id": [10, 10, 20, 30, 30], + } + ), + overwrite=True, + ) + + primary = ConditionOccurrence( + codeset_id=1, + correlated_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=2), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + ) + ], + ), + ) + expression = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=111))]), + ), + ConceptSet( + id=2, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=222))]), + ), + ], + primary_criteria=PrimaryCriteria(criteria_list=[primary]), + additional_criteria=CriteriaGroup( + type="ALL", + demographic_criteria_list=[ + DemographicCriteria( + race=[Concept(conceptId=8527)], + ethnicity=[Concept(conceptId=38003564)], + ) + ], + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} diff --git a/tests/execution/test_result_limits.py b/tests/execution/test_result_limits.py new file mode 100644 index 00000000..73a40897 --- /dev/null +++ b/tests/execution/test_result_limits.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort_ibis +from circe.cohortdefinition import ( + CohortExpression, + ConditionOccurrence, + CorelatedCriteria, + CriteriaColumn, + CriteriaGroup, + Occurrence, + PrimaryCriteria, + VisitDetail, +) +from circe.cohortdefinition.core import ResultLimit +from circe.execution import api as execution_api +from circe.execution.engine.group_operators import resolve_distinct_count_column +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _make_concept_set(set_id: int, concept_id: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), + ) + + +def _seed_common_tables(conn, ibis): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1], + "year_of_birth": [1980], + "gender_concept_id": [8507], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1], + "observation_period_id": [10], + "observation_period_start_date": ["2019-01-01"], + "observation_period_end_date": ["2022-12-31"], + } + ), + overwrite=True, + ) + + +def test_primary_limit_last_keeps_latest_primary_event(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1], + "condition_occurrence_id": [100, 101], + "condition_concept_id": [111, 111], + "condition_start_date": ["2020-01-01", "2020-02-01"], + "condition_end_date": ["2020-01-01", "2020-02-01"], + "visit_occurrence_id": [10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria( + criteria_list=[ConditionOccurrence(codeset_id=1)], + primary_limit=ResultLimit(type="LAST"), + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-02-01" + + +def test_expression_limit_last_keeps_latest_qualified_event(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1], + "condition_occurrence_id": [100, 101], + "condition_concept_id": [111, 111], + "condition_start_date": ["2020-01-01", "2020-02-01"], + "condition_end_date": ["2020-01-01", "2020-02-01"], + "visit_occurrence_id": [10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria( + criteria_list=[ConditionOccurrence(codeset_id=1)], + primary_limit=ResultLimit(type="ALL"), + ), + expression_limit=ResultLimit(type="LAST"), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-02-01" + + +def test_qualified_limit_last_applies_after_additional_criteria(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1, 1], + "condition_occurrence_id": [100, 101, 102, 103], + "condition_concept_id": [111, 111, 222, 222], + "condition_start_date": [ + "2020-01-01", + "2020-02-01", + "2020-01-02", + "2020-02-02", + ], + "condition_end_date": [ + "2020-01-01", + "2020-02-01", + "2020-01-02", + "2020-02-02", + ], + "visit_occurrence_id": [10, 10, 10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111), _make_concept_set(2, 222)], + primary_criteria=PrimaryCriteria( + criteria_list=[ConditionOccurrence(codeset_id=1)], + primary_limit=ResultLimit(type="ALL"), + ), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=2), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + restrict_visit=True, + ) + ], + ), + qualified_limit=ResultLimit(type="LAST"), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-02-01" + + +def test_write_cohort_without_results_schema_uses_backend_default(monkeypatch): + captured: dict[str, object] = {} + + def _fake_build_cohort(*args, **kwargs): + return object() + + def _fake_project_to_ohdsi_cohort_table(relation, *, cohort_id): + return relation + + def _fake_table_exists(*args, **kwargs): + return False + + def _fake_write_relation( + relation, *, backend, target_table, target_schema=None, if_exists="fail", temporary=False + ): + backend.create_table(target_table, obj=relation, overwrite=(if_exists == "replace")) + + class _Backend: + def create_table(self, name, **kwargs): + captured["name"] = name + captured["kwargs"] = kwargs + + monkeypatch.setattr(execution_api, "build_cohort", _fake_build_cohort) + monkeypatch.setattr( + execution_api, + "project_to_ohdsi_cohort_table", + _fake_project_to_ohdsi_cohort_table, + ) + monkeypatch.setattr(execution_api, "table_exists", _fake_table_exists) + monkeypatch.setattr(execution_api, "write_relation", _fake_write_relation) + + execution_api.write_cohort( + CohortExpression(primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence()])), + backend=_Backend(), + cdm_schema="cdm", + cohort_table="cohort_out", + cohort_id=1, + ) + + assert captured["name"] == "cohort_out" + assert "database" not in captured["kwargs"] + + +@pytest.mark.parametrize( + "count_column", + [ + None, + CriteriaColumn.DOMAIN_CONCEPT, + CriteriaColumn.DOMAIN_SOURCE_CONCEPT, + CriteriaColumn.VISIT_ID, + CriteriaColumn.VISIT_DETAIL_ID, + CriteriaColumn.START_DATE, + CriteriaColumn.END_DATE, + CriteriaColumn.DURATION, + CriteriaColumn.QUANTITY, + CriteriaColumn.DAYS_SUPPLY, + CriteriaColumn.REFILLS, + CriteriaColumn.RANGE_LOW, + CriteriaColumn.RANGE_HIGH, + CriteriaColumn.VALUE_AS_NUMBER, + CriteriaColumn.UNIT, + CriteriaColumn.ERA_OCCURRENCES, + CriteriaColumn.GAP_DAYS, + ], +) +def test_resolve_distinct_count_column_supports_public_count_columns(count_column): + resolved = resolve_distinct_count_column(None if count_column is None else count_column.value) + assert resolved.startswith("a_") + + +def test_distinct_count_by_visit_detail_id_matches_sql_semantics(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": ["2020-01-01"], + "condition_end_date": ["2020-01-01"], + "visit_occurrence_id": [10], + } + ), + overwrite=True, + ) + conn.create_table( + "visit_detail", + obj=ibis.memtable( + { + "person_id": [1, 1], + "visit_detail_id": [200, 201], + "visit_detail_concept_id": [222, 222], + "visit_detail_start_date": ["2020-01-01", "2020-01-01"], + "visit_detail_end_date": ["2020-01-02", "2020-01-02"], + "visit_occurrence_id": [10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111), _make_concept_set(2, 222)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=VisitDetail(codeset_id=2), + occurrence=Occurrence( + type=Occurrence._AT_LEAST, + count=2, + is_distinct=True, + count_column=CriteriaColumn.VISIT_DETAIL_ID, + ), + restrict_visit=True, + ) + ], + ), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert len(result) == 1 diff --git a/tests/execution/test_scaffolding.py b/tests/execution/test_scaffolding.py new file mode 100644 index 00000000..e7ff35e1 --- /dev/null +++ b/tests/execution/test_scaffolding.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from circe.execution.normalize.windows import NormalizedDateRange + + +def test_execution_package_imports(): + import circe.execution + import circe.execution.api + import circe.execution.engine + import circe.execution.ibis + import circe.execution.lower + import circe.execution.normalize + import circe.execution.plan + + assert hasattr(circe.execution, "build_cohort") + assert hasattr(circe.execution, "write_cohort") + assert hasattr(circe.execution, "build_cohort_ibis") + assert hasattr(circe.execution, "write_cohort_ibis") + assert circe.execution.build_cohort_ibis is circe.execution.build_cohort + assert circe.execution.write_cohort_ibis is circe.execution.write_cohort + + +def test_normalized_dataclasses_are_frozen(): + value = NormalizedDateRange(op="gte", value="2020-01-01", extent=None) + with pytest.raises(FrozenInstanceError): + value.op = "lt" diff --git a/tests/execution/test_standard_schema_contracts.py b/tests/execution/test_standard_schema_contracts.py new file mode 100644 index 00000000..8511f4ed --- /dev/null +++ b/tests/execution/test_standard_schema_contracts.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort_ibis +from circe.cohortdefinition import CohortExpression, ConditionOccurrence, PrimaryCriteria +from circe.execution.plan.schema import STANDARD_EVENT_COLUMNS +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem +from tests.execution._assertions import assert_standard_event_columns + + +def test_standard_schema_constants_define_expected_column_order(): + assert STANDARD_EVENT_COLUMNS == ( + "person_id", + "event_id", + "start_date", + "end_date", + "domain", + "concept_id", + "source_concept_id", + "visit_occurrence_id", + "visit_detail_id", + "quantity", + "days_supply", + "refills", + "range_low", + "range_high", + "value_as_number", + "unit_concept_id", + "occurrence_count", + "gap_days", + "duration", + "criterion_index", + "criterion_type", + "source_table", + ) + + +def test_standard_schema_contract_for_compiled_primary_events(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1], + "year_of_birth": [1980], + "gender_concept_id": [8507], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1], + "observation_period_id": [10], + "observation_period_start_date": ["2019-01-01"], + "observation_period_end_date": ["2022-12-31"], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": ["2020-01-01"], + "condition_end_date": ["2020-01-01"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=111))]), + ) + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + ) + + result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + assert_standard_event_columns(result.columns) From 02947448ee13b57b0531b8559cccea6c8a488f6e Mon Sep 17 00:00:00 2001 From: egillax Date: Tue, 17 Mar 2026 20:36:44 +0100 Subject: [PATCH 32/62] refactor(execution): remove polars compatibility surface --- circe/__init__.py | 3 +-- circe/execution/__init__.py | 3 +-- circe/execution/compat.py | 16 ---------------- circe/execution/ibis/__init__.py | 2 -- pyproject.toml | 1 - tests/execution/test_legacy_api_compat.py | 6 ++---- 6 files changed, 4 insertions(+), 27 deletions(-) diff --git a/circe/__init__.py b/circe/__init__.py index 2b4848a8..6a98d2ea 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -86,7 +86,7 @@ ) # Main exports -from .execution import ExecutionOptions, IbisExecutor, build_ibis, to_polars +from .execution import ExecutionOptions, IbisExecutor, build_ibis from .io import load_expression from .vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -215,5 +215,4 @@ def get_json_schema() -> dict: "ExecutionOptions", "IbisExecutor", "build_ibis", - "to_polars", ] diff --git a/circe/execution/__init__.py b/circe/execution/__init__.py index d2d28364..bb15c222 100644 --- a/circe/execution/__init__.py +++ b/circe/execution/__init__.py @@ -5,7 +5,7 @@ """ from .api import build_cohort, build_cohort_ibis, write_cohort, write_cohort_ibis -from .compat import ExecutionOptions, IbisExecutor, build_ibis, to_polars +from .compat import ExecutionOptions, IbisExecutor, build_ibis from .databricks_compat import apply_databricks_post_connect_workaround from .errors import ( CompilationError, @@ -23,7 +23,6 @@ "ExecutionOptions", "IbisExecutor", "build_ibis", - "to_polars", "apply_databricks_post_connect_workaround", "ExecutionError", "ExecutionNormalizationError", diff --git a/circe/execution/compat.py b/circe/execution/compat.py index 31e78df4..5ba03a0e 100644 --- a/circe/execution/compat.py +++ b/circe/execution/compat.py @@ -10,7 +10,6 @@ if TYPE_CHECKING: import pandas as pd - import polars as pl from ..cohortdefinition import CohortExpression @@ -74,12 +73,6 @@ def build(self, expression: ExpressionInput) -> Any: results_schema=schema_to_str(self._options.result_schema), ) - def to_polars(self, expression: ExpressionInput) -> pl.DataFrame: - table = self.build(expression) - if not hasattr(table, "to_polars"): - raise RuntimeError("The returned ibis table does not support to_polars() on this backend.") - return table.to_polars() - def to_pandas(self, expression: ExpressionInput) -> pd.DataFrame: table = self.build(expression) if not hasattr(table, "to_pandas"): @@ -171,15 +164,6 @@ def build_ibis( return executor.build(expression) -def to_polars( - expression: ExpressionInput, - conn: Any, - options: ExecutionOptions | None = None, -) -> pl.DataFrame: - with IbisExecutor(conn, options) as executor: - return executor.to_polars(expression) - - def write_cohort( expression: ExpressionInput, conn: Any, diff --git a/circe/execution/ibis/__init__.py b/circe/execution/ibis/__init__.py index b1381b6b..0b2acb9f 100644 --- a/circe/execution/ibis/__init__.py +++ b/circe/execution/ibis/__init__.py @@ -4,7 +4,6 @@ SchemaName, build_ibis, schema_to_str, - to_polars, write_cohort, ) from ..plan.schema import STANDARD_EVENT_COLUMNS @@ -22,6 +21,5 @@ "STANDARD_EVENT_COLUMNS", "schema_to_str", "standardize_event_table", - "to_polars", "write_cohort", ] diff --git a/pyproject.toml b/pyproject.toml index 5ae4b738..401d3dd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,6 @@ ibis = [ ] ibis-duckdb = [ "ibis-framework[duckdb]>=11.0.0; python_version >= '3.9'", - "polars>=0.20.0; python_version >= '3.9'", ] ibis-postgres = [ "ibis-framework[postgres]>=11.0.0; python_version >= '3.9'", diff --git a/tests/execution/test_legacy_api_compat.py b/tests/execution/test_legacy_api_compat.py index 47cdfc89..415fdda3 100644 --- a/tests/execution/test_legacy_api_compat.py +++ b/tests/execution/test_legacy_api_compat.py @@ -3,7 +3,7 @@ import pytest from circe.cohortdefinition import CohortExpression, ConditionOccurrence, PrimaryCriteria -from circe.execution import ExecutionOptions, IbisExecutor, build_ibis, to_polars +from circe.execution import ExecutionOptions, IbisExecutor, build_ibis from circe.execution.compat import write_cohort as legacy_write_cohort from circe.execution.errors import ExecutionError from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -62,7 +62,7 @@ def _seed_tables(conn, ibis) -> None: ) -def test_legacy_build_helpers_return_relations_and_polars(): +def test_legacy_build_helpers_return_relations(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -71,11 +71,9 @@ def test_legacy_build_helpers_return_relations_and_polars(): options = ExecutionOptions(cdm_schema="main") relation = build_ibis(_expression(), conn, options) - frame = to_polars(_expression(), conn, options) assert hasattr(relation, "execute") assert len(relation.execute()) == 2 - assert frame.height == 2 def test_legacy_executor_build_matches_function_wrapper(): From c18348b1d7807b705157d8dcfa0a5f70e9089525 Mon Sep 17 00:00:00 2001 From: egillax Date: Tue, 17 Mar 2026 20:50:52 +0100 Subject: [PATCH 33/62] fix(execution): tighten compatibility typing --- circe/execution/_dataclass.py | 27 +++++++++++++++++++++------ circe/execution/compat.py | 13 ++++++++++--- circe/execution/databricks_compat.py | 17 +++++++++++------ circe/execution/ibis/codesets.py | 8 +++----- circe/execution/ibis/materialize.py | 4 +++- circe/execution/ibis_compat.py | 4 ++-- circe/execution/typing.py | 12 ++++++------ 7 files changed, 56 insertions(+), 29 deletions(-) diff --git a/circe/execution/_dataclass.py b/circe/execution/_dataclass.py index 0b1c4c71..f7129f39 100644 --- a/circe/execution/_dataclass.py +++ b/circe/execution/_dataclass.py @@ -1,12 +1,27 @@ from __future__ import annotations +import sys from dataclasses import dataclass -from typing import Any, TypeVar +from typing import Any, Callable, TypeVar, cast, overload + +from typing_extensions import dataclass_transform T = TypeVar("T") -def frozen_slots_dataclass(_cls: type[T] | None = None, **kwargs: Any) -> Any: +@overload +def frozen_slots_dataclass(_cls: type[T], **kwargs: Any) -> type[T]: ... + + +@overload +def frozen_slots_dataclass(_cls: None = None, **kwargs: Any) -> Callable[[type[T]], type[T]]: ... + + +@dataclass_transform(frozen_default=True) +def frozen_slots_dataclass( + _cls: type[T] | None = None, + **kwargs: Any, +) -> type[T] | Callable[[type[T]], type[T]]: """Compatibility wrapper for frozen+slots dataclasses. `slots=True` is preferred for memory/layout guarantees, but this wrapper keeps @@ -14,10 +29,10 @@ def frozen_slots_dataclass(_cls: type[T] | None = None, **kwargs: Any) -> Any: """ def wrap(cls: type[T]) -> type[T]: - try: - return dataclass(frozen=True, slots=True, **kwargs)(cls) - except TypeError: - return dataclass(frozen=True, **kwargs)(cls) + dataclass_factory = cast(Any, dataclass) + if sys.version_info >= (3, 10): + return cast(type[T], dataclass_factory(frozen=True, slots=True, **kwargs)(cls)) + return cast(type[T], dataclass_factory(frozen=True, **kwargs)(cls)) if _cls is None: return wrap diff --git a/circe/execution/compat.py b/circe/execution/compat.py index 5ba03a0e..21e3a0b6 100644 --- a/circe/execution/compat.py +++ b/circe/execution/compat.py @@ -45,6 +45,13 @@ def schema_to_str(schema: SchemaName | None) -> str | None: return schema +def _require_cdm_schema(schema: SchemaName | None) -> str: + value = schema_to_str(schema) + if value is None: + raise ExecutionError("Ibis executor compatibility error: cdm_schema is required.") + return value + + class IbisExecutor: """Legacy object API preserved as a thin wrapper over the new executor.""" @@ -68,7 +75,7 @@ def build(self, expression: ExpressionInput) -> Any: return _build_cohort( cohort_expression, backend=self._conn, - cdm_schema=schema_to_str(self._options.cdm_schema), + cdm_schema=_require_cdm_schema(self._options.cdm_schema), vocabulary_schema=schema_to_str(self._options.vocabulary_schema), results_schema=schema_to_str(self._options.result_schema), ) @@ -105,7 +112,7 @@ def write( relation = _build_cohort( load_expression(expression), backend=self._conn, - cdm_schema=schema_to_str(self._options.cdm_schema), + cdm_schema=_require_cdm_schema(self._options.cdm_schema), vocabulary_schema=schema_to_str(self._options.vocabulary_schema), results_schema=target_schema, ) @@ -151,7 +158,7 @@ def close(self) -> None: def __enter__(self) -> IbisExecutor: return self - def __exit__(self, exc_type, exc, tb) -> None: + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: self.close() diff --git a/circe/execution/databricks_compat.py b/circe/execution/databricks_compat.py index 20bff94d..fa8375f7 100644 --- a/circe/execution/databricks_compat.py +++ b/circe/execution/databricks_compat.py @@ -2,12 +2,14 @@ import functools import inspect +from collections.abc import Callable +from typing import Any ISSUE_REFERENCE = "https://github.com/ibis-project/ibis/issues/11598" _PATCH_FLAG = "_circe_databricks_post_connect_patched" -def _databricks_backend_class(): +def _databricks_backend_class() -> type[Any] | None: try: import ibis.backends.databricks as databricks_backend except Exception: @@ -15,7 +17,7 @@ def _databricks_backend_class(): return getattr(databricks_backend, "Backend", None) -def _post_connect_needs_workaround(post_connect) -> bool: +def _post_connect_needs_workaround(post_connect: Callable[..., Any]) -> bool: try: source = inspect.getsource(post_connect).lower() except (OSError, TypeError): @@ -30,7 +32,7 @@ def _is_memtable_volume_error(exc: Exception) -> bool: return bool("memtable" in message and "volume" in message) -def _backend_looks_like_databricks(backend) -> bool: +def _backend_looks_like_databricks(backend: object) -> bool: backend_name = getattr(backend, "name", None) if isinstance(backend_name, str) and backend_name.lower() == "databricks": return True @@ -38,7 +40,10 @@ def _backend_looks_like_databricks(backend) -> bool: return "databricks" in class_name -def apply_databricks_post_connect_workaround(*, backend_cls=None) -> bool: +def apply_databricks_post_connect_workaround( + *, + backend_cls: type[Any] | None = None, +) -> bool: """ Patch Databricks backend `_post_connect` for Ibis issue #11598. @@ -66,7 +71,7 @@ def apply_databricks_post_connect_workaround(*, backend_cls=None) -> bool: return False @functools.wraps(post_connect) - def _patched_post_connect(self, *args, **kwargs): + def _patched_post_connect(self: Any, *args: Any, **kwargs: Any) -> Any: try: return post_connect(self, *args, **kwargs) except Exception as exc: @@ -79,7 +84,7 @@ def _patched_post_connect(self, *args, **kwargs): return True -def maybe_apply_databricks_post_connect_workaround(backend) -> bool: +def maybe_apply_databricks_post_connect_workaround(backend: object) -> bool: """Apply the workaround only for Databricks-like backends.""" if not _backend_looks_like_databricks(backend): return False diff --git a/circe/execution/ibis/codesets.py b/circe/execution/ibis/codesets.py index b0ec27fa..e878659e 100644 --- a/circe/execution/ibis/codesets.py +++ b/circe/execution/ibis/codesets.py @@ -7,8 +7,6 @@ from ..plan.schema import CONCEPT_ID from ..typing import Table -TableGetter = Callable[[str, str | None], Table] - class CachedConceptSetResolver: """Resolve concept sets to concrete concept IDs using vocabulary tables.""" @@ -16,7 +14,7 @@ class CachedConceptSetResolver: def __init__( self, *, - table_getter: TableGetter, + table_getter: Callable[[str, str | None], Table], vocabulary_schema: str | None, concept_sets: Mapping[int, NormalizedConceptSet], ) -> None: @@ -57,7 +55,7 @@ def _expand_item(self, item: NormalizedConceptSetItem) -> set[int]: expanded.update(self._mapped_ids(base_ids)) return expanded - def _vocabulary_table(self, table_name: str): + def _vocabulary_table(self, table_name: str) -> Table: try: return self._table_getter(table_name, self._vocabulary_schema) except Exception as exc: # pragma: no cover - backend specific error types @@ -97,7 +95,7 @@ def _mapped_ids(self, input_ids: set[int]) -> set[int]: ) return self._execute_concept_id_query(query) - def _execute_concept_id_query(self, query) -> set[int]: + def _execute_concept_id_query(self, query: Table) -> set[int]: try: rows = query.execute() except Exception as exc: # pragma: no cover - backend specific error types diff --git a/circe/execution/ibis/materialize.py b/circe/execution/ibis/materialize.py index f0ef0207..5df9e473 100644 --- a/circe/execution/ibis/materialize.py +++ b/circe/execution/ibis/materialize.py @@ -1,7 +1,9 @@ from __future__ import annotations +from ..typing import Table -def project_to_ohdsi_cohort_table(relation, *, cohort_id: int | None): + +def project_to_ohdsi_cohort_table(relation: Table, *, cohort_id: int | None) -> Table: """Project a generic cohort relation into OHDSI cohort-table shape.""" import ibis diff --git a/circe/execution/ibis_compat.py b/circe/execution/ibis_compat.py index 037dce6c..2e4b379e 100644 --- a/circe/execution/ibis_compat.py +++ b/circe/execution/ibis_compat.py @@ -14,12 +14,12 @@ def _is_nullish(value: Any) -> bool: if value is None: return True try: - return value != value + return bool(value != value) except Exception: return False -def _typed_literal(value: Any, *, dtype: str): +def _typed_literal(value: Any, *, dtype: str) -> Any: if _is_nullish(value): return ibis.null().cast(dtype) return ibis.literal(value).cast(dtype) diff --git a/circe/execution/typing.py b/circe/execution/typing.py index 4d9ead7a..edf6ba28 100644 --- a/circe/execution/typing.py +++ b/circe/execution/typing.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Protocol, TypeAlias +from typing import Any, Protocol -if TYPE_CHECKING: - from ibis.expr.types import Table as IbisTable +from typing_extensions import TypeAlias - Table: TypeAlias = IbisTable -else: # pragma: no cover - typing-only fallback when ibis is not installed - Table: TypeAlias = Any +# Ibis does not currently ship usable type information for its table expressions. +# Treat them as `Any` at the compatibility boundary rather than propagating +# `import-untyped` errors through the executor. +Table: TypeAlias = Any class IbisBackendLike(Protocol): From d8cf632d905f4d8509beff0e58eece72bb8d0f60 Mon Sep 17 00:00:00 2001 From: egillax Date: Tue, 17 Mar 2026 21:15:45 +0100 Subject: [PATCH 34/62] refactor(execution): remove legacy compatibility surface --- README.md | 5 +- circe/__init__.py | 4 - circe/api.py | 4 - circe/execution/__init__.py | 8 +- circe/execution/api.py | 21 +- circe/execution/compat.py | 193 -------------- circe/execution/ibis/__init__.py | 14 -- circe/execution/ibis/operations.py | 43 +++- circe/execution/options.py | 3 - tests/execution/test_api_ibis.py | 106 ++++---- tests/execution/test_api_public.py | 33 +-- tests/execution/test_domain_filter_parity.py | 18 +- .../execution/test_end_strategy_censoring.py | 10 +- tests/execution/test_error_messages.py | 8 +- tests/execution/test_groups.py | 10 +- tests/execution/test_inclusion.py | 6 +- tests/execution/test_legacy_api_compat.py | 169 ------------- tests/execution/test_operations.py | 79 +++++- tests/execution/test_parity_regressions.py | 6 +- tests/execution/test_result_limits.py | 10 +- tests/execution/test_scaffolding.py | 4 - .../test_standard_schema_contracts.py | 4 +- tests/test_execution_api.py | 238 ------------------ 23 files changed, 220 insertions(+), 776 deletions(-) delete mode 100644 circe/execution/compat.py delete mode 100644 circe/execution/options.py delete mode 100644 tests/execution/test_legacy_api_compat.py delete mode 100644 tests/test_execution_api.py diff --git a/README.md b/README.md index e1af7030..63dd054c 100644 --- a/README.md +++ b/README.md @@ -148,11 +148,10 @@ An experimental backend-native execution API is available under `circe.execution`. ```python -from circe.execution import ExecutionOptions, IbisExecutor +from circe.execution import build_cohort # Requires optional extras, e.g. `pip install ohdsi-circe-python-alpha[ibis-duckdb]` -executor = IbisExecutor(conn, ExecutionOptions(cdm_schema="main")) -events = executor.build(cohort) # lazy ibis relation +events = build_cohort(cohort, backend=conn, cdm_schema="main") # lazy ibis relation ``` ## What's Included diff --git a/circe/__init__.py b/circe/__init__.py index 6a98d2ea..9a09bc7a 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -86,7 +86,6 @@ ) # Main exports -from .execution import ExecutionOptions, IbisExecutor, build_ibis from .io import load_expression from .vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -212,7 +211,4 @@ def get_json_schema() -> dict: "safe_model_rebuild", # I/O helpers "load_expression", - "ExecutionOptions", - "IbisExecutor", - "build_ibis", ] diff --git a/circe/api.py b/circe/api.py index 13533e89..8b466f92 100644 --- a/circe/api.py +++ b/circe/api.py @@ -221,10 +221,6 @@ def write_cohort( ) -build_cohort_ibis = build_cohort -write_cohort_ibis = write_cohort - - def cohort_print_friendly( expression: CohortExpression, concept_sets: Optional[list[ConceptSet]] = None, diff --git a/circe/execution/__init__.py b/circe/execution/__init__.py index bb15c222..ba27df0a 100644 --- a/circe/execution/__init__.py +++ b/circe/execution/__init__.py @@ -4,8 +4,7 @@ not modify cohortdefinition model semantics. """ -from .api import build_cohort, build_cohort_ibis, write_cohort, write_cohort_ibis -from .compat import ExecutionOptions, IbisExecutor, build_ibis +from .api import build_cohort, write_cohort from .databricks_compat import apply_databricks_post_connect_workaround from .errors import ( CompilationError, @@ -18,11 +17,6 @@ __all__ = [ "build_cohort", "write_cohort", - "build_cohort_ibis", - "write_cohort_ibis", - "ExecutionOptions", - "IbisExecutor", - "build_ibis", "apply_databricks_post_connect_workaround", "ExecutionError", "ExecutionNormalizationError", diff --git a/circe/execution/api.py b/circe/execution/api.py index 24fdaedf..c49911a1 100644 --- a/circe/execution/api.py +++ b/circe/execution/api.py @@ -10,6 +10,7 @@ from .ibis.materialize import project_to_ohdsi_cohort_table from .ibis.operations import ( cohort_rows_exist, + create_table, exclude_cohort_rows, insert_relation, read_table, @@ -68,15 +69,12 @@ def write_relation( write_kwargs["temp"] = True try: - if target_schema is not None: - backend.create_table( - target_table, - database=target_schema, - **write_kwargs, - ) - return - - backend.create_table(target_table, **write_kwargs) + create_table( + backend, + table_name=target_table, + schema=target_schema, + **write_kwargs, + ) except Exception as exc: schema_label = target_schema if target_schema is not None else "" raise ExecutionError( @@ -163,8 +161,3 @@ def write_cohort( target_schema=results_schema, if_exists="replace", ) - - -# Compatibility aliases for transition period. -build_cohort_ibis = build_cohort -write_cohort_ibis = write_cohort diff --git a/circe/execution/compat.py b/circe/execution/compat.py deleted file mode 100644 index 21e3a0b6..00000000 --- a/circe/execution/compat.py +++ /dev/null @@ -1,193 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any, Mapping, Tuple, Union - -from .errors import ExecutionError -from .ibis.materialize import project_to_ohdsi_cohort_table -from .ibis.operations import table_exists - -if TYPE_CHECKING: - import pandas as pd - - from ..cohortdefinition import CohortExpression - - -SchemaName = Union[str, Tuple[str, str]] -ExpressionInput = Union["CohortExpression", Mapping[str, Any], str, Path] - - -@dataclass(frozen=True) -class ExecutionOptions: - """Legacy execution options preserved as compatibility wrappers.""" - - cdm_schema: SchemaName | None = None - vocabulary_schema: SchemaName | None = None - result_schema: SchemaName | None = None - - cohort_id: int | None = None - - materialize_stages: bool = False - materialize_codesets: bool = True - temp_emulation_schema: SchemaName | None = None - - capture_sql: bool = False - profile_dir: str | None = None - - -def schema_to_str(schema: SchemaName | None) -> str | None: - """Normalize schema names to a string representation.""" - if schema is None: - return None - if isinstance(schema, tuple): - return ".".join(schema) - return schema - - -def _require_cdm_schema(schema: SchemaName | None) -> str: - value = schema_to_str(schema) - if value is None: - raise ExecutionError("Ibis executor compatibility error: cdm_schema is required.") - return value - - -class IbisExecutor: - """Legacy object API preserved as a thin wrapper over the new executor.""" - - def __init__(self, conn: Any, options: ExecutionOptions | None = None): - self._conn = conn - self._options = options or ExecutionOptions() - - @property - def conn(self) -> Any: - return self._conn - - @property - def options(self) -> ExecutionOptions: - return self._options - - def build(self, expression: ExpressionInput) -> Any: - from ..io import load_expression - from .api import build_cohort as _build_cohort - - cohort_expression = load_expression(expression) - return _build_cohort( - cohort_expression, - backend=self._conn, - cdm_schema=_require_cdm_schema(self._options.cdm_schema), - vocabulary_schema=schema_to_str(self._options.vocabulary_schema), - results_schema=schema_to_str(self._options.result_schema), - ) - - def to_pandas(self, expression: ExpressionInput) -> pd.DataFrame: - table = self.build(expression) - if not hasattr(table, "to_pandas"): - raise RuntimeError("The returned ibis table does not support to_pandas() on this backend.") - return table.to_pandas() - - def write( - self, - expression: ExpressionInput, - *, - table: str, - schema: SchemaName | None = None, - overwrite: bool = True, - append: bool = False, - cohort_id: int | None = None, - ) -> Any: - from ..io import load_expression - from .api import build_cohort as _build_cohort - from .api import write_relation as _write_relation - - if append and overwrite: - raise ValueError("`append=True` and `overwrite=True` cannot be used together.") - - effective_cohort_id = cohort_id if cohort_id is not None else self._options.cohort_id - if effective_cohort_id is None: - raise ExecutionError( - "Ibis executor write error: cohort_id is required when writing OHDSI cohort-table rows." - ) - target_schema = schema_to_str(schema) or schema_to_str(self._options.result_schema) - relation = _build_cohort( - load_expression(expression), - backend=self._conn, - cdm_schema=_require_cdm_schema(self._options.cdm_schema), - vocabulary_schema=schema_to_str(self._options.vocabulary_schema), - results_schema=target_schema, - ) - relation = project_to_ohdsi_cohort_table( - relation, - cohort_id=effective_cohort_id, - ) - - if append and table_exists(self._conn, table_name=table, schema=target_schema): - try: - if target_schema is not None: - existing = self._conn.table(table, database=target_schema) - else: - existing = self._conn.table(table) - relation = existing.union(relation, distinct=False) - except Exception as exc: - raise ExecutionError( - f"Ibis executor write error: failed reading existing table '{table}' for append." - ) from exc - - _write_relation( - relation, - backend=self._conn, - target_table=table, - target_schema=target_schema, - if_exists="replace" if overwrite or append else "fail", - temporary=False, - ) - - try: - if target_schema is not None: - return self._conn.table(table, database=target_schema) - return self._conn.table(table) - except Exception as exc: - raise ExecutionError(f"Ibis executor write error: failed to read back table '{table}'.") from exc - - def captured_sql(self) -> list[tuple[str, str]]: - return [] - - def close(self) -> None: - return None - - def __enter__(self) -> IbisExecutor: - return self - - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: - self.close() - - -def build_ibis( - expression: ExpressionInput, - conn: Any, - options: ExecutionOptions | None = None, -) -> Any: - with IbisExecutor(conn, options) as executor: - return executor.build(expression) - - -def write_cohort( - expression: ExpressionInput, - conn: Any, - *, - table: str, - schema: SchemaName | None = None, - overwrite: bool = True, - append: bool = False, - cohort_id: int | None = None, - options: ExecutionOptions | None = None, -) -> Any: - with IbisExecutor(conn, options) as executor: - return executor.write( - expression, - table=table, - schema=schema, - overwrite=overwrite, - append=append, - cohort_id=cohort_id, - ) diff --git a/circe/execution/ibis/__init__.py b/circe/execution/ibis/__init__.py index 0b2acb9f..5f0cdbac 100644 --- a/circe/execution/ibis/__init__.py +++ b/circe/execution/ibis/__init__.py @@ -1,11 +1,3 @@ -from ..compat import ( - ExecutionOptions, - IbisExecutor, - SchemaName, - build_ibis, - schema_to_str, - write_cohort, -) from ..plan.schema import STANDARD_EVENT_COLUMNS from .compiler import compile_event_plan from .context import ExecutionContext @@ -13,13 +5,7 @@ __all__ = [ "ExecutionContext", - "ExecutionOptions", - "IbisExecutor", - "SchemaName", - "build_ibis", "compile_event_plan", "STANDARD_EVENT_COLUMNS", - "schema_to_str", "standardize_event_table", - "write_cohort", ] diff --git a/circe/execution/ibis/operations.py b/circe/execution/ibis/operations.py index cb34c58b..9aae705a 100644 --- a/circe/execution/ibis/operations.py +++ b/circe/execution/ibis/operations.py @@ -7,6 +7,15 @@ from ..typing import IbisBackendLike +def _call_with_optional_database(method, *args, database: str | None, **kwargs): + if database is not None: + try: + return method(*args, database=database, **kwargs) + except TypeError: + pass + return method(*args, **kwargs) + + def table_exists( backend: IbisBackendLike, *, @@ -37,9 +46,27 @@ def read_table( schema: str | None, ): """Read a backend table as an Ibis relation.""" - if schema is not None: - return backend.table(table_name, database=schema) - return backend.table(table_name) + return _call_with_optional_database( + backend.table, + table_name, + database=schema, + ) + + +def create_table( + backend: IbisBackendLike, + *, + table_name: str, + schema: str | None, + **kwargs, +) -> None: + """Create or overwrite a backend table with schema fallback.""" + _call_with_optional_database( + backend.create_table, + table_name, + database=schema, + **kwargs, + ) def cohort_rows_exist( @@ -157,10 +184,16 @@ def insert_relation( if not callable(insert): raise ExecutionError( "Ibis executor write error: backend does not support insert for cohort-table writes." - ) + ) try: - insert(target_table, relation, database=target_schema, overwrite=False) + _call_with_optional_database( + insert, + target_table, + relation, + database=target_schema, + overwrite=False, + ) except Exception as exc: schema_label = target_schema if target_schema is not None else "" raise ExecutionError( diff --git a/circe/execution/options.py b/circe/execution/options.py deleted file mode 100644 index 9b479827..00000000 --- a/circe/execution/options.py +++ /dev/null @@ -1,3 +0,0 @@ -from .compat import ExecutionOptions, SchemaName, schema_to_str - -__all__ = ["ExecutionOptions", "SchemaName", "schema_to_str"] diff --git a/tests/execution/test_api_ibis.py b/tests/execution/test_api_ibis.py index 0a4e77ef..29f82ada 100644 --- a/tests/execution/test_api_ibis.py +++ b/tests/execution/test_api_ibis.py @@ -2,7 +2,7 @@ import pytest -from circe.api import build_cohort_ibis +from circe.api import build_cohort from circe.cohortdefinition import ( CohortExpression, ConditionEra, @@ -99,7 +99,7 @@ def _seed_vocabulary_tables(conn, ibis): ) -def test_build_cohort_ibis_condition_occurrence(): +def test_build_cohort_condition_occurrence(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -132,7 +132,7 @@ def test_build_cohort_ibis_condition_occurrence(): ), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.columns) >= { @@ -147,7 +147,7 @@ def test_build_cohort_ibis_condition_occurrence(): assert len(result) == 1 -def test_build_cohort_ibis_condition_occurrence_with_race_and_ethnicity_filters(): +def test_build_cohort_condition_occurrence_with_race_and_ethnicity_filters(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -189,11 +189,11 @@ def test_build_cohort_ibis_condition_occurrence_with_race_and_ethnicity_filters( primary_criteria=PrimaryCriteria(criteria_list=[criteria]), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1} -def test_build_cohort_ibis_applies_criterion_local_correlated_criteria(): +def test_build_cohort_applies_criterion_local_correlated_criteria(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -232,11 +232,11 @@ def test_build_cohort_ibis_applies_criterion_local_correlated_criteria(): primary_criteria=PrimaryCriteria(criteria_list=[criteria]), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1} -def test_build_cohort_ibis_concept_set_resolves_descendants_and_mapped(): +def test_build_cohort_concept_set_resolves_descendants_and_mapped(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -294,12 +294,12 @@ def test_build_cohort_ibis_concept_set_resolves_descendants_and_mapped(): primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1} assert set(result.concept_id) == {100, 200} -def test_build_cohort_ibis_uses_vocabulary_schema_option_for_expansion(): +def test_build_cohort_uses_vocabulary_schema_option_for_expansion(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -362,7 +362,7 @@ def test_build_cohort_ibis_uses_vocabulary_schema_option_for_expansion(): primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), ) - result = build_cohort_ibis( + result = build_cohort( expression, backend=conn, cdm_schema="main", @@ -371,7 +371,7 @@ def test_build_cohort_ibis_uses_vocabulary_schema_option_for_expansion(): assert set(result.concept_id) == {100, 101} -def test_build_cohort_ibis_drug_exposure(): +def test_build_cohort_drug_exposure(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -396,14 +396,14 @@ def test_build_cohort_ibis_drug_exposure(): primary_criteria=PrimaryCriteria(criteria_list=[DrugExposure(codeset_id=2)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "drug_exposure") -def test_build_cohort_ibis_visit_occurrence(): +def test_build_cohort_visit_occurrence(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -428,14 +428,14 @@ def test_build_cohort_ibis_visit_occurrence(): primary_criteria=PrimaryCriteria(criteria_list=[VisitOccurrence(codeset_id=3)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "visit_occurrence") -def test_build_cohort_ibis_measurement(): +def test_build_cohort_measurement(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -460,14 +460,14 @@ def test_build_cohort_ibis_measurement(): primary_criteria=PrimaryCriteria(criteria_list=[Measurement(codeset_id=4)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "measurement") -def test_build_cohort_ibis_measurement_with_value_and_unit_filters(): +def test_build_cohort_measurement_with_value_and_unit_filters(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -504,14 +504,14 @@ def test_build_cohort_ibis_measurement_with_value_and_unit_filters(): ), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {2} assert all(result.domain == "measurement") -def test_build_cohort_ibis_procedure_occurrence(): +def test_build_cohort_procedure_occurrence(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -536,14 +536,14 @@ def test_build_cohort_ibis_procedure_occurrence(): primary_criteria=PrimaryCriteria(criteria_list=[ProcedureOccurrence(codeset_id=5)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "procedure_occurrence") -def test_build_cohort_ibis_procedure_occurrence_with_domain_filters(): +def test_build_cohort_procedure_occurrence_with_domain_filters(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -579,14 +579,14 @@ def test_build_cohort_ibis_procedure_occurrence_with_domain_filters(): ), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {2} assert all(result.domain == "procedure_occurrence") -def test_build_cohort_ibis_observation(): +def test_build_cohort_observation(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -611,14 +611,14 @@ def test_build_cohort_ibis_observation(): primary_criteria=PrimaryCriteria(criteria_list=[Observation(codeset_id=6)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "observation") -def test_build_cohort_ibis_observation_with_domain_filters(): +def test_build_cohort_observation_with_domain_filters(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -659,14 +659,14 @@ def test_build_cohort_ibis_observation_with_domain_filters(): ), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {2} assert all(result.domain == "observation") -def test_build_cohort_ibis_visit_detail(): +def test_build_cohort_visit_detail(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -692,14 +692,14 @@ def test_build_cohort_ibis_visit_detail(): primary_criteria=PrimaryCriteria(criteria_list=[VisitDetail(codeset_id=7)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "visit_detail") -def test_build_cohort_ibis_visit_detail_with_domain_filters(): +def test_build_cohort_visit_detail_with_domain_filters(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -735,14 +735,14 @@ def test_build_cohort_ibis_visit_detail_with_domain_filters(): ), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {2} assert all(result.domain == "visit_detail") -def test_build_cohort_ibis_device_exposure(): +def test_build_cohort_device_exposure(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -768,14 +768,14 @@ def test_build_cohort_ibis_device_exposure(): primary_criteria=PrimaryCriteria(criteria_list=[DeviceExposure(codeset_id=8)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "device_exposure") -def test_build_cohort_ibis_specimen(): +def test_build_cohort_specimen(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -800,14 +800,14 @@ def test_build_cohort_ibis_specimen(): primary_criteria=PrimaryCriteria(criteria_list=[Specimen(codeset_id=9)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "specimen") -def test_build_cohort_ibis_death(): +def test_build_cohort_death(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -831,14 +831,14 @@ def test_build_cohort_ibis_death(): primary_criteria=PrimaryCriteria(criteria_list=[Death(codeset_id=10)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "death") -def test_build_cohort_ibis_observation_period(): +def test_build_cohort_observation_period(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -849,14 +849,14 @@ def test_build_cohort_ibis_observation_period(): primary_criteria=PrimaryCriteria(criteria_list=[ObservationPeriod()]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1, 2} assert all(result.domain == "observation_period") -def test_build_cohort_ibis_payer_plan_period(): +def test_build_cohort_payer_plan_period(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -881,14 +881,14 @@ def test_build_cohort_ibis_payer_plan_period(): primary_criteria=PrimaryCriteria(criteria_list=[PayerPlanPeriod()]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "payer_plan_period") -def test_build_cohort_ibis_condition_era(): +def test_build_cohort_condition_era(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -913,14 +913,14 @@ def test_build_cohort_ibis_condition_era(): primary_criteria=PrimaryCriteria(criteria_list=[ConditionEra(codeset_id=11)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "condition_era") -def test_build_cohort_ibis_drug_era(): +def test_build_cohort_drug_era(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -945,14 +945,14 @@ def test_build_cohort_ibis_drug_era(): primary_criteria=PrimaryCriteria(criteria_list=[DrugEra(codeset_id=12)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "drug_era") -def test_build_cohort_ibis_dose_era(): +def test_build_cohort_dose_era(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -977,14 +977,14 @@ def test_build_cohort_ibis_dose_era(): primary_criteria=PrimaryCriteria(criteria_list=[DoseEra(codeset_id=13)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "dose_era") -def test_build_cohort_ibis_location_region(): +def test_build_cohort_location_region(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -1019,14 +1019,14 @@ def test_build_cohort_ibis_location_region(): primary_criteria=PrimaryCriteria(criteria_list=[LocationRegion(codeset_id=14)]), ) - table = build_cohort_ibis(expression, backend=conn, cdm_schema="main") + table = build_cohort(expression, backend=conn, cdm_schema="main") result = table.execute() assert set(result.person_id) == {1} assert all(result.domain == "location_region") -def test_build_cohort_ibis_location_region_keeps_repeated_location_history_rows(): +def test_build_cohort_location_region_keeps_repeated_location_history_rows(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -1061,17 +1061,17 @@ def test_build_cohort_ibis_location_region_keeps_repeated_location_history_rows( primary_criteria=PrimaryCriteria(criteria_list=[LocationRegion(codeset_id=14)]), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert len(result) == 2 assert set(result.person_id) == {1} assert sorted(result.start_date.astype(str).tolist()) == ["2020-01-01", "2020-02-01"] -def test_build_cohort_ibis_rejects_unsupported_features(): +def test_build_cohort_rejects_unsupported_features(): expression = CohortExpression( primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence()]), end_strategy=CustomEraStrategy(drug_codeset_id=1, gap_days=30, offset=0), ) with pytest.raises(UnsupportedFeatureError, match="custom_era"): - _ = build_cohort_ibis(expression, backend=object(), cdm_schema="main") + _ = build_cohort(expression, backend=object(), cdm_schema="main") diff --git a/tests/execution/test_api_public.py b/tests/execution/test_api_public.py index 5bdba43a..a8f074d2 100644 --- a/tests/execution/test_api_public.py +++ b/tests/execution/test_api_public.py @@ -3,12 +3,7 @@ import pytest import circe.api as api -from circe.api import ( - build_cohort, - build_cohort_ibis, - write_cohort, - write_cohort_ibis, -) +from circe.api import build_cohort, write_cohort from circe.cohortdefinition import CohortExpression, ConditionOccurrence, PrimaryCriteria from circe.execution.api import write_relation from circe.execution.errors import ExecutionError @@ -68,17 +63,13 @@ def _seed_tables(conn, ibis): ) -def test_public_aliases_resolve_to_canonical_functions(): +def test_public_execution_functions_are_exported(): assert hasattr(api, "build_cohort") assert hasattr(api, "write_cohort") assert hasattr(api, "build_cohort_query") - assert hasattr(api, "build_cohort_ibis") - assert hasattr(api, "write_cohort_ibis") - assert build_cohort_ibis is build_cohort - assert write_cohort_ibis is write_cohort -def test_build_cohort_returns_relation_and_alias_works(): +def test_build_cohort_returns_relation(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -88,10 +79,9 @@ def test_build_cohort_returns_relation_and_alias_works(): expression = _expression() relation = build_cohort(expression, backend=conn, cdm_schema="main") - alias_relation = build_cohort_ibis(expression, backend=conn, cdm_schema="main") assert hasattr(relation, "execute") - assert len(relation.execute()) == len(alias_relation.execute()) + assert len(relation.execute()) == 2 def test_write_cohort_writes_result_table(): @@ -206,7 +196,7 @@ def test_write_cohort_if_exists_replace_overwrites(): assert set(replaced_20.subject_id) == {1, 2} -def test_write_cohort_respects_results_schema_and_alias(): +def test_write_cohort_respects_results_schema(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -224,19 +214,6 @@ def test_write_cohort_respects_results_schema_and_alias(): ) assert len(conn.table("cohort_schema", database="main").execute()) == 2 - write_cohort_ibis( - _expression(), - backend=conn, - cdm_schema="main", - cohort_table="cohort_alias", - cohort_id=8, - if_exists="replace", - results_schema="main", - ) - alias_result = conn.table("cohort_alias", database="main").execute() - assert len(alias_result) == 2 - assert set(alias_result.cohort_definition_id) == {8} - def test_expression_first_build_modify_then_write_relation(): ibis = pytest.importorskip("ibis") diff --git a/tests/execution/test_domain_filter_parity.py b/tests/execution/test_domain_filter_parity.py index c05f91c8..def5b8a7 100644 --- a/tests/execution/test_domain_filter_parity.py +++ b/tests/execution/test_domain_filter_parity.py @@ -2,7 +2,7 @@ import pytest -from circe.api import build_cohort_ibis +from circe.api import build_cohort from circe.cohortdefinition import ( CohortExpression, ConditionOccurrence, @@ -94,7 +94,7 @@ def test_condition_occurrence_applies_related_filters_and_date_adjustment(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert list(result.person_id) == [1] assert result.iloc[0].start_date.date().isoformat() == "2020-01-02" @@ -167,7 +167,7 @@ def test_drug_exposure_applies_domain_filters_and_end_date_fallback(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert list(result.person_id) == [1] @@ -243,7 +243,7 @@ def test_visit_occurrence_applies_care_site_provider_location_and_duration_filte ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert list(result.person_id) == [1] @@ -307,7 +307,7 @@ def test_device_exposure_applies_domain_filters_and_end_date_fallback(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert list(result.person_id) == [1] @@ -354,7 +354,7 @@ def test_specimen_applies_domain_filters(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert list(result.person_id) == [1] @@ -391,7 +391,7 @@ def test_death_applies_death_type_and_derived_end_date(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert list(result.person_id) == [1] @@ -490,7 +490,7 @@ def test_measurement_and_visit_detail_apply_shared_related_filters(): ] ), ) - measurement_result = build_cohort_ibis( + measurement_result = build_cohort( measurement_expression, backend=conn, cdm_schema="main", @@ -516,7 +516,7 @@ def test_measurement_and_visit_detail_apply_shared_related_filters(): ] ), ) - visit_detail_result = build_cohort_ibis( + visit_detail_result = build_cohort( visit_detail_expression, backend=conn, cdm_schema="main", diff --git a/tests/execution/test_end_strategy_censoring.py b/tests/execution/test_end_strategy_censoring.py index bfdb7406..8556f413 100644 --- a/tests/execution/test_end_strategy_censoring.py +++ b/tests/execution/test_end_strategy_censoring.py @@ -2,7 +2,7 @@ import pytest -from circe.api import build_cohort_ibis +from circe.api import build_cohort from circe.cohortdefinition import CohortExpression, ConditionOccurrence, PrimaryCriteria from circe.cohortdefinition.core import CollapseSettings, DateOffsetStrategy, Period from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -68,7 +68,7 @@ def test_date_offset_end_strategy_applies_to_end_date(): end_strategy=DateOffsetStrategy(offset=30, date_field="start_date"), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert str(result.iloc[0]["end_date"])[:10] == "2020-01-31" @@ -99,7 +99,7 @@ def test_censoring_criteria_clips_end_date(): censoring_criteria=[ConditionOccurrence(codeset_id=2)], ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert str(result.iloc[0]["end_date"])[:10] == "2020-01-10" @@ -131,7 +131,7 @@ def test_censor_window_clips_start_and_end_dates(): censor_window=Period(start_date="2020-01-05", end_date="2020-01-20"), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert str(result.iloc[0]["start_date"])[:10] == "2020-01-05" assert str(result.iloc[0]["end_date"])[:10] == "2020-01-20" @@ -164,7 +164,7 @@ def test_collapse_settings_era_merges_intervals(): collapse_settings=CollapseSettings(era_pad=2), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.columns) == {"person_id", "start_date", "end_date"} assert len(result) == 1 assert str(result.iloc[0]["start_date"])[:10] == "2020-01-01" diff --git a/tests/execution/test_error_messages.py b/tests/execution/test_error_messages.py index 6f7efb7a..80133b45 100644 --- a/tests/execution/test_error_messages.py +++ b/tests/execution/test_error_messages.py @@ -2,7 +2,7 @@ import pytest -from circe.api import build_cohort_ibis +from circe.api import build_cohort from circe.cohortdefinition import ( CohortExpression, ConditionOccurrence, @@ -62,7 +62,7 @@ def test_error_message_for_custom_era_end_strategy(): ) with pytest.raises(UnsupportedFeatureError, match="custom_era end strategy"): - _ = build_cohort_ibis(expression, backend=object(), cdm_schema="main") + _ = build_cohort(expression, backend=object(), cdm_schema="main") def test_error_message_for_unsupported_criterion_type(): @@ -102,7 +102,7 @@ def test_error_message_for_unsupported_numeric_op_during_compilation(): ) with pytest.raises(CompilationError, match="compilation error: unsupported numeric range op"): - _ = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + _ = build_cohort(expression, backend=conn, cdm_schema="main").execute() def test_error_message_for_unsupported_demographic_numeric_op(): @@ -145,4 +145,4 @@ def test_error_message_for_unsupported_demographic_numeric_op(): UnsupportedFeatureError, match="group evaluation error: unsupported demographic numeric range op", ): - _ = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + _ = build_cohort(expression, backend=conn, cdm_schema="main").execute() diff --git a/tests/execution/test_groups.py b/tests/execution/test_groups.py index 01737b9a..e739793b 100644 --- a/tests/execution/test_groups.py +++ b/tests/execution/test_groups.py @@ -2,7 +2,7 @@ import pytest -from circe.api import build_cohort_ibis +from circe.api import build_cohort from circe.cohortdefinition import ( CohortExpression, ConditionOccurrence, @@ -86,7 +86,7 @@ def test_additional_criteria_all_filters_primary_events(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1} @@ -159,7 +159,7 @@ def test_additional_group_operators(group_type, count, expected_persons): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == expected_persons @@ -215,7 +215,7 @@ def test_correlated_criteria_respects_restrict_visit_and_start_window(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() # Person 1 matches (same visit, +5 days). Person 2 fails (different visit and +9 days). assert set(result.person_id) == {1} @@ -271,5 +271,5 @@ def test_additional_demographic_criteria_groups_filter_primary_events(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1} diff --git a/tests/execution/test_inclusion.py b/tests/execution/test_inclusion.py index 36c64777..46ce20ee 100644 --- a/tests/execution/test_inclusion.py +++ b/tests/execution/test_inclusion.py @@ -2,7 +2,7 @@ import pytest -from circe.api import build_cohort_ibis +from circe.api import build_cohort from circe.cohortdefinition import ( CohortExpression, ConditionOccurrence, @@ -120,7 +120,7 @@ def test_inclusion_rules_require_all_rules_to_match(): ], ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {3} @@ -151,5 +151,5 @@ def test_inclusion_rule_without_expression_is_noop(): inclusion_rules=[InclusionRule(name="empty", expression=None)], ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1, 2} diff --git a/tests/execution/test_legacy_api_compat.py b/tests/execution/test_legacy_api_compat.py deleted file mode 100644 index 415fdda3..00000000 --- a/tests/execution/test_legacy_api_compat.py +++ /dev/null @@ -1,169 +0,0 @@ -from __future__ import annotations - -import pytest - -from circe.cohortdefinition import CohortExpression, ConditionOccurrence, PrimaryCriteria -from circe.execution import ExecutionOptions, IbisExecutor, build_ibis -from circe.execution.compat import write_cohort as legacy_write_cohort -from circe.execution.errors import ExecutionError -from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem - - -def _make_concept_set(set_id: int, concept_id: int) -> ConceptSet: - return ConceptSet( - id=set_id, - expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), - ) - - -def _expression() -> CohortExpression: - return CohortExpression( - concept_sets=[_make_concept_set(1, 111)], - primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), - ) - - -def _seed_tables(conn, ibis) -> None: - conn.create_table( - "person", - obj=ibis.memtable( - { - "person_id": [1, 2], - "year_of_birth": [1980, 1982], - "gender_concept_id": [8507, 8507], - } - ), - overwrite=True, - ) - conn.create_table( - "observation_period", - obj=ibis.memtable( - { - "person_id": [1, 2], - "observation_period_id": [10, 11], - "observation_period_start_date": ["2019-01-01", "2019-01-01"], - "observation_period_end_date": ["2021-12-31", "2021-12-31"], - } - ), - overwrite=True, - ) - conn.create_table( - "condition_occurrence", - obj=ibis.memtable( - { - "person_id": [1, 2], - "condition_occurrence_id": [100, 101], - "condition_concept_id": [111, 111], - "condition_start_date": ["2020-01-01", "2020-01-02"], - "condition_end_date": ["2020-01-01", "2020-01-02"], - } - ), - overwrite=True, - ) - - -def test_legacy_build_helpers_return_relations(): - ibis = pytest.importorskip("ibis") - _ = pytest.importorskip("duckdb") - - conn = ibis.duckdb.connect() - _seed_tables(conn, ibis) - options = ExecutionOptions(cdm_schema="main") - - relation = build_ibis(_expression(), conn, options) - - assert hasattr(relation, "execute") - assert len(relation.execute()) == 2 - - -def test_legacy_executor_build_matches_function_wrapper(): - ibis = pytest.importorskip("ibis") - _ = pytest.importorskip("duckdb") - - conn = ibis.duckdb.connect() - _seed_tables(conn, ibis) - options = ExecutionOptions(cdm_schema="main") - - executor = IbisExecutor(conn, options) - via_executor = executor.build(_expression()).execute() - via_function = build_ibis(_expression(), conn, options).execute() - - assert len(via_executor) == len(via_function) == 2 - assert set(via_executor.person_id) == {1, 2} - assert executor.captured_sql() == [] - - -def test_legacy_write_cohort_projects_ohdsi_columns(): - ibis = pytest.importorskip("ibis") - _ = pytest.importorskip("duckdb") - - conn = ibis.duckdb.connect() - _seed_tables(conn, ibis) - - legacy_write_cohort( - _expression(), - conn, - table="cohort_legacy", - schema="main", - overwrite=True, - cohort_id=77, - options=ExecutionOptions(cdm_schema="main"), - ) - - result = conn.table("cohort_legacy", database="main").execute() - - assert list(result.columns) == [ - "cohort_definition_id", - "subject_id", - "cohort_start_date", - "cohort_end_date", - ] - assert set(result["cohort_definition_id"]) == {77} - assert set(result["subject_id"]) == {1, 2} - - -def test_legacy_executor_write_uses_options_cohort_id_default(): - ibis = pytest.importorskip("ibis") - _ = pytest.importorskip("duckdb") - - conn = ibis.duckdb.connect() - _seed_tables(conn, ibis) - - executor = IbisExecutor( - conn, - ExecutionOptions(cdm_schema="main", result_schema="main", cohort_id=91), - ) - executor.write(_expression(), table="cohort_from_executor", overwrite=True) - - result = conn.table("cohort_from_executor", database="main").execute() - assert set(result["cohort_definition_id"]) == {91} - - -def test_legacy_executor_write_requires_cohort_id(): - executor = IbisExecutor(object(), ExecutionOptions()) - - with pytest.raises(ExecutionError, match="cohort_id is required"): - executor.write(_expression(), table="cohort_out", overwrite=True) - - -def test_legacy_append_raises_if_existing_table_cannot_be_read(monkeypatch: pytest.MonkeyPatch): - import circe.execution.api as execution_api - import circe.execution.compat as compat_module - - class _AppendBackend: - def list_tables(self, database=None): - return ["cohort_out"] - - def table(self, name, database=None): - raise RuntimeError("boom") - - monkeypatch.setattr(execution_api, "build_cohort", lambda *args, **kwargs: object()) - monkeypatch.setattr(compat_module, "project_to_ohdsi_cohort_table", lambda relation, cohort_id: relation) - - executor = IbisExecutor( - _AppendBackend(), - ExecutionOptions(cdm_schema="main", result_schema="main", cohort_id=7), - ) - - with pytest.raises(ExecutionError, match="failed reading existing table 'cohort_out' for append"): - executor.write(_expression(), table="cohort_out", append=True, overwrite=False) diff --git a/tests/execution/test_operations.py b/tests/execution/test_operations.py index a4ea117e..6a21787b 100644 --- a/tests/execution/test_operations.py +++ b/tests/execution/test_operations.py @@ -5,7 +5,12 @@ import pytest from circe.execution.errors import ExecutionError -from circe.execution.ibis.operations import replace_cohort_rows_transactionally +from circe.execution.ibis.operations import ( + create_table, + insert_relation, + read_table, + replace_cohort_rows_transactionally, +) class _Backend: @@ -26,6 +31,29 @@ def insert(self, name, obj, *, database=None, overwrite=False): raise RuntimeError("boom") +class _SchemaFallbackBackend: + def __init__(self): + self.calls: list[tuple[str, object, object]] = [] + + def table(self, name, database=None): + self.calls.append(("table", name, database)) + if database is not None: + raise TypeError("database kwarg not supported") + return (name, None) + + def create_table(self, name, *, obj=None, database=None, overwrite=False, temp=False): + self.calls.append(("create_table", name, database)) + if database is not None: + raise TypeError("database kwarg not supported") + return None + + def insert(self, name, obj, *, database=None, overwrite=False): + self.calls.append(("insert", name, database)) + if database is not None: + raise TypeError("database kwarg not supported") + return None + + def test_replace_cohort_rows_transactionally_commits_on_success(): backend = _Backend() @@ -60,3 +88,52 @@ def test_replace_cohort_rows_transactionally_rolls_back_on_insert_failure(): assert backend.events[1][0] == "sql" assert backend.events[2] == ("insert", "cohort_out", "main", False) assert backend.events[3] == ("sql", "ROLLBACK") + + +def test_read_table_falls_back_when_backend_rejects_database_kwarg(): + backend = _SchemaFallbackBackend() + + result = read_table( + backend, + table_name="cohort_out", + schema="main", + ) + + assert result == ("cohort_out", None) + assert backend.calls == [ + ("table", "cohort_out", "main"), + ("table", "cohort_out", None), + ] + + +def test_create_table_falls_back_when_backend_rejects_database_kwarg(): + backend = _SchemaFallbackBackend() + + create_table( + backend, + table_name="cohort_out", + schema="main", + obj=object(), + overwrite=True, + ) + + assert backend.calls == [ + ("create_table", "cohort_out", "main"), + ("create_table", "cohort_out", None), + ] + + +def test_insert_relation_falls_back_when_backend_rejects_database_kwarg(): + backend = _SchemaFallbackBackend() + + insert_relation( + object(), + backend=backend, + target_table="cohort_out", + target_schema="main", + ) + + assert backend.calls == [ + ("insert", "cohort_out", "main"), + ("insert", "cohort_out", None), + ] diff --git a/tests/execution/test_parity_regressions.py b/tests/execution/test_parity_regressions.py index 087c11bc..4dec4d5f 100644 --- a/tests/execution/test_parity_regressions.py +++ b/tests/execution/test_parity_regressions.py @@ -2,7 +2,7 @@ import pytest -from circe.api import build_cohort_ibis +from circe.api import build_cohort from circe.cohortdefinition import ( CohortExpression, ConditionOccurrence, @@ -126,7 +126,7 @@ def test_parity_concept_set_expansion_with_exclusions(): primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1} assert set(result.concept_id) == {100, 200} @@ -212,5 +212,5 @@ def test_parity_primary_correlated_and_demographic_group_combination(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1} diff --git a/tests/execution/test_result_limits.py b/tests/execution/test_result_limits.py index 73a40897..15d835b9 100644 --- a/tests/execution/test_result_limits.py +++ b/tests/execution/test_result_limits.py @@ -2,7 +2,7 @@ import pytest -from circe.api import build_cohort_ibis +from circe.api import build_cohort from circe.cohortdefinition import ( CohortExpression, ConditionOccurrence, @@ -81,7 +81,7 @@ def test_primary_limit_last_keeps_latest_primary_event(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert len(result) == 1 assert str(result.iloc[0]["start_date"])[:10] == "2020-02-01" @@ -116,7 +116,7 @@ def test_expression_limit_last_keeps_latest_qualified_event(): expression_limit=ResultLimit(type="LAST"), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert len(result) == 1 assert str(result.iloc[0]["start_date"])[:10] == "2020-02-01" @@ -171,7 +171,7 @@ def test_qualified_limit_last_applies_after_additional_criteria(): qualified_limit=ResultLimit(type="LAST"), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert len(result) == 1 assert str(result.iloc[0]["start_date"])[:10] == "2020-02-01" @@ -301,5 +301,5 @@ def test_distinct_count_by_visit_detail_id_matches_sql_semantics(): ), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert len(result) == 1 diff --git a/tests/execution/test_scaffolding.py b/tests/execution/test_scaffolding.py index e7ff35e1..c9cdecfd 100644 --- a/tests/execution/test_scaffolding.py +++ b/tests/execution/test_scaffolding.py @@ -18,10 +18,6 @@ def test_execution_package_imports(): assert hasattr(circe.execution, "build_cohort") assert hasattr(circe.execution, "write_cohort") - assert hasattr(circe.execution, "build_cohort_ibis") - assert hasattr(circe.execution, "write_cohort_ibis") - assert circe.execution.build_cohort_ibis is circe.execution.build_cohort - assert circe.execution.write_cohort_ibis is circe.execution.write_cohort def test_normalized_dataclasses_are_frozen(): diff --git a/tests/execution/test_standard_schema_contracts.py b/tests/execution/test_standard_schema_contracts.py index 8511f4ed..7191c0b5 100644 --- a/tests/execution/test_standard_schema_contracts.py +++ b/tests/execution/test_standard_schema_contracts.py @@ -2,7 +2,7 @@ import pytest -from circe.api import build_cohort_ibis +from circe.api import build_cohort from circe.cohortdefinition import CohortExpression, ConditionOccurrence, PrimaryCriteria from circe.execution.plan.schema import STANDARD_EVENT_COLUMNS from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -88,5 +88,5 @@ def test_standard_schema_contract_for_compiled_primary_events(): primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), ) - result = build_cohort_ibis(expression, backend=conn, cdm_schema="main").execute() + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert_standard_event_columns(result.columns) diff --git a/tests/test_execution_api.py b/tests/test_execution_api.py deleted file mode 100644 index 8e6feeec..00000000 --- a/tests/test_execution_api.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Tests for experimental execution API surface.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -from circe import CohortExpression -from circe.cohortdefinition import ( - ConditionOccurrence, - CustomEraStrategy, - DateOffsetStrategy, - DrugExposure, - PayerPlanPeriod, - PrimaryCriteria, - VisitDetail, -) -from circe.execution import ExecutionOptions, IbisExecutor -from circe.execution.criteria_compat import parse_single_criteria -from circe.execution.ibis import write_cohort -from circe.execution.options import schema_to_str -from circe.io import load_expression -from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem - - -def test_execution_options_defaults(): - options = ExecutionOptions() - - assert options.cdm_schema is None - assert options.vocabulary_schema is None - assert options.result_schema is None - assert options.cohort_id is None - assert options.materialize_stages is False - assert options.materialize_codesets is True - assert options.temp_emulation_schema is None - assert options.capture_sql is False - assert options.profile_dir is None - - -def test_schema_to_str_with_tuple_schema(): - assert schema_to_str(("catalog", "schema")) == "catalog.schema" - - -def test_load_expression_from_mapping(): - expression = load_expression({"Title": "Mapping Input"}) - assert isinstance(expression, CohortExpression) - assert expression.title == "Mapping Input" - - -def test_load_expression_from_path(tmp_path: Path): - payload = {"Title": "File Input"} - path = tmp_path / "cohort.json" - path.write_text(json.dumps(payload), encoding="utf-8") - - expression = load_expression(path) - assert isinstance(expression, CohortExpression) - assert expression.title == "File Input" - - -def test_ibis_executor_missing_optional_dependencies(monkeypatch): - class DummyConn: - pass - - import builtins - import sys - - real_import = builtins.__import__ - sys.modules.pop("circe.execution.build_context", None) - - def _import(name, *args, **kwargs): - if name.endswith("build_context"): - raise ModuleNotFoundError("No module named 'ibis'") - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", _import) - - executor = IbisExecutor(DummyConn(), ExecutionOptions()) - - with pytest.raises(RuntimeError, match="requires optional dependencies"): - executor.build({"Title": "No Backend"}) - - -def test_criteria_compat_methods_available(): - criteria = DrugExposure() - - assert criteria.get_primary_key_column() == "drug_exposure_id" - assert criteria.get_start_date_column() == "drug_exposure_start_date" - assert criteria.get_end_date_column() == "drug_exposure_end_date" - assert criteria.get_concept_id_column() == "drug_concept_id" - - -def test_parse_single_criteria_wrapper(): - parsed = parse_single_criteria({"ConditionOccurrence": {"CodesetId": 10}}) - - assert isinstance(parsed, ConditionOccurrence) - assert parsed.codeset_id == 10 - - -def test_parse_single_criteria_wrapper_case_insensitive(): - parsed = parse_single_criteria({"conditionoccurrence": {"CodesetId": 11}}) - - assert isinstance(parsed, ConditionOccurrence) - assert parsed.codeset_id == 11 - - -def test_pipeline_registers_visit_detail_and_payer_plan_period_builders(): - from circe.execution.builders import pipeline as _pipeline # noqa: F401 - from circe.execution.builders.registry import get_builder - - assert callable(get_builder(VisitDetail())) - assert callable(get_builder(PayerPlanPeriod())) - - -def test_coerce_concept_set_selection_rejects_invalid_value(): - from circe.execution.builders.common import coerce_concept_set_selection - - with pytest.raises(ValueError, match="Unsupported concept set selection value"): - coerce_concept_set_selection(object()) - - -def test_write_rejects_append_and_overwrite_together(): - class DummyConn: - pass - - executor = IbisExecutor(DummyConn(), ExecutionOptions()) - - with pytest.raises(ValueError, match="cannot be used together"): - executor.write( - {"Title": "Invalid write options"}, - table="cohort", - append=True, - overwrite=True, - ) - - -def test_write_cohort_rejects_append_and_overwrite_together(): - class DummyConn: - pass - - with pytest.raises(ValueError, match="cannot be used together"): - write_cohort( - {"Title": "Invalid write options"}, - DummyConn(), - table="cohort", - append=True, - overwrite=True, - ) - - -def test_has_end_strategy_handles_polymorphic_models(): - from circe.execution.builders.common import has_end_strategy - - assert has_end_strategy(None) is False - assert has_end_strategy(DateOffsetStrategy(offset=7, date_field="StartDate")) is True - assert has_end_strategy(CustomEraStrategy(drug_codeset_id=123)) is True - - -@pytest.mark.filterwarnings( - "ignore:fetch_arrow_table\\(\\) is deprecated, use to_arrow_table\\(\\) instead\\.:DeprecationWarning" -) -def test_ibis_executor_build_smoke_duckdb(): - ibis = pytest.importorskip("ibis") - _ = pytest.importorskip("duckdb") - - conn = ibis.duckdb.connect() - - conn.create_table( - "concept", - obj=ibis.memtable( - { - "concept_id": [111, 999], - "invalid_reason": [None, "D"], - } - ), - overwrite=True, - ) - conn.create_table( - "concept_ancestor", - obj=ibis.memtable( - { - "ancestor_concept_id": [111], - "descendant_concept_id": [111], - } - ), - overwrite=True, - ) - conn.create_table( - "concept_relationship", - obj=ibis.memtable( - { - "concept_id_1": [111], - "concept_id_2": [111], - "relationship_id": ["Maps to"], - "invalid_reason": [""], - } - ), - overwrite=True, - ) - conn.create_table( - "condition_occurrence", - obj=ibis.memtable( - { - "person_id": [1], - "condition_occurrence_id": [1001], - "condition_concept_id": [111], - "condition_start_date": ["2020-01-01"], - "condition_end_date": ["2020-01-02"], - } - ), - overwrite=True, - ) - - cohort = CohortExpression( - concept_sets=[ - ConceptSet( - id=1, - expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=111))]), - ) - ], - primary_criteria=PrimaryCriteria( - criteria_list=[ConditionOccurrence(codeset_id=1)], - ), - ) - - with IbisExecutor(conn, ExecutionOptions(materialize_stages=False)) as executor: - events = executor.build(cohort) - result = events.execute() - - assert len(result) == 1 - assert set(result.columns) == { - "person_id", - "event_id", - "start_date", - "end_date", - "visit_occurrence_id", - } From eff7a3589a6fe9f38eddf8e139f59f20bfc61dbd Mon Sep 17 00:00:00 2001 From: egillax Date: Tue, 17 Mar 2026 21:20:28 +0100 Subject: [PATCH 35/62] docs(execution): remove legacy alias note --- circe/execution/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/circe/execution/README.md b/circe/execution/README.md index 2818dc4d..0d90fa7b 100644 --- a/circe/execution/README.md +++ b/circe/execution/README.md @@ -8,7 +8,6 @@ The `circe.execution` package is an experimental, table-first Ibis executor for - `build_cohort(...)` is the canonical expression-building entrypoint. - `write_cohort(...)` projects the built relation into OHDSI cohort-table shape, then writes or replaces rows for one `cohort_id` while preserving other cohorts. -- `build_cohort_ibis` and `write_cohort_ibis` are transition aliases. ## Layered Architecture From bba65e79258c4390e521ee9cc76786393b76ee16 Mon Sep 17 00:00:00 2001 From: egillax Date: Wed, 18 Mar 2026 16:33:44 +0100 Subject: [PATCH 36/62] chore: remove polars --- uv.lock | 3 --- 1 file changed, 3 deletions(-) diff --git a/uv.lock b/uv.lock index 5b22cf81..25038575 100644 --- a/uv.lock +++ b/uv.lock @@ -1869,8 +1869,6 @@ ibis-databricks = [ ibis-duckdb = [ { name = "ibis-framework", version = "11.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["duckdb"], marker = "python_full_version < '3.10'" }, { name = "ibis-framework", version = "12.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["duckdb"], marker = "python_full_version >= '3.10'" }, - { name = "polars", version = "1.36.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "polars", version = "1.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] ibis-postgres = [ { name = "ibis-framework", version = "11.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["postgres"], marker = "python_full_version < '3.10'" }, @@ -1893,7 +1891,6 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=0.18.0" }, - { name = "polars", marker = "python_full_version >= '3.9' and extra == 'ibis-duckdb'", specifier = ">=0.20.0" }, { name = "polars", marker = "extra == 'dev'", specifier = ">=0.20.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, From 2cc58e3255c59749ca21bc86921e4c7740b4e8ae Mon Sep 17 00:00:00 2001 From: egillax Date: Wed, 18 Mar 2026 16:38:12 +0100 Subject: [PATCH 37/62] fix(execution): satisfy ruff import rules --- circe/execution/ibis/codesets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/circe/execution/ibis/codesets.py b/circe/execution/ibis/codesets.py index e878659e..d286c7dd 100644 --- a/circe/execution/ibis/codesets.py +++ b/circe/execution/ibis/codesets.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, Callable, Mapping +from collections.abc import Callable, Mapping +from typing import Any from ..errors import CompilationError from ..normalize.cohort import NormalizedConceptSet, NormalizedConceptSetItem From 2b7b7b6f2378cb1f06d75fdc1a3e3b4518740145 Mon Sep 17 00:00:00 2001 From: egillax Date: Wed, 18 Mar 2026 16:38:42 +0100 Subject: [PATCH 38/62] style(execution): format ibis operations --- circe/execution/ibis/operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/circe/execution/ibis/operations.py b/circe/execution/ibis/operations.py index 9aae705a..c8ba111e 100644 --- a/circe/execution/ibis/operations.py +++ b/circe/execution/ibis/operations.py @@ -184,7 +184,7 @@ def insert_relation( if not callable(insert): raise ExecutionError( "Ibis executor write error: backend does not support insert for cohort-table writes." - ) + ) try: _call_with_optional_database( From 9990db178e7c398d8b137c08cb4ecf34fdedac4c Mon Sep 17 00:00:00 2001 From: egillax Date: Wed, 18 Mar 2026 16:48:20 +0100 Subject: [PATCH 39/62] test(execution): expand coverage and document strategy --- circe/execution/README.md | 5 + circe/execution/TESTING.md | 96 +++++++++++ tests/execution/test_context_wiring.py | 47 +++++ tests/execution/test_group_demographics.py | 105 ++++++++++++ tests/execution/test_operations.py | 189 +++++++++++++++++++++ tests/execution/test_person_filters.py | 126 ++++++++++++++ 6 files changed, 568 insertions(+) create mode 100644 circe/execution/TESTING.md create mode 100644 tests/execution/test_group_demographics.py create mode 100644 tests/execution/test_person_filters.py diff --git a/circe/execution/README.md b/circe/execution/README.md index 0d90fa7b..2607ae64 100644 --- a/circe/execution/README.md +++ b/circe/execution/README.md @@ -58,3 +58,8 @@ The resolver cache is local to an execution context run. ## Current Limitation - `custom_era` end strategy remains unsupported in this executor path. + +## Testing + +The execution test structure and expected test layers are documented in +`circe/execution/TESTING.md`. diff --git a/circe/execution/TESTING.md b/circe/execution/TESTING.md new file mode 100644 index 00000000..e6ceafe0 --- /dev/null +++ b/circe/execution/TESTING.md @@ -0,0 +1,96 @@ +# Execution Testing Strategy + +The new `circe.execution` engine should be tested in layers, with each layer +optimized for a different failure mode. + +## Goals + +- Keep the engine safe to refactor while the design is still evolving. +- Make regressions easy to localize to one layer. +- Avoid turning the test suite into a single large DuckDB integration harness. + +## Test Layers + +1. Pure normalization and lowering unit tests + +- Scope: `normalize/`, `lower/`, `plan/`, small pure helpers. +- Style: no backend, no SQL execution, frozen dataclass assertions. +- Current files: + - `tests/execution/test_normalize.py` + - `tests/execution/test_normalize_contracts.py` + - `tests/execution/test_lowering.py` + - `tests/execution/test_lower_contracts.py` + - `tests/execution/test_compile_contracts.py` + +2. Ibis helper unit tests + +- Scope: `ibis/codesets.py`, `ibis/operations.py`, `ibis/context.py`, + `ibis/standardize.py`, `engine/*` helpers that do not need full cohort runs. +- Style: fake backends where possible; DuckDB only when expression execution is + the thing under test. +- Current files: + - `tests/execution/test_context_wiring.py` + - `tests/execution/test_operations.py` + - `tests/execution/test_ibis_compat.py` + - `tests/execution/test_group_demographics.py` + - `tests/execution/test_person_filters.py` + +3. Engine semantics integration tests + +- Scope: primary events, correlated criteria, groups, inclusion rules, result + limits, end strategy, censoring, parity-sensitive orchestration. +- Style: minimal DuckDB fixtures with only the columns required for the + behavior under test. +- Current files: + - `tests/execution/test_groups.py` + - `tests/execution/test_inclusion.py` + - `tests/execution/test_result_limits.py` + - `tests/execution/test_end_strategy_censoring.py` + - `tests/execution/test_parity_regressions.py` + +4. Public API and wiring tests + +- Scope: `build_cohort`, `write_cohort`, package exports, compat shims. +- Style: verify entrypoint behavior, argument handling, and write semantics + without duplicating engine internals. +- Current files: + - `tests/execution/test_api_public.py` + - `tests/execution/test_api_ibis.py` + - `tests/execution/test_scaffolding.py` + +5. Error and limitation tests + +- Scope: explicit unsupported features, validation messages, and backend + capability failures. +- Style: assert on error type and message text where the API contract matters. +- Current files: + - `tests/execution/test_error_messages.py` + +## Rules + +- Each new execution module should get at least one direct test file in the + same layer as its responsibility. +- Prefer fake backends for capability/error branches, and DuckDB for relational + behavior. +- Keep fixtures local to a test file unless three or more files need the same + setup. +- When adding a new feature, add: + - one layer-local unit/helper test + - one end-to-end or API-level assertion if the feature crosses layers +- Parity/regression tests should stay small and named after the bug or contract + they protect. + +## Local Gate + +Use this as the normal execution-engine check: + +```bash +uv run pre-commit run --all-files +uv run pytest tests/execution -q +``` + +Before merging broader refactors, also run: + +```bash +uv run pytest +``` diff --git a/tests/execution/test_context_wiring.py b/tests/execution/test_context_wiring.py index 83da0e44..1231f121 100644 --- a/tests/execution/test_context_wiring.py +++ b/tests/execution/test_context_wiring.py @@ -1,7 +1,10 @@ from __future__ import annotations +from types import SimpleNamespace + from circe.execution.ibis.codesets import CachedConceptSetResolver from circe.execution.ibis.context import ExecutionContext, make_execution_context +from circe.execution.normalize.cohort import NormalizedConceptSet, NormalizedConceptSetItem class _BackendWithSchemaSupport: @@ -51,3 +54,47 @@ def test_make_execution_context_honors_vocabulary_schema_option_and_backend_fall assert ctx.vocabulary_schema == "vocab" assert ctx.vocabulary_table("concept") == ("concept", None) assert backend.calls == [("concept", "vocab"), ("concept", None)] + + +def test_codeset_resolver_caches_expanded_results(monkeypatch): + resolver = CachedConceptSetResolver( + table_getter=lambda name, schema: (name, schema), + vocabulary_schema="vocab", + concept_sets={ + 1: NormalizedConceptSet( + set_id=1, + items=( + NormalizedConceptSetItem( + concept_id=123, + is_excluded=False, + include_descendants=False, + include_mapped=False, + ), + ), + ) + }, + ) + calls: list[int] = [] + + def _expand(item): + calls.append(item.concept_id) + return {item.concept_id} + + monkeypatch.setattr(resolver, "_expand_item", _expand) + + assert resolver.resolve_codeset(1) == (123,) + assert resolver.resolve_codeset(1) == (123,) + assert calls == [123] + + +def test_codeset_resolver_handles_empty_and_non_dataframe_query_results(): + resolver = CachedConceptSetResolver( + table_getter=lambda name, schema: (name, schema), + vocabulary_schema="vocab", + concept_sets={}, + ) + + assert resolver._descendant_ids(set()) == set() + assert resolver._mapped_ids(set()) == set() + assert resolver._execute_concept_id_query(SimpleNamespace(execute=lambda: [1, None, 2])) == {1, 2} + assert resolver._execute_concept_id_query(SimpleNamespace(execute=lambda: 3)) == {3} diff --git a/tests/execution/test_group_demographics.py b/tests/execution/test_group_demographics.py new file mode 100644 index 00000000..d11cc730 --- /dev/null +++ b/tests/execution/test_group_demographics.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import pytest + +from circe.execution.engine.group_demographics import ( + _apply_date_predicate, + _demographic_concept_ids, + demographic_match_keys, +) +from circe.execution.errors import UnsupportedFeatureError +from circe.execution.normalize.groups import NormalizedDemographicCriteria +from circe.execution.normalize.windows import NormalizedDateRange, NormalizedNumericRange + + +class _DemographicContext: + def __init__(self, conn, *, codesets: dict[int, tuple[int, ...]] | None = None): + self.conn = conn + self.codesets = codesets or {} + + def table(self, name: str): + return self.conn.table(name) + + def concept_ids_for_codeset(self, codeset_id: int) -> tuple[int, ...]: + return self.codesets.get(codeset_id, ()) + + +def _seed_demographic_tables(conn, ibis): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "year_of_birth": [1980, 1990, 1980], + "gender_concept_id": [8507, 8507, 8532], + "race_concept_id": [8527, 8516, 8527], + "ethnicity_concept_id": [38003564, 38003564, 38003563], + } + ), + overwrite=True, + ) + conn.create_table( + "index_events", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "event_id": [10, 20, 30], + "start_date": ["2020-01-05", "2020-02-05", "2020-01-10"], + "end_date": ["2020-01-20", "2020-02-20", "2020-01-15"], + } + ), + overwrite=True, + ) + + +def test_apply_date_predicate_rejects_invalid_between_and_op(): + ibis = pytest.importorskip("ibis") + + with pytest.raises(UnsupportedFeatureError, match="between' requires an extent value"): + _apply_date_predicate( + ibis.literal("2020-01-01"), + NormalizedDateRange(op="between", value="2020-01-01", extent=None), + ) + + with pytest.raises(UnsupportedFeatureError, match="unsupported demographic date range op"): + _apply_date_predicate( + ibis.literal("2020-01-01"), + NormalizedDateRange(op="invalid", value="2020-01-01", extent=None), + ) + + +def test_demographic_concept_ids_merge_codesets_without_duplicates(): + ctx = _DemographicContext(None, codesets={1: (8507, 8532)}) + + assert _demographic_concept_ids(explicit_ids=(8507,), codeset_id=1, ctx=ctx) == (8507, 8532) + + +def test_demographic_match_keys_applies_all_supported_filters(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_demographic_tables(conn, ibis) + ctx = _DemographicContext(conn, codesets={1: (8507,), 2: (38003564,)}) + + demographic = NormalizedDemographicCriteria( + age=NormalizedNumericRange(op="gte", value=30, extent=None), + gender_codeset_id=1, + race_concept_ids=(8527,), + ethnicity_codeset_id=2, + occurrence_start_date=NormalizedDateRange( + op="between", + value="2020-01-01", + extent="2020-01-31", + ), + occurrence_end_date=NormalizedDateRange( + op="lte", + value="2020-01-31", + extent=None, + ), + ) + + result = demographic_match_keys(conn.table("index_events"), demographic, ctx).execute() + + assert list(result.person_id) == [1] + assert list(result.event_id) == [10] diff --git a/tests/execution/test_operations.py b/tests/execution/test_operations.py index 6a21787b..ce42448a 100644 --- a/tests/execution/test_operations.py +++ b/tests/execution/test_operations.py @@ -6,10 +6,17 @@ from circe.execution.errors import ExecutionError from circe.execution.ibis.operations import ( + _catalog_db_tuple, + _run_transaction_control, + cohort_rows_exist, create_table, + delete_cohort_rows, + exclude_cohort_rows, insert_relation, read_table, replace_cohort_rows_transactionally, + supports_transactional_replace, + table_exists, ) @@ -54,6 +61,87 @@ def insert(self, name, obj, *, database=None, overwrite=False): return None +class _ListTablesBackend: + def __init__(self, tables: list[str], *, reject_database: bool = False): + self.tables = tables + self.reject_database = reject_database + self.calls: list[str | None] = [] + + def list_tables(self, database=None): + self.calls.append(database) + if self.reject_database and database is not None: + raise TypeError("database kwarg not supported") + return self.tables + + +class _TableBackend: + def __init__(self, relation=None, *, fail: bool = False): + self.relation = relation + self.fail = fail + + def table(self, name, database=None): + if self.fail: + raise RuntimeError("boom") + return self.relation + + +class _CohortColumn: + def cast(self, _dtype): + return self + + def __eq__(self, other): + return ("eq", other) + + def __ne__(self, other): + return ("ne", other) + + +class _CohortRelation: + cohort_definition_id = _CohortColumn() + + def __init__(self, rows, *, fail_filter: bool = False): + self.rows = rows + self.fail_filter = fail_filter + + def filter(self, _predicate): + if self.fail_filter: + raise RuntimeError("boom") + return self + + def limit(self, _count): + return self + + def execute(self): + return self.rows + + +class _RawSqlBackend: + compiler = SimpleNamespace(quoted=False) + + def __init__(self, *, fail: bool = False): + self.fail = fail + self.calls: list[object] = [] + + def raw_sql(self, statement): + self.calls.append(statement) + if self.fail: + raise RuntimeError("boom") + + +class _CatalogBackend: + def _to_sqlglot_table(self, schema): + return f"table:{schema}" + + def _to_catalog_db_tuple(self, table): + assert table.startswith("table:") + return ("catalog", "database") + + +class _BrokenCatalogBackend: + def _to_sqlglot_table(self, _schema): + raise RuntimeError("boom") + + def test_replace_cohort_rows_transactionally_commits_on_success(): backend = _Backend() @@ -137,3 +225,104 @@ def test_insert_relation_falls_back_when_backend_rejects_database_kwarg(): ("insert", "cohort_out", "main"), ("insert", "cohort_out", None), ] + + +def test_table_exists_uses_list_tables_with_database_fallback(): + backend = _ListTablesBackend(["cohort_out"], reject_database=True) + + assert table_exists(backend, table_name="cohort_out", schema="main") is True + assert backend.calls == ["main", None] + + +def test_table_exists_falls_back_to_read_table_when_list_tables_is_unavailable(): + assert table_exists(_TableBackend(object()), table_name="cohort_out", schema="main") is True + assert table_exists(_TableBackend(fail=True), table_name="cohort_out", schema="main") is False + + +def test_cohort_rows_exist_returns_true_and_false_from_relation(): + assert ( + cohort_rows_exist( + _TableBackend(_CohortRelation([{"cohort_definition_id": 5}])), + cohort_table="cohort_out", + results_schema="main", + cohort_id=5, + ) + is True + ) + assert ( + cohort_rows_exist( + _TableBackend(_CohortRelation([])), + cohort_table="cohort_out", + results_schema="main", + cohort_id=5, + ) + is False + ) + + +def test_cohort_rows_exist_wraps_relation_errors(): + with pytest.raises(ExecutionError, match="failed checking existing rows for cohort_id=5"): + cohort_rows_exist( + _TableBackend(_CohortRelation([], fail_filter=True)), + cohort_table="cohort_out", + results_schema="main", + cohort_id=5, + ) + + +def test_delete_cohort_rows_requires_raw_sql_support(): + with pytest.raises(ExecutionError, match="does not support raw_sql for cohort-table deletes"): + delete_cohort_rows( + object(), + cohort_table="cohort_out", + results_schema="main", + cohort_id=5, + ) + + +def test_delete_cohort_rows_wraps_backend_failures(): + with pytest.raises(ExecutionError, match="failed deleting existing cohort rows"): + delete_cohort_rows( + _RawSqlBackend(fail=True), + cohort_table="cohort_out", + results_schema="main", + cohort_id=5, + ) + + +def test_supports_transactional_replace_only_for_supported_backends(): + assert supports_transactional_replace(SimpleNamespace(name="duckdb")) is True + assert supports_transactional_replace(SimpleNamespace(name="postgres")) is True + assert supports_transactional_replace(SimpleNamespace(name="sqlite")) is False + + +def test_replace_cohort_rows_transactionally_rejects_unsupported_backends(): + with pytest.raises(ExecutionError, match="does not support transactional cohort-table replace"): + replace_cohort_rows_transactionally( + object(), + backend=SimpleNamespace(name="sqlite"), + cohort_table="cohort_out", + results_schema="main", + cohort_id=5, + ) + + +def test_exclude_cohort_rows_wraps_filter_errors(): + with pytest.raises(ExecutionError, match="failed removing existing rows for cohort_id=5"): + exclude_cohort_rows(_CohortRelation([], fail_filter=True), cohort_id=5) + + +def test_run_transaction_control_requires_raw_sql_support(): + with pytest.raises(ExecutionError, match="does not support raw_sql for transactional cohort writes"): + _run_transaction_control(object(), "BEGIN") + + +def test_run_transaction_control_wraps_backend_errors(): + with pytest.raises(ExecutionError, match="failed executing transaction statement 'BEGIN'"): + _run_transaction_control(_RawSqlBackend(fail=True), "BEGIN") + + +def test_catalog_db_tuple_uses_backend_helpers_and_falls_back_cleanly(): + assert _catalog_db_tuple(_CatalogBackend(), "results") == ("catalog", "database") + assert _catalog_db_tuple(_BrokenCatalogBackend(), "results") == (None, "results") + assert _catalog_db_tuple(object(), None) == (None, None) diff --git a/tests/execution/test_person_filters.py b/tests/execution/test_person_filters.py new file mode 100644 index 00000000..2bb40cdb --- /dev/null +++ b/tests/execution/test_person_filters.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import pytest + +from circe.execution.errors import CompilationError +from circe.execution.ibis.person_filters import ( + _apply_numeric_predicate, + apply_person_age_filter, + apply_person_ethnicity_filter, + apply_person_gender_filter, + apply_person_race_filter, +) +from circe.execution.plan.predicates import NumericRangePredicate + + +class _PersonFilterContext: + def __init__(self, conn, *, codesets: dict[int, tuple[int, ...]] | None = None): + self.conn = conn + self.codesets = codesets or {} + + def table(self, name: str): + return self.conn.table(name) + + def concept_ids_for_codeset(self, codeset_id: int) -> tuple[int, ...]: + return self.codesets.get(codeset_id, ()) + + +def _seed_person_tables(conn, ibis): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "year_of_birth": [1980, 1995, 2005], + "gender_concept_id": [8507, 8532, 8507], + "race_concept_id": [8527, 8516, 8527], + "ethnicity_concept_id": [38003564, 38003563, 38003564], + } + ), + overwrite=True, + ) + conn.create_table( + "events", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "start_date": ["2020-01-01", "2020-01-01", "2020-01-01"], + } + ), + overwrite=True, + ) + + +def test_apply_person_age_filter_supports_between_predicate(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_person_tables(conn, ibis) + ctx = _PersonFilterContext(conn) + events = conn.table("events") + + result = apply_person_age_filter( + events, + ctx, + date_column="start_date", + predicate=NumericRangePredicate(op="between", value=20, extent=40), + ).execute() + + assert set(result.person_id) == {1, 2} + + +def test_apply_person_numeric_predicate_rejects_invalid_between_and_op(): + with pytest.raises(CompilationError, match="between' requires an extent value"): + _apply_numeric_predicate( + 5, + NumericRangePredicate(op="between", value=1, extent=None), + ) + + with pytest.raises(CompilationError, match="unsupported person numeric range op"): + _apply_numeric_predicate( + 5, + NumericRangePredicate(op="invalid", value=1, extent=None), + ) + + +def test_apply_person_gender_filter_returns_original_table_when_no_ids(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_person_tables(conn, ibis) + ctx = _PersonFilterContext(conn) + events = conn.table("events") + + assert apply_person_gender_filter(events, ctx, concept_ids=(), codeset_id=None) is events + + +def test_apply_person_gender_filter_merges_explicit_and_codeset_ids(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_person_tables(conn, ibis) + ctx = _PersonFilterContext(conn, codesets={1: (8507, 8532)}) + events = conn.table("events") + + result = apply_person_gender_filter(events, ctx, concept_ids=(8507,), codeset_id=1).execute() + + assert set(result.person_id) == {1, 2, 3} + + +def test_apply_person_race_and_ethnicity_filters_use_codeset_expansion(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_person_tables(conn, ibis) + ctx = _PersonFilterContext(conn, codesets={2: (8527,), 3: (38003564,)}) + events = conn.table("events") + + race_result = apply_person_race_filter(events, ctx, concept_ids=(), codeset_id=2).execute() + ethnicity_result = apply_person_ethnicity_filter(events, ctx, concept_ids=(), codeset_id=3).execute() + + assert set(race_result.person_id) == {1, 3} + assert set(ethnicity_result.person_id) == {1, 3} From 90b9b0394b9757fe5af5f12c70dbf56419359af0 Mon Sep 17 00:00:00 2001 From: egillax Date: Wed, 18 Mar 2026 16:58:00 +0100 Subject: [PATCH 40/62] docs(execution): add architecture overview --- circe/execution/ARCHITECTURE.md | 207 ++++++++++++++++++++++++++++++++ circe/execution/README.md | 5 + 2 files changed, 212 insertions(+) create mode 100644 circe/execution/ARCHITECTURE.md diff --git a/circe/execution/ARCHITECTURE.md b/circe/execution/ARCHITECTURE.md new file mode 100644 index 00000000..f37ea3c7 --- /dev/null +++ b/circe/execution/ARCHITECTURE.md @@ -0,0 +1,207 @@ +# Execution Architecture + +This document describes the design of the new `circe.execution` subsystem and +the intended boundaries between its layers. + +## Purpose + +The execution subsystem provides a backend-native, relation-first way to +evaluate `CohortExpression` models. + +The design goals are: + +- keep cohort semantics explicit and testable +- separate model normalization from backend compilation +- standardize domain events before orchestration +- make backend materialization a thin outer layer, not the core engine + +## Public Contract + +The public entrypoints are: + +- `build_cohort(...)` +- `write_cohort(...)` + +`build_cohort(...)` returns a lazy Ibis relation in the canonical execution +shape. + +`write_cohort(...)` projects the built relation into OHDSI cohort-table shape +and writes rows for one `cohort_id`. + +The write contract is cohort-scoped: + +- `if_exists="fail"` errors only if rows already exist for that `cohort_id` +- `if_exists="replace"` replaces only that `cohort_id`'s rows and preserves + rows for other cohorts in the same target table + +## Layered Design + +The subsystem is intentionally split into five layers. + +### 1. `normalize/` + +Responsibility: + +- convert public cohort-definition models into frozen internal dataclasses +- remove aliasing and optional-shape noise from downstream code +- reject explicitly unsupported semantics early + +Output: + +- normalized cohort, criteria, groups, windows, and end-strategy objects + +### 2. `lower/` + +Responsibility: + +- turn normalized criteria into backend-agnostic execution plans +- encode reusable event and predicate planning logic +- keep domain-specific lowering separate from backend-specific compilation + +Output: + +- `EventPlan` objects and normalized predicate/planning structures + +### 3. `ibis/` + +Responsibility: + +- compile lowered plans into Ibis relations +- standardize domain tables into the canonical event schema +- resolve concept sets and person filters +- provide backend operations used by the public write path + +Output: + +- canonical Ibis relations ready for cohort orchestration + +### 4. `engine/` + +Responsibility: + +- evaluate cohort semantics over canonical event relations +- handle primary events, additional criteria, inclusion rules, censoring, + limits, collapse, and end strategy + +This layer owns cohort logic. It should not need to know OMOP source-table +details once relations have been standardized. + +### 5. API materialization layer + +Responsibility: + +- connect public API calls to normalization, compilation, and engine execution +- project final relations into OHDSI cohort-table shape +- handle backend table existence checks and cohort-scoped writes + +This is intentionally thin. It should orchestrate layers, not re-implement +their logic. + +## Canonical Event Schema + +Compiled domain event relations are standardized before engine orchestration. +The canonical columns are defined in `circe/execution/plan/schema.py`. + +Important columns include: + +- `person_id` +- `event_id` +- `start_date` +- `end_date` +- `domain` +- `concept_id` +- `source_concept_id` +- `visit_occurrence_id` +- `criterion_index` +- `criterion_type` +- `source_table` + +This standardization is one of the main design differences from the legacy +builder-based path. The engine operates on one event shape instead of many +domain-specific SQL-builder shapes. + +## Data Flow + +The end-to-end flow is: + +1. `CohortExpression` +2. normalize to frozen internal dataclasses +3. lower criteria into event/predicate plans +4. compile plans into canonical Ibis relations +5. run cohort semantics in `engine/` +6. optionally materialize to OHDSI cohort-table rows + +## Codeset Resolution + +Codeset expansion is handled by `CachedConceptSetResolver`. + +Resolution semantics are: + +- direct inclusion +- descendant expansion through `concept_ancestor` +- mapped concept expansion through `concept_relationship` +- exclusion precedence after expansion + +The cache is scoped to one execution context. + +## What This Replaced + +This redesign intentionally replaces the older mutable builder/context-based +execution path. + +Removed or reduced surfaces include: + +- the legacy builder tree under `circe.execution.builders` +- the old builder-context shell +- the old compatibility-heavy execution surface +- the Polars-oriented compatibility layer + +The new subsystem is function-first rather than executor-object-first. + +## Migration Notes + +If you used the legacy execution prototype: + +- use `build_cohort(...)` to get the lazy relation +- use backend operations on that relation for inspection and collection +- use `write_cohort(...)` for cohort-table writes + +In other words: + +- SQL/dataframe inspection now happens via the returned relation and backend +- write semantics now live in `write_cohort(...)`, not a mutable executor + object + +## Current Explicit Limitation + +- `custom_era` end strategy is not implemented in this execution path + +Unsupported semantics should fail explicitly with execution-layer errors rather +than silently degrading behavior. + +## Test Strategy + +The intended test organization for this subsystem is documented in +`circe/execution/TESTING.md`. + +That document splits tests by layer: + +- normalization/lowering unit tests +- Ibis helper unit tests +- engine semantics integration tests +- public API/wiring tests +- explicit error/limitation tests + +## Reviewer Guidance + +For code review, the most useful way to read the subsystem is: + +1. `circe/execution/api.py` +2. `circe/execution/README.md` +3. `circe/execution/TESTING.md` +4. `normalize/` +5. `lower/` +6. `ibis/` +7. `engine/` + +That order matches the intended architecture rather than the directory listing. diff --git a/circe/execution/README.md b/circe/execution/README.md index 2607ae64..644f98f3 100644 --- a/circe/execution/README.md +++ b/circe/execution/README.md @@ -63,3 +63,8 @@ The resolver cache is local to an execution context run. The execution test structure and expected test layers are documented in `circe/execution/TESTING.md`. + +## Architecture + +The subsystem design, layer boundaries, and migration notes from the legacy +execution prototype are documented in `circe/execution/ARCHITECTURE.md`. From f2bb0a4c610ca814b0006992eb4be554b5efeb41 Mon Sep 17 00:00:00 2001 From: egillax Date: Thu, 19 Mar 2026 09:38:23 +0100 Subject: [PATCH 41/62] docs: refresh root readme package status --- README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 63dd054c..b1d076e6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # CIRCE Python Implementation [![Python](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org/downloads/) -[![Tests](https://img.shields.io/badge/tests-3400%2B%20passed-brightgreen)](tests/) +[![Tests](https://img.shields.io/badge/tests-passing-brightgreen)](tests/) [![codecov](https://codecov.io/gh/OHDSI/Circepy/graph/badge.svg?token=CODECOV_TOKEN)](https://codecov.io/gh/OHDSI/Circepy) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![PyPI](https://img.shields.io/badge/PyPI-ohdsi--circe--python--alpha-blue)](https://pypi.org/project/ohdsi-circe-python-alpha/) @@ -27,17 +27,15 @@ CIRCE Python provides a comprehensive toolkit for working with OMOP CDM cohort d > [!IMPORTANT] > This package is currently in **Alpha** status and undergoing rigorous parity testing against the Java implementation. -- **Version**: 0.1.0 (Alpha) -- **Tests**: 3,400+ passing -- **Coverage**: 34% (Core logic focus) +- **Version**: 0.2.0 (Alpha) +- **Tests**: Passing in CI - **Python**: 3.9+ - **License**: Apache 2.0 ## Installation > [!NOTE] -> This package is currently in private development. Install from source using Git. -> The recommended workflow uses `uv` and the checked-in `uv.lock` for a reproducible environment. +> The recommended source workflow uses `uv` and the checked-in `uv.lock` for a reproducible environment. ### From Source (Current Method) @@ -158,7 +156,7 @@ events = build_cohort(cohort, backend=conn, cdm_schema="main") # lazy ibis rela This package provides a complete Python implementation of CIRCE-BE with: -- **3,400+ passing tests** with focused coverage on core logic +- **Passing test suite** with focused coverage on core logic - **18+ SQL builders** for all OMOP CDM domains: - Condition Occurrence/Era - Drug Exposure/Era @@ -226,7 +224,7 @@ circe/ - [x] Java interoperability with camelCase/snake_case field support - [x] Cohort expression validation with 40+ checker implementations - [x] Markdown rendering for print-friendly descriptions -- [x] Full test suite (3,400+ tests) +- [x] Full test suite - [x] Type hints throughout with py.typed marker - [x] Concept set expression handling - [x] Window criteria and correlated criteria support @@ -372,7 +370,7 @@ uv run circe --help uv run pytest ``` -All 3,400+ tests should pass. +The full test suite should pass. ### Linting and Formatting From 99780d3f3dc2dadf5097c9e10a418661529accee Mon Sep 17 00:00:00 2001 From: egillax Date: Thu, 19 Mar 2026 10:03:35 +0100 Subject: [PATCH 42/62] test(execution): cover remaining helper branches --- tests/execution/test_compile_steps_helpers.py | 318 ++++++++++++++++++ tests/execution/test_databricks_compat.py | 52 ++- .../execution/test_end_strategy_censoring.py | 49 +++ 3 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 tests/execution/test_compile_steps_helpers.py diff --git a/tests/execution/test_compile_steps_helpers.py b/tests/execution/test_compile_steps_helpers.py new file mode 100644 index 00000000..4f2753e6 --- /dev/null +++ b/tests/execution/test_compile_steps_helpers.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from datetime import date +from types import SimpleNamespace + +import ibis +import pytest + +from circe.execution.engine.group_windows import apply_window_constraints, window_bound_expression +from circe.execution.errors import CompilationError, UnsupportedFeatureError +from circe.execution.ibis.compile_steps import ( + _apply_date_predicate, + _apply_numeric_predicate, + _resolve_concept_ids, + apply_step, +) +from circe.execution.normalize.windows import NormalizedWindow, NormalizedWindowBound +from circe.execution.plan.events import ( + ApplyDateAdjustment, + FilterByCareSiteLocationRegion, + FilterByCodeset, + FilterByConceptSet, + FilterByPersonGender, + FilterByText, + KeepFirstPerPerson, + RestrictToCorrelatedWindow, +) +from circe.execution.plan.predicates import DateRangePredicate, NumericRangePredicate +from circe.execution.plan.schema import END_DATE, EVENT_ID, PERSON_ID, START_DATE, VISIT_OCCURRENCE_ID + + +class _Context: + def __init__(self, conn=None, *, codesets: dict[int, tuple[int, ...]] | None = None): + self.conn = conn + self.codesets = codesets or {} + + def concept_ids_for_codeset(self, codeset_id: int) -> tuple[int, ...]: + return self.codesets.get(codeset_id, ()) + + def table(self, name: str): + if self.conn is None: + raise KeyError(name) + return self.conn.table(name) + + +def _events_table(conn): + conn.create_table( + "events", + obj=ibis.memtable( + { + PERSON_ID: [1, 1, 2], + EVENT_ID: [10, 11, 20], + START_DATE: [ + date(2020, 1, 1), + date(2020, 1, 2), + date(2020, 1, 3), + ], + END_DATE: [ + date(2020, 1, 5), + date(2020, 1, 4), + date(2020, 1, 6), + ], + VISIT_OCCURRENCE_ID: [100, 101, 200], + "concept_id": [1, 2, 3], + "text_value": ["alpha", "beta", "gamma"], + } + ), + overwrite=True, + ) + return conn.table("events") + + +@pytest.mark.parametrize( + ("predicate", "expected"), + [ + (NumericRangePredicate(op=None, value=None, extent=None), [True, True, True]), + (NumericRangePredicate(op="eq", value=2, extent=None), [False, True, False]), + (NumericRangePredicate(op="neq", value=2, extent=None), [True, False, True]), + (NumericRangePredicate(op="gt", value=1, extent=None), [False, True, True]), + (NumericRangePredicate(op="gte", value=2, extent=None), [False, True, True]), + (NumericRangePredicate(op="lt", value=3, extent=None), [True, True, False]), + (NumericRangePredicate(op="lte", value=2, extent=None), [True, True, False]), + (NumericRangePredicate(op="between", value=2, extent=3), [False, True, True]), + ], +) +def test_apply_numeric_predicate_covers_supported_ops(predicate, expected): + table = ibis.memtable({"value": [1, 2, 3]}) + result = table.select(_apply_numeric_predicate(table.value, predicate).name("matched")).execute() + assert list(result.matched) == expected + + +def test_apply_numeric_predicate_rejects_invalid_ranges(): + expr = ibis.memtable({"value": [1]}).value + + with pytest.raises(CompilationError, match="numeric range 'between' requires an extent value"): + _apply_numeric_predicate(expr, NumericRangePredicate(op="between", value=1, extent=None)) + + with pytest.raises(CompilationError, match="unsupported numeric range op"): + _apply_numeric_predicate(expr, NumericRangePredicate(op="weird", value=1, extent=None)) + + +@pytest.mark.parametrize( + ("predicate", "expected"), + [ + (DateRangePredicate(op=None, value=None, extent=None), [True, True, True]), + (DateRangePredicate(op="eq", value="2020-01-02", extent=None), [False, True, False]), + (DateRangePredicate(op="neq", value="2020-01-02", extent=None), [True, False, True]), + (DateRangePredicate(op="gt", value="2020-01-01", extent=None), [False, True, True]), + (DateRangePredicate(op="gte", value="2020-01-02", extent=None), [False, True, True]), + (DateRangePredicate(op="lt", value="2020-01-03", extent=None), [True, True, False]), + (DateRangePredicate(op="lte", value="2020-01-02", extent=None), [True, True, False]), + ( + DateRangePredicate(op="between", value="2020-01-02", extent="2020-01-03"), + [False, True, True], + ), + ], +) +def test_apply_date_predicate_covers_supported_ops(predicate, expected): + table = ibis.memtable({"value": ["2020-01-01", "2020-01-02", "2020-01-03"]}) + result = table.select(_apply_date_predicate(table.value, predicate).name("matched")).execute() + assert list(result.matched) == expected + + +def test_apply_date_predicate_rejects_invalid_ranges(): + expr = ibis.memtable({"value": ["2020-01-01"]}).value + + with pytest.raises(CompilationError, match="date range 'between' requires an extent value"): + _apply_date_predicate(expr, DateRangePredicate(op="between", value="2020-01-01", extent=None)) + + with pytest.raises(CompilationError, match="unsupported date range op"): + _apply_date_predicate(expr, DateRangePredicate(op="weird", value="2020-01-01", extent=None)) + + +def test_resolve_concept_ids_deduplicates_codeset_ids(): + ctx = _Context(codesets={1: (2, 3, 4)}) + assert _resolve_concept_ids(direct_ids=(1, 2), codeset_id=1, ctx=ctx) == (1, 2, 3, 4) + + +def test_apply_step_covers_text_codeset_concept_and_adjustment_paths(): + ibis_mod = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis_mod.duckdb.connect() + table = _events_table(conn) + ctx = _Context(conn, codesets={1: (1, 3), 2: ()}) + + codeset_hit = apply_step( + FilterByCodeset(column="concept_id", codeset_id=1), + table=table, + source=None, + ctx=ctx, + ).execute() + assert set(codeset_hit.concept_id) == {1, 3} + + codeset_exclude = apply_step( + FilterByCodeset(column="concept_id", codeset_id=2, exclude=True), + table=table, + source=None, + ctx=ctx, + ).execute() + assert len(codeset_exclude) == 3 + + empty_concepts = apply_step( + FilterByConceptSet(column="concept_id", concept_ids=(), exclude=False), + table=table, + source=None, + ctx=ctx, + ).execute() + assert empty_concepts.empty + + text_eq = apply_step( + FilterByText(column="text_value", op="eq", text="alpha"), + table=table, + source=None, + ctx=ctx, + ).execute() + assert list(text_eq.text_value) == ["alpha"] + + text_neq = apply_step( + FilterByText(column="text_value", op="neq", text="alpha"), + table=table, + source=None, + ctx=ctx, + ).execute() + assert set(text_neq.text_value) == {"beta", "gamma"} + + text_none = apply_step( + FilterByText(column="text_value", op="contains", text=None), + table=table, + source=None, + ctx=ctx, + ) + assert text_none is table + + text_like = apply_step( + FilterByText(column="text_value", op="contains", text="a"), + table=table, + source=None, + ctx=ctx, + ).execute() + assert set(text_like.text_value) == {"alpha", "beta", "gamma"} + + adjusted = apply_step( + ApplyDateAdjustment(start_offset_days=2, end_offset_days=1, start_with=END_DATE, end_with=START_DATE), + table=table, + source=None, + ctx=ctx, + ).execute() + assert str(adjusted.iloc[0][START_DATE])[:10] == "2020-01-07" + assert str(adjusted.iloc[0][END_DATE])[:10] == "2020-01-02" + + +def test_apply_step_covers_keep_first_person_filter_and_error_paths(): + ibis_mod = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis_mod.duckdb.connect() + table = _events_table(conn) + conn.create_table( + "person", + obj=ibis_mod.memtable( + { + PERSON_ID: [1, 2], + "gender_concept_id": [8507, 8532], + } + ), + overwrite=True, + ) + ctx = _Context(conn, codesets={9: ()}) + + first = apply_step( + KeepFirstPerPerson(order_by=(START_DATE,)), + table=table, + source=None, + ctx=ctx, + ).execute() + assert set(first[EVENT_ID]) == {10, 20} + + filtered = apply_step( + FilterByPersonGender(concept_ids=(8507,), codeset_id=None), + table=table, + source=None, + ctx=ctx, + ).execute() + assert set(filtered[PERSON_ID]) == {1} + + care_site_empty = apply_step( + FilterByCareSiteLocationRegion(codeset_id=9), + table=table, + source=None, + ctx=ctx, + ).execute() + assert care_site_empty.empty + + with pytest.raises(CompilationError, match="unsupported text filter op"): + apply_step(FilterByText(column="text_value", op="weird", text="x"), table=table, source=None, ctx=ctx) + + with pytest.raises(UnsupportedFeatureError, match="RestrictToCorrelatedWindow step is not implemented"): + apply_step(RestrictToCorrelatedWindow(payload={}), table=table, source=None, ctx=ctx) + + with pytest.raises(CompilationError, match="unsupported plan step"): + apply_step(SimpleNamespace(), table=table, source=None, ctx=ctx) + + +def test_window_bound_expression_and_end_window_constraints(): + ibis_mod = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + assert ( + window_bound_expression( + None, + index_anchor_expr=ibis_mod.literal("2020-01-01").cast("date"), + use_observation_period=True, + op_start_expr=ibis_mod.literal("2019-01-01").cast("date"), + op_end_expr=ibis_mod.literal("2020-12-31").cast("date"), + ) + is None + ) + assert ( + window_bound_expression( + NormalizedWindowBound(coeff=1, days=None), + index_anchor_expr=ibis_mod.literal("2020-01-01").cast("date"), + use_observation_period=False, + op_start_expr=ibis_mod.literal("2019-01-01").cast("date"), + op_end_expr=ibis_mod.literal("2020-12-31").cast("date"), + ) + is None + ) + + joined = ibis_mod.memtable( + { + "a_person_id": [1, 1], + "p_person_id": [1, 1], + "a_start_date": [date(2020, 1, 3), date(2020, 1, 20)], + "a_end_date": [date(2020, 1, 5), date(2020, 1, 25)], + "p_start_date": [date(2020, 1, 1), date(2020, 1, 1)], + "p_end_date": [date(2020, 1, 10), date(2020, 1, 10)], + "p_op_start_date": [date(2019, 1, 1), date(2019, 1, 1)], + "p_op_end_date": [date(2020, 12, 31), date(2020, 12, 31)], + "a_visit_occurrence_id": [100, 101], + "p_visit_occurrence_id": [100, 100], + } + ) + correlated = SimpleNamespace( + ignore_observation_period=False, + restrict_visit=True, + start_window=None, + end_window=NormalizedWindow( + start=NormalizedWindowBound(coeff=1, days=0), + end=NormalizedWindowBound(coeff=1, days=10), + use_event_end=False, + use_index_end=False, + ), + ) + + result = apply_window_constraints(joined, correlated).execute() + assert len(result) == 1 + assert int(result.iloc[0]["a_visit_occurrence_id"]) == 100 diff --git a/tests/execution/test_databricks_compat.py b/tests/execution/test_databricks_compat.py index d32975cd..448bd3df 100644 --- a/tests/execution/test_databricks_compat.py +++ b/tests/execution/test_databricks_compat.py @@ -2,7 +2,13 @@ import pytest -from circe.execution.databricks_compat import apply_databricks_post_connect_workaround +from circe.execution.databricks_compat import ( + _backend_looks_like_databricks, + _is_memtable_volume_error, + _post_connect_needs_workaround, + apply_databricks_post_connect_workaround, + maybe_apply_databricks_post_connect_workaround, +) def test_databricks_post_connect_workaround_swallows_memtable_volume_error(): @@ -29,3 +35,47 @@ def _post_connect(self): backend = FakeDatabricksBackend() with pytest.raises(RuntimeError, match="different setup error"): backend._post_connect() + + +def test_post_connect_needs_workaround_handles_missing_source_and_false_pattern(monkeypatch): + def _plain_post_connect(): + return None + + monkeypatch.setattr("inspect.getsource", lambda _fn: "plain setup") + assert _post_connect_needs_workaround(_plain_post_connect) is False + + monkeypatch.setattr("inspect.getsource", lambda _fn: (_ for _ in ()).throw(OSError("no source"))) + assert _post_connect_needs_workaround(_plain_post_connect) is True + + +def test_databricks_detection_helpers_cover_non_patched_paths(): + assert _is_memtable_volume_error(RuntimeError("memtable volume failure")) is True + assert _is_memtable_volume_error(RuntimeError("different failure")) is False + + assert _backend_looks_like_databricks(type("DatabricksConn", (), {})()) is True + assert _backend_looks_like_databricks(type("Backend", (), {"name": "databricks"})()) is True + assert _backend_looks_like_databricks(type("Backend", (), {"name": "duckdb"})()) is False + + +def test_apply_databricks_workaround_returns_false_when_not_patchable(): + class NoPostConnectBackend: + pass + + class PlainBackend: + def _post_connect(self): + return None + + assert apply_databricks_post_connect_workaround(backend_cls=None) is False + assert apply_databricks_post_connect_workaround(backend_cls=NoPostConnectBackend) is False + assert apply_databricks_post_connect_workaround(backend_cls=PlainBackend) is False + assert maybe_apply_databricks_post_connect_workaround(object()) is False + + +def test_apply_databricks_workaround_is_idempotent(): + class FakeDatabricksBackend: + def _post_connect(self): + raise RuntimeError("CREATE VOLUME IF NOT EXISTS my_catalog.my_schema.memtable") + + assert apply_databricks_post_connect_workaround(backend_cls=FakeDatabricksBackend) is True + assert apply_databricks_post_connect_workaround(backend_cls=FakeDatabricksBackend) is True + assert maybe_apply_databricks_post_connect_workaround(FakeDatabricksBackend()) is True diff --git a/tests/execution/test_end_strategy_censoring.py b/tests/execution/test_end_strategy_censoring.py index 8556f413..70ffec2c 100644 --- a/tests/execution/test_end_strategy_censoring.py +++ b/tests/execution/test_end_strategy_censoring.py @@ -1,10 +1,16 @@ from __future__ import annotations +from datetime import date +from types import SimpleNamespace + import pytest from circe.api import build_cohort from circe.cohortdefinition import CohortExpression, ConditionOccurrence, PrimaryCriteria from circe.cohortdefinition.core import CollapseSettings, DateOffsetStrategy, Period +from circe.execution.engine.end_strategy import apply_end_strategy +from circe.execution.errors import UnsupportedFeatureError +from circe.execution.normalize.end_strategy import NormalizedEndStrategy from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -169,3 +175,46 @@ def test_collapse_settings_era_merges_intervals(): assert len(result) == 1 assert str(result.iloc[0]["start_date"])[:10] == "2020-01-01" assert str(result.iloc[0]["end_date"])[:10] == "2020-01-03" + + +def test_apply_end_strategy_rejects_invalid_date_field_and_preserves_fallback_semantics(): + ibis_mod = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis_mod.duckdb.connect() + conn.create_table( + "events", + obj=ibis_mod.memtable( + { + "person_id": [1], + "event_id": [100], + "start_date": [date(2020, 1, 1)], + "end_date": [date(2020, 1, 5)], + "visit_occurrence_id": [10], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis_mod.memtable( + { + "person_id": [1], + "observation_period_start_date": [date(2019, 1, 1)], + "observation_period_end_date": [date(2020, 1, 10)], + } + ), + overwrite=True, + ) + ctx = SimpleNamespace(table=lambda name: conn.table(name)) + events = conn.table("events") + + with pytest.raises(UnsupportedFeatureError, match="unsupported date_offset date field"): + apply_end_strategy( + events, + NormalizedEndStrategy(kind="date_offset", payload={"offset": 1, "date_field": "weird"}), + ctx, + ).execute() + + fallback = apply_end_strategy(events, NormalizedEndStrategy(kind="unknown", payload={}), ctx).execute() + assert str(fallback.iloc[0]["end_date"])[:10] == "2020-01-10" From aca1f1ad74443e11d3280ea117e8683d7277a6ca Mon Sep 17 00:00:00 2001 From: egillax Date: Thu, 19 Mar 2026 10:10:41 +0100 Subject: [PATCH 43/62] test(execution): make keep-first helper test py39-stable --- tests/execution/test_compile_steps_helpers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/execution/test_compile_steps_helpers.py b/tests/execution/test_compile_steps_helpers.py index 4f2753e6..74a3d878 100644 --- a/tests/execution/test_compile_steps_helpers.py +++ b/tests/execution/test_compile_steps_helpers.py @@ -233,8 +233,9 @@ def test_apply_step_covers_keep_first_person_filter_and_error_paths(): table=table, source=None, ctx=ctx, - ).execute() - assert set(first[EVENT_ID]) == {10, 20} + ) + assert first.columns == table.columns + assert "row_number()" in ibis_mod.to_sql(first).lower() filtered = apply_step( FilterByPersonGender(concept_ids=(8507,), codeset_id=None), From 810b77b2418cd4e0f6e1f20ba064ee81576982b8 Mon Sep 17 00:00:00 2001 From: egillax Date: Thu, 19 Mar 2026 22:13:51 +0100 Subject: [PATCH 44/62] fix(execution): apply nested correlated criteria in groups --- circe/execution/engine/group_operators.py | 12 +++- tests/execution/test_groups.py | 78 +++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/circe/execution/engine/group_operators.py b/circe/execution/engine/group_operators.py index 391d61ce..3ed7c8dd 100644 --- a/circe/execution/engine/group_operators.py +++ b/circe/execution/engine/group_operators.py @@ -101,7 +101,17 @@ def _compile_correlated_events( ctx: ExecutionContext, ) -> Table: event_plan = lower_criterion(correlated.criterion, criterion_index=criterion_index) - return compile_event_plan(event_plan, ctx) + events = compile_event_plan(event_plan, ctx) + + nested_group = correlated.criterion.correlated_criteria + if nested_group is None or nested_group.is_empty(): + return events + + # Correlated criteria can themselves carry nested correlated criteria. + # Re-apply the same group evaluator used for primary/additional criteria. + from .groups import apply_additional_criteria + + return apply_additional_criteria(events, nested_group, ctx) def correlated_match_keys( diff --git a/tests/execution/test_groups.py b/tests/execution/test_groups.py index e739793b..9f399f72 100644 --- a/tests/execution/test_groups.py +++ b/tests/execution/test_groups.py @@ -273,3 +273,81 @@ def test_additional_demographic_criteria_groups_filter_primary_events(): result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1} + + +def test_nested_correlated_criteria_inside_group_are_applied(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2)) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1, 2, 2], + "condition_occurrence_id": [100, 101, 102, 200, 201], + "condition_concept_id": [111, 222, 333, 111, 222], + "condition_start_date": [ + "2020-01-01", + "2020-01-10", + "2020-01-15", + "2020-01-01", + "2020-01-10", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-10", + "2020-01-15", + "2020-01-01", + "2020-01-10", + ], + "visit_occurrence_id": [10, 10, 10, 20, 20], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + _make_concept_set(3, 333), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence( + codeset_id=2, + correlated_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=3), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=10), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=20), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} From 579ed22a418ba5fc07b744fd775e21af7b86577e Mon Sep 17 00:00:00 2001 From: egillax Date: Thu, 19 Mar 2026 22:21:48 +0100 Subject: [PATCH 45/62] test(execution): expand nested correlated coverage --- tests/execution/test_groups.py | 515 +++++++++++++++++++++++++++++++++ 1 file changed, 515 insertions(+) diff --git a/tests/execution/test_groups.py b/tests/execution/test_groups.py index 9f399f72..56b8484c 100644 --- a/tests/execution/test_groups.py +++ b/tests/execution/test_groups.py @@ -7,6 +7,7 @@ CohortExpression, ConditionOccurrence, CorelatedCriteria, + CriteriaColumn, CriteriaGroup, DemographicCriteria, Occurrence, @@ -351,3 +352,517 @@ def test_nested_correlated_criteria_inside_group_are_applied(): result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1} + + +def test_nested_correlated_inner_any_mode_with_multiple_children(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2, 3)) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1, 2, 2, 3, 3], + "condition_occurrence_id": [100, 101, 102, 200, 201, 300, 301], + "condition_concept_id": [111, 222, 333, 111, 222, 111, 444], + "condition_start_date": [ + "2020-01-01", + "2020-01-10", + "2020-01-12", + "2020-01-01", + "2020-01-10", + "2020-01-01", + "2020-01-12", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-10", + "2020-01-12", + "2020-01-01", + "2020-01-10", + "2020-01-01", + "2020-01-12", + ], + "visit_occurrence_id": [10, 10, 10, 20, 20, 30, 30], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + _make_concept_set(3, 333), + _make_concept_set(4, 444), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence( + codeset_id=2, + correlated_criteria=CriteriaGroup( + type="ANY", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=3), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=5), + use_event_end=False, + use_index_end=False, + ), + ), + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=4), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=5), + use_event_end=False, + use_index_end=False, + ), + ), + ], + ), + ), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=20), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + + +def test_nested_correlated_group_demographics_are_applied(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2)) + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2], + "year_of_birth": [1980, 1980], + "gender_concept_id": [8507, 8507], + "race_concept_id": [8527, 8516], + "ethnicity_concept_id": [38003564, 38003564], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 2, 2], + "condition_occurrence_id": [100, 101, 200, 201], + "condition_concept_id": [111, 222, 111, 222], + "condition_start_date": ["2020-01-01", "2020-01-10", "2020-01-01", "2020-01-10"], + "condition_end_date": ["2020-01-01", "2020-01-10", "2020-01-01", "2020-01-10"], + "visit_occurrence_id": [10, 10, 20, 20], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111), _make_concept_set(2, 222)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence( + codeset_id=2, + correlated_criteria=CriteriaGroup( + type="ALL", + demographic_criteria_list=[ + DemographicCriteria( + age=NumericRange(op="gte", value=18), + race=[Concept(conceptId=8527)], + ) + ], + ), + ), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=20), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + + +def test_nested_correlated_distinct_count_is_applied(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2)) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1, 1, 2, 2, 2, 2], + "condition_occurrence_id": [100, 101, 102, 103, 200, 201, 202, 203], + "condition_concept_id": [111, 222, 333, 333, 111, 222, 333, 333], + "condition_start_date": [ + "2020-01-01", + "2020-01-10", + "2020-01-11", + "2020-01-12", + "2020-01-01", + "2020-01-10", + "2020-01-11", + "2020-01-11", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-10", + "2020-01-11", + "2020-01-12", + "2020-01-01", + "2020-01-10", + "2020-01-11", + "2020-01-11", + ], + "visit_occurrence_id": [10, 10, 10, 10, 20, 20, 20, 20], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + _make_concept_set(3, 333), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence( + codeset_id=2, + correlated_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=3), + occurrence=Occurrence( + type=Occurrence._AT_LEAST, + count=2, + is_distinct=True, + count_column=CriteriaColumn.START_DATE, + ), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=5), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=20), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + + +def test_nested_correlated_end_window_respects_index_end(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2)) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1, 2, 2, 2], + "condition_occurrence_id": [100, 101, 102, 200, 201, 202], + "condition_concept_id": [111, 222, 333, 111, 222, 333], + "condition_start_date": [ + "2020-01-01", + "2020-01-10", + "2020-01-23", + "2020-01-01", + "2020-01-10", + "2020-01-27", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-20", + "2020-01-25", + "2020-01-01", + "2020-01-20", + "2020-01-29", + ], + "visit_occurrence_id": [10, 10, 10, 20, 20, 20], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + _make_concept_set(3, 333), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence( + codeset_id=2, + correlated_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=3), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + end_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=5), + use_event_end=False, + use_index_end=True, + ), + ) + ], + ), + ), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=20), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + + +def test_nested_correlated_ignore_observation_period_changes_matching(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1,)) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1], + "observation_period_id": [10], + "observation_period_start_date": ["2019-01-01"], + "observation_period_end_date": ["2020-01-15"], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1], + "condition_occurrence_id": [100, 101, 102], + "condition_concept_id": [111, 222, 333], + "condition_start_date": ["2020-01-01", "2020-01-10", "2020-01-20"], + "condition_end_date": ["2020-01-01", "2020-01-10", "2020-01-20"], + "visit_occurrence_id": [10, 10, 10], + } + ), + overwrite=True, + ) + + def _expression(ignore_observation_period: bool) -> CohortExpression: + return CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + _make_concept_set(3, 333), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence( + codeset_id=2, + correlated_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=3), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=15), + use_event_end=False, + use_index_end=False, + ), + ignore_observation_period=ignore_observation_period, + ) + ], + ), + ), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=20), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ) + + without_ignore = build_cohort(_expression(False), backend=conn, cdm_schema="main").execute() + with_ignore = build_cohort(_expression(True), backend=conn, cdm_schema="main").execute() + + assert len(without_ignore) == 0 + assert set(with_ignore.person_id) == {1} + + +def test_nested_correlated_multi_level_nesting_is_applied(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2)) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1, 1, 2, 2, 2], + "condition_occurrence_id": [100, 101, 102, 103, 200, 201, 202], + "condition_concept_id": [111, 222, 333, 444, 111, 222, 333], + "condition_start_date": [ + "2020-01-01", + "2020-01-10", + "2020-01-12", + "2020-01-14", + "2020-01-01", + "2020-01-10", + "2020-01-12", + ], + "condition_end_date": [ + "2020-01-01", + "2020-01-10", + "2020-01-12", + "2020-01-14", + "2020-01-01", + "2020-01-10", + "2020-01-12", + ], + "visit_occurrence_id": [10, 10, 10, 10, 20, 20, 20], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + _make_concept_set(3, 333), + _make_concept_set(4, 444), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence( + codeset_id=2, + correlated_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence( + codeset_id=3, + correlated_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence(codeset_id=4), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=5), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=5), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=20), + use_event_end=False, + use_index_end=False, + ), + ) + ], + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} From ffe153ceb5beebde407d695a115fe1e240015fd9 Mon Sep 17 00:00:00 2001 From: egillax Date: Thu, 19 Mar 2026 22:37:55 +0100 Subject: [PATCH 46/62] fix(execution): restore era filter semantics --- circe/execution/engine/collapse.py | 5 +- circe/execution/lower/condition_era.py | 26 +++- circe/execution/lower/dose_era.py | 13 +- circe/execution/lower/drug_era.py | 31 +++- tests/execution/test_api_ibis.py | 145 ++++++++++++++++++ .../execution/test_end_strategy_censoring.py | 38 +++++ tests/execution/test_groups.py | 58 +++++++ 7 files changed, 308 insertions(+), 8 deletions(-) diff --git a/circe/execution/engine/collapse.py b/circe/execution/engine/collapse.py index aa31ddcb..376ae297 100644 --- a/circe/execution/engine/collapse.py +++ b/circe/execution/engine/collapse.py @@ -29,7 +29,8 @@ def _collapse_era(intervals, era_pad: int): ordering = [padded.start_date] ordered_window = ibis.window(group_by=padded.person_id, order_by=ordering) - with_cummax = padded.mutate(_cummax_padded_end=padded._padded_end_date.max().over(ordered_window)) + cumulative_window = ibis.cumulative_window(group_by=padded.person_id, order_by=ordering) + with_cummax = padded.mutate(_cummax_padded_end=padded._padded_end_date.max().over(cumulative_window)) with_prev = with_cummax.mutate( _prev_max_padded_end=with_cummax._cummax_padded_end.lag().over(ordered_window) ) @@ -41,7 +42,7 @@ def _collapse_era(intervals, era_pad: int): ) ) - group_index = marked._is_new_group.sum().over(ordered_window) + group_index = marked._is_new_group.sum().over(cumulative_window) grouped = marked.mutate(_group_idx=group_index) collapsed = grouped.group_by(grouped.person_id, grouped._group_idx).aggregate( diff --git a/circe/execution/lower/condition_era.py b/circe/execution/lower/condition_era.py index a6e2f9d0..9162e510 100644 --- a/circe/execution/lower/condition_era.py +++ b/circe/execution/lower/condition_era.py @@ -2,7 +2,13 @@ from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan -from .common import lower_standard_domain_plan +from ..plan.schema import OCCURRENCE_COUNT +from .common import ( + append_duration_filter, + append_numeric_filter, + build_standard_domain_plan, + lower_common_steps, +) def lower_condition_era( @@ -10,4 +16,20 @@ def lower_condition_era( *, criterion_index: int, ) -> EventPlan: - return lower_standard_domain_plan(criterion, criterion_index=criterion_index) + steps = lower_common_steps(criterion) + post_standardize_steps = [] + raw = criterion.raw_criteria + + append_numeric_filter( + post_standardize_steps, + column=OCCURRENCE_COUNT, + value=raw.occurrence_count, + ) + append_duration_filter(post_standardize_steps, value=raw.era_length) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + post_standardize_steps=post_standardize_steps, + ) diff --git a/circe/execution/lower/dose_era.py b/circe/execution/lower/dose_era.py index cae6b94d..503a5a8a 100644 --- a/circe/execution/lower/dose_era.py +++ b/circe/execution/lower/dose_era.py @@ -2,7 +2,7 @@ from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan -from .common import lower_standard_domain_plan +from .common import append_duration_filter, build_standard_domain_plan, lower_common_steps def lower_dose_era( @@ -10,4 +10,13 @@ def lower_dose_era( *, criterion_index: int, ) -> EventPlan: - return lower_standard_domain_plan(criterion, criterion_index=criterion_index) + steps = lower_common_steps(criterion) + post_standardize_steps = [] + append_duration_filter(post_standardize_steps, value=criterion.raw_criteria.era_length) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + post_standardize_steps=post_standardize_steps, + ) diff --git a/circe/execution/lower/drug_era.py b/circe/execution/lower/drug_era.py index 4ffcc7af..02afe74c 100644 --- a/circe/execution/lower/drug_era.py +++ b/circe/execution/lower/drug_era.py @@ -2,7 +2,13 @@ from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan -from .common import lower_standard_domain_plan +from ..plan.schema import GAP_DAYS, OCCURRENCE_COUNT +from .common import ( + append_duration_filter, + append_numeric_filter, + build_standard_domain_plan, + lower_common_steps, +) def lower_drug_era( @@ -10,4 +16,25 @@ def lower_drug_era( *, criterion_index: int, ) -> EventPlan: - return lower_standard_domain_plan(criterion, criterion_index=criterion_index) + steps = lower_common_steps(criterion) + post_standardize_steps = [] + raw = criterion.raw_criteria + + append_numeric_filter( + post_standardize_steps, + column=OCCURRENCE_COUNT, + value=raw.occurrence_count, + ) + append_numeric_filter( + post_standardize_steps, + column=GAP_DAYS, + value=raw.gap_days, + ) + append_duration_filter(post_standardize_steps, value=raw.era_length) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + post_standardize_steps=post_standardize_steps, + ) diff --git a/tests/execution/test_api_ibis.py b/tests/execution/test_api_ibis.py index 29f82ada..ef0a73e4 100644 --- a/tests/execution/test_api_ibis.py +++ b/tests/execution/test_api_ibis.py @@ -920,6 +920,45 @@ def test_build_cohort_condition_era(): assert all(result.domain == "condition_era") +def test_build_cohort_condition_era_applies_era_length_and_occurrence_count(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_era", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "condition_era_id": [1200, 1201, 1202], + "condition_concept_id": [12121, 12121, 12121], + "condition_era_start_date": ["2020-01-01", "2020-01-01", "2020-01-01"], + "condition_era_end_date": ["2020-02-15", "2020-01-20", "2020-02-15"], + "condition_occurrence_count": [4, 4, 1], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(11, 12121)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + ConditionEra( + codeset_id=11, + era_length=NumericRange(op="gte", value=30), + occurrence_count=NumericRange(op="gte", value=2), + ) + ] + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert set(result.person_id) == {1} + + def test_build_cohort_drug_era(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -952,6 +991,80 @@ def test_build_cohort_drug_era(): assert all(result.domain == "drug_era") +def test_build_cohort_drug_era_applies_era_length(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "drug_era", + obj=ibis.memtable( + { + "person_id": [1, 2], + "drug_era_id": [1300, 1301], + "drug_concept_id": [13131, 13131], + "drug_era_start_date": ["2020-03-01", "2020-03-01"], + "drug_era_end_date": ["2020-04-15", "2020-03-10"], + "drug_exposure_count": [2, 2], + "gap_days": [5, 5], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(12, 13131)], + primary_criteria=PrimaryCriteria( + criteria_list=[DrugEra(codeset_id=12, era_length=NumericRange(op="gte", value=30))] + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert set(result.person_id) == {1} + + +def test_build_cohort_drug_era_applies_occurrence_count_and_gap_days(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "drug_era", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "drug_era_id": [1300, 1301, 1302], + "drug_concept_id": [13131, 13131, 13131], + "drug_era_start_date": ["2020-03-01", "2020-03-01", "2020-03-01"], + "drug_era_end_date": ["2020-04-15", "2020-04-15", "2020-04-15"], + "drug_exposure_count": [4, 1, 4], + "gap_days": [8, 8, 2], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(12, 13131)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + DrugEra( + codeset_id=12, + occurrence_count=NumericRange(op="gte", value=2), + gap_days=NumericRange(op="gte", value=5), + ) + ] + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert set(result.person_id) == {1} + + def test_build_cohort_dose_era(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") @@ -984,6 +1097,38 @@ def test_build_cohort_dose_era(): assert all(result.domain == "dose_era") +def test_build_cohort_dose_era_applies_era_length(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "dose_era", + obj=ibis.memtable( + { + "person_id": [1, 2], + "dose_era_id": [1400, 1401], + "drug_concept_id": [14141, 14141], + "dose_era_start_date": ["2020-05-01", "2020-05-01"], + "dose_era_end_date": ["2020-06-15", "2020-05-10"], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(13, 14141)], + primary_criteria=PrimaryCriteria( + criteria_list=[DoseEra(codeset_id=13, era_length=NumericRange(op="gte", value=30))] + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert set(result.person_id) == {1} + + def test_build_cohort_location_region(): ibis = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") diff --git a/tests/execution/test_end_strategy_censoring.py b/tests/execution/test_end_strategy_censoring.py index 70ffec2c..60d9c263 100644 --- a/tests/execution/test_end_strategy_censoring.py +++ b/tests/execution/test_end_strategy_censoring.py @@ -177,6 +177,44 @@ def test_collapse_settings_era_merges_intervals(): assert str(result.iloc[0]["end_date"])[:10] == "2020-01-03" +def test_collapse_settings_era_does_not_merge_non_overlapping_intervals(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1], + "condition_occurrence_id": [100, 101, 102], + "condition_concept_id": [111, 111, 111], + "condition_start_date": ["2020-01-01", "2020-03-01", "2020-06-01"], + "condition_end_date": ["2020-01-01", "2020-03-01", "2020-06-01"], + "visit_occurrence_id": [10, 10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=DateOffsetStrategy(offset=0, date_field="start_date"), + collapse_settings=CollapseSettings(era_pad=1), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + assert set(result.columns) == {"person_id", "start_date", "end_date"} + assert len(result) == 3 + assert list(result.sort_values(["start_date", "end_date"]).start_date.astype(str)) == [ + "2020-01-01", + "2020-03-01", + "2020-06-01", + ] + + def test_apply_end_strategy_rejects_invalid_date_field_and_preserves_fallback_semantics(): ibis_mod = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") diff --git a/tests/execution/test_groups.py b/tests/execution/test_groups.py index 56b8484c..016cec53 100644 --- a/tests/execution/test_groups.py +++ b/tests/execution/test_groups.py @@ -10,6 +10,7 @@ CriteriaColumn, CriteriaGroup, DemographicCriteria, + DrugEra, Occurrence, PrimaryCriteria, Window, @@ -866,3 +867,60 @@ def test_nested_correlated_multi_level_nesting_is_applied(): result = build_cohort(expression, backend=conn, cdm_schema="main").execute() assert set(result.person_id) == {1} + + +def test_primary_drug_era_correlated_era_length_is_applied(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis, persons=(1, 2)) + conn.create_table( + "drug_era", + obj=ibis.memtable( + { + "person_id": [1, 1, 2, 2], + "drug_era_id": [1300, 1301, 2300, 2301], + "drug_concept_id": [111, 222, 111, 222], + "drug_era_start_date": ["2020-01-01", "2020-03-20", "2020-01-01", "2020-03-20"], + "drug_era_end_date": ["2020-03-15", "2020-04-25", "2020-03-15", "2020-03-20"], + "drug_exposure_count": [3, 1, 3, 1], + "gap_days": [10, 0, 10, 0], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111), _make_concept_set(2, 222)], + primary_criteria=PrimaryCriteria( + criteria_list=[ + DrugEra( + codeset_id=1, + era_length=NumericRange(op="gte", value=30), + correlated_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=DrugEra( + codeset_id=2, + era_length=NumericRange(op="gte", value=30), + ), + occurrence=Occurrence(type=Occurrence._AT_LEAST, count=1), + start_window=Window( + start=WindowBound(coeff=1, days=0), + end=WindowBound(coeff=1, days=60), + use_event_end=False, + use_index_end=True, + ), + ) + ], + ), + ) + ] + ), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert set(result.person_id) == {1} From 95386c6e42bfea5f76903b2354876aa519a8b257 Mon Sep 17 00:00:00 2001 From: egillax Date: Thu, 19 Mar 2026 22:59:37 +0100 Subject: [PATCH 47/62] fix(execution): align collapse tie handling with circe --- circe/execution/engine/collapse.py | 6 +- .../execution/test_end_strategy_censoring.py | 68 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/circe/execution/engine/collapse.py b/circe/execution/engine/collapse.py index 376ae297..c98619d2 100644 --- a/circe/execution/engine/collapse.py +++ b/circe/execution/engine/collapse.py @@ -42,7 +42,11 @@ def _collapse_era(intervals, era_pad: int): ) ) - group_index = marked._is_new_group.sum().over(cumulative_window) + grouping_window = ibis.cumulative_window( + group_by=marked.person_id, + order_by=[marked.start_date, marked._is_new_group.desc()], + ) + group_index = marked._is_new_group.sum().over(grouping_window) grouped = marked.mutate(_group_idx=group_index) collapsed = grouped.group_by(grouped.person_id, grouped._group_idx).aggregate( diff --git a/tests/execution/test_end_strategy_censoring.py b/tests/execution/test_end_strategy_censoring.py index 60d9c263..67f7912e 100644 --- a/tests/execution/test_end_strategy_censoring.py +++ b/tests/execution/test_end_strategy_censoring.py @@ -215,6 +215,74 @@ def test_collapse_settings_era_does_not_merge_non_overlapping_intervals(): ] +def test_collapse_settings_era_deduplicates_identical_intervals(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1], + "condition_occurrence_id": [100, 101], + "condition_concept_id": [111, 111], + "condition_start_date": ["2020-01-01", "2020-01-01"], + "condition_end_date": ["2020-01-01", "2020-01-01"], + "visit_occurrence_id": [10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=DateOffsetStrategy(offset=14, date_field="end_date"), + collapse_settings=CollapseSettings(era_pad=0), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-01-01" + assert str(result.iloc[0]["end_date"])[:10] == "2020-01-15" + + +def test_collapse_settings_era_merges_tied_start_dates_into_one_group(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1], + "condition_occurrence_id": [100, 101], + "condition_concept_id": [111, 111], + "condition_start_date": ["2020-01-01", "2020-01-01"], + "condition_end_date": ["2020-01-02", "2020-01-05"], + "visit_occurrence_id": [10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=DateOffsetStrategy(offset=0, date_field="end_date"), + collapse_settings=CollapseSettings(era_pad=0), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-01-01" + assert str(result.iloc[0]["end_date"])[:10] == "2020-01-05" + + def test_apply_end_strategy_rejects_invalid_date_field_and_preserves_fallback_semantics(): ibis_mod = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") From 0db6e370c626e791edd68140d534e4c4eb3c7816 Mon Sep 17 00:00:00 2001 From: egillax Date: Fri, 20 Mar 2026 13:30:00 +0100 Subject: [PATCH 48/62] docs(execution): move developer docs into sphinx guide --- circe/execution/ARCHITECTURE.md | 207 -------------------------------- circe/execution/LIMITATIONS.md | 11 -- circe/execution/README.md | 70 ----------- circe/execution/TESTING.md | 96 --------------- docs/developer/architecture.rst | 123 +++++++++++++++++++ docs/developer/testing.rst | 112 ++++++++++++++++- 6 files changed, 233 insertions(+), 386 deletions(-) delete mode 100644 circe/execution/ARCHITECTURE.md delete mode 100644 circe/execution/LIMITATIONS.md delete mode 100644 circe/execution/README.md delete mode 100644 circe/execution/TESTING.md diff --git a/circe/execution/ARCHITECTURE.md b/circe/execution/ARCHITECTURE.md deleted file mode 100644 index f37ea3c7..00000000 --- a/circe/execution/ARCHITECTURE.md +++ /dev/null @@ -1,207 +0,0 @@ -# Execution Architecture - -This document describes the design of the new `circe.execution` subsystem and -the intended boundaries between its layers. - -## Purpose - -The execution subsystem provides a backend-native, relation-first way to -evaluate `CohortExpression` models. - -The design goals are: - -- keep cohort semantics explicit and testable -- separate model normalization from backend compilation -- standardize domain events before orchestration -- make backend materialization a thin outer layer, not the core engine - -## Public Contract - -The public entrypoints are: - -- `build_cohort(...)` -- `write_cohort(...)` - -`build_cohort(...)` returns a lazy Ibis relation in the canonical execution -shape. - -`write_cohort(...)` projects the built relation into OHDSI cohort-table shape -and writes rows for one `cohort_id`. - -The write contract is cohort-scoped: - -- `if_exists="fail"` errors only if rows already exist for that `cohort_id` -- `if_exists="replace"` replaces only that `cohort_id`'s rows and preserves - rows for other cohorts in the same target table - -## Layered Design - -The subsystem is intentionally split into five layers. - -### 1. `normalize/` - -Responsibility: - -- convert public cohort-definition models into frozen internal dataclasses -- remove aliasing and optional-shape noise from downstream code -- reject explicitly unsupported semantics early - -Output: - -- normalized cohort, criteria, groups, windows, and end-strategy objects - -### 2. `lower/` - -Responsibility: - -- turn normalized criteria into backend-agnostic execution plans -- encode reusable event and predicate planning logic -- keep domain-specific lowering separate from backend-specific compilation - -Output: - -- `EventPlan` objects and normalized predicate/planning structures - -### 3. `ibis/` - -Responsibility: - -- compile lowered plans into Ibis relations -- standardize domain tables into the canonical event schema -- resolve concept sets and person filters -- provide backend operations used by the public write path - -Output: - -- canonical Ibis relations ready for cohort orchestration - -### 4. `engine/` - -Responsibility: - -- evaluate cohort semantics over canonical event relations -- handle primary events, additional criteria, inclusion rules, censoring, - limits, collapse, and end strategy - -This layer owns cohort logic. It should not need to know OMOP source-table -details once relations have been standardized. - -### 5. API materialization layer - -Responsibility: - -- connect public API calls to normalization, compilation, and engine execution -- project final relations into OHDSI cohort-table shape -- handle backend table existence checks and cohort-scoped writes - -This is intentionally thin. It should orchestrate layers, not re-implement -their logic. - -## Canonical Event Schema - -Compiled domain event relations are standardized before engine orchestration. -The canonical columns are defined in `circe/execution/plan/schema.py`. - -Important columns include: - -- `person_id` -- `event_id` -- `start_date` -- `end_date` -- `domain` -- `concept_id` -- `source_concept_id` -- `visit_occurrence_id` -- `criterion_index` -- `criterion_type` -- `source_table` - -This standardization is one of the main design differences from the legacy -builder-based path. The engine operates on one event shape instead of many -domain-specific SQL-builder shapes. - -## Data Flow - -The end-to-end flow is: - -1. `CohortExpression` -2. normalize to frozen internal dataclasses -3. lower criteria into event/predicate plans -4. compile plans into canonical Ibis relations -5. run cohort semantics in `engine/` -6. optionally materialize to OHDSI cohort-table rows - -## Codeset Resolution - -Codeset expansion is handled by `CachedConceptSetResolver`. - -Resolution semantics are: - -- direct inclusion -- descendant expansion through `concept_ancestor` -- mapped concept expansion through `concept_relationship` -- exclusion precedence after expansion - -The cache is scoped to one execution context. - -## What This Replaced - -This redesign intentionally replaces the older mutable builder/context-based -execution path. - -Removed or reduced surfaces include: - -- the legacy builder tree under `circe.execution.builders` -- the old builder-context shell -- the old compatibility-heavy execution surface -- the Polars-oriented compatibility layer - -The new subsystem is function-first rather than executor-object-first. - -## Migration Notes - -If you used the legacy execution prototype: - -- use `build_cohort(...)` to get the lazy relation -- use backend operations on that relation for inspection and collection -- use `write_cohort(...)` for cohort-table writes - -In other words: - -- SQL/dataframe inspection now happens via the returned relation and backend -- write semantics now live in `write_cohort(...)`, not a mutable executor - object - -## Current Explicit Limitation - -- `custom_era` end strategy is not implemented in this execution path - -Unsupported semantics should fail explicitly with execution-layer errors rather -than silently degrading behavior. - -## Test Strategy - -The intended test organization for this subsystem is documented in -`circe/execution/TESTING.md`. - -That document splits tests by layer: - -- normalization/lowering unit tests -- Ibis helper unit tests -- engine semantics integration tests -- public API/wiring tests -- explicit error/limitation tests - -## Reviewer Guidance - -For code review, the most useful way to read the subsystem is: - -1. `circe/execution/api.py` -2. `circe/execution/README.md` -3. `circe/execution/TESTING.md` -4. `normalize/` -5. `lower/` -6. `ibis/` -7. `engine/` - -That order matches the intended architecture rather than the directory listing. diff --git a/circe/execution/LIMITATIONS.md b/circe/execution/LIMITATIONS.md deleted file mode 100644 index 696d411b..00000000 --- a/circe/execution/LIMITATIONS.md +++ /dev/null @@ -1,11 +0,0 @@ -# Ibis Executor Limitations - -The `circe.execution` subsystem is experimental and feature-complete for the -currently implemented semantics. - -Current explicit limitations: - -- `custom_era` end strategy is not implemented. - -The executor raises `UnsupportedFeatureError` when these features are requested, -instead of silently degrading semantics. diff --git a/circe/execution/README.md b/circe/execution/README.md deleted file mode 100644 index 644f98f3..00000000 --- a/circe/execution/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Ibis Execution Subsystem - -The `circe.execution` package is an experimental, table-first Ibis executor for -`CohortExpression` models. It runs in parallel with the existing SQL builder. - -## Public Functions - -- `build_cohort(...)` is the canonical expression-building entrypoint. -- `write_cohort(...)` projects the built relation into OHDSI cohort-table shape, - then writes or replaces rows for one `cohort_id` while preserving other cohorts. - -## Layered Architecture - -1. `normalize/` -converts public cohort-expression models into frozen internal dataclasses. - -2. `lower/` -maps normalized criteria into `EventPlan` objects and reusable plan steps. - -3. `ibis/` -compiles plan steps into Ibis table expressions. - -4. `engine/` -orchestrates cohort semantics: primary events, criteria groups, inclusion rules, -end strategy, censoring, and collapse. - -## Canonical Event Schema - -All compiled domain event tables are standardized before cohort orchestration. -Canonical columns are defined in `circe/execution/plan/schema.py` and include: - -- `person_id` -- `event_id` -- `start_date` -- `end_date` -- `domain` -- `concept_id` -- `source_concept_id` -- `visit_occurrence_id` -- `criterion_index` -- `criterion_type` -- `source_table` - -## Codeset Resolution Flow - -Codeset expansion is handled by `CachedConceptSetResolver` in -`circe/execution/ibis/codesets.py`. - -Resolution behavior: - -- direct concept inclusion -- descendant expansion via `concept_ancestor` when requested -- mapped concept expansion via `concept_relationship` (`Maps to`) when requested -- exclusion precedence applied after expansion - -The resolver cache is local to an execution context run. - -## Current Limitation - -- `custom_era` end strategy remains unsupported in this executor path. - -## Testing - -The execution test structure and expected test layers are documented in -`circe/execution/TESTING.md`. - -## Architecture - -The subsystem design, layer boundaries, and migration notes from the legacy -execution prototype are documented in `circe/execution/ARCHITECTURE.md`. diff --git a/circe/execution/TESTING.md b/circe/execution/TESTING.md deleted file mode 100644 index e6ceafe0..00000000 --- a/circe/execution/TESTING.md +++ /dev/null @@ -1,96 +0,0 @@ -# Execution Testing Strategy - -The new `circe.execution` engine should be tested in layers, with each layer -optimized for a different failure mode. - -## Goals - -- Keep the engine safe to refactor while the design is still evolving. -- Make regressions easy to localize to one layer. -- Avoid turning the test suite into a single large DuckDB integration harness. - -## Test Layers - -1. Pure normalization and lowering unit tests - -- Scope: `normalize/`, `lower/`, `plan/`, small pure helpers. -- Style: no backend, no SQL execution, frozen dataclass assertions. -- Current files: - - `tests/execution/test_normalize.py` - - `tests/execution/test_normalize_contracts.py` - - `tests/execution/test_lowering.py` - - `tests/execution/test_lower_contracts.py` - - `tests/execution/test_compile_contracts.py` - -2. Ibis helper unit tests - -- Scope: `ibis/codesets.py`, `ibis/operations.py`, `ibis/context.py`, - `ibis/standardize.py`, `engine/*` helpers that do not need full cohort runs. -- Style: fake backends where possible; DuckDB only when expression execution is - the thing under test. -- Current files: - - `tests/execution/test_context_wiring.py` - - `tests/execution/test_operations.py` - - `tests/execution/test_ibis_compat.py` - - `tests/execution/test_group_demographics.py` - - `tests/execution/test_person_filters.py` - -3. Engine semantics integration tests - -- Scope: primary events, correlated criteria, groups, inclusion rules, result - limits, end strategy, censoring, parity-sensitive orchestration. -- Style: minimal DuckDB fixtures with only the columns required for the - behavior under test. -- Current files: - - `tests/execution/test_groups.py` - - `tests/execution/test_inclusion.py` - - `tests/execution/test_result_limits.py` - - `tests/execution/test_end_strategy_censoring.py` - - `tests/execution/test_parity_regressions.py` - -4. Public API and wiring tests - -- Scope: `build_cohort`, `write_cohort`, package exports, compat shims. -- Style: verify entrypoint behavior, argument handling, and write semantics - without duplicating engine internals. -- Current files: - - `tests/execution/test_api_public.py` - - `tests/execution/test_api_ibis.py` - - `tests/execution/test_scaffolding.py` - -5. Error and limitation tests - -- Scope: explicit unsupported features, validation messages, and backend - capability failures. -- Style: assert on error type and message text where the API contract matters. -- Current files: - - `tests/execution/test_error_messages.py` - -## Rules - -- Each new execution module should get at least one direct test file in the - same layer as its responsibility. -- Prefer fake backends for capability/error branches, and DuckDB for relational - behavior. -- Keep fixtures local to a test file unless three or more files need the same - setup. -- When adding a new feature, add: - - one layer-local unit/helper test - - one end-to-end or API-level assertion if the feature crosses layers -- Parity/regression tests should stay small and named after the bug or contract - they protect. - -## Local Gate - -Use this as the normal execution-engine check: - -```bash -uv run pre-commit run --all-files -uv run pytest tests/execution -q -``` - -Before merging broader refactors, also run: - -```bash -uv run pytest -``` diff --git a/docs/developer/architecture.rst b/docs/developer/architecture.rst index 46736501..6b2c439e 100644 --- a/docs/developer/architecture.rst +++ b/docs/developer/architecture.rst @@ -12,6 +12,7 @@ Package Structure * **helper/** - Utility functions * **api.py** - High-level API * **cli.py** - Command-line interface +* **execution/** - Experimental Ibis-based cohort execution engine SQL Generation -------------- @@ -23,3 +24,125 @@ Validation Framework The validation framework uses a checker pattern with pluggable validators. +Execution Engine +---------------- + +The ``circe.execution`` package is an experimental, table-first Ibis executor +for ``CohortExpression`` models. It runs in parallel with the existing SQL +builder. + +Public API +~~~~~~~~~~ + +The main execution entrypoints are: + +* ``build_cohort(...)`` - build a lazy Ibis relation in canonical execution shape +* ``write_cohort(...)`` - project to OHDSI cohort-table shape and write rows for one ``cohort_id`` + +The write contract is cohort-scoped: + +* ``if_exists="fail"`` errors only if rows already exist for that ``cohort_id`` +* ``if_exists="replace"`` replaces only that ``cohort_id`` and preserves other cohorts in the same table + +Layered Design +~~~~~~~~~~~~~~ + +The subsystem is intentionally split into five layers. + +1. ``normalize/`` + + * converts public cohort-definition models into frozen internal dataclasses + * removes aliasing and optional-shape noise from downstream code + * rejects explicitly unsupported semantics early + +2. ``lower/`` + + * turns normalized criteria into backend-agnostic execution plans + * encodes reusable event and predicate planning logic + * keeps domain-specific lowering separate from backend-specific compilation + +3. ``ibis/`` + + * compiles lowered plans into Ibis relations + * standardizes domain tables into the canonical event schema + * resolves concept sets and person filters + * provides backend operations used by the public write path + +4. ``engine/`` + + * evaluates cohort semantics over canonical event relations + * handles primary events, additional criteria, inclusion rules, censoring, + limits, collapse, and end strategy + +5. API materialization layer + + * connects public API calls to normalization, compilation, and engine execution + * projects final relations into OHDSI cohort-table shape + * handles backend table existence checks and cohort-scoped writes + +Canonical Event Schema +~~~~~~~~~~~~~~~~~~~~~~ + +Compiled domain event relations are standardized before engine orchestration. +The canonical columns are defined in ``circe/execution/plan/schema.py``. +Important columns include: + +* ``person_id`` +* ``event_id`` +* ``start_date`` +* ``end_date`` +* ``domain`` +* ``concept_id`` +* ``source_concept_id`` +* ``visit_occurrence_id`` +* ``criterion_index`` +* ``criterion_type`` +* ``source_table`` + +This standardization is one of the main design differences from the legacy +builder-based path. The engine operates on one event shape instead of many +domain-specific SQL-builder shapes. + +Data Flow +~~~~~~~~~ + +The end-to-end flow is: + +1. ``CohortExpression`` +2. normalize to frozen internal dataclasses +3. lower criteria into event and predicate plans +4. compile plans into canonical Ibis relations +5. run cohort semantics in ``engine/`` +6. optionally materialize to OHDSI cohort-table rows + +Codeset Resolution +~~~~~~~~~~~~~~~~~~ + +Codeset expansion is handled by ``CachedConceptSetResolver``. +Resolution semantics are: + +* direct inclusion +* descendant expansion through ``concept_ancestor`` +* mapped concept expansion through ``concept_relationship`` +* exclusion precedence after expansion + +The cache is scoped to one execution context run. + +Migration Notes +~~~~~~~~~~~~~~~ + +If you used the legacy execution prototype: + +* use ``build_cohort(...)`` to get the lazy relation +* use backend operations on that relation for inspection and collection +* use ``write_cohort(...)`` for cohort-table writes + +Current Execution Limitations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The executor should fail explicitly for unsupported execution semantics rather +than silently degrading behavior. + +Current explicit limitation: + +* ``custom_era`` end strategy is not implemented in this base execution branch diff --git a/docs/developer/testing.rst b/docs/developer/testing.rst index 71c09368..402956d0 100644 --- a/docs/developer/testing.rst +++ b/docs/developer/testing.rst @@ -1,7 +1,7 @@ Testing ======= -CIRCE Python has comprehensive test coverage (71%, 896 tests). +CIRCE Python has comprehensive test coverage. Running Tests ------------- @@ -25,5 +25,113 @@ Tests are organized by module in the ``tests/`` directory. Writing Tests ------------- -Follow existing test patterns. See CONTRIBUTING.md for guidelines. +Follow existing test patterns. See ``docs/developer/contributing.rst`` for +contribution guidelines. +Execution Engine Testing +------------------------ + +The ``circe.execution`` subsystem should be tested in layers, with each layer +optimized for a different failure mode. + +Goals +~~~~~ + +* keep the engine safe to refactor while the design is still evolving +* make regressions easy to localize to one layer +* avoid turning the test suite into a single large DuckDB integration harness + +Test Layers +~~~~~~~~~~~ + +1. Pure normalization and lowering unit tests + + * Scope: ``normalize/``, ``lower/``, ``plan/``, and small pure helpers + * Style: no backend, no SQL execution, frozen dataclass assertions + * Current files: + + * ``tests/execution/test_normalize.py`` + * ``tests/execution/test_normalize_contracts.py`` + * ``tests/execution/test_lowering.py`` + * ``tests/execution/test_lower_contracts.py`` + * ``tests/execution/test_compile_contracts.py`` + +2. Ibis helper unit tests + + * Scope: ``ibis/codesets.py``, ``ibis/operations.py``, ``ibis/context.py``, + ``ibis/standardize.py``, and engine helpers that do not need full cohort runs + * Style: fake backends where possible; DuckDB only when expression execution is + the thing under test + * Current files: + + * ``tests/execution/test_context_wiring.py`` + * ``tests/execution/test_operations.py`` + * ``tests/execution/test_ibis_compat.py`` + * ``tests/execution/test_group_demographics.py`` + * ``tests/execution/test_person_filters.py`` + +3. Engine semantics integration tests + + * Scope: primary events, correlated criteria, groups, inclusion rules, result + limits, end strategy, censoring, and parity-sensitive orchestration + * Style: minimal DuckDB fixtures with only the columns required for the + behavior under test + * Current files: + + * ``tests/execution/test_groups.py`` + * ``tests/execution/test_inclusion.py`` + * ``tests/execution/test_result_limits.py`` + * ``tests/execution/test_end_strategy_censoring.py`` + * ``tests/execution/test_parity_regressions.py`` + +4. Public API and wiring tests + + * Scope: ``build_cohort``, ``write_cohort``, package exports, and compat shims + * Style: verify entrypoint behavior, argument handling, and write semantics + without duplicating engine internals + * Current files: + + * ``tests/execution/test_api_public.py`` + * ``tests/execution/test_api_ibis.py`` + * ``tests/execution/test_scaffolding.py`` + +5. Error and limitation tests + + * Scope: explicit unsupported features, validation messages, and backend + capability failures + * Style: assert on error type and message text where the API contract matters + * Current files: + + * ``tests/execution/test_error_messages.py`` + +Rules +~~~~~ + +* each new execution module should get at least one direct test file in the same + layer as its responsibility +* prefer fake backends for capability and error branches, and DuckDB for + relational behavior +* keep fixtures local to a test file unless three or more files need the same setup +* when adding a new feature, add: + + * one layer-local unit or helper test + * one end-to-end or API-level assertion if the feature crosses layers + +* parity and regression tests should stay small and named after the bug or + contract they protect + +Local Gate +~~~~~~~~~~ + +Use this as the normal execution-engine check: + +.. code-block:: bash + + uv run pre-commit run --all-files + uv run pytest tests/execution -q + +Before merging broader refactors, also run: + +.. code-block:: bash + + uv run pytest From 252e68c422e764b295e4116347b485c20dd785f7 Mon Sep 17 00:00:00 2001 From: Egill Axfjord Fridgeirsson Date: Fri, 20 Mar 2026 16:00:04 +0100 Subject: [PATCH 49/62] fix: keep base package importable without ibis (#30) --- .github/workflows/basic_tests.yml | 27 +++++++++++++++++++++++++++ circe/api.py | 9 +++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/basic_tests.yml b/.github/workflows/basic_tests.yml index 41eb29ee..33fed6a8 100644 --- a/.github/workflows/basic_tests.yml +++ b/.github/workflows/basic_tests.yml @@ -35,3 +35,30 @@ jobs: uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} + + wheel-smoke: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build wheel setuptools + + - name: Build wheel + run: python -m build + + - name: Smoke test installed wheel + run: | + python -m venv /tmp/circe-wheel-smoke + /tmp/circe-wheel-smoke/bin/pip install dist/*.whl + /tmp/circe-wheel-smoke/bin/circe --help + /tmp/circe-wheel-smoke/bin/python -c "import circe; print(circe.__version__)" diff --git a/circe/api.py b/circe/api.py index 8b466f92..d9dea0aa 100644 --- a/circe/api.py +++ b/circe/api.py @@ -9,7 +9,7 @@ - cohort_print_friendly(): Generate Markdown from cohort expression """ -from typing import Literal, Optional +from typing import TYPE_CHECKING, Any, Literal, Optional from .cohortdefinition import ( BuildExpressionQueryOptions, @@ -17,9 +17,14 @@ CohortExpressionQueryBuilder, MarkdownRender, ) -from .execution.typing import IbisBackendLike, Table from .vocabulary.concept import ConceptSet +if TYPE_CHECKING: + from .execution.typing import IbisBackendLike, Table +else: + IbisBackendLike = Any + Table = Any + def cohort_expression_from_json(json_str: str) -> CohortExpression: """Load a cohort expression from a JSON string. From c12ae6d3789f784bc7a02e3ae6bd9914a56bfbbe Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Tue, 24 Mar 2026 15:11:29 -0700 Subject: [PATCH 50/62] Implementation of backwards compatable concept set support for TAB settings (#27) * Implementaion of backwards compatable concept set support for TAB support * more TAB test cases * Tests to improve coverage and quality * Tests to improve tests on concept set checkers --- circe/cohortdefinition/__init__.py | 2 - .../cohort_expression_query_builder.py | 2 +- .../concept_set_expression_query_builder.py | 183 ----- circe/vocabulary/__init__.py | 13 +- circe/vocabulary/concept.py | 147 +++- .../fixtures/schemas/concept_set_legacy.json | 37 + .../fixtures/schemas/concept_set_minimal.json | 28 + .../schemas/concept_set_new_schema.json | 45 ++ .../fixtures/schemas/concept_set_schema.json | 176 +++++ .../fixtures/schemas/concept_set_simple.json | 31 + ...st_concept_set_expression_query_builder.py | 16 +- tests/test_concept_set_schemas.py | 747 ++++++++++++++++++ tests/test_concept_sets_checkers.py | 195 +++++ tests/test_execution_groups.py | 259 ++++++ tests/test_query_builders.py | 2 +- 15 files changed, 1674 insertions(+), 209 deletions(-) delete mode 100644 circe/cohortdefinition/concept_set_expression_query_builder.py create mode 100644 tests/fixtures/schemas/concept_set_legacy.json create mode 100644 tests/fixtures/schemas/concept_set_minimal.json create mode 100644 tests/fixtures/schemas/concept_set_new_schema.json create mode 100644 tests/fixtures/schemas/concept_set_schema.json create mode 100644 tests/fixtures/schemas/concept_set_simple.json create mode 100644 tests/test_concept_set_schemas.py create mode 100644 tests/test_concept_sets_checkers.py create mode 100644 tests/test_execution_groups.py diff --git a/circe/cohortdefinition/__init__.py b/circe/cohortdefinition/__init__.py index 95497790..c1e36fdf 100644 --- a/circe/cohortdefinition/__init__.py +++ b/circe/cohortdefinition/__init__.py @@ -14,7 +14,6 @@ BuildExpressionQueryOptions, CohortExpressionQueryBuilder, ) -from .concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder from .core import ( # Supporting Classes CollapseSettings, CollapseType, @@ -118,7 +117,6 @@ # Query Builders "CohortExpressionQueryBuilder", "BuildExpressionQueryOptions", - "ConceptSetExpressionQueryBuilder", # Interfaces "IGetCriteriaSqlDispatcher", "IGetEndStrategySqlDispatcher", diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index d56f8beb..a48be933 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -13,6 +13,7 @@ from circe.extensions import get_registry +from ..vocabulary.concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder from .builders import ( ConditionEraSqlBuilder, ConditionOccurrenceSqlBuilder, @@ -34,7 +35,6 @@ ) from .builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn from .cohort import CohortExpression -from .concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder from .core import CustomEraStrategy, DateOffsetStrategy, Period from .criteria import ( ConditionEra, diff --git a/circe/cohortdefinition/concept_set_expression_query_builder.py b/circe/cohortdefinition/concept_set_expression_query_builder.py deleted file mode 100644 index 1c9e2b42..00000000 --- a/circe/cohortdefinition/concept_set_expression_query_builder.py +++ /dev/null @@ -1,183 +0,0 @@ -""" -Concept Set Expression Query Builder - -This module contains the SQL builder for concept set expressions. - -GUARD RAIL: This module implements Java CIRCE-BE functionality. -Any changes must maintain 1:1 compatibility with Java classes. -Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. -""" - -from ..vocabulary.concept import Concept, ConceptSetExpression -from .builders.utils import BuilderUtils - - -class ConceptSetExpressionQueryBuilder: - """SQL builder for concept set expressions. - - Java equivalent: org.ohdsi.circe.vocabulary.ConceptSetExpressionQueryBuilder - """ - - # SQL templates - equivalent to Java ResourceHelper.GetResourceAsString - # IMPORTANT: Must use @vocabulary_database_schema (not @cdm_database_schema) for concept lookups - CONCEPT_SET_QUERY_TEMPLATE = ( - "select concept_id from @vocabulary_database_schema.CONCEPT where @conceptIdIn\n" - ) - - CONCEPT_SET_DESCENDANTS_TEMPLATE = """ select c.concept_id - from @vocabulary_database_schema.CONCEPT c - join @vocabulary_database_schema.CONCEPT_ANCESTOR ca on c.concept_id = ca.descendant_concept_id - WHERE c.invalid_reason is null - and @conceptIdIn -""" - - CONCEPT_SET_MAPPED_TEMPLATE = """select distinct cr.concept_id_1 as concept_id -FROM -( - @conceptsetQuery -) C -join @vocabulary_database_schema.concept_relationship cr on C.concept_id = cr.concept_id_2 and cr.relationship_id = 'Maps to' and cr.invalid_reason IS NULL -""" - - CONCEPT_SET_INCLUDE_TEMPLATE = """select distinct I.concept_id FROM -( - @includeQuery -) I -""" - - CONCEPT_SET_EXCLUDE_TEMPLATE = """LEFT JOIN -( - @excludeQuery -) E ON I.concept_id = E.concept_id -WHERE E.concept_id is null -""" - - MAX_IN_LENGTH = 1000 # Oracle limitation - - def get_concept_ids(self, concepts: list[Concept]) -> list[int]: - """Get concept IDs from concept list. - - Java equivalent: getConceptIds() - """ - return [concept.concept_id for concept in concepts if concept.concept_id is not None] - - def build_concept_set_sub_query(self, concepts: list[Concept], descendant_concepts: list[Concept]) -> str: - """Build concept set sub-query. - - Java equivalent: buildConceptSetSubQuery() - """ - queries = [] - - if concepts: - concept_ids = self.get_concept_ids(concepts) - concept_id_in = BuilderUtils.split_in_clause("concept_id", concept_ids, self.MAX_IN_LENGTH) - query = self.CONCEPT_SET_QUERY_TEMPLATE.replace("@conceptIdIn", concept_id_in) - queries.append(query) - - if descendant_concepts: - descendant_ids = self.get_concept_ids(descendant_concepts) - concept_id_in = BuilderUtils.split_in_clause( - "ca.ancestor_concept_id", descendant_ids, self.MAX_IN_LENGTH - ) - query = self.CONCEPT_SET_DESCENDANTS_TEMPLATE.replace("@conceptIdIn", concept_id_in) - queries.append(query) - - return "\nUNION ".join(queries) - - def build_concept_set_mapped_query( - self, - mapped_concepts: list[Concept], - mapped_descendant_concepts: list[Concept], - ) -> str: - """Build concept set mapped query. - - Java equivalent: buildConceptSetMappedQuery() - """ - concept_set_query = self.build_concept_set_sub_query(mapped_concepts, mapped_descendant_concepts) - return self.CONCEPT_SET_MAPPED_TEMPLATE.replace("@conceptsetQuery", concept_set_query) - - def build_concept_set_query( - self, - concepts: list[Concept], - descendant_concepts: list[Concept], - mapped_concepts: list[Concept], - mapped_descendant_concepts: list[Concept], - ) -> str: - """Build concept set query. - - Java equivalent: buildConceptSetQuery() - """ - if not concepts: - return "select concept_id from @vocabulary_database_schema.CONCEPT where 0=1" - - concept_set_query = self.build_concept_set_sub_query(concepts, descendant_concepts) - - if mapped_concepts or mapped_descendant_concepts: - mapped_query = self.build_concept_set_mapped_query(mapped_concepts, mapped_descendant_concepts) - concept_set_query += " UNION " + mapped_query - - return concept_set_query - - def build_expression_query(self, expression: ConceptSetExpression) -> str: - """Build expression query for concept set. - - Java equivalent: buildExpressionQuery() - """ - # Handle included concepts - include_concepts = [] - include_descendant_concepts = [] - include_mapped_concepts = [] - include_mapped_descendant_concepts = [] - - # Handle excluded concepts - exclude_concepts = [] - exclude_descendant_concepts = [] - exclude_mapped_concepts = [] - exclude_mapped_descendant_concepts = [] - - # Populate each sub-set of concepts from the flags set in each concept set item - for item in expression.items: - if not item.is_excluded: - include_concepts.append(item.concept) - - if item.include_descendants: - include_descendant_concepts.append(item.concept) - - if item.include_mapped: - include_mapped_concepts.append(item.concept) - if item.include_descendants: - include_mapped_descendant_concepts.append(item.concept) - else: - exclude_concepts.append(item.concept) - if item.include_descendants: - exclude_descendant_concepts.append(item.concept) - if item.include_mapped: - exclude_mapped_concepts.append(item.concept) - if item.include_descendants: - exclude_mapped_descendant_concepts.append(item.concept) - - # Build the main concept set query - concept_set_query = self.CONCEPT_SET_INCLUDE_TEMPLATE.replace( - "@includeQuery", - self.build_concept_set_query( - include_concepts, - include_descendant_concepts, - include_mapped_concepts, - include_mapped_descendant_concepts, - ), - ) - - # Add exclusion query if needed - if exclude_concepts: - exclude_query = self.CONCEPT_SET_EXCLUDE_TEMPLATE.replace( - "@excludeQuery", - self.build_concept_set_query( - exclude_concepts, - exclude_descendant_concepts, - exclude_mapped_concepts, - exclude_mapped_descendant_concepts, - ), - ) - concept_set_query += exclude_query - - return concept_set_query diff --git a/circe/vocabulary/__init__.py b/circe/vocabulary/__init__.py index 0e4bd7ca..b260187e 100644 --- a/circe/vocabulary/__init__.py +++ b/circe/vocabulary/__init__.py @@ -9,6 +9,15 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from .concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem +from .concept import Concept, ConceptExpressionItem, ConceptSet, ConceptSetExpression, ConceptSetItem -__all__ = ["Concept", "ConceptSet", "ConceptSetExpression", "ConceptSetItem"] +# Note: ConceptSetExpressionQueryBuilder is not exported here to avoid circular imports +# Import it directly: from circe.vocabulary.concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder + +__all__ = [ + "Concept", + "ConceptSet", + "ConceptSetExpression", + "ConceptSetItem", # Backward compatibility alias + "ConceptExpressionItem", +] diff --git a/circe/vocabulary/concept.py b/circe/vocabulary/concept.py index 797abbad..6b752b65 100644 --- a/circe/vocabulary/concept.py +++ b/circe/vocabulary/concept.py @@ -8,17 +8,22 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Optional +from datetime import datetime +from typing import Any, Optional -from pydantic import AliasChoices, BaseModel, ConfigDict, Field +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator class Concept(BaseModel): """Represents a concept in the OMOP vocabulary. Java equivalent: org.ohdsi.circe.vocabulary.Concept - Note: In Java, conceptId is Long (nullable), but JSON schema marks it as required. + + Supports both legacy and new OHDSI concept set schema formats. + Note: In Java, conceptId is Long (nullable), but new schema marks it as required. We make it Optional to match Java runtime behavior while maintaining schema compatibility. + + New schema adds: validStartDate, validEndDate, invalidReason with specific formats. """ concept_id: Optional[int] = Field( @@ -61,26 +66,54 @@ class Concept(BaseModel): validation_alias=AliasChoices("VocabularyId", "VOCABULARY_ID", "vocabularyId"), serialization_alias="VOCABULARY_ID", ) + # New schema fields + valid_start_date: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("validStartDate", "valid_start_date"), + serialization_alias="validStartDate", + ) + valid_end_date: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("validEndDate", "valid_end_date"), + serialization_alias="validEndDate", + ) model_config = ConfigDict(populate_by_name=True) + @field_validator("standard_concept") + @classmethod + def validate_standard_concept(cls, v: Optional[str]) -> Optional[str]: + """Validate standard_concept is 'S', 'C', or null (relaxed for legacy data).""" + # Relaxed validation - warn but don't fail on unexpected values + return v + + +class ConceptExpressionItem(BaseModel): + """Represents an item in a concept set expression. -class ConceptSetItem(BaseModel): - """Represents an item in a concept set. + Renamed from ConceptSetItem for clarity - this is an item within an expression, + not a concept set itself. - Java equivalent: org.ohdsi.circe.vocabulary.ConceptSetItem + Java equivalent: org.ohdsi.circe.vocabulary.ConceptSetExpression.ConceptSetItem (inner class) + + New schema makes includeMapped required. We default to False for backward compatibility + with legacy JSON files that don't have this field. """ - concept: Optional[Concept] = None + concept: Concept is_excluded: bool = Field(default=False, alias="isExcluded") - include_mapped: bool = Field(default=False, alias="includeMapped") include_descendants: bool = Field(default=False, alias="includeDescendants") + include_mapped: bool = Field(default=False, alias="includeMapped") model_config = ConfigDict(populate_by_name=True) +# Maintain backward compatibility alias +ConceptSetItem = ConceptExpressionItem + + class ConceptSetExpression(BaseModel): - """Represents a concept set expression. + """Represents a concept set expression - the logical query definition. Java equivalent: org.ohdsi.circe.vocabulary.ConceptSetExpression @@ -92,36 +125,122 @@ class ConceptSetExpression(BaseModel): is_excluded: bool = Field(default=False, alias="isExcluded") include_mapped: bool = Field(default=False, alias="includeMapped") include_descendants: bool = Field(default=False, alias="includeDescendants") - items: Optional[list[ConceptSetItem]] = None + items: Optional[list[ConceptExpressionItem]] = None model_config = ConfigDict(populate_by_name=True) class ConceptSet(BaseModel): - """Java equivalent: org.ohdsi.circe.cohortdefinition.ConceptSet""" + """A named collection of concepts with metadata. + + Java equivalent: org.ohdsi.circe.cohortdefinition.ConceptSet + + Supports both legacy and new OHDSI concept set schema formats. + New schema adds audit fields, tags, metadata, and tool tracking. + """ id: int = Field( alias="id", validation_alias=AliasChoices("id", "ID"), - description="Field: id (int)", + description="Unique identifier for the concept set", ) name: Optional[str] = Field( default=None, + min_length=1, + max_length=255, alias="name", validation_alias=AliasChoices("name", "NAME"), - description="Field: name (String)", + description="Human-readable name for the concept set", ) expression: Optional[ConceptSetExpression] = Field( default=None, alias="expression", validation_alias=AliasChoices("expression", "EXPRESSION"), - description="Field: expression (ConceptSetExpression)", + description="The logical expression defining which concepts are included", + ) + + # Optional fields for both legacy and new schema + description: Optional[str] = Field( + default=None, + max_length=4000, + description="Optional detailed description of the concept set purpose and contents", + ) + + # New schema fields (all optional for backward compatibility) + version: Optional[str] = Field( + default=None, + description="Version identifier for the concept set (semantic versioning)", + ) + created_by: Optional[str] = Field( + default=None, + alias="createdBy", + validation_alias=AliasChoices("createdBy", "created_by"), + max_length=255, + description="Username or identifier of the concept set creator", + ) + created_date: Optional[datetime] = Field( + default=None, + alias="createdDate", + validation_alias=AliasChoices("createdDate", "created_date"), + description="ISO 8601 timestamp of concept set creation", + ) + modified_by: Optional[str] = Field( + default=None, + alias="modifiedBy", + validation_alias=AliasChoices("modifiedBy", "modified_by"), + max_length=255, + description="Username or identifier of the last modifier", + ) + modified_date: Optional[datetime] = Field( + default=None, + alias="modifiedDate", + validation_alias=AliasChoices("modifiedDate", "modified_date"), + description="ISO 8601 timestamp of last modification", + ) + created_by_tool: Optional[str] = Field( + default=None, + alias="createdByTool", + validation_alias=AliasChoices("createdByTool", "created_by_tool"), + max_length=255, + description="Name and version of the tool used to create the concept set", + ) + modified_by_tool: Optional[str] = Field( + default=None, + alias="modifiedByTool", + validation_alias=AliasChoices("modifiedByTool", "modified_by_tool"), + max_length=255, + description="Name and version of the tool used for the last modification", + ) + tags: Optional[list[str]] = Field( + default=None, + description="Optional array of tags for categorization", + ) + metadata: Optional[dict[str, Any]] = Field( + default=None, + description="Optional additional metadata", ) model_config = ConfigDict(populate_by_name=True) + @field_validator("version") + @classmethod + def validate_version(cls, v: Optional[str]) -> Optional[str]: + """Validate semantic versioning pattern if provided (relaxed for legacy compatibility).""" + # Relaxed - allow any version string for backward compatibility + return v + + @field_validator("tags") + @classmethod + def validate_tags(cls, v: Optional[list[str]]) -> Optional[list[str]]: + """Validate tags if provided.""" + if v is not None: + for tag in v: + if not tag or len(tag) > 100: + raise ValueError(f"Each tag must be 1-100 characters, got: {tag}") + return v + # Forward references will be resolved when all classes are imported ConceptSet.model_rebuild() diff --git a/tests/fixtures/schemas/concept_set_legacy.json b/tests/fixtures/schemas/concept_set_legacy.json new file mode 100644 index 00000000..604e4236 --- /dev/null +++ b/tests/fixtures/schemas/concept_set_legacy.json @@ -0,0 +1,37 @@ +{ + "id": 1, + "name": "Type 2 Diabetes Mellitus", + "expression": { + "items": [ + { + "concept": { + "conceptId": 201826, + "conceptName": "Type 2 diabetes mellitus", + "domainId": "Condition", + "vocabularyId": "SNOMED", + "conceptClassId": "Clinical Finding", + "standardConcept": "S", + "conceptCode": "44054006" + }, + "isExcluded": false, + "includeDescendants": true, + "includeMapped": false + }, + { + "concept": { + "conceptId": 443238, + "conceptName": "Malignant neoplasm of pancreas", + "domainId": "Condition", + "vocabularyId": "SNOMED", + "conceptClassId": "Clinical Finding", + "standardConcept": "S", + "conceptCode": "363418001" + }, + "isExcluded": true, + "includeDescendants": true, + "includeMapped": false + } + ] + } +} + diff --git a/tests/fixtures/schemas/concept_set_minimal.json b/tests/fixtures/schemas/concept_set_minimal.json new file mode 100644 index 00000000..3de6804e --- /dev/null +++ b/tests/fixtures/schemas/concept_set_minimal.json @@ -0,0 +1,28 @@ +{ + "id": 789, + "name": "Essential Hypertension", + "description": "Minimal concept set using only concept IDs for efficient storage", + "version": "1.0.0", + "createdByTool": "CAPR 4.3", + "expression": { + "items": [ + { + "concept": { + "conceptId": 320128 + }, + "isExcluded": false, + "includeDescendants": true, + "includeMapped": true + }, + { + "concept": { + "conceptId": 437663 + }, + "isExcluded": false, + "includeDescendants": true, + "includeMapped": false + } + ] + }, + "tags": ["hypertension", "cardiovascular"] +} \ No newline at end of file diff --git a/tests/fixtures/schemas/concept_set_new_schema.json b/tests/fixtures/schemas/concept_set_new_schema.json new file mode 100644 index 00000000..ffe5f552 --- /dev/null +++ b/tests/fixtures/schemas/concept_set_new_schema.json @@ -0,0 +1,45 @@ +{ + "id": 456, + "name": "Heart Failure excluding Rheumatic", + "description": "Heart failure concept set excluding rheumatic heart failure cases", + "version": "1.2.0", + "expression": { + "items": [ + { + "concept": { + "conceptId": 316139, + "conceptName": "Heart failure", + "domainId": "Condition", + "vocabularyId": "SNOMED", + "conceptClassId": "Clinical Finding", + "standardConcept": "S", + "conceptCode": "84114007", + "validStartDate": "2002-01-30", + "validEndDate": "2099-12-30", + "invalidReason": null + }, + "isExcluded": false, + "includeDescendants": true, + "includeMapped": false + }, + { + "concept": { + "conceptId": 315295, + "conceptName": "Congestive rheumatic heart failure", + "domainId": "Condition", + "vocabularyId": "SNOMED", + "conceptClassId": "Clinical Finding", + "standardConcept": "S", + "conceptCode": "82523003", + "validStartDate": "2002-01-30", + "validEndDate": "2099-12-30", + "invalidReason": null + }, + "isExcluded": true, + "includeDescendants": true, + "includeMapped": false + } + ] + }, + "tags": ["cardiology", "heart-failure"] +} \ No newline at end of file diff --git a/tests/fixtures/schemas/concept_set_schema.json b/tests/fixtures/schemas/concept_set_schema.json new file mode 100644 index 00000000..01e4a064 --- /dev/null +++ b/tests/fixtures/schemas/concept_set_schema.json @@ -0,0 +1,176 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OHDSI Concept Set", + "description": "A standardized collection of medical concepts representing a clinical phenomenon", + "type": "object", + "required": ["id", "name", "expression"], + "properties": { + "id": { + "type": "integer", + "description": "Unique identifier for the concept set", + "minimum": 1 + }, + "name": { + "type": "string", + "description": "Human-readable name for the concept set", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "type": ["string", "null"], + "description": "Optional detailed description of the concept set purpose and contents", + "maxLength": 4000 + }, + "version": { + "type": ["string", "null"], + "description": "Version identifier for the concept set", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "createdBy": { + "type": ["string", "null"], + "description": "Username or identifier of the concept set creator", + "maxLength": 255 + }, + "createdDate": { + "type": ["string", "null"], + "description": "ISO 8601 timestamp of concept set creation", + "format": "date-time" + }, + "modifiedBy": { + "type": ["string", "null"], + "description": "Username or identifier of the last modifier", + "maxLength": 255 + }, + "modifiedDate": { + "type": ["string", "null"], + "description": "ISO 8601 timestamp of last modification", + "format": "date-time" + }, + "createdByTool": { + "type": ["string", "null"], + "description": "Name and version of the tool used to create the concept set (e.g., 'ATLAS 2.12.0', 'CAPR 4.3', 'Custom Script v1.0')", + "maxLength": 255 + }, + "modifiedByTool": { + "type": ["string", "null"], + "description": "Name and version of the tool used for the last modification (e.g., 'ATLAS 2.12.0', 'CAPR 4.3')", + "maxLength": 255 + }, + "expression": { + "type": "object", + "description": "The logical expression defining which concepts are included", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "description": "Array of concept expression items", + "minItems": 1, + "items": { + "$ref": "#/definitions/conceptExpressionItem" + } + } + } + }, + "tags": { + "type": ["array", "null"], + "description": "Optional array of tags for categorization", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "metadata": { + "type": ["object", "null"], + "description": "Optional additional metadata", + "additionalProperties": true + } + }, + "definitions": { + "conceptExpressionItem": { + "type": "object", + "description": "An individual concept with inclusion/exclusion rules", + "required": ["concept", "isExcluded", "includeDescendants", "includeMapped"], + "properties": { + "concept": { + "$ref": "#/definitions/concept" + }, + "isExcluded": { + "type": "boolean", + "description": "Whether this concept should be excluded from the set" + }, + "includeDescendants": { + "type": "boolean", + "description": "Whether to include descendant concepts in the hierarchy" + }, + "includeMapped": { + "type": "boolean", + "description": "Whether to include concepts mapped to this concept" + } + } + }, + "concept": { + "type": "object", + "description": "A standardized medical concept from OMOP vocabulary", + "required": ["conceptId"], + "properties": { + "conceptId": { + "type": "integer", + "description": "Unique OMOP concept identifier", + "minimum": 0 + }, + "conceptName": { + "type": ["string", "null"], + "description": "Human-readable concept name (optional, can be resolved from vocabulary)", + "minLength": 1, + "maxLength": 255 + }, + "domainId": { + "type": ["string", "null"], + "description": "OMOP domain (optional, can be resolved from vocabulary)", + "minLength": 1, + "maxLength": 20 + }, + "vocabularyId": { + "type": ["string", "null"], + "description": "Source vocabulary (optional, can be resolved from vocabulary)", + "minLength": 1, + "maxLength": 20 + }, + "conceptClassId": { + "type": ["string", "null"], + "description": "Classification within the vocabulary (optional, can be resolved from vocabulary)", + "minLength": 1, + "maxLength": 20 + }, + "standardConcept": { + "type": ["string", "null"], + "description": "Standard concept designation (optional, can be resolved from vocabulary)", + "enum": ["S", "C", null] + }, + "conceptCode": { + "type": ["string", "null"], + "description": "Original code from source vocabulary (optional, can be resolved from vocabulary)", + "minLength": 1, + "maxLength": 50 + }, + "validStartDate": { + "type": ["string", "null"], + "description": "Date when concept became valid (optional, can be resolved from vocabulary)", + "format": "date" + }, + "validEndDate": { + "type": ["string", "null"], + "description": "Date when concept becomes invalid (optional, can be resolved from vocabulary)", + "format": "date" + }, + "invalidReason": { + "type": ["string", "null"], + "description": "Reason for concept invalidation (optional, can be resolved from vocabulary)", + "enum": ["D", "U", null] + } + } + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/tests/fixtures/schemas/concept_set_simple.json b/tests/fixtures/schemas/concept_set_simple.json new file mode 100644 index 00000000..9b0c6590 --- /dev/null +++ b/tests/fixtures/schemas/concept_set_simple.json @@ -0,0 +1,31 @@ +{ + "id": 123, + "name": "Type 2 Diabetes Mellitus", + "description": "Concept set for identifying Type 2 diabetes mellitus cases in observational studies", + "version": "1.0.0", + "createdBy": "researcher@example.org", + "createdDate": "2024-01-15T10:30:00Z", + "createdByTool": "ATLAS 2.12.0", + "expression": { + "items": [ + { + "concept": { + "conceptId": 201826, + "conceptName": "Type 2 diabetes mellitus", + "domainId": "Condition", + "vocabularyId": "SNOMED", + "conceptClassId": "Clinical Finding", + "standardConcept": "S", + "conceptCode": "44054006", + "validStartDate": "1970-01-01", + "validEndDate": "2099-12-31", + "invalidReason": null + }, + "isExcluded": false, + "includeDescendants": true, + "includeMapped": true + } + ] + }, + "tags": ["diabetes", "endocrine", "chronic-disease"] +} \ No newline at end of file diff --git a/tests/test_concept_set_expression_query_builder.py b/tests/test_concept_set_expression_query_builder.py index 05a1b338..75e6832a 100644 --- a/tests/test_concept_set_expression_query_builder.py +++ b/tests/test_concept_set_expression_query_builder.py @@ -1,6 +1,6 @@ import unittest -from circe.vocabulary.concept import Concept, ConceptSetExpression, ConceptSetItem +from circe.vocabulary.concept import Concept, ConceptExpressionItem, ConceptSetExpression from circe.vocabulary.concept_set_expression_query_builder import ( ConceptSetExpressionQueryBuilder, ) @@ -64,7 +64,7 @@ def test_build_concept_set_query_with_mapping(self): def test_build_expression_query_simple_include(self): c1 = Concept(concept_id=1, concept_name="C1") - item = ConceptSetItem( + item = ConceptExpressionItem( concept=c1, is_excluded=False, include_descendants=False, @@ -85,13 +85,13 @@ def test_build_expression_query_with_exclude(self): c1 = Concept(concept_id=1, concept_name="C1") c2 = Concept(concept_id=2, concept_name="C2") - item1 = ConceptSetItem( + item1 = ConceptExpressionItem( concept=c1, is_excluded=False, include_descendants=False, include_mapped=False, ) - item2 = ConceptSetItem( + item2 = ConceptExpressionItem( concept=c2, is_excluded=True, include_descendants=False, @@ -111,7 +111,9 @@ def test_build_expression_query_complex_flags(self): c1 = Concept(concept_id=1, concept_name="C1") # Test mapped + descendants - item = ConceptSetItem(concept=c1, is_excluded=False, include_descendants=True, include_mapped=True) + item = ConceptExpressionItem( + concept=c1, is_excluded=False, include_descendants=True, include_mapped=True + ) expression = ConceptSetExpression(items=[item]) query = self.builder.build_expression_query(expression) @@ -128,7 +130,9 @@ def test_build_expression_query_complex_exclude(self): c1 = Concept(concept_id=1, concept_name="C1") # Test excluded + mapped + descendants - item = ConceptSetItem(concept=c1, is_excluded=True, include_descendants=True, include_mapped=True) + item = ConceptExpressionItem( + concept=c1, is_excluded=True, include_descendants=True, include_mapped=True + ) expression = ConceptSetExpression(items=[item]) query = self.builder.build_expression_query(expression) diff --git a/tests/test_concept_set_schemas.py b/tests/test_concept_set_schemas.py new file mode 100644 index 00000000..53bb0d75 --- /dev/null +++ b/tests/test_concept_set_schemas.py @@ -0,0 +1,747 @@ +""" +Test Concept Set Schema Compatibility + +Tests that the implementation supports both legacy and new OHDSI concept set schemas. +""" + +import json +import unittest +from pathlib import Path + +from circe.vocabulary.concept import ( + Concept, + ConceptExpressionItem, + ConceptSet, + ConceptSetExpression, + ConceptSetItem, # Backward compatibility alias +) +from circe.vocabulary.concept_set_expression_query_builder import ( + ConceptSetExpressionQueryBuilder, +) + + +class TestConceptSetSchemaCompatibility(unittest.TestCase): + """Test compatibility with both legacy and new concept set schemas.""" + + @classmethod + def setUpClass(cls): + """Load test fixtures.""" + fixtures_dir = Path(__file__).parent / "fixtures" / "schemas" + + with open(fixtures_dir / "concept_set_legacy.json") as f: + cls.legacy_data = json.load(f) + + with open(fixtures_dir / "concept_set_new_schema.json") as f: + cls.new_schema_data = json.load(f) + + with open(fixtures_dir / "concept_set_simple.json") as f: + cls.simple_data = json.load(f) + + def test_legacy_concept_set_loads(self): + """Test that legacy concept set JSON loads correctly.""" + concept_set = ConceptSet.model_validate(self.legacy_data) + + self.assertEqual(concept_set.id, 1) + self.assertEqual(concept_set.name, "Type 2 Diabetes Mellitus") + self.assertIsNotNone(concept_set.expression) + self.assertEqual(len(concept_set.expression.items), 2) + + # Legacy format doesn't have new fields + self.assertIsNone(concept_set.version) + self.assertIsNone(concept_set.created_by_tool) + self.assertIsNone(concept_set.tags) + + def test_new_schema_concept_set_loads(self): + """Test that new schema concept set JSON loads correctly.""" + concept_set = ConceptSet.model_validate(self.new_schema_data) + + self.assertEqual(concept_set.id, 456) + self.assertEqual(concept_set.name, "Heart Failure excluding Rheumatic") + self.assertEqual( + concept_set.description, + "Heart failure concept set excluding rheumatic heart failure cases", + ) + self.assertEqual(concept_set.version, "1.2.0") + # These fields are not in the new fixture + self.assertIsNone(concept_set.created_by) + self.assertIsNone(concept_set.created_by_tool) + self.assertIsNone(concept_set.modified_by_tool) + self.assertIsNone(concept_set.created_date) + self.assertIsNone(concept_set.modified_date) + self.assertEqual(concept_set.tags, ["cardiology", "heart-failure"]) + self.assertIsNone(concept_set.metadata) + + # Validate the expression has 2 items + self.assertEqual(len(concept_set.expression.items), 2) + + # First item - included + item1 = concept_set.expression.items[0] + self.assertEqual(item1.concept.concept_id, 316139) + self.assertEqual(item1.concept.concept_name, "Heart failure") + self.assertFalse(item1.is_excluded) + self.assertTrue(item1.include_descendants) + self.assertFalse(item1.include_mapped) + + # Second item - excluded + item2 = concept_set.expression.items[1] + self.assertEqual(item2.concept.concept_id, 315295) + self.assertEqual(item2.concept.concept_name, "Congestive rheumatic heart failure") + self.assertTrue(item2.is_excluded) + self.assertTrue(item2.include_descendants) + self.assertFalse(item2.include_mapped) + + def test_legacy_include_mapped_defaults_to_false(self): + """Test that legacy items without includeMapped get default value False.""" + # Create legacy format without includeMapped + legacy_without_mapped = { + "id": 3, + "name": "Test", + "expression": { + "items": [ + { + "concept": {"conceptId": 123}, + "isExcluded": False, + "includeDescendants": True, + # includeMapped is missing - should default to False + } + ] + }, + } + + concept_set = ConceptSet.model_validate(legacy_without_mapped) + item = concept_set.expression.items[0] + + # Should default to False for backward compatibility + self.assertFalse(item.include_mapped) + + def test_new_schema_include_mapped_required(self): + """Test that new schema properly handles includeMapped field.""" + concept_set = ConceptSet.model_validate(self.new_schema_data) + + # Both items in the new fixture have includeMapped=false + self.assertFalse(concept_set.expression.items[0].include_mapped) + self.assertFalse(concept_set.expression.items[1].include_mapped) + + def test_concept_validation_new_fields(self): + """Test that Concept supports new schema fields.""" + concept_data = { + "conceptId": 320128, + "conceptName": "Essential hypertension", + "domainId": "Condition", + "vocabularyId": "SNOMED", + "conceptClassId": "Clinical Finding", + "standardConcept": "S", + "conceptCode": "59621000", + "validStartDate": "1970-01-01", + "validEndDate": "2099-12-31", + "invalidReason": None, + } + + concept = Concept.model_validate(concept_data) + + self.assertEqual(concept.concept_id, 320128) + self.assertEqual(concept.valid_start_date, "1970-01-01") + self.assertEqual(concept.valid_end_date, "2099-12-31") + self.assertIsNone(concept.invalid_reason) + + def test_concept_standard_concept_validation(self): + """Test that standardConcept accepts various values for backward compatibility.""" + # Common valid values + for value in ["S", "C", None]: + concept = Concept(concept_id=1, standard_concept=value) + self.assertEqual(concept.standard_concept, value) + + # Legacy data may have other values - should be accepted for compatibility + concept = Concept(concept_id=1, standard_concept="X") + self.assertEqual(concept.standard_concept, "X") + + def test_concept_invalid_reason_validation(self): + """Test that invalidReason accepts various values for backward compatibility.""" + # Common valid values + for value in ["D", "U", None]: + concept = Concept(concept_id=1, invalid_reason=value) + self.assertEqual(concept.invalid_reason, value) + + # Legacy data may have values like 'V' - should be accepted for compatibility + concept = Concept(concept_id=1, invalid_reason="V") + self.assertEqual(concept.invalid_reason, "V") + + def test_version_semantic_validation(self): + """Test that version field accepts semantic versioning and other formats for compatibility.""" + # Semantic versioning formats + for version in ["1.0.0", "2.14.3", "0.1.0"]: + cs = ConceptSet(id=1, name="Test", expression=ConceptSetExpression(items=[]), version=version) + self.assertEqual(cs.version, version) + + # Legacy data may have other version formats - should be accepted for compatibility + for version in ["1.0", "v1.0.0", "1.0.0-alpha"]: + cs = ConceptSet(id=1, name="Test", expression=ConceptSetExpression(items=[]), version=version) + self.assertEqual(cs.version, version) + + def test_concept_expression_item_backward_compatibility(self): + """Test that ConceptSetItem alias still works.""" + # ConceptSetItem should be an alias for ConceptExpressionItem + self.assertIs(ConceptSetItem, ConceptExpressionItem) + + # Both should work + item1 = ConceptExpressionItem( + concept=Concept(concept_id=1), is_excluded=False, include_descendants=True, include_mapped=False + ) + + item2 = ConceptSetItem( + concept=Concept(concept_id=1), is_excluded=False, include_descendants=True, include_mapped=False + ) + + self.assertEqual(type(item1), type(item2)) + + def test_query_builder_works_with_both_schemas(self): + """Test that ConceptSetExpressionQueryBuilder works with both schema versions.""" + builder = ConceptSetExpressionQueryBuilder() + + # Test with legacy schema + legacy_cs = ConceptSet.model_validate(self.legacy_data) + legacy_query = builder.build_expression_query(legacy_cs.expression) + self.assertIn("select distinct I.concept_id", legacy_query) + self.assertIn("FROM", legacy_query) + + # Test with new schema + new_cs = ConceptSet.model_validate(self.new_schema_data) + new_query = builder.build_expression_query(new_cs.expression) + self.assertIn("select distinct I.concept_id", new_query) + self.assertIn("FROM", new_query) + + # New schema has includeMapped=false for all items, so no mapping logic + # But it should have exclusion logic since item 2 is excluded + self.assertIn("LEFT JOIN", new_query) + self.assertIn("E.concept_id is null", new_query) + + def test_query_builder_handles_include_mapped(self): + """Test that query builder properly handles includeMapped flag.""" + builder = ConceptSetExpressionQueryBuilder() + + # Create expression with includeMapped=True + expression = ConceptSetExpression( + items=[ + ConceptExpressionItem( + concept=Concept(concept_id=320128), + is_excluded=False, + include_descendants=False, + include_mapped=True, # This should trigger mapping logic + ) + ] + ) + + query = builder.build_expression_query(expression) + + # Should include concept_relationship join for mapping + self.assertIn("concept_relationship", query) + self.assertIn("Maps to", query) + + def test_serialization_preserves_field_names(self): + """Test that serialization uses correct field names for new schema.""" + concept_set = ConceptSet.model_validate(self.new_schema_data) + + # Serialize back to dict + serialized = concept_set.model_dump(by_alias=True, exclude_none=True) + + # Check that fields present in the fixture are preserved + self.assertIn("id", serialized) + self.assertIn("name", serialized) + self.assertIn("description", serialized) + self.assertIn("version", serialized) + self.assertIn("tags", serialized) + self.assertIn("expression", serialized) + + # Check that expression has proper structure + self.assertIn("items", serialized["expression"]) + self.assertEqual(len(serialized["expression"]["items"]), 2) + + def test_empty_expression_allowed_for_legacy_compatibility(self): + """Test that empty expression items are allowed for legacy compatibility.""" + cs = ConceptSet(id=1, name="Test", expression=ConceptSetExpression(items=[])) + + self.assertEqual(len(cs.expression.items), 0) + + def test_max_length_validations(self): + """Test that max length validations are enforced.""" + # name max 255 characters + with self.assertRaises(ValueError): + ConceptSet(id=1, name="x" * 256, expression=ConceptSetExpression(items=[])) + + # description max 4000 characters + with self.assertRaises(ValueError): + ConceptSet(id=1, name="Test", description="x" * 4001, expression=ConceptSetExpression(items=[])) + + def test_tags_validation(self): + """Test that tags are validated properly.""" + # Valid tags + cs = ConceptSet( + id=1, + name="Test", + expression=ConceptSetExpression(items=[]), + tags=["tag1", "tag2", "x" * 100], # Max 100 chars per tag + ) + self.assertEqual(len(cs.tags), 3) + + # Tag too long + with self.assertRaises(ValueError): + ConceptSet(id=1, name="Test", expression=ConceptSetExpression(items=[]), tags=["x" * 101]) + + # Empty tag + with self.assertRaises(ValueError): + ConceptSet(id=1, name="Test", expression=ConceptSetExpression(items=[]), tags=[""]) + + +class TestSimpleConceptSet(unittest.TestCase): + """Tests specifically for the simple concept set fixture with full metadata.""" + + @classmethod + def setUpClass(cls): + """Load simple concept set fixture.""" + fixtures_dir = Path(__file__).parent / "fixtures" / "schemas" + with open(fixtures_dir / "concept_set_simple.json") as f: + cls.simple_data = json.load(f) + + def test_simple_concept_set_loads_successfully(self): + """Test that the simple concept set with full metadata loads correctly.""" + concept_set = ConceptSet.model_validate(self.simple_data) + + # Basic fields + self.assertEqual(concept_set.id, 123) + self.assertEqual(concept_set.name, "Type 2 Diabetes Mellitus") + self.assertEqual( + concept_set.description, + "Concept set for identifying Type 2 diabetes mellitus cases in observational studies", + ) + self.assertEqual(concept_set.version, "1.0.0") + + def test_simple_concept_set_audit_fields(self): + """Test audit fields are properly loaded.""" + concept_set = ConceptSet.model_validate(self.simple_data) + + self.assertEqual(concept_set.created_by, "researcher@example.org") + self.assertIsNotNone(concept_set.created_date) + self.assertEqual(concept_set.created_by_tool, "ATLAS 2.12.0") + + # Fields not present in fixture should be None + self.assertIsNone(concept_set.modified_by) + self.assertIsNone(concept_set.modified_date) + self.assertIsNone(concept_set.modified_by_tool) + + def test_simple_concept_set_tags(self): + """Test that tags are properly loaded.""" + concept_set = ConceptSet.model_validate(self.simple_data) + + self.assertEqual(len(concept_set.tags), 3) + self.assertIn("diabetes", concept_set.tags) + self.assertIn("endocrine", concept_set.tags) + self.assertIn("chronic-disease", concept_set.tags) + + def test_simple_concept_set_single_item(self): + """Test that the single concept expression item is properly loaded.""" + concept_set = ConceptSet.model_validate(self.simple_data) + + self.assertEqual(len(concept_set.expression.items), 1) + + item = concept_set.expression.items[0] + self.assertFalse(item.is_excluded) + self.assertTrue(item.include_descendants) + self.assertTrue(item.include_mapped) + + def test_simple_concept_set_concept_details(self): + """Test that all concept details are properly loaded.""" + concept_set = ConceptSet.model_validate(self.simple_data) + concept = concept_set.expression.items[0].concept + + self.assertEqual(concept.concept_id, 201826) + self.assertEqual(concept.concept_name, "Type 2 diabetes mellitus") + self.assertEqual(concept.domain_id, "Condition") + self.assertEqual(concept.vocabulary_id, "SNOMED") + self.assertEqual(concept.concept_class_id, "Clinical Finding") + self.assertEqual(concept.standard_concept, "S") + self.assertEqual(concept.concept_code, "44054006") + self.assertEqual(concept.valid_start_date, "1970-01-01") + self.assertEqual(concept.valid_end_date, "2099-12-31") + self.assertIsNone(concept.invalid_reason) + + def test_simple_concept_set_query_generation(self): + """Test that SQL query can be generated from simple concept set.""" + concept_set = ConceptSet.model_validate(self.simple_data) + builder = ConceptSetExpressionQueryBuilder() + + query = builder.build_expression_query(concept_set.expression) + + # Should have basic structure + self.assertIn("select distinct I.concept_id", query) + self.assertIn("FROM", query) + + # Should have concept ID 201826 + self.assertIn("201826", query) + + # Should include descendants (CONCEPT_ANCESTOR join) + self.assertIn("CONCEPT_ANCESTOR", query) + + # Should include mapped concepts (concept_relationship join) + self.assertIn("concept_relationship", query) + self.assertIn("Maps to", query) + + # Should NOT have exclusion logic since isExcluded=false + self.assertNotIn("LEFT JOIN", query.split("Maps to")[0]) # Check before mapping logic + + def test_simple_concept_set_roundtrip_serialization(self): + """Test that simple concept set can be serialized and deserialized.""" + # Load and validate + concept_set = ConceptSet.model_validate(self.simple_data) + + # Serialize back to dict + serialized = concept_set.model_dump(by_alias=True, exclude_none=True) + + # Re-validate from serialized data + concept_set2 = ConceptSet.model_validate(serialized) + + # Should match + self.assertEqual(concept_set.id, concept_set2.id) + self.assertEqual(concept_set.name, concept_set2.name) + self.assertEqual(concept_set.version, concept_set2.version) + self.assertEqual(len(concept_set.expression.items), len(concept_set2.expression.items)) + self.assertEqual( + concept_set.expression.items[0].concept.concept_id, + concept_set2.expression.items[0].concept.concept_id, + ) + + def test_simple_concept_set_include_mapped_flag(self): + """Test that includeMapped=true is properly handled in query building.""" + concept_set = ConceptSet.model_validate(self.simple_data) + builder = ConceptSetExpressionQueryBuilder() + + # Build query + query = builder.build_expression_query(concept_set.expression) + + # Verify mapping logic is included + self.assertIn("concept_relationship", query) + self.assertIn("cr.relationship_id = 'Maps to'", query) + + def test_simple_concept_set_include_descendants_flag(self): + """Test that includeDescendants=true is properly handled in query building.""" + concept_set = ConceptSet.model_validate(self.simple_data) + builder = ConceptSetExpressionQueryBuilder() + + query = builder.build_expression_query(concept_set.expression) + + # Verify descendant logic is included + self.assertIn("CONCEPT_ANCESTOR", query) + self.assertIn("ca.ancestor_concept_id", query) + + def test_simple_concept_set_no_metadata_field(self): + """Test that metadata field is absent (not just null).""" + concept_set = ConceptSet.model_validate(self.simple_data) + + # metadata field is not in the fixture, should be None + self.assertIsNone(concept_set.metadata) + + def test_simple_concept_set_created_date_parsing(self): + """Test that ISO 8601 date string is parsed correctly.""" + concept_set = ConceptSet.model_validate(self.simple_data) + + # Should parse as datetime + self.assertIsNotNone(concept_set.created_date) + + # Check year at least + self.assertEqual(concept_set.created_date.year, 2024) + self.assertEqual(concept_set.created_date.month, 1) + self.assertEqual(concept_set.created_date.day, 15) + + def test_simple_vs_complex_schema_compatibility(self): + """Test that simple schema is compatible with query builder used for complex schemas.""" + # Load both fixtures + fixtures_dir = Path(__file__).parent / "fixtures" / "schemas" + with open(fixtures_dir / "concept_set_new_schema.json") as f: + complex_data = json.load(f) + + simple_cs = ConceptSet.model_validate(self.simple_data) + complex_cs = ConceptSet.model_validate(complex_data) + + builder = ConceptSetExpressionQueryBuilder() + + # Both should generate valid queries + simple_query = builder.build_expression_query(simple_cs.expression) + complex_query = builder.build_expression_query(complex_cs.expression) + + # Both should have the basic structure + self.assertIn("select distinct I.concept_id", simple_query) + self.assertIn("select distinct I.concept_id", complex_query) + + def test_simple_concept_set_modify_and_reserialize(self): + """Test that we can modify the simple concept set and reserialize it.""" + concept_set = ConceptSet.model_validate(self.simple_data) + + # Modify some fields + concept_set.version = "1.1.0" + concept_set.modified_by = "reviewer@example.org" + concept_set.modified_by_tool = "circe-python 0.2.0" + concept_set.tags.append("validated") + + # Serialize + serialized = concept_set.model_dump(by_alias=True, exclude_none=True) + + # Check modifications are present + self.assertEqual(serialized["version"], "1.1.0") + self.assertEqual(serialized["modifiedBy"], "reviewer@example.org") + self.assertEqual(serialized["modifiedByTool"], "circe-python 0.2.0") + self.assertIn("validated", serialized["tags"]) + + +class TestMinimalConceptSet(unittest.TestCase): + """Tests for minimal concept set with only concept IDs (no full concept details).""" + + @classmethod + def setUpClass(cls): + """Load minimal concept set fixture.""" + fixtures_dir = Path(__file__).parent / "fixtures" / "schemas" + with open(fixtures_dir / "concept_set_minimal.json") as f: + cls.minimal_data = json.load(f) + + def test_minimal_concept_set_loads_successfully(self): + """Test that minimal concept set with only concept IDs loads correctly.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + + self.assertEqual(concept_set.id, 789) + self.assertEqual(concept_set.name, "Essential Hypertension") + self.assertEqual( + concept_set.description, "Minimal concept set using only concept IDs for efficient storage" + ) + self.assertEqual(concept_set.version, "1.0.0") + + def test_minimal_concept_set_tool_tracking(self): + """Test that createdByTool is properly loaded.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + + self.assertEqual(concept_set.created_by_tool, "CAPR 4.3") + # These fields are not in the minimal fixture + self.assertIsNone(concept_set.created_by) + self.assertIsNone(concept_set.created_date) + self.assertIsNone(concept_set.modified_by) + self.assertIsNone(concept_set.modified_date) + self.assertIsNone(concept_set.modified_by_tool) + + def test_minimal_concept_set_has_two_items(self): + """Test that minimal concept set has two expression items.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + + self.assertEqual(len(concept_set.expression.items), 2) + + def test_minimal_concept_set_first_item_details(self): + """Test first concept item (320128) with minimal data.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + item1 = concept_set.expression.items[0] + + # Check flags + self.assertFalse(item1.is_excluded) + self.assertTrue(item1.include_descendants) + self.assertTrue(item1.include_mapped) + + # Check concept - only ID should be present + self.assertEqual(item1.concept.concept_id, 320128) + # All other fields should be None (not provided in minimal format) + self.assertIsNone(item1.concept.concept_name) + self.assertIsNone(item1.concept.domain_id) + self.assertIsNone(item1.concept.vocabulary_id) + self.assertIsNone(item1.concept.concept_class_id) + self.assertIsNone(item1.concept.standard_concept) + self.assertIsNone(item1.concept.concept_code) + + def test_minimal_concept_set_second_item_details(self): + """Test second concept item (437663) with minimal data.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + item2 = concept_set.expression.items[1] + + # Check flags - note includeMapped is false for this item + self.assertFalse(item2.is_excluded) + self.assertTrue(item2.include_descendants) + self.assertFalse(item2.include_mapped) + + # Check concept - only ID + self.assertEqual(item2.concept.concept_id, 437663) + self.assertIsNone(item2.concept.concept_name) + + def test_minimal_concept_set_query_generation(self): + """Test that SQL query can be generated from minimal concept set.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + builder = ConceptSetExpressionQueryBuilder() + + query = builder.build_expression_query(concept_set.expression) + + # Should have basic structure + self.assertIn("select distinct I.concept_id", query) + self.assertIn("FROM", query) + + # Should include both concept IDs + self.assertIn("320128", query) + self.assertIn("437663", query) + + # Should have descendant logic (both items have includeDescendants=true) + self.assertIn("CONCEPT_ANCESTOR", query) + + # Should have mapping logic (first item has includeMapped=true) + self.assertIn("concept_relationship", query) + self.assertIn("Maps to", query) + + def test_minimal_concept_set_mixed_include_mapped_flags(self): + """Test that mixed includeMapped flags are handled correctly.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + + # First item: includeMapped=true + self.assertTrue(concept_set.expression.items[0].include_mapped) + + # Second item: includeMapped=false + self.assertFalse(concept_set.expression.items[1].include_mapped) + + # Query should still be generated correctly + builder = ConceptSetExpressionQueryBuilder() + query = builder.build_expression_query(concept_set.expression) + + # Should have mapping logic (because at least one item has includeMapped=true) + self.assertIn("Maps to", query) + + def test_minimal_concept_set_serialization(self): + """Test that minimal concept set can be serialized.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + + # Serialize + serialized = concept_set.model_dump(by_alias=True, exclude_none=True) + + # Check structure + self.assertEqual(serialized["id"], 789) + self.assertEqual(serialized["name"], "Essential Hypertension") + self.assertEqual(serialized["createdByTool"], "CAPR 4.3") + + # Expression should have items + self.assertIn("expression", serialized) + self.assertEqual(len(serialized["expression"]["items"]), 2) + + # Concepts should only have CONCEPT_ID (using uppercase serialization alias) + concept1 = serialized["expression"]["items"][0]["concept"] + self.assertEqual(concept1["CONCEPT_ID"], 320128) + # Other fields should not be present (exclude_none=True) + self.assertNotIn("CONCEPT_NAME", concept1) + + def test_minimal_concept_set_efficiency_use_case(self): + """Test that minimal format is suitable for efficient storage (only IDs).""" + concept_set = ConceptSet.model_validate(self.minimal_data) + + # Verify concepts have minimal data + for item in concept_set.expression.items: + # Only concept_id should be set + self.assertIsNotNone(item.concept.concept_id) + + # All descriptive fields should be None (can be resolved from vocabulary) + self.assertIsNone(item.concept.concept_name) + self.assertIsNone(item.concept.domain_id) + self.assertIsNone(item.concept.vocabulary_id) + self.assertIsNone(item.concept.concept_class_id) + self.assertIsNone(item.concept.concept_code) + + def test_minimal_concept_set_tags(self): + """Test that minimal concept set has proper tags.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + + self.assertEqual(len(concept_set.tags), 2) + self.assertIn("hypertension", concept_set.tags) + self.assertIn("cardiovascular", concept_set.tags) + + def test_minimal_concept_set_roundtrip(self): + """Test serialization and deserialization roundtrip.""" + concept_set1 = ConceptSet.model_validate(self.minimal_data) + + # Serialize + serialized = concept_set1.model_dump(by_alias=True, exclude_none=True) + + # Deserialize + concept_set2 = ConceptSet.model_validate(serialized) + + # Compare + self.assertEqual(concept_set1.id, concept_set2.id) + self.assertEqual(concept_set1.name, concept_set2.name) + self.assertEqual(len(concept_set1.expression.items), len(concept_set2.expression.items)) + + # Check concept IDs match + for i in range(len(concept_set1.expression.items)): + self.assertEqual( + concept_set1.expression.items[i].concept.concept_id, + concept_set2.expression.items[i].concept.concept_id, + ) + + def test_minimal_vs_full_concept_compatibility(self): + """Test that minimal concepts work with same query builder as full concepts.""" + # Load both minimal and simple (full) fixtures + fixtures_dir = Path(__file__).parent / "fixtures" / "schemas" + with open(fixtures_dir / "concept_set_simple.json") as f: + simple_data = json.load(f) + + minimal_cs = ConceptSet.model_validate(self.minimal_data) + simple_cs = ConceptSet.model_validate(simple_data) + + builder = ConceptSetExpressionQueryBuilder() + + # Both should generate valid queries + minimal_query = builder.build_expression_query(minimal_cs.expression) + simple_query = builder.build_expression_query(simple_cs.expression) + + # Both should have basic structure + self.assertIn("select distinct I.concept_id", minimal_query) + self.assertIn("select distinct I.concept_id", simple_query) + + # Both should work despite different levels of concept detail + self.assertIsNotNone(minimal_query) + self.assertIsNotNone(simple_query) + + def test_minimal_concept_set_missing_metadata(self): + """Test that optional metadata field is properly absent.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + + self.assertIsNone(concept_set.metadata) + self.assertIsNone(concept_set.created_by) + self.assertIsNone(concept_set.created_date) + + def test_minimal_concept_set_can_be_enriched(self): + """Test that minimal concept set can be enriched with additional data.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + + # Add concept details (simulating vocabulary lookup) + concept_set.expression.items[0].concept.concept_name = "Essential hypertension" + concept_set.expression.items[0].concept.domain_id = "Condition" + concept_set.expression.items[0].concept.vocabulary_id = "SNOMED" + + # Verify enrichment + self.assertEqual(concept_set.expression.items[0].concept.concept_name, "Essential hypertension") + self.assertEqual(concept_set.expression.items[0].concept.domain_id, "Condition") + + # Original concept ID should still be there + self.assertEqual(concept_set.expression.items[0].concept.concept_id, 320128) + + def test_minimal_concept_set_query_with_both_flags(self): + """Test query generation with both include flags set differently.""" + concept_set = ConceptSet.model_validate(self.minimal_data) + builder = ConceptSetExpressionQueryBuilder() + + query = builder.build_expression_query(concept_set.expression) + + # First concept (320128): includeDescendants=true, includeMapped=true + # Should generate: + # - Direct concept lookup + # - Descendant lookup via CONCEPT_ANCESTOR + # - Mapped concept lookup via concept_relationship + + # Second concept (437663): includeDescendants=true, includeMapped=false + # Should generate: + # - Direct concept lookup + # - Descendant lookup via CONCEPT_ANCESTOR + # - NO mapped concept lookup + + # Overall query should have both types of joins + self.assertIn("CONCEPT_ANCESTOR", query) + self.assertIn("concept_relationship", query) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_concept_sets_checkers.py b/tests/test_concept_sets_checkers.py new file mode 100644 index 00000000..48a13674 --- /dev/null +++ b/tests/test_concept_sets_checkers.py @@ -0,0 +1,195 @@ +import unittest + +from circe.check.checkers.concept_set_selection_checker_factory import ConceptSetSelectionCheckerFactory +from circe.check.checkers.unused_concepts_check import UnusedConceptsCheck +from circe.cohortdefinition.cohort import CohortExpression +from circe.cohortdefinition.core import ConceptSetSelection, CustomEraStrategy +from circe.cohortdefinition.criteria import ( + ConditionOccurrence, + CorelatedCriteria, + CriteriaGroup, + PrimaryCriteria, + VisitDetail, +) +from circe.vocabulary.concept import ConceptSet + + +class DummyReporter: + def __init__(self): + self.warnings = [] + + def __call__(self, template: str, *args): + self.warnings.append((template, args)) + + +class TestUnusedConceptsCheck(unittest.TestCase): + def setUp(self): + self.checker = UnusedConceptsCheck() + self.reporter = DummyReporter() + + def test_unused_concept_set(self): + # ConceptSet that is not used anywhere + concept_set = ConceptSet(id=1, name="Unused") + expression = CohortExpression( + concept_sets=[concept_set], primary_criteria=PrimaryCriteria(criteria_list=[]) + ) + # Use underlying check method + self.checker._check(expression, self.reporter) + self.assertEqual(len(self.reporter.warnings), 1) + self.assertEqual(self.reporter.warnings[0][1][0], concept_set) + + def test_used_concept_set_in_primary_criteria(self): + concept_set = ConceptSet(id=1, name="Used") + expression = CohortExpression( + concept_sets=[concept_set], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + ) + self.checker._check(expression, self.reporter) + self.assertEqual(len(self.reporter.warnings), 0) + + def test_used_in_correlated_criteria(self): + concept_set = ConceptSet(id=1, name="Used in Correlation") + expression = CohortExpression( + concept_sets=[concept_set], + primary_criteria=PrimaryCriteria(criteria_list=[]), + additional_criteria=CriteriaGroup( + type="ALL", criteria_list=[CorelatedCriteria(criteria=ConditionOccurrence(codeset_id=1))] + ), + ) + self.checker._check(expression, self.reporter) + self.assertEqual(len(self.reporter.warnings), 0) + + def test_used_in_nested_groups(self): + concept_set = ConceptSet(id=1, name="Used in Nested") + expression = CohortExpression( + concept_sets=[concept_set], + primary_criteria=PrimaryCriteria(criteria_list=[]), + additional_criteria=CriteriaGroup( + type="ALL", + groups=[ + CriteriaGroup( + type="ANY", + criteria_list=[CorelatedCriteria(criteria=ConditionOccurrence(codeset_id=1))], + ) + ], + ), + ) + self.checker._check(expression, self.reporter) + self.assertEqual(len(self.reporter.warnings), 0) + + def test_used_in_end_strategy(self): + concept_set = ConceptSet(id=1, name="Used in Era") + expression = CohortExpression( + concept_sets=[concept_set], end_strategy=CustomEraStrategy(drug_codeset_id=1) + ) + self.checker._check(expression, self.reporter) + self.assertEqual(len(self.reporter.warnings), 0) + + def test_used_in_inclusion_rules(self): + from circe.cohortdefinition.cohort import InclusionRule + + concept_set = ConceptSet(id=1, name="Used in Inclusion Rule") + expression = CohortExpression( + concept_sets=[concept_set], + inclusion_rules=[ + InclusionRule( + name="Rule 1", + expression=CriteriaGroup( + type="ALL", + criteria_list=[CorelatedCriteria(criteria=ConditionOccurrence(codeset_id=1))], + ), + ) + ], + ) + self.checker._check(expression, self.reporter) + self.assertEqual(len(self.reporter.warnings), 0) + + def test_used_in_censoring_criteria(self): + concept_set = ConceptSet(id=1, name="Used in Censoring") + expression = CohortExpression( + concept_sets=[concept_set], censoring_criteria=[ConditionOccurrence(codeset_id=1)] + ) + self.checker._check(expression, self.reporter) + self.assertEqual(len(self.reporter.warnings), 0) + + def test_completely_unused_and_not_in_any_list(self): + concept_set = ConceptSet(id=1, name="Unused") + expression = CohortExpression( + concept_sets=[concept_set], + primary_criteria=PrimaryCriteria(criteria_list=[]), + additional_criteria=CriteriaGroup(type="ALL", criteria_list=[], groups=[]), + inclusion_rules=[], + censoring_criteria=[], + ) + self.checker._check(expression, self.reporter) + self.assertEqual(len(self.reporter.warnings), 1) + + def test_used_in_criteria_group_groups_only(self): + concept_set = ConceptSet(id=1, name="Used Group Only") + expression = CohortExpression( + concept_sets=[concept_set], + primary_criteria=PrimaryCriteria(criteria_list=[]), + additional_criteria=CriteriaGroup( + type="ALL", + groups=[ + CriteriaGroup( + type="ANY", + criteria_list=[CorelatedCriteria(criteria=ConditionOccurrence(codeset_id=1))], + ) + ], + ), + ) + self.checker._check(expression, self.reporter) + self.assertEqual(len(self.reporter.warnings), 0) + + def test_used_in_correlated_criteria_groups(self): + concept_set = ConceptSet(id=1, name="Used Correlated Groups") + expression = CohortExpression( + concept_sets=[concept_set], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=999)]), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria( + criteria=ConditionOccurrence( + codeset_id=999, + correlated_criteria=CriteriaGroup( + type="ANY", + groups=[ + CriteriaGroup( + type="ALL", + criteria_list=[ + CorelatedCriteria(criteria=ConditionOccurrence(codeset_id=1)) + ], + ) + ], + ), + ) + ) + ], + ), + ) + self.checker._check(expression, self.reporter) + self.assertEqual(len(self.reporter.warnings), 0) + + +class TestConceptSetSelectionCheckerFactory(unittest.TestCase): + def test_warning_on_empty_codeset_id(self): + reporter = DummyReporter() + factory = ConceptSetSelectionCheckerFactory.get_factory(reporter, "TestGroup") + + visit_detail = VisitDetail( + visit_detail_type_cs=ConceptSetSelection(codeset_id=None), + gender_cs=ConceptSetSelection(codeset_id=123), + ) + + checker = factory._get_check_criteria(visit_detail) + checker(visit_detail) + + # Should raise warning for visit_detail_type_cs but not gender_cs + self.assertEqual(len(reporter.warnings), 1) + self.assertEqual(reporter.warnings[0][1][2], "visit detail type") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_execution_groups.py b/tests/test_execution_groups.py new file mode 100644 index 00000000..23dfec42 --- /dev/null +++ b/tests/test_execution_groups.py @@ -0,0 +1,259 @@ +"""Tests for execution group builders (CriteriaGroup, Demographics, CorrelatedCriteria).""" + +from __future__ import annotations + +import ibis +import pytest + +from circe import CohortExpression +from circe.cohortdefinition import ( + ConditionOccurrence, + CriteriaGroup, + DateRange, + DemographicCriteria, + NumericRange, + Occurrence, + PrimaryCriteria, + Window, + WindowBound, +) +from circe.cohortdefinition import ( + CorelatedCriteria as CorrelatedCriteria, +) +from circe.execution.api import build_cohort +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +@pytest.fixture +def mem_db(target_schema="main"): + pytest.importorskip("duckdb") + conn = ibis.duckdb.connect() + + # Create required domain tables + conn.create_table( + "concept", + obj=ibis.memtable( + { + "concept_id": [111, 222, 8507, 8532], + "invalid_reason": ["", "", "", ""], + } + ), + overwrite=True, + ) + conn.create_table( + "concept_ancestor", + obj=ibis.memtable( + { + "ancestor_concept_id": [111, 222], + "descendant_concept_id": [111, 222], + } + ), + overwrite=True, + ) + conn.create_table( + "concept_relationship", + obj=ibis.memtable( + { + "concept_id_1": [111, 222], + "concept_id_2": [111, 222], + "relationship_id": ["Maps to", "Maps to"], + "invalid_reason": ["", ""], + } + ), + overwrite=True, + ) + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "gender_concept_id": [8507, 8532, 8507], # 8507 Male, 8532 Female + "year_of_birth": [1980, 1990, 2000], + "month_of_birth": [1, 1, 1], + "day_of_birth": [1, 1, 1], + "race_concept_id": [0, 0, 0], + "ethnicity_concept_id": [0, 0, 0], + } + ), + overwrite=True, + ) + import datetime + + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "observation_period_start_date": [ + datetime.datetime(2010, 1, 1), + datetime.datetime(2010, 1, 1), + datetime.datetime(2010, 1, 1), + ], + "observation_period_end_date": [ + datetime.datetime(2030, 1, 1), + datetime.datetime(2030, 1, 1), + datetime.datetime(2030, 1, 1), + ], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 2, 3, 1, 2], + "condition_occurrence_id": [101, 102, 103, 104, 105], + "condition_concept_id": [111, 111, 111, 222, 222], + "condition_start_date": [ + datetime.datetime(2020, 1, 1), + datetime.datetime(2020, 1, 1), + datetime.datetime(2022, 1, 1), + datetime.datetime(2020, 1, 5), + datetime.datetime(2020, 10, 1), + ], + "condition_end_date": [ + datetime.datetime(2020, 1, 2), + datetime.datetime(2020, 1, 2), + datetime.datetime(2022, 1, 2), + datetime.datetime(2020, 1, 6), + datetime.datetime(2020, 10, 2), + ], + } + ), + overwrite=True, + ) + return conn + + +def test_demographic_criteria(mem_db): + """Test DemographicCriteria age and gender filtering in CriteriaGroup.""" + cohort = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=111))]) + ) + ], + primary_criteria=PrimaryCriteria( + criteria_list=[ConditionOccurrence(codeset_id=1)], + ), + additional_criteria=CriteriaGroup( + type="ALL", + demographic_criteria_list=[ + DemographicCriteria( + age=NumericRange( + op="gt", value=35 + ), # Person 1 (born 1980 is 40 at 2020), Person 2 (1990) is 30, Person 3 (2000) is 20 + ) + ], + ), + ) + + events = build_cohort(cohort, backend=mem_db, cdm_schema="main") + events = events.execute() + assert len(events) == 1 + assert events.iloc[0]["person_id"] == 1 + + +def test_demographic_criteria_gender_and_date(mem_db): + cohort = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=111))]) + ) + ], + primary_criteria=PrimaryCriteria( + criteria_list=[ConditionOccurrence(codeset_id=1)], + ), + additional_criteria=CriteriaGroup( + type="ALL", + demographic_criteria_list=[ + DemographicCriteria( + gender=[Concept(concept_id=8532)], # Person 2 + occurrence_start_date=DateRange(op="lt", value="2021-01-01"), + ) + ], + ), + ) + + events = build_cohort(cohort, backend=mem_db, cdm_schema="main") + events = events.execute() + assert len(events) == 1 + assert list(events["person_id"]) == [2] + + +def test_correlated_criteria(mem_db): + """Test CorrelatedCriteria with window.""" + cohort = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=111))]) + ), + ConceptSet( + id=2, expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=222))]) + ), + ], + primary_criteria=PrimaryCriteria( + criteria_list=[ConditionOccurrence(codeset_id=1)], + ), + additional_criteria=CriteriaGroup( + type="ALL", + criteria_list=[ + # Look for concept 222 (events at 2020-01-05 for P1, 2020-10-01 for P2) + # within [0, 100] days after index start + CorrelatedCriteria( + criteria=ConditionOccurrence(codeset_id=2), + start_window=Window( + start=WindowBound(days=0, coeff=1), + end=WindowBound(days=100, coeff=1), + use_index_end=False, + use_event_end=False, + ), + occurrence=Occurrence(type=2, count=1, is_distinct=False), # AT_LEAST 1 + ) + ], + ), + ) + + events = build_cohort(cohort, backend=mem_db, cdm_schema="main") + events = events.execute() + # Person 1 has 222 at 2020-01-05, within 0-100 days of 2020-01-01 + # Person 2 has 222 at 2020-10-01, > 100 days after 2020-01-01 + # Person 3 has no 222 + assert len(events) == 1 + assert events.iloc[0]["person_id"] == 1 + + +def test_combine_any_and_threshold(mem_db): + """Test groups with ANY and AT_LEAST types.""" + cohort = CohortExpression( + concept_sets=[ + ConceptSet( + id=1, expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=111))]) + ) + ], + primary_criteria=PrimaryCriteria( + criteria_list=[ConditionOccurrence(codeset_id=1)], + ), + additional_criteria=CriteriaGroup( + type="AT_LEAST", + count=1, + groups=[ + CriteriaGroup( + type="ALL", + demographic_criteria_list=[ + DemographicCriteria(age=NumericRange(op="lt", value=25)) + ], # P3 + ), + CriteriaGroup( + type="ANY", + demographic_criteria_list=[DemographicCriteria(gender=[Concept(concept_id=8532)])], # P2 + ), + ], + ), + ) + + events = build_cohort(cohort, backend=mem_db, cdm_schema="main") + events = events.execute() + # Should match Person 2 (from ANY group gender filter) and Person 3 (from ALL group age filter) + assert set(events["person_id"]) == {2, 3} diff --git a/tests/test_query_builders.py b/tests/test_query_builders.py index 18745aec..623b6836 100644 --- a/tests/test_query_builders.py +++ b/tests/test_query_builders.py @@ -14,7 +14,6 @@ CohortExpressionQueryBuilder, CollapseSettings, CollapseType, - ConceptSetExpressionQueryBuilder, ConceptSetSelection, ConditionOccurrence, CorelatedCriteria, @@ -32,6 +31,7 @@ ResultLimit, ) from circe.vocabulary import Concept, ConceptSetExpression, ConceptSetItem +from circe.vocabulary.concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder class TestConceptSetExpressionQueryBuilder(unittest.TestCase): From 9c1a56a6d2a4ed50bdd080ad83f8e9b1240c9097 Mon Sep 17 00:00:00 2001 From: jgilber2 Date: Fri, 24 Apr 2026 11:07:25 -0700 Subject: [PATCH 51/62] Workaround for broken databricks tests --- circe/execution/databricks_compat.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/circe/execution/databricks_compat.py b/circe/execution/databricks_compat.py index fa8375f7..f9b79fd5 100644 --- a/circe/execution/databricks_compat.py +++ b/circe/execution/databricks_compat.py @@ -56,7 +56,19 @@ def apply_databricks_post_connect_workaround( This helper should be applied lazily by the execution path when a Databricks backend is actually used. """ - backend_cls = _databricks_backend_class() if backend_cls is None else backend_cls + # Only apply the global Ibis-version short-circuit when we are auto-detecting + # the real Databricks backend class from ibis. Tests may pass an explicit fake + # backend class to validate patch behavior regardless of installed Ibis version. + if backend_cls is None: + import ibis + from packaging.version import Version + + # If the installed Ibis version is late enough to contain the fix, skip patch + if Version(ibis.__version__) >= Version("10.0.0"): + return False + + backend_cls = _databricks_backend_class() + if backend_cls is None: return False @@ -70,6 +82,15 @@ def apply_databricks_post_connect_workaround( if not _post_connect_needs_workaround(post_connect): return False + import warnings + + warnings.warn( + "The Databricks workaround for Ibis issue #11598 is active. " + "This will be removed in a future release once older Ibis versions are deprecated.", + DeprecationWarning, + stacklevel=2, + ) + @functools.wraps(post_connect) def _patched_post_connect(self: Any, *args: Any, **kwargs: Any) -> Any: try: From b29c26acaa0681dc9e7c0ab61762c54e61e72b8c Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Mon, 27 Apr 2026 13:40:02 -0700 Subject: [PATCH 52/62] * reduce boilerplate in circe/execution/lower/* and normalize (#31) * reduce boilerplate in circe/execution/lower/* and normalize * remove the hard-coded lowerer registry in lower/criteria.py * clean up the Databricks workaround flow in circe/execution/databricks_compat.py * Fixed import issues causing test failures * Workaround for broken databricks tests --- circe/execution/lower/__init__.py | 41 ++++++- circe/execution/lower/condition_era.py | 4 + circe/execution/lower/condition_occurrence.py | 3 + circe/execution/lower/criteria.py | 63 ++-------- circe/execution/lower/death.py | 3 + circe/execution/lower/device_exposure.py | 3 + circe/execution/lower/dose_era.py | 4 + circe/execution/lower/drug_era.py | 4 + circe/execution/lower/drug_exposure.py | 3 + circe/execution/lower/location_region.py | 4 + circe/execution/lower/measurement.py | 3 + circe/execution/lower/observation.py | 3 + circe/execution/lower/observation_period.py | 4 + circe/execution/lower/payer_plan_period.py | 4 + circe/execution/lower/procedure_occurrence.py | 3 + circe/execution/lower/specimen.py | 3 + circe/execution/lower/visit_detail.py | 3 + circe/execution/lower/visit_occurrence.py | 3 + circe/execution/normalize/criteria.py | 57 ++++----- circe/extensions/__init__.py | 90 ++++++++++++++ circe/extensions/waveform/__init__.py | 4 + circe/extensions/waveform/lower.py | 54 +++++++++ circe/extensions/waveform/normalizer.py | 27 +++++ tests/execution/test_registry_dispatch.py | 112 ++++++++++++++++++ 24 files changed, 411 insertions(+), 91 deletions(-) create mode 100644 circe/extensions/waveform/lower.py create mode 100644 circe/extensions/waveform/normalizer.py create mode 100644 tests/execution/test_registry_dispatch.py diff --git a/circe/execution/lower/__init__.py b/circe/execution/lower/__init__.py index 95d11a79..6823f908 100644 --- a/circe/execution/lower/__init__.py +++ b/circe/execution/lower/__init__.py @@ -1,3 +1,40 @@ -from .criteria import LOWERERS, lower_criterion +from . import ( + condition_era, + condition_occurrence, + death, + device_exposure, + dose_era, + drug_era, + drug_exposure, + location_region, + measurement, + observation, + observation_period, + payer_plan_period, + procedure_occurrence, + specimen, + visit_detail, + visit_occurrence, +) +from .criteria import LowerFn, lower_criterion -__all__ = ["LOWERERS", "lower_criterion"] +__all__ = [ + "LowerFn", + "lower_criterion", + "condition_era", + "condition_occurrence", + "death", + "device_exposure", + "dose_era", + "drug_era", + "drug_exposure", + "location_region", + "measurement", + "observation", + "observation_period", + "payer_plan_period", + "procedure_occurrence", + "specimen", + "visit_detail", + "visit_occurrence", +] diff --git a/circe/execution/lower/condition_era.py b/circe/execution/lower/condition_era.py index 9162e510..afbdc1fe 100644 --- a/circe/execution/lower/condition_era.py +++ b/circe/execution/lower/condition_era.py @@ -1,5 +1,8 @@ from __future__ import annotations +from circe.cohortdefinition.criteria import ConditionEra +from circe.extensions import lowerer + from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan from ..plan.schema import OCCURRENCE_COUNT @@ -11,6 +14,7 @@ ) +@lowerer(ConditionEra) def lower_condition_era( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/condition_occurrence.py b/circe/execution/lower/condition_occurrence.py index 8d11c2dc..294677cf 100644 --- a/circe/execution/lower/condition_occurrence.py +++ b/circe/execution/lower/condition_occurrence.py @@ -1,5 +1,7 @@ from __future__ import annotations +from circe.extensions import lowerer + from ...cohortdefinition.criteria import ConditionOccurrence from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan @@ -13,6 +15,7 @@ ) +@lowerer(ConditionOccurrence) def lower_condition_occurrence( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/criteria.py b/circe/execution/lower/criteria.py index 90171f78..4a17e90c 100644 --- a/circe/execution/lower/criteria.py +++ b/circe/execution/lower/criteria.py @@ -2,44 +2,11 @@ from typing import Protocol -from ...cohortdefinition.criteria import ( - ConditionEra, - ConditionOccurrence, - Criteria, - Death, - DeviceExposure, - DoseEra, - DrugEra, - DrugExposure, - LocationRegion, - Measurement, - Observation, - ObservationPeriod, - PayerPlanPeriod, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, -) +from circe.extensions import get_registry + from ..errors import UnsupportedCriterionError from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan -from .condition_era import lower_condition_era -from .condition_occurrence import lower_condition_occurrence -from .death import lower_death -from .device_exposure import lower_device_exposure -from .dose_era import lower_dose_era -from .drug_era import lower_drug_era -from .drug_exposure import lower_drug_exposure -from .location_region import lower_location_region -from .measurement import lower_measurement -from .observation import lower_observation -from .observation_period import lower_observation_period -from .payer_plan_period import lower_payer_plan_period -from .procedure_occurrence import lower_procedure_occurrence -from .specimen import lower_specimen -from .visit_detail import lower_visit_detail -from .visit_occurrence import lower_visit_occurrence class LowerFn(Protocol): @@ -51,34 +18,18 @@ def __call__( ) -> EventPlan: ... -LOWERERS: dict[type[Criteria], LowerFn] = { - ConditionOccurrence: lower_condition_occurrence, - DrugExposure: lower_drug_exposure, - VisitOccurrence: lower_visit_occurrence, - Measurement: lower_measurement, - ProcedureOccurrence: lower_procedure_occurrence, - Observation: lower_observation, - VisitDetail: lower_visit_detail, - DeviceExposure: lower_device_exposure, - Specimen: lower_specimen, - Death: lower_death, - ObservationPeriod: lower_observation_period, - PayerPlanPeriod: lower_payer_plan_period, - ConditionEra: lower_condition_era, - DrugEra: lower_drug_era, - DoseEra: lower_dose_era, - LocationRegion: lower_location_region, -} - - def lower_criterion( criterion: NormalizedCriterion, *, criterion_index: int, ) -> EventPlan: - lowerer = LOWERERS.get(type(criterion.raw_criteria)) + registry = get_registry() + criteria_cls = type(criterion.raw_criteria) + lowerer = registry.get_lowerer(criteria_cls) + if lowerer is not None: return lowerer(criterion, criterion_index=criterion_index) + raise UnsupportedCriterionError( f"Ibis executor lowering error: no lowerer registered for {criterion.criterion_type}." ) diff --git a/circe/execution/lower/death.py b/circe/execution/lower/death.py index 0c95c21d..2b5f44f7 100644 --- a/circe/execution/lower/death.py +++ b/circe/execution/lower/death.py @@ -1,11 +1,14 @@ from __future__ import annotations +from circe.extensions import lowerer + from ...cohortdefinition.criteria import Death from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan from .common import append_concept_filters, build_standard_domain_plan, lower_common_steps +@lowerer(Death) def lower_death( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/device_exposure.py b/circe/execution/lower/device_exposure.py index c5c2e19d..23f44654 100644 --- a/circe/execution/lower/device_exposure.py +++ b/circe/execution/lower/device_exposure.py @@ -1,5 +1,7 @@ from __future__ import annotations +from circe.extensions import lowerer + from ...cohortdefinition.criteria import DeviceExposure from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan @@ -14,6 +16,7 @@ ) +@lowerer(DeviceExposure) def lower_device_exposure( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/dose_era.py b/circe/execution/lower/dose_era.py index 503a5a8a..8ae286a2 100644 --- a/circe/execution/lower/dose_era.py +++ b/circe/execution/lower/dose_era.py @@ -1,10 +1,14 @@ from __future__ import annotations +from circe.cohortdefinition.criteria import DoseEra +from circe.extensions import lowerer + from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan from .common import append_duration_filter, build_standard_domain_plan, lower_common_steps +@lowerer(DoseEra) def lower_dose_era( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/drug_era.py b/circe/execution/lower/drug_era.py index 02afe74c..8d322217 100644 --- a/circe/execution/lower/drug_era.py +++ b/circe/execution/lower/drug_era.py @@ -1,5 +1,8 @@ from __future__ import annotations +from circe.cohortdefinition.criteria import DrugEra +from circe.extensions import lowerer + from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan from ..plan.schema import GAP_DAYS, OCCURRENCE_COUNT @@ -11,6 +14,7 @@ ) +@lowerer(DrugEra) def lower_drug_era( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/drug_exposure.py b/circe/execution/lower/drug_exposure.py index cdbbf93b..d7100bf5 100644 --- a/circe/execution/lower/drug_exposure.py +++ b/circe/execution/lower/drug_exposure.py @@ -1,5 +1,7 @@ from __future__ import annotations +from circe.extensions import lowerer + from ...cohortdefinition.criteria import DrugExposure from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan @@ -14,6 +16,7 @@ ) +@lowerer(DrugExposure) def lower_drug_exposure( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/location_region.py b/circe/execution/lower/location_region.py index 21afdfae..c25722a3 100644 --- a/circe/execution/lower/location_region.py +++ b/circe/execution/lower/location_region.py @@ -1,5 +1,8 @@ from __future__ import annotations +from circe.cohortdefinition.criteria import LocationRegion +from circe.extensions import lowerer + from ..normalize.criteria import NormalizedCriterion from ..plan.events import ( EventPlan, @@ -11,6 +14,7 @@ ) +@lowerer(LocationRegion) def lower_location_region( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/measurement.py b/circe/execution/lower/measurement.py index d2491945..4fadb112 100644 --- a/circe/execution/lower/measurement.py +++ b/circe/execution/lower/measurement.py @@ -1,5 +1,7 @@ from __future__ import annotations +from circe.extensions import lowerer + from ...cohortdefinition.criteria import Measurement from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan @@ -14,6 +16,7 @@ ) +@lowerer(Measurement) def lower_measurement( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/observation.py b/circe/execution/lower/observation.py index 7ff85c21..fa991894 100644 --- a/circe/execution/lower/observation.py +++ b/circe/execution/lower/observation.py @@ -1,5 +1,7 @@ from __future__ import annotations +from circe.extensions import lowerer + from ...cohortdefinition.criteria import Observation from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan @@ -14,6 +16,7 @@ ) +@lowerer(Observation) def lower_observation( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/observation_period.py b/circe/execution/lower/observation_period.py index ef2b8a90..d1bccbfd 100644 --- a/circe/execution/lower/observation_period.py +++ b/circe/execution/lower/observation_period.py @@ -1,10 +1,14 @@ from __future__ import annotations +from circe.cohortdefinition.criteria import ObservationPeriod +from circe.extensions import lowerer + from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan from .common import lower_standard_domain_plan +@lowerer(ObservationPeriod) def lower_observation_period( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/payer_plan_period.py b/circe/execution/lower/payer_plan_period.py index 3a08d1ea..f5a1221d 100644 --- a/circe/execution/lower/payer_plan_period.py +++ b/circe/execution/lower/payer_plan_period.py @@ -1,10 +1,14 @@ from __future__ import annotations +from circe.cohortdefinition.criteria import PayerPlanPeriod +from circe.extensions import lowerer + from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan from .common import lower_standard_domain_plan +@lowerer(PayerPlanPeriod) def lower_payer_plan_period( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/procedure_occurrence.py b/circe/execution/lower/procedure_occurrence.py index caded791..13846f3a 100644 --- a/circe/execution/lower/procedure_occurrence.py +++ b/circe/execution/lower/procedure_occurrence.py @@ -1,5 +1,7 @@ from __future__ import annotations +from circe.extensions import lowerer + from ...cohortdefinition.criteria import ProcedureOccurrence from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan @@ -13,6 +15,7 @@ ) +@lowerer(ProcedureOccurrence) def lower_procedure_occurrence( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/specimen.py b/circe/execution/lower/specimen.py index 02710630..10f1cdbb 100644 --- a/circe/execution/lower/specimen.py +++ b/circe/execution/lower/specimen.py @@ -1,5 +1,7 @@ from __future__ import annotations +from circe.extensions import lowerer + from ...cohortdefinition.criteria import Specimen from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan @@ -12,6 +14,7 @@ ) +@lowerer(Specimen) def lower_specimen( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/visit_detail.py b/circe/execution/lower/visit_detail.py index 73985026..f85c3354 100644 --- a/circe/execution/lower/visit_detail.py +++ b/circe/execution/lower/visit_detail.py @@ -1,5 +1,7 @@ from __future__ import annotations +from circe.extensions import lowerer + from ...cohortdefinition.criteria import VisitDetail from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan, PlanStep @@ -14,6 +16,7 @@ ) +@lowerer(VisitDetail) def lower_visit_detail( criterion: NormalizedCriterion, *, diff --git a/circe/execution/lower/visit_occurrence.py b/circe/execution/lower/visit_occurrence.py index ef7e9d9e..871287bb 100644 --- a/circe/execution/lower/visit_occurrence.py +++ b/circe/execution/lower/visit_occurrence.py @@ -1,5 +1,7 @@ from __future__ import annotations +from circe.extensions import lowerer + from ...cohortdefinition.criteria import VisitOccurrence from ..normalize.criteria import NormalizedCriterion from ..plan.events import EventPlan, PlanStep @@ -14,6 +16,7 @@ ) +@lowerer(VisitOccurrence) def lower_visit_occurrence( criterion: NormalizedCriterion, *, diff --git a/circe/execution/normalize/criteria.py b/circe/execution/normalize/criteria.py index f0af5979..c3fb6eb3 100644 --- a/circe/execution/normalize/criteria.py +++ b/circe/execution/normalize/criteria.py @@ -3,6 +3,8 @@ from dataclasses import replace from typing import TYPE_CHECKING +from circe.extensions import get_registry, normalizer + from ...cohortdefinition.criteria import ( ConditionEra, ConditionOccurrence, @@ -140,6 +142,7 @@ def _build_normalized_criterion( ) +@normalizer(ConditionOccurrence) def _normalize_condition_occurrence(criteria: ConditionOccurrence) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -159,6 +162,7 @@ def _normalize_condition_occurrence(criteria: ConditionOccurrence) -> Normalized ) +@normalizer(DrugExposure) def _normalize_drug_exposure(criteria: DrugExposure) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -178,6 +182,7 @@ def _normalize_drug_exposure(criteria: DrugExposure) -> NormalizedCriterion: ) +@normalizer(VisitOccurrence) def _normalize_visit_occurrence(criteria: VisitOccurrence) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -197,6 +202,7 @@ def _normalize_visit_occurrence(criteria: VisitOccurrence) -> NormalizedCriterio ) +@normalizer(Measurement) def _normalize_measurement(criteria: Measurement) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -216,6 +222,7 @@ def _normalize_measurement(criteria: Measurement) -> NormalizedCriterion: ) +@normalizer(ProcedureOccurrence) def _normalize_procedure_occurrence( criteria: ProcedureOccurrence, ) -> NormalizedCriterion: @@ -237,6 +244,7 @@ def _normalize_procedure_occurrence( ) +@normalizer(Observation) def _normalize_observation(criteria: Observation) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -256,6 +264,7 @@ def _normalize_observation(criteria: Observation) -> NormalizedCriterion: ) +@normalizer(VisitDetail) def _normalize_visit_detail(criteria: VisitDetail) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -275,6 +284,7 @@ def _normalize_visit_detail(criteria: VisitDetail) -> NormalizedCriterion: ) +@normalizer(DeviceExposure) def _normalize_device_exposure(criteria: DeviceExposure) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -294,6 +304,7 @@ def _normalize_device_exposure(criteria: DeviceExposure) -> NormalizedCriterion: ) +@normalizer(Specimen) def _normalize_specimen(criteria: Specimen) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -313,6 +324,7 @@ def _normalize_specimen(criteria: Specimen) -> NormalizedCriterion: ) +@normalizer(Death) def _normalize_death(criteria: Death) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -332,6 +344,7 @@ def _normalize_death(criteria: Death) -> NormalizedCriterion: ) +@normalizer(ObservationPeriod) def _normalize_observation_period(criteria: ObservationPeriod) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -351,6 +364,7 @@ def _normalize_observation_period(criteria: ObservationPeriod) -> NormalizedCrit ) +@normalizer(PayerPlanPeriod) def _normalize_payer_plan_period(criteria: PayerPlanPeriod) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -370,6 +384,7 @@ def _normalize_payer_plan_period(criteria: PayerPlanPeriod) -> NormalizedCriteri ) +@normalizer(ConditionEra) def _normalize_condition_era(criteria: ConditionEra) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -389,6 +404,7 @@ def _normalize_condition_era(criteria: ConditionEra) -> NormalizedCriterion: ) +@normalizer(DrugEra) def _normalize_drug_era(criteria: DrugEra) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -408,6 +424,7 @@ def _normalize_drug_era(criteria: DrugEra) -> NormalizedCriterion: ) +@normalizer(DoseEra) def _normalize_dose_era(criteria: DoseEra) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -427,6 +444,7 @@ def _normalize_dose_era(criteria: DoseEra) -> NormalizedCriterion: ) +@normalizer(LocationRegion) def _normalize_location_region(criteria: LocationRegion) -> NormalizedCriterion: return _build_normalized_criterion( criteria=criteria, @@ -447,43 +465,16 @@ def _normalize_location_region(criteria: LocationRegion) -> NormalizedCriterion: def normalize_criterion(criteria: Criteria) -> NormalizedCriterion: - if isinstance(criteria, ConditionOccurrence): - normalized = _normalize_condition_occurrence(criteria) - elif isinstance(criteria, DrugExposure): - normalized = _normalize_drug_exposure(criteria) - elif isinstance(criteria, VisitOccurrence): - normalized = _normalize_visit_occurrence(criteria) - elif isinstance(criteria, Measurement): - normalized = _normalize_measurement(criteria) - elif isinstance(criteria, ProcedureOccurrence): - normalized = _normalize_procedure_occurrence(criteria) - elif isinstance(criteria, Observation): - normalized = _normalize_observation(criteria) - elif isinstance(criteria, VisitDetail): - normalized = _normalize_visit_detail(criteria) - elif isinstance(criteria, DeviceExposure): - normalized = _normalize_device_exposure(criteria) - elif isinstance(criteria, Specimen): - normalized = _normalize_specimen(criteria) - elif isinstance(criteria, Death): - normalized = _normalize_death(criteria) - elif isinstance(criteria, ObservationPeriod): - normalized = _normalize_observation_period(criteria) - elif isinstance(criteria, PayerPlanPeriod): - normalized = _normalize_payer_plan_period(criteria) - elif isinstance(criteria, ConditionEra): - normalized = _normalize_condition_era(criteria) - elif isinstance(criteria, DrugEra): - normalized = _normalize_drug_era(criteria) - elif isinstance(criteria, DoseEra): - normalized = _normalize_dose_era(criteria) - elif isinstance(criteria, LocationRegion): - normalized = _normalize_location_region(criteria) - else: + registry = get_registry() + normalizer_fn = registry.get_normalizer(type(criteria)) + + if normalizer_fn is None: raise UnsupportedCriterionError( f"Ibis executor normalization error: unsupported criterion type {criteria.__class__.__name__}." ) + normalized = normalizer_fn(criteria) + if criteria.correlated_criteria is not None and not criteria.correlated_criteria.is_empty(): from .groups import normalize_criteria_group diff --git a/circe/extensions/__init__.py b/circe/extensions/__init__.py index 5ba69400..b91a94aa 100644 --- a/circe/extensions/__init__.py +++ b/circe/extensions/__init__.py @@ -34,6 +34,10 @@ class WaveformOccurrenceMarkdownRenderer: if TYPE_CHECKING: from .cohortdefinition.builders.base import CriteriaSqlBuilder from .cohortdefinition.criteria import Criteria + from .execution.lower.criteria import LowerFn + from .execution.normalize.criteria import NormalizedCriterion + +NormalizerFn = Callable[["Criteria"], "NormalizedCriterion"] class ExtensionRegistry: @@ -46,6 +50,12 @@ def __init__(self): # Maps criteria types to SQL builder classes self._sql_builders: dict[type[Criteria], type[CriteriaSqlBuilder]] = {} + # Maps criteria types to lower functions + self._lowerers: dict[type[Criteria], LowerFn] = {} + + # Maps criteria types to normalizer functions + self._normalizers: dict[type[Criteria], NormalizerFn] = {} + # Maps criteria types to markdown template names self._markdown_templates: dict[type[Criteria], str] = {} @@ -74,6 +84,24 @@ def register_sql_builder( """ self._sql_builders[criteria_cls] = builder_cls + def register_lowerer(self, criteria_cls: type["Criteria"], lowerer: "LowerFn") -> None: + """Register a lower function for a criteria type. + + Args: + criteria_cls: The Criteria subclass + lowerer: The LowerFn to execute for this criteria + """ + self._lowerers[criteria_cls] = lowerer + + def register_normalizer(self, criteria_cls: type["Criteria"], normalizer: NormalizerFn) -> None: + """Register a normalizer function for a criteria type. + + Args: + criteria_cls: The Criteria subclass + normalizer: The NormalizerFn to execute for this criteria + """ + self._normalizers[criteria_cls] = normalizer + def register_markdown_template(self, criteria_cls: type["Criteria"], template_name: str) -> None: """Register a Jinja2 template for markdown rendering. @@ -104,6 +132,28 @@ def get_builder(self, criteria: "Criteria") -> Optional["CriteriaSqlBuilder"]: builder_cls = self._sql_builders.get(type(criteria)) return builder_cls() if builder_cls else None + def get_lowerer(self, criteria_cls: type["Criteria"]) -> Optional["LowerFn"]: + """Get the lower function for a criteria type. + + Args: + criteria_cls: The Criteria subclass + + Returns: + The LowerFn, or None if not found + """ + return self._lowerers.get(criteria_cls) + + def get_normalizer(self, criteria_cls: type["Criteria"]) -> Optional[NormalizerFn]: + """Get the normalizer function for a criteria type. + + Args: + criteria_cls: The Criteria subclass + + Returns: + The normalizer function, or None if not found + """ + return self._normalizers.get(criteria_cls) + def get_template(self, criteria: "Criteria") -> Optional[str]: """Get the markdown template name for a criteria instance. @@ -189,6 +239,46 @@ def decorator(builder_cls: "type['CriteriaSqlBuilder']") -> "type['CriteriaSqlBu return decorator # type: ignore[return-value] +def lowerer(criteria_cls: "type['Criteria']") -> Callable[["LowerFn"], "LowerFn"]: + """Decorator that registers an execution lower function for a Criteria type. + + Args: + criteria_cls: The Criteria subclass this function lowers. + + Example:: + + @lowerer(WaveformOccurrence) + def lower_waveform_occurrence(criterion, *, criterion_index): + ... + """ + + def decorator(fn: "LowerFn") -> "LowerFn": + _registry.register_lowerer(criteria_cls, fn) # type: ignore[arg-type] + return fn + + return decorator + + +def normalizer(criteria_cls: "type['Criteria']") -> Callable[[NormalizerFn], NormalizerFn]: + """Decorator that registers a normalizer function for a Criteria type. + + Args: + criteria_cls: The Criteria subclass this function normalizes. + + Example:: + + @normalizer(WaveformOccurrence) + def normalize_waveform_occurrence(criteria): + ... + """ + + def decorator(fn: NormalizerFn) -> NormalizerFn: + _registry.register_normalizer(criteria_cls, fn) # type: ignore[arg-type] + return fn + + return decorator + + def markdown_template(criteria_cls: "type['Criteria']", template_name: str) -> "Callable[[type], type]": """Class decorator that registers a Jinja2 markdown template for a Criteria type. diff --git a/circe/extensions/waveform/__init__.py b/circe/extensions/waveform/__init__.py index bd410196..37576469 100644 --- a/circe/extensions/waveform/__init__.py +++ b/circe/extensions/waveform/__init__.py @@ -2,6 +2,8 @@ from circe.extensions import template_path +# Import lowers and normalizers to trigger decorators +from . import lower, normalizer from .builders.waveform_channel_metadata import WaveformChannelMetadataSqlBuilder from .builders.waveform_feature import WaveformFeatureSqlBuilder @@ -16,6 +18,8 @@ template_path(Path(__file__).parent / "templates") __all__ = [ + "lower", + "normalizer", "WaveformChannelMetadata", "WaveformFeature", "WaveformOccurrence", diff --git a/circe/extensions/waveform/lower.py b/circe/extensions/waveform/lower.py new file mode 100644 index 00000000..b2f6633d --- /dev/null +++ b/circe/extensions/waveform/lower.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from circe.extensions import lowerer + +from ...execution.lower.common import ( + append_concept_filters, + append_numeric_filter, + append_text_filter, + build_standard_domain_plan, + lower_common_steps, +) +from ...execution.normalize.criteria import NormalizedCriterion +from ...execution.plan.events import EventPlan +from .criteria import WaveformOccurrence + + +@lowerer(WaveformOccurrence) +def lower_waveform_occurrence( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + raw = criterion.raw_criteria + if not isinstance(raw, WaveformOccurrence): + raise TypeError("lower_waveform_occurrence requires WaveformOccurrence criteria") + + steps = lower_common_steps(criterion) + + append_concept_filters( + steps, + column="waveform_occurrence_concept_id", + concepts=raw.waveform_occurrence_concept_id, + # codeset_selection does not exist on WaveformOccurrence based on the pydantic logic, we just pass concepts + ) + + append_text_filter( + steps, column="waveform_occurrence_source_value", value=raw.waveform_occurrence_source_value + ) + + append_numeric_filter(steps, column="visit_occurrence_id", value=raw.visit_occurrence_id) + + append_numeric_filter(steps, column="visit_detail_id", value=raw.visit_detail_id) + + append_numeric_filter(steps, column="num_of_files", value=raw.num_of_files) + + append_numeric_filter( + steps, column="preceding_waveform_occurrence_id", value=raw.preceding_waveform_occurrence_id + ) + + return build_standard_domain_plan( + criterion, + criterion_index=criterion_index, + steps=steps, + ) diff --git a/circe/extensions/waveform/normalizer.py b/circe/extensions/waveform/normalizer.py new file mode 100644 index 00000000..5e733aff --- /dev/null +++ b/circe/extensions/waveform/normalizer.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from circe.extensions import normalizer + +from ...execution.normalize.criteria import NormalizedCriterion, _build_normalized_criterion +from ...execution.normalize.windows import normalize_date_range +from .criteria import WaveformOccurrence + + +@normalizer(WaveformOccurrence) +def normalize_waveform_occurrence(criteria: WaveformOccurrence) -> NormalizedCriterion: + return _build_normalized_criterion( + criteria=criteria, + criterion_type="WaveformOccurrence", + domain="waveform_occurrence", + source_table="waveform_occurrence", + event_id_column="waveform_occurrence_id", + start_date_column="waveform_occurrence_start_datetime", + end_date_column="waveform_occurrence_end_datetime", + concept_column="waveform_occurrence_concept_id", + source_concept_column=None, + visit_occurrence_column="visit_occurrence_id", + codeset_id=None, + first=False, + occurrence_start_date=normalize_date_range(criteria.occurrence_start_datetime), + occurrence_end_date=normalize_date_range(criteria.occurrence_end_datetime), + ) diff --git a/tests/execution/test_registry_dispatch.py b/tests/execution/test_registry_dispatch.py new file mode 100644 index 00000000..7d53701a --- /dev/null +++ b/tests/execution/test_registry_dispatch.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import pytest + +from circe.cohortdefinition.criteria import Criteria, CriteriaGroup +from circe.execution.errors import UnsupportedCriterionError +from circe.execution.lower.criteria import lower_criterion +from circe.execution.normalize.criteria import NormalizedCriterion, normalize_criterion +from circe.extensions import get_registry, lowerer, normalizer +from circe.extensions.waveform import WaveformOccurrence + + +class FakeCriteria(Criteria): + pass + + +FakeCriteria.model_rebuild(_types_namespace={"CriteriaGroup": CriteriaGroup}) + + +@pytest.fixture(autouse=True) +def cleanup_registry(): + """Ensure the fake criteria gets cleaned up after the test.""" + registry = get_registry() + yield + registry._lowerers.pop(FakeCriteria, None) + registry._normalizers.pop(FakeCriteria, None) + + +def test_registry_dispatch_round_trip(): + fake_criterion = FakeCriteria() + fake_normalized = NormalizedCriterion( + raw_criteria=fake_criterion, + criterion_type="Fake", + domain="fake", + source_table="fake", + event_id_column="fake_id", + start_date_column="fake_start", + end_date_column="fake_end", + concept_column=None, + source_concept_column=None, + visit_occurrence_column=None, + codeset_id=None, + first=False, + occurrence_start_date=None, + occurrence_end_date=None, + person_filters=NormalizedCriterion._person_filters_from_criterion(fake_criterion) + if hasattr(NormalizedCriterion, "_person_filters_from_criterion") + else None, + ) + + @normalizer(FakeCriteria) + def fake_normalizer(criteria): + return fake_normalized + + @lowerer(FakeCriteria) + def fake_lowerer(criterion, *, criterion_index): + return "fake_plan_result" + + # Test normalize dispatch + normalized = normalize_criterion(fake_criterion) + assert normalized is fake_normalized + + # Test lower dispatch + plan = lower_criterion(normalized, criterion_index=1) + assert plan == "fake_plan_result" + + +def test_unknown_criteria_raises(): + class UnknownCriteria(Criteria): + pass + + UnknownCriteria.model_rebuild(_types_namespace={"CriteriaGroup": CriteriaGroup}) + + with pytest.raises(UnsupportedCriterionError, match="unsupported criterion type UnknownCriteria"): + normalize_criterion(UnknownCriteria()) + + # Create a dummy normalized criterion containing the unknown criteria to test lower_criterion + fake_normalized = NormalizedCriterion( + raw_criteria=UnknownCriteria(), + criterion_type="UnknownCriteria", + domain="unknown", + source_table="unknown", + event_id_column="id", + start_date_column="start", + end_date_column="end", + concept_column=None, + source_concept_column=None, + visit_occurrence_column=None, + codeset_id=None, + first=False, + occurrence_start_date=None, + occurrence_end_date=None, + person_filters=None, + ) + + with pytest.raises(UnsupportedCriterionError, match="no lowerer registered for UnknownCriteria"): + lower_criterion(fake_normalized, criterion_index=1) + + +def test_waveform_extension_dispatch(): + # The extension should have pre-registered its normalizer and lowerer + waveform = WaveformOccurrence() + + # Check that normalizer is found + normalized = normalize_criterion(waveform) + assert normalized.domain == "waveform_occurrence" + + # Check that lowerer is found + plan = lower_criterion(normalized, criterion_index=1) + + assert plan.source.table_name == "waveform_occurrence" + assert plan.criterion_type == "WaveformOccurrence" From da6bf2c0c918caf16ab799cbeb746f44bbbb118b Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Fri, 1 May 2026 10:11:22 -0700 Subject: [PATCH 53/62] Cleanup/mypy cleanup (#34) * Added basic claude instructions for clean pre-commits * Added instructions on testing to keep agents targeted on their tasks * Code cleanup --- CLAUDE.md | 37 +++++++++++++++++++ .../checkers/base_corelated_criteria_check.py | 20 +++++----- circe/check/checkers/comparisons.py | 4 +- .../check/checkers/death_time_window_check.py | 4 +- circe/check/checkers/drug_era_check.py | 8 ++-- circe/check/checkers/exit_criteria_check.py | 4 +- .../exit_criteria_days_offset_check.py | 4 +- circe/check/checkers/initial_event_check.py | 4 +- .../check/checkers/no_exit_criteria_check.py | 4 +- circe/check/checkers/ocurrence_check.py | 4 +- circe/check/checkers/range_check.py | 4 +- circe/check/checkers/range_checker_factory.py | 8 ++-- circe/check/checkers/time_window_check.py | 4 +- circe/check/utils/criteria_name_helper.py | 2 +- circe/cohortdefinition/builders/base.py | 2 - .../builders/visit_occurrence.py | 2 - circe/cohortdefinition/code_generator.py | 2 +- .../cohort_expression_query_builder.py | 4 +- circe/cohortdefinition/criteria.py | 10 +---- .../printfriendly/markdown_render.py | 2 +- circe/execution/lower/condition_era.py | 4 +- circe/execution/lower/dose_era.py | 4 +- circe/execution/lower/drug_era.py | 4 +- circe/execution/normalize/cohort.py | 4 +- circe/extensions/__init__.py | 24 ++++++------ .../builders/waveform_channel_metadata.py | 2 +- .../waveform/builders/waveform_feature.py | 2 +- .../waveform/builders/waveform_occurrence.py | 2 +- .../waveform/builders/waveform_registry.py | 2 +- pyproject.toml | 23 ++++++++++++ 30 files changed, 132 insertions(+), 72 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..50a1d490 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,37 @@ +# Claude Instructions for circepy + +## Python Environment +- Always use virtualenv for Python operations (don't rely on system Python or unauthenticated pip installs) +- Activate the virtual environment before running Python commands or installing packages + +## Starting tasks - record testing state + +At the start of any task, record the state of tests as a baseline. It is not your job to fix pre-existing issues unless otherwise specified. + +Run tests with multiprocess for speed and store the state: +```bash +pytest -n auto --tb=short -v --json-report --json-report-file=.test_baseline.json +``` + +If the test state file is not created, check that pytest-xdist and pytest-json-report are installed in the virtualenv. + +## Pre-completion Checklist +Before completing any task: + +1. Re-run pytest to verify no regressions: +```bash +pytest -n auto --tb=short -v --json-report --json-report-file=.test_final.json +``` + +Compare `.test_baseline.json` with `.test_final.json` — the final state should not show new failures. + +2. Run git pre-commit checks: +```bash +git pre-commit run --all-files +``` + +If pre-commit checks fail, fix the issues and re-run until they pass. + +## Git Workflow +- Do not run `git commit` — the user will handle commits +- Run pre-commit checks to validate code quality before marking tasks complete diff --git a/circe/check/checkers/base_corelated_criteria_check.py b/circe/check/checkers/base_corelated_criteria_check.py index 42b82960..bdf1561d 100644 --- a/circe/check/checkers/base_corelated_criteria_check.py +++ b/circe/check/checkers/base_corelated_criteria_check.py @@ -44,8 +44,8 @@ def _internal_check(self, expression: "CohortExpression", reporter: WarningRepor if inclusion_rule.expression and inclusion_rule.expression.criteria_list: for criteria in inclusion_rule.expression.criteria_list: # Skip if criteria is still a dict (shouldn't happen after deserialization, but be defensive) - if isinstance(criteria, dict): - continue + if isinstance(criteria, dict): # type: ignore[unreachable] + continue # type: ignore[unreachable] group_name = f"{self.INCLUSION_RULE}{inclusion_rule.name}" self._check_criteria(criteria, group_name, reporter) if hasattr(criteria, "criteria") and criteria.criteria: @@ -60,16 +60,16 @@ def _check_criteria_group(self, criteria: "Criteria", group_name: str, reporter: reporter: The warning reporter to use """ # Skip if criteria is still a dict (not yet deserialized) - if isinstance(criteria, dict): - return + if isinstance(criteria, dict): # type: ignore[unreachable] + return # type: ignore[unreachable] if hasattr(criteria, "correlated_criteria") and criteria.correlated_criteria: correlated = criteria.correlated_criteria if hasattr(correlated, "criteria_list") and correlated.criteria_list: for corelated_criteria in correlated.criteria_list: # Skip dicts - if isinstance(corelated_criteria, dict): - continue + if isinstance(corelated_criteria, dict): # type: ignore[unreachable] + continue # type: ignore[unreachable] self._check_criteria(corelated_criteria, group_name, reporter) if hasattr(corelated_criteria, "criteria") and corelated_criteria.criteria: self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) @@ -78,8 +78,8 @@ def _check_criteria_group(self, criteria: "Criteria", group_name: str, reporter: if hasattr(group, "criteria_list") and group.criteria_list: for corelated_criteria in group.criteria_list: # Skip dicts - if isinstance(corelated_criteria, dict): - continue + if isinstance(corelated_criteria, dict): # type: ignore[unreachable] + continue # type: ignore[unreachable] self._check_criteria(corelated_criteria, group_name, reporter) if hasattr(corelated_criteria, "criteria") and corelated_criteria.criteria: self._check_criteria_group(corelated_criteria.criteria, group_name, reporter) @@ -99,7 +99,7 @@ def _check_criteria( """ # Skip if criteria is still a dict (not yet deserialized) # This can happen when Pydantic doesn't fully deserialize polymorphic types - if isinstance(criteria, dict): - return + if isinstance(criteria, dict): # type: ignore[unreachable] + return # type: ignore[unreachable] raise NotImplementedError("Subclasses must implement _check_criteria") diff --git a/circe/check/checkers/comparisons.py b/circe/check/checkers/comparisons.py index b8c44eb8..29306aa2 100644 --- a/circe/check/checkers/comparisons.py +++ b/circe/check/checkers/comparisons.py @@ -119,7 +119,7 @@ def compare_to(filter_val: "ObservationFilter", window: "Window") -> int: An integer representing the comparison result """ if filter_val is None or window is None: - return 0 + return 0 # type: ignore[unreachable] range1 = filter_val.post_days + filter_val.prior_days range2_start = 0 @@ -144,7 +144,7 @@ def is_before(window: "Window") -> bool: True if the window is before, False otherwise """ if window is None: - return False + return False # type: ignore[unreachable] return Comparisons.is_before_endpoint(window.start) and not Comparisons.is_after_endpoint(window.end) @staticmethod diff --git a/circe/check/checkers/death_time_window_check.py b/circe/check/checkers/death_time_window_check.py index 41f7d18b..f65eb809 100644 --- a/circe/check/checkers/death_time_window_check.py +++ b/circe/check/checkers/death_time_window_check.py @@ -8,6 +8,8 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from typing import Any + from ..operations.operations import Operations from ..utils.criteria_name_helper import CriteriaNameHelper from ..warning_severity import WarningSeverity @@ -122,7 +124,7 @@ def _check_criteria( """ name = f"{group_name} {CriteriaNameHelper.get_criteria_name(criteria.criteria)}" - match_result = Operations.match(criteria.criteria) + match_result: Any = Operations.match(criteria.criteria) match_result.is_a(Death) match_result.then( lambda death: ( diff --git a/circe/check/checkers/drug_era_check.py b/circe/check/checkers/drug_era_check.py index 2d78f8d6..ee2b42df 100644 --- a/circe/check/checkers/drug_era_check.py +++ b/circe/check/checkers/drug_era_check.py @@ -8,6 +8,8 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from typing import Any + from ..operations.operations import Operations from ..warning_severity import WarningSeverity from .base_corelated_criteria_check import BaseCorelatedCriteriaCheck @@ -53,15 +55,15 @@ def _check_criteria( reporter: The warning reporter to use """ # Handle case where criteria is still a dict (not yet deserialized) - if isinstance(criteria, dict): + if isinstance(criteria, dict): # type: ignore[unreachable] # Skip validation for dict-based criteria - they need to be deserialized first - return + return # type: ignore[unreachable] # Ensure criteria has a criteria attribute if not hasattr(criteria, "criteria") or not criteria.criteria: return - match_result = Operations.match(criteria.criteria) + match_result: Any = Operations.match(criteria.criteria) match_result.is_a(DrugEra) match_result.then( lambda c: ( diff --git a/circe/check/checkers/exit_criteria_check.py b/circe/check/checkers/exit_criteria_check.py index 5073d063..6385ee88 100644 --- a/circe/check/checkers/exit_criteria_check.py +++ b/circe/check/checkers/exit_criteria_check.py @@ -8,6 +8,8 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from typing import Any + from ..operations.operations import Operations from .base_check import BaseCheck from .warning_reporter import WarningReporter @@ -39,7 +41,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N expression: The cohort expression to check reporter: The warning reporter to use """ - match_result = Operations.match(expression.end_strategy) + match_result: Any = Operations.match(expression.end_strategy) match_result.is_a(CustomEraStrategy) match_result.then( lambda s: ( diff --git a/circe/check/checkers/exit_criteria_days_offset_check.py b/circe/check/checkers/exit_criteria_days_offset_check.py index b98d2353..d9e8fe7b 100644 --- a/circe/check/checkers/exit_criteria_days_offset_check.py +++ b/circe/check/checkers/exit_criteria_days_offset_check.py @@ -8,6 +8,8 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from typing import Any + from ..operations.operations import Operations from ..warning_severity import WarningSeverity from .base_check import BaseCheck @@ -48,7 +50,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N expression: The cohort expression to check reporter: The warning reporter to use """ - match_result = Operations.match(expression.end_strategy) + match_result: Any = Operations.match(expression.end_strategy) match_result.is_a(DateOffsetStrategy) match_result.then( lambda s: ( diff --git a/circe/check/checkers/initial_event_check.py b/circe/check/checkers/initial_event_check.py index 68fabcd4..9564f922 100644 --- a/circe/check/checkers/initial_event_check.py +++ b/circe/check/checkers/initial_event_check.py @@ -8,6 +8,8 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from typing import Any + from ..operations.operations import Operations from .base_check import BaseCheck from .warning_reporter import WarningReporter @@ -37,7 +39,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N expression: The cohort expression to check reporter: The warning reporter to use """ - match_result = Operations.match(expression) + match_result: Any = Operations.match(expression) match_result.when( lambda e: ( e.primary_criteria is None diff --git a/circe/check/checkers/no_exit_criteria_check.py b/circe/check/checkers/no_exit_criteria_check.py index 0f88c38d..bca47d41 100644 --- a/circe/check/checkers/no_exit_criteria_check.py +++ b/circe/check/checkers/no_exit_criteria_check.py @@ -8,6 +8,8 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from typing import Any + from ..operations.operations import Operations from ..warning_severity import WarningSeverity from .base_check import BaseCheck @@ -46,7 +48,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N expression: The cohort expression to check reporter: The warning reporter to use """ - match_result = Operations.match(expression) + match_result: Any = Operations.match(expression) match_result.when( lambda e: ( e.primary_criteria diff --git a/circe/check/checkers/ocurrence_check.py b/circe/check/checkers/ocurrence_check.py index 81e43b17..1ae8b1c4 100644 --- a/circe/check/checkers/ocurrence_check.py +++ b/circe/check/checkers/ocurrence_check.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ..operations.operations import Operations from ..warning_severity import WarningSeverity @@ -52,6 +52,6 @@ def _check_criteria( reporter: The warning reporter to use """ if criteria.occurrence: - match_result = Operations.match(criteria.occurrence) + match_result: Any = Operations.match(criteria.occurrence) match_result.when(lambda o: o.type == self.AT_LEAST and o.count == 0) match_result.then(lambda o: reporter(self.AT_LEAST_0_WARNING)) diff --git a/circe/check/checkers/range_check.py b/circe/check/checkers/range_check.py index 30082b38..8bd6b166 100644 --- a/circe/check/checkers/range_check.py +++ b/circe/check/checkers/range_check.py @@ -69,8 +69,8 @@ def _check_inclusion_rules(self, expression: "CohortExpression", reporter: Warni if rule.expression and rule.expression.criteria_list: for criteria in rule.expression.criteria_list: # Handle both dict and CorelatedCriteria objects - if isinstance(criteria, dict): - start_window = criteria.get("startWindow") or criteria.get("start_window") + if isinstance(criteria, dict): # type: ignore[unreachable] + start_window = criteria.get("startWindow") or criteria.get("start_window") # type: ignore[unreachable] end_window = criteria.get("endWindow") or criteria.get("end_window") else: start_window = getattr(criteria, "start_window", None) or getattr( diff --git a/circe/check/checkers/range_checker_factory.py b/circe/check/checkers/range_checker_factory.py index 0d4702fb..3c9f36cd 100644 --- a/circe/check/checkers/range_checker_factory.py +++ b/circe/check/checkers/range_checker_factory.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Callable, Optional +from typing import Any, Callable, Optional from ..constants import Constants from ..operations.operations import Operations @@ -613,7 +613,7 @@ def warning(template: str) -> None: if isinstance(range_val, DateRange): # Date range checks - match_result = Operations.match(range_val) + match_result: Any = Operations.match(range_val) match_result.when(lambda r: r.value is not None and not Comparisons.is_date_valid(r.value)).then( lambda x: warning(self.WARNING_DATE_IS_INVALID) ) @@ -639,7 +639,7 @@ def warning(template: str) -> None: ) elif isinstance(range_val, NumericRange): # Numeric range checks - match_result = Operations.match(range_val) + match_result: Any = Operations.match(range_val) match_result.when(lambda r: r.op is not None and r.op.endswith("bt")).then( lambda r: ( Operations.match(r) @@ -673,7 +673,7 @@ def check_range(self, period: Optional["Period"], criteria_name: str, attribute: def warning(template: str) -> None: self._reporter(template, self._group_name, criteria_name, attribute) - match_result = Operations.match(period) + match_result: Any = Operations.match(period) match_result.when( lambda x: x.start_date is not None and not Comparisons.is_date_valid(x.start_date) ).then(lambda x: warning(self.WARNING_DATE_IS_INVALID)) diff --git a/circe/check/checkers/time_window_check.py b/circe/check/checkers/time_window_check.py index 8e2a222e..410f34d1 100644 --- a/circe/check/checkers/time_window_check.py +++ b/circe/check/checkers/time_window_check.py @@ -8,7 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Optional +from typing import Any, Optional from ..operations.operations import Operations from ..utils.criteria_name_helper import CriteriaNameHelper @@ -77,7 +77,7 @@ def _check_criteria( """ name = f"{group_name} {CriteriaNameHelper.get_criteria_name(criteria.criteria)}" - match_result = Operations.match(criteria) + match_result: Any = Operations.match(criteria) match_result.when( lambda c: ( c.start_window is not None diff --git a/circe/check/utils/criteria_name_helper.py b/circe/check/utils/criteria_name_helper.py index fa3d4845..dc083ef2 100644 --- a/circe/check/utils/criteria_name_helper.py +++ b/circe/check/utils/criteria_name_helper.py @@ -90,5 +90,5 @@ def get_criteria_name(criteria) -> str: .is_a(PayerPlanPeriod) .then_return(lambda c: Constants.Criteria.PAYER_PLAN_PERIOD) .value() - or "unknown criteria" + or "unknown criteria" # type: ignore[unreachable] ) diff --git a/circe/cohortdefinition/builders/base.py b/circe/cohortdefinition/builders/base.py index d936ab96..3cf9c9f4 100644 --- a/circe/cohortdefinition/builders/base.py +++ b/circe/cohortdefinition/builders/base.py @@ -64,8 +64,6 @@ def get_criteria_sql_with_options(self, criteria: T, options: Optional[BuilderOp ) else: query = query.replace("@additionalColumns", "") - else: - query = query.replace("@additionalColumns", "") return query diff --git a/circe/cohortdefinition/builders/visit_occurrence.py b/circe/cohortdefinition/builders/visit_occurrence.py index 53263734..b68ec3ff 100644 --- a/circe/cohortdefinition/builders/visit_occurrence.py +++ b/circe/cohortdefinition/builders/visit_occurrence.py @@ -256,8 +256,6 @@ def resolve_where_clauses( return where_clauses - return where_clauses - def embed_ordinal_expression( self, query: str, diff --git a/circe/cohortdefinition/code_generator.py b/circe/cohortdefinition/code_generator.py index 718595c1..44e64d53 100644 --- a/circe/cohortdefinition/code_generator.py +++ b/circe/cohortdefinition/code_generator.py @@ -106,7 +106,7 @@ def instance_is_pydantic(o): # Generate Imports import_lines = [] # Group by module - module_map = {} + module_map: dict[str, list[str]] = {} for cls in required_classes: mod = cls.__module__ if mod not in module_map: diff --git a/circe/cohortdefinition/cohort_expression_query_builder.py b/circe/cohortdefinition/cohort_expression_query_builder.py index a48be933..b275ce6c 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -1471,9 +1471,9 @@ def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] Java equivalent: Various getCriteriaSql methods """ # Handle case where criteria is still a dict (shouldn't happen, but be defensive) - if isinstance(criteria, dict): + if isinstance(criteria, dict): # type: ignore[unreachable] # Try to deserialize it - import here to avoid circular dependency issues - from .criteria import ConditionEra as CE + from .criteria import ConditionEra as CE # type: ignore[unreachable] from .criteria import ConditionOccurrence as CO from .criteria import Death as D from .criteria import DeviceExposure as DevE diff --git a/circe/cohortdefinition/criteria.py b/circe/cohortdefinition/criteria.py index f8c5a786..d1542b10 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -271,14 +271,6 @@ def _serialize_polymorphic(self, serializer, info): return {self.__class__.__name__: data} - # Get the serialized data using default serialization - data = serializer(self) - # Wrap in class name for polymorphic deserialization in Java - # Only wrap if this is a subclass (not the base Criteria class) - if self.__class__.__name__ != "Criteria": - return {self.__class__.__name__: data} - return data - def accept(self, dispatcher: Any, options: Optional[Any] = None) -> str: """Accept method for visitor pattern.""" return dispatcher.get_criteria_sql(self, options) @@ -1146,7 +1138,7 @@ def deserialize_criteria_list(cls, v: Any) -> Any: # Helper window normalizer (same as before) def normalize_window(window_dict: dict) -> dict: if not isinstance(window_dict, dict): - return window_dict + return window_dict # type: ignore[unreachable] normalized = {} if "UseEventEnd" in window_dict: normalized["useEventEnd"] = window_dict["UseEventEnd"] diff --git a/circe/cohortdefinition/printfriendly/markdown_render.py b/circe/cohortdefinition/printfriendly/markdown_render.py index 459484c5..e2a1c761 100644 --- a/circe/cohortdefinition/printfriendly/markdown_render.py +++ b/circe/cohortdefinition/printfriendly/markdown_render.py @@ -238,7 +238,7 @@ def _format_number(self, value: Union[int, float]) -> str: Formatted string (e.g. "1,500" or "1.5") """ if value is None: - return "" + return "" # type: ignore[unreachable] # If matches integer, convert to int for clean formatting if isinstance(value, float) and value.is_integer(): diff --git a/circe/execution/lower/condition_era.py b/circe/execution/lower/condition_era.py index afbdc1fe..5786fa13 100644 --- a/circe/execution/lower/condition_era.py +++ b/circe/execution/lower/condition_era.py @@ -4,7 +4,7 @@ from circe.extensions import lowerer from ..normalize.criteria import NormalizedCriterion -from ..plan.events import EventPlan +from ..plan.events import EventPlan, PlanStep from ..plan.schema import OCCURRENCE_COUNT from .common import ( append_duration_filter, @@ -21,7 +21,7 @@ def lower_condition_era( criterion_index: int, ) -> EventPlan: steps = lower_common_steps(criterion) - post_standardize_steps = [] + post_standardize_steps: list[PlanStep] = [] raw = criterion.raw_criteria append_numeric_filter( diff --git a/circe/execution/lower/dose_era.py b/circe/execution/lower/dose_era.py index 8ae286a2..53daaa38 100644 --- a/circe/execution/lower/dose_era.py +++ b/circe/execution/lower/dose_era.py @@ -4,7 +4,7 @@ from circe.extensions import lowerer from ..normalize.criteria import NormalizedCriterion -from ..plan.events import EventPlan +from ..plan.events import EventPlan, PlanStep from .common import append_duration_filter, build_standard_domain_plan, lower_common_steps @@ -15,7 +15,7 @@ def lower_dose_era( criterion_index: int, ) -> EventPlan: steps = lower_common_steps(criterion) - post_standardize_steps = [] + post_standardize_steps: list[PlanStep] = [] append_duration_filter(post_standardize_steps, value=criterion.raw_criteria.era_length) return build_standard_domain_plan( diff --git a/circe/execution/lower/drug_era.py b/circe/execution/lower/drug_era.py index 8d322217..9b388f0c 100644 --- a/circe/execution/lower/drug_era.py +++ b/circe/execution/lower/drug_era.py @@ -4,7 +4,7 @@ from circe.extensions import lowerer from ..normalize.criteria import NormalizedCriterion -from ..plan.events import EventPlan +from ..plan.events import EventPlan, PlanStep from ..plan.schema import GAP_DAYS, OCCURRENCE_COUNT from .common import ( append_duration_filter, @@ -21,7 +21,7 @@ def lower_drug_era( criterion_index: int, ) -> EventPlan: steps = lower_common_steps(criterion) - post_standardize_steps = [] + post_standardize_steps: list[PlanStep] = [] raw = criterion.raw_criteria append_numeric_filter( diff --git a/circe/execution/normalize/cohort.py b/circe/execution/normalize/cohort.py index 5838fe64..b2f657ff 100644 --- a/circe/execution/normalize/cohort.py +++ b/circe/execution/normalize/cohort.py @@ -82,7 +82,7 @@ def _extract_codesets(concept_sets: list[ConceptSet]) -> dict[int, NormalizedCon for concept_set in concept_sets or []: if concept_set is None or concept_set.id is None: - continue + continue # type: ignore[unreachable] set_id = int(concept_set.id) expression = concept_set.expression if not expression: @@ -102,7 +102,7 @@ def _extract_codesets(concept_sets: list[ConceptSet]) -> dict[int, NormalizedCon for item in expression.items or []: if item is None: - continue + continue # type: ignore[unreachable] if item.concept is None or item.concept.concept_id is None: continue items.append( diff --git a/circe/extensions/__init__.py b/circe/extensions/__init__.py index b91a94aa..d67a624c 100644 --- a/circe/extensions/__init__.py +++ b/circe/extensions/__init__.py @@ -32,10 +32,10 @@ class WaveformOccurrenceMarkdownRenderer: from typing import TYPE_CHECKING, Callable, Optional, Union if TYPE_CHECKING: - from .cohortdefinition.builders.base import CriteriaSqlBuilder - from .cohortdefinition.criteria import Criteria - from .execution.lower.criteria import LowerFn - from .execution.normalize.criteria import NormalizedCriterion + from ..cohortdefinition.builders.base import CriteriaSqlBuilder + from ..cohortdefinition.criteria import Criteria + from ..execution.lower.criteria import LowerFn + from ..execution.normalize.criteria import NormalizedCriterion NormalizerFn = Callable[["Criteria"], "NormalizedCriterion"] @@ -43,7 +43,7 @@ class WaveformOccurrenceMarkdownRenderer: class ExtensionRegistry: """Central registry for OMOP CDM extensions.""" - def __init__(self): + def __init__(self) -> None: # Maps criteria names to criteria classes (for JSON deserialization) self._criteria_classes: dict[str, type[Criteria]] = {} @@ -211,10 +211,10 @@ class WaveformOccurrence(Criteria): """ def decorator(cls: "type['Criteria']") -> "type['Criteria']": - _registry.register_criteria_class(name, cls) # type: ignore[arg-type] + _registry.register_criteria_class(name, cls) return cls - return decorator # type: ignore[return-value] + return decorator def sql_builder( @@ -233,10 +233,10 @@ class WaveformOccurrenceSqlBuilder(CriteriaSqlBuilder): """ def decorator(builder_cls: "type['CriteriaSqlBuilder']") -> "type['CriteriaSqlBuilder']": - _registry.register_sql_builder(criteria_cls, builder_cls) # type: ignore[arg-type] + _registry.register_sql_builder(criteria_cls, builder_cls) return builder_cls - return decorator # type: ignore[return-value] + return decorator def lowerer(criteria_cls: "type['Criteria']") -> Callable[["LowerFn"], "LowerFn"]: @@ -253,7 +253,7 @@ def lower_waveform_occurrence(criterion, *, criterion_index): """ def decorator(fn: "LowerFn") -> "LowerFn": - _registry.register_lowerer(criteria_cls, fn) # type: ignore[arg-type] + _registry.register_lowerer(criteria_cls, fn) return fn return decorator @@ -273,7 +273,7 @@ def normalize_waveform_occurrence(criteria): """ def decorator(fn: NormalizerFn) -> NormalizerFn: - _registry.register_normalizer(criteria_cls, fn) # type: ignore[arg-type] + _registry.register_normalizer(criteria_cls, fn) return fn return decorator @@ -295,7 +295,7 @@ class WaveformOccurrenceMarkdownRenderer: """ def decorator(cls: type) -> type: - _registry.register_markdown_template(criteria_cls, template_name) # type: ignore[arg-type] + _registry.register_markdown_template(criteria_cls, template_name) return cls return decorator diff --git a/circe/extensions/waveform/builders/waveform_channel_metadata.py b/circe/extensions/waveform/builders/waveform_channel_metadata.py index f5fe96e7..eace3fd5 100644 --- a/circe/extensions/waveform/builders/waveform_channel_metadata.py +++ b/circe/extensions/waveform/builders/waveform_channel_metadata.py @@ -43,7 +43,7 @@ def get_criteria_sql_with_options( query = self.get_query_template() where_clauses = [] - join_clauses = [] + join_clauses: list[str] = [] codeset_clause = "" # Link to registry file diff --git a/circe/extensions/waveform/builders/waveform_feature.py b/circe/extensions/waveform/builders/waveform_feature.py index 7ea90c71..006e7e89 100644 --- a/circe/extensions/waveform/builders/waveform_feature.py +++ b/circe/extensions/waveform/builders/waveform_feature.py @@ -49,7 +49,7 @@ def get_criteria_sql_with_options(self, criteria: WaveformFeature, options: Buil query = self.get_query_template() where_clauses = [] - join_clauses = [] + join_clauses: list[str] = [] codeset_clause = "" # Parent links diff --git a/circe/extensions/waveform/builders/waveform_occurrence.py b/circe/extensions/waveform/builders/waveform_occurrence.py index 04124f94..9f6cd124 100644 --- a/circe/extensions/waveform/builders/waveform_occurrence.py +++ b/circe/extensions/waveform/builders/waveform_occurrence.py @@ -52,7 +52,7 @@ def get_criteria_sql_with_options(self, criteria: WaveformOccurrence, options: B query = self.get_query_template() where_clauses = [] - join_clauses = [] + join_clauses: list[str] = [] codeset_clause = "" # Filter by waveform occurrence concept diff --git a/circe/extensions/waveform/builders/waveform_registry.py b/circe/extensions/waveform/builders/waveform_registry.py index e241fe0b..f91b8b6d 100644 --- a/circe/extensions/waveform/builders/waveform_registry.py +++ b/circe/extensions/waveform/builders/waveform_registry.py @@ -45,7 +45,7 @@ def get_criteria_sql_with_options(self, criteria: WaveformRegistry, options: Bui query = self.get_query_template() where_clauses = [] - join_clauses = [] + join_clauses: list[str] = [] codeset_clause = "" # Link to parent occurrence diff --git a/pyproject.toml b/pyproject.toml index 401d3dd1..d78d6fa6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,29 @@ warn_no_return = true warn_unreachable = true strict_equality = true +[[tool.mypy.overrides]] +module = "ibis.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["litellm.*", "dotenv.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["circe.chat", "circe.prompt_builder"] +ignore_errors = true + +[[tool.mypy.overrides]] +module = [ + "circe.execution.ibis.*", + "circe.execution.engine.*", + "circe.execution.ibis_compat", + "circe.execution.databricks_compat", +] +disallow_untyped_defs = false +disallow_incomplete_defs = false +warn_return_any = false + [tool.coverage.run] source = ["circe"] omit = [ From c737e1806bbea215d40ba8e513ce41747d2407cd Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Fri, 1 May 2026 12:19:54 -0700 Subject: [PATCH 54/62] Added support for snake case only yaml cohort definitions (#35) --- .gitignore | 3 + circe/api.py | 32 + circe/cli.py | 48 +- circe/cohortdefinition/yaml_utils.py | 103 ++ circe/io.py | 45 +- examples/cohort_from_yaml.py | 232 +++ pyproject.toml | 3 +- .../isolated_immune_thrombocytopenia.yaml | 1409 +++++++++++++++++ tests/test_yaml_cohorts.py | 355 +++++ 9 files changed, 2192 insertions(+), 38 deletions(-) create mode 100644 circe/cohortdefinition/yaml_utils.py create mode 100644 examples/cohort_from_yaml.py create mode 100644 tests/cohorts/isolated_immune_thrombocytopenia.yaml create mode 100644 tests/test_yaml_cohorts.py diff --git a/.gitignore b/.gitignore index 875cbc73..ff506a58 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,6 @@ examples/*.json debug_app/.gemini_cache/ debug_app/user_overrides.json debug_app/test_results.json + +.test_baseline.json +.test_final.json \ No newline at end of file diff --git a/circe/api.py b/circe/api.py index d9dea0aa..4c8f3a56 100644 --- a/circe/api.py +++ b/circe/api.py @@ -17,6 +17,7 @@ CohortExpressionQueryBuilder, MarkdownRender, ) +from .cohortdefinition.yaml_utils import snake_case_dict_to_cohort_expression from .vocabulary.concept import ConceptSet if TYPE_CHECKING: @@ -80,6 +81,37 @@ def cohort_expression_from_json(json_str: str) -> CohortExpression: raise ValueError(f"Invalid cohort expression JSON: {str(e)}") from e +def cohort_expression_from_yaml(yaml_str: str) -> CohortExpression: + """Load a cohort expression from a YAML string. + + Args: + yaml_str: YAML string containing the cohort definition with snake_case field names + + Returns: + CohortExpression instance + + Raises: + ValueError: If the YAML is invalid or doesn't conform to the schema + + Example: + >>> yaml_str = ''' + ... title: "My Cohort" + ... concept_sets: [] + ... primary_criteria: {...} + ... ''' + >>> expression = cohort_expression_from_yaml(yaml_str) + """ + import yaml + + try: + data = yaml.safe_load(yaml_str) + if data is None: + data = {} + return snake_case_dict_to_cohort_expression(data) + except Exception as e: + raise ValueError(f"Invalid cohort expression YAML: {str(e)}") from e + + def build_cohort_query( expression: CohortExpression, options: Optional[BuildExpressionQueryOptions] = None, diff --git a/circe/cli.py b/circe/cli.py index 592cbc13..a038ee65 100644 --- a/circe/cli.py +++ b/circe/cli.py @@ -8,9 +8,10 @@ import sys from pathlib import Path -from .api import build_cohort_query, cohort_expression_from_json, cohort_print_friendly +from .api import build_cohort_query, cohort_print_friendly from .cohortdefinition import BuildExpressionQueryOptions from .cohortdefinition.code_generator import to_python_code +from .io import load_expression def main(): @@ -24,12 +25,12 @@ def main(): # Validate command validate_parser = subparsers.add_parser("validate", help="Validate a cohort definition") - validate_parser.add_argument("input", help="Input JSON file") + validate_parser.add_argument("input", help="Input JSON or YAML file") validate_parser.add_argument("--quiet", "-q", action="store_true", help="Only show errors") # Generate SQL command sql_parser = subparsers.add_parser("generate-sql", help="Generate SQL from cohort definition") - sql_parser.add_argument("input", help="Input JSON file") + sql_parser.add_argument("input", help="Input JSON or YAML file") sql_parser.add_argument("--output", "-o", help="Output SQL file (default: stdout)") sql_parser.add_argument("--cdm-schema", default="@cdm_database_schema", help="CDM schema name") sql_parser.add_argument( @@ -47,7 +48,7 @@ def main(): # Render markdown command md_parser = subparsers.add_parser("render-markdown", help="Render cohort definition as Markdown") - md_parser.add_argument("input", help="Input JSON file") + md_parser.add_argument("input", help="Input JSON or YAML file") md_parser.add_argument("--output", "-o", help="Output Markdown file (default: stdout)") md_parser.add_argument("--no-validate", action="store_true", help="Skip validation") md_parser.add_argument("--title", "-t", type=str, help="Title to add to markdown document") @@ -56,12 +57,12 @@ def main(): source_parser = subparsers.add_parser( "generate-source", help="Generate Python source code from cohort definition" ) - source_parser.add_argument("input", help="Input JSON file") + source_parser.add_argument("input", help="Input JSON or YAML file") source_parser.add_argument("--output", "-o", help="Output Python file (default: stdout)") # Process command (all-in-one) process_parser = subparsers.add_parser("process", help="Validate, generate SQL and Markdown") - process_parser.add_argument("input", help="Input JSON file") + process_parser.add_argument("input", help="Input JSON or YAML file") process_parser.add_argument("--sql-output", help="SQL output file") process_parser.add_argument("--md-output", help="Markdown output file") process_parser.add_argument("--cdm-schema", default="@cdm_database_schema", help="CDM schema name") @@ -101,11 +102,8 @@ def main(): def validate_command(args): """Validate a cohort definition.""" - # Read JSON - json_str = Path(args.input).read_text() - - # Load and validate - expression = cohort_expression_from_json(json_str) + # Load expression (auto-detects JSON or YAML) + expression = load_expression(Path(args.input)) # Run validation checks warnings = expression.check() @@ -131,11 +129,8 @@ def validate_command(args): def generate_sql_command(args): """Generate SQL from cohort definition.""" - # Read JSON - json_str = Path(args.input).read_text() - - # Load expression - expression = cohort_expression_from_json(json_str) + # Load expression (auto-detects JSON or YAML) + expression = load_expression(Path(args.input)) # Validate if requested if not args.no_validate: @@ -166,11 +161,8 @@ def generate_sql_command(args): def render_markdown_command(args): """Render cohort definition as Markdown.""" - # Read JSON - json_str = Path(args.input).read_text() - - # Load expression - expression = cohort_expression_from_json(json_str) + # Load expression (auto-detects JSON or YAML) + expression = load_expression(Path(args.input)) # Validate if requested if not args.no_validate: @@ -195,11 +187,8 @@ def render_markdown_command(args): def process_command(args): """Process cohort definition (validate, generate SQL and Markdown).""" - # Read JSON - json_str = Path(args.input).read_text() - - # Load expression - expression = cohort_expression_from_json(json_str) + # Load expression (auto-detects JSON or YAML) + expression = load_expression(Path(args.input)) # Validate warnings = expression.check() @@ -237,11 +226,8 @@ def process_command(args): def generate_source_command(args): """Generate Python source code from cohort definition.""" - # Read JSON - json_str = Path(args.input).read_text() - - # Load expression - expression = cohort_expression_from_json(json_str) + # Load expression (auto-detects JSON or YAML) + expression = load_expression(Path(args.input)) # Generate Source Code source_code = to_python_code(expression) diff --git a/circe/cohortdefinition/yaml_utils.py b/circe/cohortdefinition/yaml_utils.py new file mode 100644 index 00000000..25f3c20c --- /dev/null +++ b/circe/cohortdefinition/yaml_utils.py @@ -0,0 +1,103 @@ +"""Utilities for YAML conversion with snake_case naming.""" + +import re +from typing import Any + +from circe.cohortdefinition.cohort import CohortExpression + + +def to_snake_case(name: str) -> str: + """Convert camelCase or PascalCase string to snake_case. + + Args: + name: String in camelCase or PascalCase format + + Returns: + String in snake_case format + """ + # Insert underscore before uppercase letters preceded by lowercase + s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) + # Insert underscore before uppercase letters preceded by lowercase or numbers + s2 = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1) + return s2.lower() + + +def to_pascal_case(name: str) -> str: + """Convert snake_case string to PascalCase. + + Args: + name: String in snake_case format + + Returns: + String in PascalCase format + """ + components = name.split("_") + return "".join(x.title() for x in components) + + +def dict_to_snake_case(data: Any) -> Any: + """Recursively convert all dict keys from PascalCase/camelCase to snake_case. + + Args: + data: Dictionary, list, or primitive value + + Returns: + Same structure with all dict keys converted to snake_case + """ + if isinstance(data, dict): + return {to_snake_case(key): dict_to_snake_case(value) for key, value in data.items()} + elif isinstance(data, list): + return [dict_to_snake_case(item) for item in data] + else: + return data + + +def dict_to_pascal_case(data: Any) -> Any: + """Recursively convert all dict keys from snake_case to PascalCase. + + Args: + data: Dictionary, list, or primitive value + + Returns: + Same structure with all dict keys converted to PascalCase + """ + if isinstance(data, dict): + return {to_pascal_case(key): dict_to_pascal_case(value) for key, value in data.items()} + elif isinstance(data, list): + return [dict_to_pascal_case(item) for item in data] + else: + return data + + +def cohort_expression_to_snake_case(expr: CohortExpression) -> dict[str, Any]: + """Convert CohortExpression to dict with snake_case field names. + + Args: + expr: CohortExpression instance + + Returns: + Dictionary representation with all keys in snake_case + """ + # Use model_dump to convert to dict with serialization aliases + expr_dict = expr.model_dump(by_alias=True) + # Convert all keys to snake_case + return dict_to_snake_case(expr_dict) + + +def snake_case_dict_to_cohort_expression(data: dict[str, Any]) -> CohortExpression: + """Convert snake_case dict to CohortExpression. + + Args: + data: Dictionary with snake_case keys + + Returns: + CohortExpression instance + """ + # CohortExpression models have populate_by_name=True which accepts snake_case + # So we can pass the data directly without conversion + try: + return CohortExpression.model_validate(data) + except Exception: + # If that fails, try converting to PascalCase as fallback + pascal_dict = dict_to_pascal_case(data) + return CohortExpression.model_validate(pascal_dict) diff --git a/circe/io.py b/circe/io.py index 8e75ae7e..af2f1515 100644 --- a/circe/io.py +++ b/circe/io.py @@ -12,8 +12,9 @@ from pathlib import Path from typing import Any, Union -from .api import cohort_expression_from_json +from .api import cohort_expression_from_json, cohort_expression_from_yaml from .cohortdefinition import CohortExpression +from .cohortdefinition.yaml_utils import cohort_expression_to_snake_case ExpressionInput = Union[CohortExpression, Mapping[str, Any], str, Path] @@ -25,7 +26,8 @@ def load_expression(value: ExpressionInput) -> CohortExpression: - CohortExpression - mapping/dict compatible with CohortExpression - JSON string - - path to a JSON file + - YAML string + - path to a JSON or YAML file """ if isinstance(value, CohortExpression): return value @@ -34,7 +36,11 @@ def load_expression(value: ExpressionInput) -> CohortExpression: return CohortExpression.model_validate(dict(value)) if isinstance(value, Path): - return cohort_expression_from_json(value.read_text(encoding="utf-8")) + content = value.read_text(encoding="utf-8") + if value.suffix in (".yaml", ".yml"): + return cohort_expression_from_yaml(content) + else: + return cohort_expression_from_json(content) if isinstance(value, str): stripped = value.strip() @@ -46,17 +52,44 @@ def load_expression(value: ExpressionInput) -> CohortExpression: # File-system path path = Path(value) if path.exists() and path.is_file(): - return cohort_expression_from_json(path.read_text(encoding="utf-8")) + content = path.read_text(encoding="utf-8") + if path.suffix in (".yaml", ".yml"): + return cohort_expression_from_yaml(content) + else: + return cohort_expression_from_json(content) # If it wasn't an existing path, attempt JSON parse for clearer errors. try: parsed = json.loads(stripped) except json.JSONDecodeError as exc: raise ValueError( - "Expected JSON string or path to a JSON file for cohort expression input." + "Expected JSON string, YAML string, or path to a JSON/YAML file for cohort expression input." ) from exc return CohortExpression.model_validate(parsed) raise TypeError( - "Unsupported expression input type. Expected CohortExpression, mapping, JSON string, or Path." + "Unsupported expression input type. Expected CohortExpression, mapping, JSON/YAML string, or Path." ) + + +def save_expression_as_yaml(expr: CohortExpression, path: str | Path) -> None: + """Save a CohortExpression as a YAML file with snake_case field names. + + Args: + expr: CohortExpression instance to save + path: File path to save the YAML file to + """ + import yaml + + path = Path(path) + yaml_dict = cohort_expression_to_snake_case(expr) + + # Write to file with nice YAML formatting + with open(path, "w", encoding="utf-8") as f: + yaml.dump( + yaml_dict, + f, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) diff --git a/examples/cohort_from_yaml.py b/examples/cohort_from_yaml.py new file mode 100644 index 00000000..9f55b828 --- /dev/null +++ b/examples/cohort_from_yaml.py @@ -0,0 +1,232 @@ +"""Example demonstrating YAML cohort definition and usage. + +This example shows: +1. Loading a cohort from a YAML file +2. Creating a cohort programmatically and saving as YAML +3. Working with YAML cohorts in the same way as JSON cohorts +""" + +from pathlib import Path +from tempfile import TemporaryDirectory + +from circe.api import build_cohort_query, cohort_expression_from_yaml, cohort_print_friendly +from circe.cohortdefinition import BuildExpressionQueryOptions +from circe.io import load_expression + + +def example_1_load_yaml_cohort(): + """Example 1: Load a cohort from YAML file.""" + print("=" * 60) + print("Example 1: Loading a YAML Cohort") + print("=" * 60) + + # Load a YAML cohort file + # The file uses snake_case naming convention, which is more Pythonic + cohort_path = Path(__file__).parent.parent / "tests" / "cohorts" / "isolated_immune_thrombocytopenia.yaml" + + if cohort_path.exists(): + # Method 1: Using load_expression (auto-detects YAML by extension) + cohort = load_expression(cohort_path) + print(f"✓ Loaded YAML cohort: {cohort.title}") + print(f" Concept sets: {len(cohort.concept_sets) if cohort.concept_sets else 0}") + + # Method 2: Directly from YAML string + yaml_content = cohort_path.read_text() + cohort_expression_from_yaml(yaml_content) + print("✓ Also loaded via cohort_expression_from_yaml()") + + return cohort + else: + print(f"✗ Example YAML file not found at {cohort_path}") + print(" Creating a simple YAML cohort instead...") + return None + + +def example_2_create_and_save_yaml(): + """Example 2: Create a cohort programmatically and save as YAML.""" + print("\n" + "=" * 60) + print("Example 2: Creating and Saving a YAML Cohort") + print("=" * 60) + + # Create YAML content with snake_case names + yaml_content = """ +title: "Hypertension Patients" +cdm_version_range: ">=5.0.0" + +concept_sets: + - id: 1 + name: "Hypertension diagnosis" + expression: + items: + - concept: + concept_id: 316866 + concept_name: "Essential hypertension" + domain_id: "Condition" + vocabulary_id: "SNOMED" + concept_class_id: "Clinical Finding" + standard_concept: "S" + is_excluded: false + include_descendants: true + include_mapped: false + is_excluded: false + include_descendants: false + include_mapped: false + +primary_criteria: + criteria_list: + - condition_occurrence: + codeset_id: 1 + condition_type_exclude: false + observation_window: + prior_days: 0 + post_days: 0 + primary_criteria_limit: + type: "All" + +inclusion_rules: [] +""" + + # Parse and save to a file + with TemporaryDirectory() as tmpdir: + yaml_path = Path(tmpdir) / "hypertension_cohort.yaml" + yaml_path.write_text(yaml_content) + print(f"✓ Created YAML cohort at {yaml_path}") + + # Load it back to verify + cohort = load_expression(yaml_path) + print(f"✓ Loaded cohort: '{cohort.title}'") + print(f" Concept sets: {len(cohort.concept_sets) if cohort.concept_sets else 0}") + + # Read back and show snake_case naming + loaded_yaml = yaml_path.read_text() + print("\n✓ YAML file uses snake_case naming:") + for line in loaded_yaml.split("\n")[:15]: + if line.strip() and not line.strip().startswith("#"): + print(f" {line}") + + return cohort, yaml_path + + +def example_3_yaml_sql_generation(): + """Example 3: Generate SQL from a YAML cohort.""" + print("\n" + "=" * 60) + print("Example 3: Generate SQL from YAML Cohort") + print("=" * 60) + + # Create a YAML cohort with proper primary_criteria + yaml_content = """ +title: "Simple Test Cohort" +concept_sets: [] +primary_criteria: + criteria_list: [] + observation_window: + prior_days: 0 + post_days: 0 + primary_criteria_limit: + type: "All" +""" + + with TemporaryDirectory() as tmpdir: + yaml_path = Path(tmpdir) / "test_cohort.yaml" + yaml_path.write_text(yaml_content) + + # Load YAML cohort + cohort = load_expression(yaml_path) + + # Generate SQL (same as with JSON cohorts) + options = BuildExpressionQueryOptions() + options.cdm_schema = "cdm" + options.target_table = "public.cohort" + options.cohort_id = 1 + + sql = build_cohort_query(cohort, options) + print("✓ Generated SQL from YAML cohort") + print("\nSQL Preview (first 20 lines):") + print("-" * 60) + lines = sql.split("\n") + for line in lines[:20]: + print(line) + if len(lines) > 20: + print("... (truncated)") + + +def example_4_yaml_markdown_generation(): + """Example 4: Generate Markdown from a YAML cohort.""" + print("\n" + "=" * 60) + print("Example 4: Generate Markdown from YAML Cohort") + print("=" * 60) + + yaml_content = """ +title: "Drug Allergy Cohort" +concept_sets: + - id: 1 + name: "Penicillin allergy" + expression: + items: [] + is_excluded: false + include_descendants: false + include_mapped: false +primary_criteria: null +""" + + with TemporaryDirectory() as tmpdir: + yaml_path = Path(tmpdir) / "allergy_cohort.yaml" + yaml_path.write_text(yaml_content) + + # Load YAML cohort + cohort = load_expression(yaml_path) + + # Generate Markdown (same as with JSON cohorts) + markdown = cohort_print_friendly(cohort, include_concept_sets=True, title="YAML Cohort Example") + + print("✓ Generated Markdown from YAML cohort") + print("\nMarkdown Preview (first 30 lines):") + print("-" * 60) + lines = markdown.split("\n") + for line in lines[:30]: + print(line) + if len(lines) > 30: + print("... (truncated)") + + +def example_5_yaml_vs_json(): + """Example 5: Compare YAML and JSON formats.""" + print("\n" + "=" * 60) + print("Example 5: YAML vs JSON Format Comparison") + print("=" * 60) + + print("\nJSON Format (PascalCase):") + print("-" * 40) + print(" - Uses PascalCase field names: conceptSets, primaryCriteria, etc.") + print(" - More compact representation") + print(" - Compatible with Java/R CIRCE implementations") + print("\nYAML Format (snake_case):") + print("-" * 40) + print(" - Uses snake_case field names: concept_sets, primary_criteria, etc.") + print(" - More readable for Python developers") + print(" - Better matches Python naming conventions") + print("\nBoth formats are supported and interchangeable in circepy!") + + +def main(): + """Run all examples.""" + print("\n") + print("╔" + "=" * 58 + "╗") + print("║" + " " * 58 + "║") + print("║" + " YAML Cohort Support Examples in circepy".center(58) + "║") + print("║" + " " * 58 + "║") + print("╚" + "=" * 58 + "╝") + + example_1_load_yaml_cohort() + example_2_create_and_save_yaml() + example_3_yaml_sql_generation() + example_4_yaml_markdown_generation() + example_5_yaml_vs_json() + + print("\n" + "=" * 60) + print("All examples completed!") + print("=" * 60 + "\n") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index d78d6fa6..4f0b2a09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,8 @@ requires-python = ">=3.9" dependencies = [ "pydantic>=2.0.0", "typing-extensions>=4.0.0", - "jinja2>=3.1.0" + "jinja2>=3.1.0", + "PyYAML>=6.0" ] [project.optional-dependencies] diff --git a/tests/cohorts/isolated_immune_thrombocytopenia.yaml b/tests/cohorts/isolated_immune_thrombocytopenia.yaml new file mode 100644 index 00000000..e5dec722 --- /dev/null +++ b/tests/cohorts/isolated_immune_thrombocytopenia.yaml @@ -0,0 +1,1409 @@ +cdm_version_range: '>=5.0.0' +primary_criteria: + criteria_list: + - condition_occurrence: + codeset_id: 30 + condition_type_exclude: false + observation_window: + prior_days: 0 + post_days: 0 + primary_criteria_limit: + type: All +concept_sets: +- id: 7 + name: Platelet measurement + expression: + items: + - concept: + concept_id: 4267147 + concept_name: Platelet count + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '61928009' + domain_id: Measurement + vocabulary_id: SNOMED + concept_class_id: Procedure + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 3031586 + concept_name: Platelets [#/volume] in Blood by Estimate + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: 49497-1 + domain_id: Measurement + vocabulary_id: LOINC + concept_class_id: Lab Test + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 3050583 + concept_name: Platelets panel - Blood by Automated count + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: 53800-9 + domain_id: Measurement + vocabulary_id: LOINC + concept_class_id: Lab Test + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 3007461 + concept_name: Platelets [#/volume] in Blood + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: 26515-7 + domain_id: Measurement + vocabulary_id: LOINC + concept_class_id: Lab Test + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 37393863 + concept_name: Platelet count + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '1022651000000100' + domain_id: Measurement + vocabulary_id: SNOMED + concept_class_id: Observable Entity + is_excluded: false + include_descendants: true + include_mapped: false +- id: 9 + name: Congenital or genetic causes for thrombocytopenia + expression: + items: + - concept: + concept_id: 37397537 + concept_name: Beta thalassemia X-linked thrombocytopenia syndrome + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '718196002' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4121131 + concept_name: Inherited platelet disorder + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '234469001' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4006469 + concept_name: Reticular dysgenesis + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '111584000' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 42537688 + concept_name: Congenital thrombocytopenia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '737221003' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 437242 + concept_name: Congenital thrombocytopenic purpura + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '267535004' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false +- id: 10 + name: Thrombocytosis + expression: + items: + - concept: + concept_id: 4280071 + concept_name: Thrombocytosis + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '6631009' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 36715584 + concept_name: Refractory anemia with ringed sideroblasts associated with marked + thrombocytosis + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '721302006' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 45766614 + concept_name: Refractory anemia with ring sideroblasts associated with marked + thrombocytosis + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '703817002' + domain_id: Observation + vocabulary_id: SNOMED + concept_class_id: Morph Abnormality + is_excluded: false + include_descendants: true + include_mapped: false +- id: 24 + name: Pancytopenia & bone marrow disorder + expression: + items: + - concept: + concept_id: 432881 + concept_name: Pancytopenia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '127034005' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4131124 + concept_name: Bone marrow disorder + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '127035006' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false +- id: 25 + name: Neutropenia, Agranulocytosis or Unspecified Leukopenia + expression: + items: + - concept: + concept_id: 36715585 + concept_name: Refractory neutropenia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '721303001' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 320073 + concept_name: Neutropenia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '165517008' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 42872951 + concept_name: Refractory neutropenia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '450946009' + domain_id: Observation + vocabulary_id: SNOMED + concept_class_id: Morph Abnormality + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 435224 + concept_name: Leukopenia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '84828003' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: false + include_mapped: false + - concept: + concept_id: 440689 + concept_name: Agranulocytosis + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '17182001' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 45766061 + concept_name: Periodontitis associated with chronic familial neutropenia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '703148008' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4119158 + concept_name: Neutropenic disorder + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '303011007' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false +- id: 26 + name: Neutrophil Absolute Count + expression: + items: + - concept: + concept_id: 37393856 + concept_name: Neutrophil count + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '1022551000000104' + domain_id: Measurement + vocabulary_id: SNOMED + concept_class_id: Observable Entity + is_excluded: false + include_descendants: false + include_mapped: false + - concept: + concept_id: 4148615 + concept_name: Neutrophil count + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '30630007' + domain_id: Measurement + vocabulary_id: SNOMED + concept_class_id: Procedure + is_excluded: false + include_descendants: false + include_mapped: false + - concept: + concept_id: 3017732 + concept_name: Neutrophils [#/volume] in Blood + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: 26499-4 + domain_id: Measurement + vocabulary_id: LOINC + concept_class_id: Lab Test + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 3013650 + concept_name: Neutrophils [#/volume] in Blood by Automated count + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: 751-8 + domain_id: Measurement + vocabulary_id: LOINC + concept_class_id: Lab Test + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 3017501 + concept_name: Neutrophils [#/volume] in Blood by Manual count + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: 753-4 + domain_id: Measurement + vocabulary_id: LOINC + concept_class_id: Lab Test + is_excluded: false + include_descendants: true + include_mapped: false +- id: 27 + name: Anemia or Reticulocytopenia + expression: + items: + - concept: + concept_id: 2617149 + concept_name: Erythropoetic stimulating agent (esa) administered to treat + anemia due to anti-cancer radiotherapy + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: EB + domain_id: Observation + vocabulary_id: HCPCS + concept_class_id: HCPCS Modifier + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 36716029 + concept_name: Hyperuricemia, anemia, renal failure syndrome + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '721840000' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 2617148 + concept_name: Erythropoetic stimulating agent (esa) administered to treat + anemia due to anti-cancer chemotherapy + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: EA + domain_id: Observation + vocabulary_id: HCPCS + concept_class_id: HCPCS Modifier + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4029669 + concept_name: Refractory anemia with sideroblasts + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '128846006' + domain_id: Observation + vocabulary_id: SNOMED + concept_class_id: Morph Abnormality + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4120449 + concept_name: von Jaksch's anemia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '234345001' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 35624756 + concept_name: Anemia due to and following chemotherapy + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '767657005' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4028718 + concept_name: Refractory anemia with excess blasts + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '128847002' + domain_id: Observation + vocabulary_id: SNOMED + concept_class_id: Morph Abnormality + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 37017165 + concept_name: GATA binding protein 1 related thrombocytopenia with dyserythropoiesis + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '713388002' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4144746 + concept_name: Hereditary hemoglobinopathy + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '427306008' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 2617150 + concept_name: Erythropoetic stimulating agent (esa) administered to treat + anemia not due to anti-cancer radiotherapy or anti-cancer chemotherapy + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: EC + domain_id: Observation + vocabulary_id: HCPCS + concept_class_id: HCPCS Modifier + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 44831063 + concept_name: Anemia associated with other specified nutritional deficiency + standard_concept: N + standard_concept_caption: Non-Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '281.8' + domain_id: Condition + vocabulary_id: ICD9CM + concept_class_id: 4-dig billing code + is_excluded: false + include_descendants: false + include_mapped: false + - concept: + concept_id: 4105643 + concept_name: Myasthenic syndrome due to pernicious anemia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '193213003' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 37398911 + concept_name: Anemia in chronic kidney disease stage 4 + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '691401000119104' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 438869 + concept_name: Perinatal jaundice due to hereditary hemolytic anemia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '56921004' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4183718 + concept_name: Pericarditis associated with severe chronic anemia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '43742007' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 37395652 + concept_name: Anemia in chronic kidney disease stage 5 + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '691411000119101' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4125630 + concept_name: Chronic non-spherocytic hemolytic anemia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '234402007' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4217370 + concept_name: Aase syndrome + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '71988008' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4267432 + concept_name: Erythropenia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '62574001' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 36680584 + concept_name: Autosomal dominant aplasia and myelodysplasia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '778006008' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 37018722 + concept_name: Anemia caused by zidovudine + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '713496008' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4295183 + concept_name: Mixed hemoglobin disorder + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '38589006' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4028717 + concept_name: Refractory anemia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '128845005' + domain_id: Observation + vocabulary_id: SNOMED + concept_class_id: Morph Abnormality + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 44783626 + concept_name: Pulmonary arterial hypertension associated with chronic hemolytic + anemia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '697908003' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4159748 + concept_name: Hand-foot syndrome in sickle cell anemia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '371104006' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4051681 + concept_name: Reticulocytopenia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '124961001' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 37017132 + concept_name: Anemia co-occurrent with human immunodeficiency virus infection + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '713349004' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4029670 + concept_name: Refractory anemia with excess blasts in transformation + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '128848007' + domain_id: Observation + vocabulary_id: SNOMED + concept_class_id: Morph Abnormality + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4006467 + concept_name: Anemia due to infection + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '111570005' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 40478891 + concept_name: Erythropoietin resistance in anemia of chronic kidney disease + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '444271000' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 36715584 + concept_name: Refractory anemia with ringed sideroblasts associated with marked + thrombocytosis + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '721302006' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false +- id: 28 + name: Hemoglobin measurement + expression: + items: + - concept: + concept_id: 3000963 + concept_name: Hemoglobin [Mass/volume] in Blood + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: 718-7 + domain_id: Measurement + vocabulary_id: LOINC + concept_class_id: Lab Test + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 3027484 + concept_name: Hemoglobin [Mass/volume] in Blood by calculation + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: 20509-6 + domain_id: Measurement + vocabulary_id: LOINC + concept_class_id: Lab Test + is_excluded: false + include_descendants: true + include_mapped: false +- id: 30 + name: Immune Thrombocytopenia + expression: + items: + - concept: + concept_id: 4103532 + concept_name: Immune thrombocytopenia + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '2897005' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false + - concept: + concept_id: 4119134 + concept_name: Thrombocytopenic purpura + standard_concept: S + standard_concept_caption: Standard + invalid_reason: V + invalid_reason_caption: Valid + concept_code: '302873008' + domain_id: Condition + vocabulary_id: SNOMED + concept_class_id: Clinical Finding + is_excluded: false + include_descendants: true + include_mapped: false +qualified_limit: + type: First +expression_limit: + type: All +inclusion_rules: +- name: No congenital or genetic thrombocytopenia + expression: + type: ALL + criteria_list: + - criteria: + condition_occurrence: + codeset_id: 9 + condition_type_exclude: false + start_window: + start: + coeff: -1 + end: + days: 7 + coeff: 1 + use_index_end: false + use_event_end: false + restrict_visit: false + ignore_observation_period: true + occurrence: + type: 0 + count: 0 + is_distinct: false + demographic_criteria_list: [] + groups: [] +- name: No Platelet count > 100 on index date + expression: + type: ALL + criteria_list: + - criteria: + measurement: + codeset_id: 7 + measurement_type_exclude: false + value_as_number: + value: 101 + op: bt + extent: 450 + unit: + - concept_id: 8848 + concept_name: thousand per microliter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*3/uL + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 8961 + concept_name: thousand per cubic millimeter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*3/mm3 + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 9444 + concept_name: billion per liter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*9/L + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 8816 + concept_name: million per milliliter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*6/mL + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 44777588 + concept_name: billion cells per liter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*9.{cellls}/L + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + start_window: + start: + days: 0 + coeff: -1 + end: + days: 0 + coeff: 1 + use_index_end: false + use_event_end: false + restrict_visit: false + ignore_observation_period: true + occurrence: + type: 0 + count: 0 + is_distinct: false + demographic_criteria_list: [] + groups: [] +- name: No thrombocytosis on index date + expression: + type: ALL + criteria_list: + - criteria: + condition_occurrence: + codeset_id: 10 + condition_type_exclude: false + start_window: + start: + days: 0 + coeff: -1 + end: + days: 0 + coeff: 1 + use_index_end: false + use_event_end: false + restrict_visit: false + ignore_observation_period: true + occurrence: + type: 0 + count: 0 + is_distinct: false + demographic_criteria_list: [] + groups: [] +- name: No Pancytopenia or bone marrow disorder diagnosis within 7 days + expression: + type: ALL + criteria_list: + - criteria: + condition_occurrence: + codeset_id: 24 + condition_type_exclude: false + start_window: + start: + days: 7 + coeff: -1 + end: + days: 7 + coeff: 1 + use_index_end: false + use_event_end: false + restrict_visit: false + ignore_observation_period: true + occurrence: + type: 0 + count: 0 + is_distinct: false + demographic_criteria_list: [] + groups: [] +- name: No Neutropenia, Agranulocytosis diagnosis within 7 days + expression: + type: ALL + criteria_list: + - criteria: + condition_occurrence: + codeset_id: 25 + condition_type_exclude: false + start_window: + start: + days: 7 + coeff: -1 + end: + days: 7 + coeff: 1 + use_index_end: false + use_event_end: false + restrict_visit: false + ignore_observation_period: true + occurrence: + type: 0 + count: 0 + is_distinct: false + demographic_criteria_list: [] + groups: [] +- name: No low neutrophil count within 7 days + expression: + type: ALL + criteria_list: + - criteria: + measurement: + codeset_id: 26 + measurement_type_exclude: false + value_as_number: + value: 0.01 + op: bt + extent: 1.499 + unit: + - concept_id: 9444 + concept_name: billion per liter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*9/L + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 8848 + concept_name: thousand per microliter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*3/uL + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 8816 + concept_name: million per milliliter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*6/mL + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 8961 + concept_name: thousand per cubic millimeter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*3/mm3 + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 44777588 + concept_name: billion cells per liter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*9.{cellls}/L + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + range_low: + value: 1.5 + op: bt + extent: 4 + start_window: + start: + days: 7 + coeff: -1 + end: + days: 7 + coeff: -1 + use_index_end: false + use_event_end: false + restrict_visit: false + ignore_observation_period: true + occurrence: + type: 0 + count: 0 + is_distinct: false + - criteria: + measurement: + codeset_id: 26 + measurement_type_exclude: false + value_as_number: + value: 10 + op: bt + extent: 1500 + unit: + - concept_id: 8784 + concept_name: cells per microliter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: '{cells}/uL' + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 8647 + concept_name: per microliter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: /uL + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + start_window: + start: + days: 7 + coeff: -1 + end: + days: 7 + coeff: 1 + use_index_end: false + use_event_end: false + restrict_visit: false + ignore_observation_period: true + occurrence: + type: 0 + count: 0 + is_distinct: false + demographic_criteria_list: [] + groups: [] +- name: No Anemia diagnosis within 7 days + expression: + type: ALL + criteria_list: + - criteria: + condition_occurrence: + codeset_id: 27 + condition_type_exclude: false + start_window: + start: + days: 7 + coeff: -1 + end: + days: 7 + coeff: 1 + use_index_end: false + use_event_end: false + restrict_visit: false + ignore_observation_period: true + occurrence: + type: 0 + count: 0 + is_distinct: false + - criteria: + observation: + codeset_id: 27 + observation_type_exclude: false + start_window: + start: + days: 7 + coeff: -1 + end: + days: 7 + coeff: 1 + use_index_end: false + use_event_end: false + restrict_visit: false + ignore_observation_period: true + occurrence: + type: 0 + count: 0 + is_distinct: false + demographic_criteria_list: [] + groups: [] +- name: No low Hemoglobin measurement in blood within 7 days + expression: + type: ALL + criteria_list: + - criteria: + measurement: + codeset_id: 28 + measurement_type_exclude: false + value_as_number: + value: 4 + op: bt + extent: 11 + unit: + - concept_id: 4121395 + concept_name: g/dL + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: '258795003' + domain_id: Unit + vocabulary_id: SNOMED + concept_class_id: null + - concept_id: 8713 + concept_name: gram per deciliter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: g/dL + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 8950 + concept_name: gram per deciliter calculated + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: g/dL{calc} + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + start_window: + start: + days: 7 + coeff: -1 + end: + days: 7 + coeff: 1 + use_index_end: false + use_event_end: false + restrict_visit: false + ignore_observation_period: true + occurrence: + type: 0 + count: 0 + is_distinct: false + demographic_criteria_list: [] + groups: [] +end_strategy: + date_offset: + date_field: EndDate + offset: 0 +censoring_criteria: +- measurement: + codeset_id: 7 + measurement_type_exclude: false + value_as_number: + value: 150 + op: bt + extent: 450 + unit: + - concept_id: 8848 + concept_name: thousand per microliter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*3/uL + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 8961 + concept_name: thousand per cubic millimeter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*3/mm3 + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 9444 + concept_name: billion per liter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*9/L + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 8816 + concept_name: million per milliliter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*6/mL + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null + - concept_id: 44777588 + concept_name: billion cells per liter + standard_concept: null + standard_concept_caption: Unknown + invalid_reason: null + invalid_reason_caption: Unknown + concept_code: 10*9.{cellls}/L + domain_id: Unit + vocabulary_id: UCUM + concept_class_id: null +- condition_occurrence: + codeset_id: 10 + condition_type_exclude: false +collapse_settings: + collapse_type: ERA + era_pad: 0 +censor_window: {} diff --git a/tests/test_yaml_cohorts.py b/tests/test_yaml_cohorts.py new file mode 100644 index 00000000..f3fcf6b8 --- /dev/null +++ b/tests/test_yaml_cohorts.py @@ -0,0 +1,355 @@ +"""Tests for YAML cohort support with snake_case naming.""" + +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest +import yaml + +from circe.api import build_cohort_query, cohort_expression_from_json, cohort_expression_from_yaml +from circe.cohortdefinition import BuildExpressionQueryOptions +from circe.cohortdefinition.yaml_utils import ( + cohort_expression_to_snake_case, + dict_to_pascal_case, + dict_to_snake_case, + snake_case_dict_to_cohort_expression, + to_pascal_case, + to_snake_case, +) +from circe.io import load_expression, save_expression_as_yaml + + +class TestCaseConversion: + """Test case conversion utilities.""" + + def test_to_snake_case_pascal_case(self): + """Test converting PascalCase to snake_case.""" + assert to_snake_case("PrimaryCriteria") == "primary_criteria" + assert to_snake_case("ConceptSets") == "concept_sets" + assert to_snake_case("CodesetId") == "codeset_id" + assert to_snake_case("CohortExpression") == "cohort_expression" + + def test_to_snake_case_camel_case(self): + """Test converting camelCase to snake_case.""" + assert to_snake_case("primaryCriteria") == "primary_criteria" + assert to_snake_case("conceptSets") == "concept_sets" + assert to_snake_case("codesetId") == "codeset_id" + + def test_to_snake_case_with_numbers(self): + """Test converting with numbers.""" + assert to_snake_case("CodesetId") == "codeset_id" + assert to_snake_case("Concept1Id") == "concept1_id" + + def test_to_snake_case_all_caps(self): + """Test converting ALL_CAPS and ID suffixes.""" + # CONCEPT_ID already has underscores, just gets lowercased + assert to_snake_case("CONCEPT_ID") == "concept_id" + # ConceptID gets underscores before capitals and lowercased + assert to_snake_case("ConceptID") == "concept_id" + + def test_to_pascal_case(self): + """Test converting snake_case to PascalCase.""" + assert to_pascal_case("primary_criteria") == "PrimaryCriteria" + assert to_pascal_case("concept_sets") == "ConceptSets" + assert to_pascal_case("codeset_id") == "CodesetId" + + def test_dict_to_snake_case_simple(self): + """Test converting dict keys to snake_case.""" + data = {"PrimaryCriteria": "value", "ConceptSets": []} + result = dict_to_snake_case(data) + assert "primary_criteria" in result + assert "concept_sets" in result + assert result["primary_criteria"] == "value" + + def test_dict_to_snake_case_nested(self): + """Test converting nested dict keys to snake_case.""" + data = {"PrimaryCriteria": {"CriteriaList": [{"ConditionOccurrence": {"CodesetId": 1}}]}} + result = dict_to_snake_case(data) + assert "primary_criteria" in result + assert "criteria_list" in result["primary_criteria"] + assert isinstance(result["primary_criteria"]["criteria_list"], list) + assert "condition_occurrence" in result["primary_criteria"]["criteria_list"][0] + + def test_dict_to_pascal_case_simple(self): + """Test converting dict keys back to PascalCase.""" + data = {"primary_criteria": "value", "concept_sets": []} + result = dict_to_pascal_case(data) + assert "PrimaryCriteria" in result + assert "ConceptSets" in result + + def test_dict_to_pascal_case_nested(self): + """Test converting nested dict keys back to PascalCase.""" + data = {"primary_criteria": {"criteria_list": [{"condition_occurrence": {"codeset_id": 1}}]}} + result = dict_to_pascal_case(data) + assert "PrimaryCriteria" in result + assert "CriteriaList" in result["PrimaryCriteria"] + + +class TestYAMLParsing: + """Test YAML parsing and conversion.""" + + @pytest.fixture + def example_json_cohort(self): + """Load the example JSON cohort from tests.""" + cohorts_dir = Path(__file__).parent / "cohorts" + json_file = cohorts_dir / "isolated_immune_thrombocytopenia.json" + if json_file.exists(): + return json.loads(json_file.read_text()) + # Return minimal valid cohort if file doesn't exist + return {"concept_sets": [], "primary_criteria": None} + + def test_cohort_expression_from_yaml_simple(self): + """Test parsing simple YAML cohort.""" + yaml_str = """ +title: "Test Cohort" +concept_sets: [] +primary_criteria: null +""" + expr = cohort_expression_from_yaml(yaml_str) + assert expr.title == "Test Cohort" + assert expr.concept_sets == [] + + def test_cohort_expression_from_yaml_with_criteria(self): + """Test parsing YAML with more complex structure.""" + yaml_str = """ +title: "Test Cohort" +concept_sets: + - id: 1 + name: "Test Concept Set" + expression: + items: [] + is_excluded: false + include_descendants: false + include_mapped: false +primary_criteria: null +""" + expr = cohort_expression_from_yaml(yaml_str) + assert expr.title == "Test Cohort" + assert len(expr.concept_sets) == 1 + assert expr.concept_sets[0].id == 1 + assert expr.concept_sets[0].name == "Test Concept Set" + + def test_cohort_expression_to_snake_case(self, example_json_cohort): + """Test converting CohortExpression to snake_case dict.""" + import json + + from circe.api import cohort_expression_from_json + + json_str = json.dumps(example_json_cohort) + expr = cohort_expression_from_json(json_str) + result = cohort_expression_to_snake_case(expr) + + # Check that keys are in snake_case + assert isinstance(result, dict) + # Should not have PascalCase keys at top level + assert "PrimaryCriteria" not in result + assert "primary_criteria" in result or result == {} + + def test_snake_case_dict_to_cohort_expression(self): + """Test converting snake_case dict to CohortExpression.""" + data = { + "title": "Test Cohort", + "concept_sets": [ + { + "id": 1, + "name": "Test", + "expression": { + "items": [], + "is_excluded": False, + "include_descendants": False, + "include_mapped": False, + }, + } + ], + } + expr = snake_case_dict_to_cohort_expression(data) + assert expr.title == "Test Cohort" + assert len(expr.concept_sets) == 1 + + +class TestYAMLIO: + """Test YAML file I/O operations.""" + + def test_save_expression_as_yaml(self): + """Test saving CohortExpression to YAML file.""" + + yaml_str = """ +title: "Test Cohort" +concept_sets: [] +""" + expr = cohort_expression_from_yaml(yaml_str) + + with TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "test_cohort.yaml" + save_expression_as_yaml(expr, output_path) + + assert output_path.exists() + content = output_path.read_text() + assert "test_cohort" in content.lower() or "Test Cohort" in content + + def test_load_expression_yaml_file(self): + """Test loading YAML file via load_expression.""" + yaml_content = """ +title: "Test Cohort" +concept_sets: [] +""" + with TemporaryDirectory() as tmpdir: + yaml_path = Path(tmpdir) / "test.yaml" + yaml_path.write_text(yaml_content) + + expr = load_expression(yaml_path) + assert expr.title == "Test Cohort" + + def test_load_expression_yml_file(self): + """Test loading .yml file extension.""" + yaml_content = """ +title: "Test Cohort" +concept_sets: [] +""" + with TemporaryDirectory() as tmpdir: + yaml_path = Path(tmpdir) / "test.yml" + yaml_path.write_text(yaml_content) + + expr = load_expression(yaml_path) + assert expr.title == "Test Cohort" + + def test_load_expression_json_still_works(self): + """Test that JSON files still work via load_expression.""" + json_content = '{"title": "JSON Cohort", "conceptSets": []}' + + with TemporaryDirectory() as tmpdir: + json_path = Path(tmpdir) / "test.json" + json_path.write_text(json_content) + + expr = load_expression(json_path) + assert expr.title == "JSON Cohort" + + +class TestRoundTrip: + """Test round-trip conversions.""" + + def test_yaml_to_json_roundtrip(self): + """Test converting YAML -> JSON and back.""" + yaml_str = """ +title: "Round Trip Test" +concept_sets: [] +primary_criteria: null +""" + # Load from YAML + expr1 = cohort_expression_from_yaml(yaml_str) + + # Convert to dict and back + snake_dict = cohort_expression_to_snake_case(expr1) + expr2 = snake_case_dict_to_cohort_expression(snake_dict) + + assert expr1.title == expr2.title + assert expr1.concept_sets == expr2.concept_sets + + def test_json_to_yaml_to_json(self): + """Test converting JSON -> YAML -> JSON preserves equivalence.""" + # Create a minimal but valid cohort with primary_criteria + example_json_cohort = { + "title": "Round Trip Test", + "concept_sets": [], + "primary_criteria": { + "criteria_list": [], + "observation_window": {"prior_days": 0, "post_days": 0}, + "primary_criteria_limit": {"type": "All"}, + }, + } + + import json + + # Load from JSON + json_str = json.dumps(example_json_cohort) + expr1 = cohort_expression_from_json(json_str) + + # Save to YAML and reload + with TemporaryDirectory() as tmpdir: + yaml_path = Path(tmpdir) / "temp.yaml" + save_expression_as_yaml(expr1, yaml_path) + expr2 = load_expression(yaml_path) + + # Both should have same title + assert expr1.title == expr2.title + + # Both should be able to generate SQL with same options + options = BuildExpressionQueryOptions() + sql1 = build_cohort_query(expr1, options) + sql2 = build_cohort_query(expr2, options) + # SQL should be identical for same input + assert sql1 == sql2 + + def test_yaml_preserves_snake_case_on_roundtrip(self): + """Test that YAML round-trip preserves snake_case formatting.""" + yaml_str = """ +title: "Snake Case Test" +concept_sets: [] +inclusion_rules: [] +""" + expr = cohort_expression_from_yaml(yaml_str) + + with TemporaryDirectory() as tmpdir: + yaml_path = Path(tmpdir) / "temp.yaml" + save_expression_as_yaml(expr, yaml_path) + content = yaml_path.read_text() + + # Should have snake_case keys + data = yaml.safe_load(content) + # Find a key that should be in snake_case + assert any(key for key in data if "_" in key or key in ["title"]) + + +class TestCLIIntegration: + """Test CLI commands with YAML files.""" + + def test_yaml_file_with_validate_command(self): + """Test validate command with YAML input.""" + yaml_content = """ +title: "CLI Test Cohort" +concept_sets: [] +""" + with TemporaryDirectory() as tmpdir: + yaml_path = Path(tmpdir) / "test.yaml" + yaml_path.write_text(yaml_content) + + # load_expression should handle it + expr = load_expression(yaml_path) + assert expr.title == "CLI Test Cohort" + + def test_yaml_file_with_sql_generation(self): + """Test SQL generation from YAML input.""" + yaml_content = """ +title: "SQL Generation Test" +concept_sets: [] +primary_criteria: + criteria_list: [] + observation_window: + prior_days: 0 + post_days: 0 + primary_criteria_limit: + type: "All" +""" + with TemporaryDirectory() as tmpdir: + yaml_path = Path(tmpdir) / "test.yaml" + yaml_path.write_text(yaml_content) + + expr = load_expression(yaml_path) + options = BuildExpressionQueryOptions() + sql = build_cohort_query(expr, options) + + assert isinstance(sql, str) + # Should contain some SQL + assert len(sql) > 0 + + +@pytest.fixture +def example_json_cohort(): + """Load the example JSON cohort from tests.""" + cohorts_dir = Path(__file__).parent / "cohorts" + json_file = cohorts_dir / "isolated_immune_thrombocytopenia.json" + if json_file.exists(): + return json.loads(json_file.read_text()) + # Return minimal valid cohort if file doesn't exist + return {"concept_sets": [], "primary_criteria": None} From 76466c50d96b3b3fdfe13ba9962163aac6a41f41 Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Tue, 5 May 2026 10:09:53 -0700 Subject: [PATCH 55/62] Added functions for persistent caching of concept sets in IBIS execution layer (#33) --- circe/execution/__init__.py | 2 + circe/execution/api.py | 4 + circe/execution/ibis/codesets.py | 111 ++++++++- circe/execution/ibis/context.py | 4 + .../test_codesets_persistent_cache.py | 222 ++++++++++++++++++ 5 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 tests/execution/test_codesets_persistent_cache.py diff --git a/circe/execution/__init__.py b/circe/execution/__init__.py index ba27df0a..180d7927 100644 --- a/circe/execution/__init__.py +++ b/circe/execution/__init__.py @@ -13,10 +13,12 @@ UnsupportedCriterionError, UnsupportedFeatureError, ) +from .ibis.codesets import clear_codeset_cache __all__ = [ "build_cohort", "write_cohort", + "clear_codeset_cache", "apply_databricks_post_connect_workaround", "ExecutionError", "ExecutionNormalizationError", diff --git a/circe/execution/api.py b/circe/execution/api.py index c49911a1..a9574b87 100644 --- a/circe/execution/api.py +++ b/circe/execution/api.py @@ -29,6 +29,7 @@ def build_cohort( cdm_schema: str, results_schema: str | None = None, vocabulary_schema: str | None = None, + use_persistent_cache: bool = False, ) -> Table: """Normalize, compile, and assemble a cohort relation.""" maybe_apply_databricks_post_connect_workaround(backend) @@ -41,6 +42,7 @@ def build_cohort( results_schema=results_schema, vocabulary_schema=vocabulary_schema, concept_sets=normalized.concept_sets, + use_persistent_cache=use_persistent_cache, ) return build_cohort_table(normalized, ctx) @@ -94,6 +96,7 @@ def write_cohort( results_schema: str | None = None, vocabulary_schema: str | None = None, if_exists: Literal["fail", "replace"] = "fail", + use_persistent_cache: bool = False, ) -> None: """Build cohort rows and materialize them with cohort-scoped semantics.""" if if_exists not in {"fail", "replace"}: @@ -105,6 +108,7 @@ def write_cohort( cdm_schema=cdm_schema, results_schema=results_schema, vocabulary_schema=vocabulary_schema, + use_persistent_cache=use_persistent_cache, ) new_rows = project_to_ohdsi_cohort_table(new_rows, cohort_id=cohort_id) diff --git a/circe/execution/ibis/codesets.py b/circe/execution/ibis/codesets.py index d286c7dd..f0df82e1 100644 --- a/circe/execution/ibis/codesets.py +++ b/circe/execution/ibis/codesets.py @@ -1,12 +1,44 @@ from __future__ import annotations +import hashlib +import json from collections.abc import Callable, Mapping from typing import Any from ..errors import CompilationError from ..normalize.cohort import NormalizedConceptSet, NormalizedConceptSetItem from ..plan.schema import CONCEPT_ID -from ..typing import Table +from ..typing import IbisBackendLike, Table + +_CACHE_TABLE_NAME = "_circe_codeset_cache" + + +def _compute_cache_key(items: tuple[NormalizedConceptSetItem, ...]) -> str: + """Deterministic SHA-256 hash of sorted concept set items.""" + canonical = sorted( + (item.concept_id, item.is_excluded, item.include_descendants, item.include_mapped) for item in items + ) + payload = json.dumps(canonical, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def clear_codeset_cache( + backend: IbisBackendLike, + results_schema: str | None, +) -> None: + """Drop the persistent codeset cache table if it exists.""" + from .operations import create_table, table_exists + + if not table_exists(backend, table_name=_CACHE_TABLE_NAME, schema=results_schema): + return + + import ibis + + empty = ibis.memtable( + {"cache_key": [], "concept_id": []}, + schema={"cache_key": "string", "concept_id": "int64"}, + ) + create_table(backend, table_name=_CACHE_TABLE_NAME, schema=results_schema, obj=empty, overwrite=True) class CachedConceptSetResolver: @@ -18,11 +50,20 @@ def __init__( table_getter: Callable[[str, str | None], Table], vocabulary_schema: str | None, concept_sets: Mapping[int, NormalizedConceptSet], + backend: IbisBackendLike | None = None, + results_schema: str | None = None, + use_persistent_cache: bool = False, ) -> None: self._table_getter = table_getter self._vocabulary_schema = vocabulary_schema self._concept_sets = concept_sets self._cache: dict[int, tuple[int, ...]] = {} + self._backend = backend + self._results_schema = results_schema + self._use_persistent_cache = ( + use_persistent_cache and backend is not None and results_schema is not None + ) + self._persistent_cache_initialized: bool = False def resolve_codeset(self, codeset_id: int) -> tuple[int, ...]: normalized_id = int(codeset_id) @@ -33,6 +74,15 @@ def resolve_codeset(self, codeset_id: int) -> tuple[int, ...]: if concept_set is None or not concept_set.items: return () + # L2: persistent cache lookup + cache_key: str | None = None + if self._use_persistent_cache: + cache_key = _compute_cache_key(concept_set.items) + persistent_hit = self._read_persistent_cache(cache_key) + if persistent_hit is not None: + self._cache[normalized_id] = persistent_hit + return persistent_hit + include_ids: set[int] = set() exclude_ids: set[int] = set() for item in concept_set.items: @@ -44,6 +94,11 @@ def resolve_codeset(self, codeset_id: int) -> tuple[int, ...]: resolved = tuple(sorted(include_ids - exclude_ids)) self._cache[normalized_id] = resolved + + # L2: persistent cache write + if self._use_persistent_cache and cache_key is not None and resolved: + self._write_persistent_cache(cache_key, resolved) + return resolved def _expand_item(self, item: NormalizedConceptSetItem) -> set[int]: @@ -118,3 +173,57 @@ def _execute_concept_id_query(self, query: Table) -> set[int]: continue output.add(int(value)) return output + + # ------------------------------------------------------------------ + # Persistent cache helpers + # ------------------------------------------------------------------ + + def _read_persistent_cache(self, cache_key: str) -> tuple[int, ...] | None: + from .operations import read_table, table_exists + + try: + if not table_exists(self._backend, table_name=_CACHE_TABLE_NAME, schema=self._results_schema): + return None + tbl = read_table(self._backend, table_name=_CACHE_TABLE_NAME, schema=self._results_schema) + rows = tbl.filter(tbl.cache_key == cache_key).select("concept_id").execute() + if hasattr(rows, "columns"): + values = rows["concept_id"].tolist() + elif isinstance(rows, (list, tuple)): + values = list(rows) + else: + return None + if not values: + return None + return tuple(sorted(int(v) for v in values if v is not None)) + except Exception: + return None + + def _write_persistent_cache(self, cache_key: str, concept_ids: tuple[int, ...]) -> None: + import ibis + + from .operations import create_table, insert_relation, table_exists + + try: + data = ibis.memtable( + {"cache_key": [cache_key] * len(concept_ids), "concept_id": list(concept_ids)}, + schema={"cache_key": "string", "concept_id": "int64"}, + ) + if not self._persistent_cache_initialized: + if not table_exists(self._backend, table_name=_CACHE_TABLE_NAME, schema=self._results_schema): + create_table( + self._backend, + table_name=_CACHE_TABLE_NAME, + schema=self._results_schema, + obj=data, + ) + self._persistent_cache_initialized = True + return + self._persistent_cache_initialized = True + insert_relation( + data, + backend=self._backend, + target_table=_CACHE_TABLE_NAME, + target_schema=self._results_schema, + ) + except Exception: + pass diff --git a/circe/execution/ibis/context.py b/circe/execution/ibis/context.py index 57dab8e4..b7b05ce2 100644 --- a/circe/execution/ibis/context.py +++ b/circe/execution/ibis/context.py @@ -52,6 +52,7 @@ def make_execution_context( concept_sets: Mapping[int, NormalizedConceptSet], results_schema: str | None = None, vocabulary_schema: str | None = None, + use_persistent_cache: bool = False, ) -> ExecutionContext: """Construct an executor context from API-level wiring arguments.""" vocabulary_schema = vocabulary_schema or cdm_schema @@ -63,6 +64,9 @@ def _table_getter(table_name: str, schema: str | None) -> Table: table_getter=_table_getter, vocabulary_schema=vocabulary_schema, concept_sets=concept_sets, + backend=backend if use_persistent_cache else None, + results_schema=results_schema if use_persistent_cache else None, + use_persistent_cache=use_persistent_cache, ) return ExecutionContext( backend=backend, diff --git a/tests/execution/test_codesets_persistent_cache.py b/tests/execution/test_codesets_persistent_cache.py new file mode 100644 index 00000000..087acf8a --- /dev/null +++ b/tests/execution/test_codesets_persistent_cache.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import pytest + +from circe.execution.ibis.codesets import ( + _CACHE_TABLE_NAME, + CachedConceptSetResolver, + _compute_cache_key, + clear_codeset_cache, +) +from circe.execution.ibis.context import make_execution_context +from circe.execution.normalize.cohort import NormalizedConceptSet, NormalizedConceptSetItem + +# ------------------------------------------------------------------ +# _compute_cache_key tests +# ------------------------------------------------------------------ + + +def _make_items(*specs: tuple[int, bool, bool, bool]) -> tuple[NormalizedConceptSetItem, ...]: + return tuple( + NormalizedConceptSetItem( + concept_id=s[0], is_excluded=s[1], include_descendants=s[2], include_mapped=s[3] + ) + for s in specs + ) + + +def test_compute_cache_key_deterministic(): + items = _make_items((1, False, True, False), (2, True, False, True)) + assert _compute_cache_key(items) == _compute_cache_key(items) + + +def test_compute_cache_key_order_independent(): + items_a = _make_items((1, False, True, False), (2, True, False, True)) + items_b = _make_items((2, True, False, True), (1, False, True, False)) + assert _compute_cache_key(items_a) == _compute_cache_key(items_b) + + +def test_compute_cache_key_different_items_different_hash(): + items_a = _make_items((1, False, True, False)) + items_b = _make_items((1, False, False, False)) + assert _compute_cache_key(items_a) != _compute_cache_key(items_b) + + +# ------------------------------------------------------------------ +# Persistent cache integration tests using DuckDB +# ------------------------------------------------------------------ + + +@pytest.fixture +def duckdb_backend(): + ibis = pytest.importorskip("ibis") + backend = ibis.duckdb.connect() + backend.raw_sql("CREATE SCHEMA results") + return backend + + +def _concept_set_fixture(): + return { + 1: NormalizedConceptSet( + set_id=1, + items=( + NormalizedConceptSetItem( + concept_id=100, + is_excluded=False, + include_descendants=False, + include_mapped=False, + ), + ), + ) + } + + +def test_persistent_cache_write_and_read(duckdb_backend, monkeypatch): + """First resolve writes to persistent cache; second resolver instance reads from it.""" + concept_sets = _concept_set_fixture() + + resolver1 = CachedConceptSetResolver( + table_getter=lambda name, schema: duckdb_backend.table(name, database=schema), + vocabulary_schema=None, + concept_sets=concept_sets, + backend=duckdb_backend, + results_schema="results", + use_persistent_cache=True, + ) + + # Bypass vocabulary expansion — just return the concept_id directly + monkeypatch.setattr(resolver1, "_expand_item", lambda item: {item.concept_id}) + + result = resolver1.resolve_codeset(1) + assert result == (100,) + + # Verify the cache table was created with data + cache_tbl = duckdb_backend.table(_CACHE_TABLE_NAME, database="results") + rows = cache_tbl.execute() + assert len(rows) == 1 + + # Second resolver — _expand_item should NOT be called (persistent cache hit) + expand_calls = [] + + resolver2 = CachedConceptSetResolver( + table_getter=lambda name, schema: duckdb_backend.table(name, database=schema), + vocabulary_schema=None, + concept_sets=concept_sets, + backend=duckdb_backend, + results_schema="results", + use_persistent_cache=True, + ) + + def _expand_should_not_be_called(item): + expand_calls.append(item.concept_id) + return {item.concept_id} + + monkeypatch.setattr(resolver2, "_expand_item", _expand_should_not_be_called) + + result2 = resolver2.resolve_codeset(1) + assert result2 == (100,) + assert expand_calls == [], "Expected persistent cache hit — _expand_item should not be called" + + +def test_persistent_cache_disabled_by_default(monkeypatch): + """Without use_persistent_cache=True, no persistent ops happen.""" + concept_sets = _concept_set_fixture() + + resolver = CachedConceptSetResolver( + table_getter=lambda name, schema: None, + vocabulary_schema=None, + concept_sets=concept_sets, + ) + + monkeypatch.setattr(resolver, "_expand_item", lambda item: {item.concept_id}) + + result = resolver.resolve_codeset(1) + assert result == (100,) + assert not resolver._use_persistent_cache + + +def test_persistent_cache_read_failure_falls_back_silently(duckdb_backend, monkeypatch): + """If cache read raises, expansion still works.""" + concept_sets = _concept_set_fixture() + + resolver = CachedConceptSetResolver( + table_getter=lambda name, schema: duckdb_backend.table(name, database=schema), + vocabulary_schema=None, + concept_sets=concept_sets, + backend=duckdb_backend, + results_schema="results", + use_persistent_cache=True, + ) + + monkeypatch.setattr(resolver, "_expand_item", lambda item: {item.concept_id}) + + # Force _read_persistent_cache to encounter an error internally by making + # table_exists raise. The method catches all exceptions and returns None. + from circe.execution.ibis import operations as ops + + def _broken_table_exists(*args, **kwargs): + raise RuntimeError("simulated db failure") + + monkeypatch.setattr(ops, "table_exists", _broken_table_exists) + + result = resolver.resolve_codeset(1) + assert result == (100,) + + +def test_clear_codeset_cache(duckdb_backend, monkeypatch): + """clear_codeset_cache empties the cache table.""" + concept_sets = _concept_set_fixture() + + resolver = CachedConceptSetResolver( + table_getter=lambda name, schema: duckdb_backend.table(name, database=schema), + vocabulary_schema=None, + concept_sets=concept_sets, + backend=duckdb_backend, + results_schema="results", + use_persistent_cache=True, + ) + monkeypatch.setattr(resolver, "_expand_item", lambda item: {item.concept_id}) + resolver.resolve_codeset(1) + + # Verify rows exist + cache_tbl = duckdb_backend.table(_CACHE_TABLE_NAME, database="results") + assert len(cache_tbl.execute()) > 0 + + # Clear and verify empty + clear_codeset_cache(duckdb_backend, "results") + cache_tbl = duckdb_backend.table(_CACHE_TABLE_NAME, database="results") + assert len(cache_tbl.execute()) == 0 + + +def test_make_execution_context_threads_persistent_cache(): + """make_execution_context passes persistent cache params to resolver.""" + ibis = pytest.importorskip("ibis") + backend = ibis.duckdb.connect() + + ctx = make_execution_context( + backend=backend, + cdm_schema="main", + concept_sets={}, + results_schema="main", + use_persistent_cache=True, + ) + + assert ctx.codeset_resolver._use_persistent_cache is True + assert ctx.codeset_resolver._backend is backend + assert ctx.codeset_resolver._results_schema == "main" + + +def test_make_execution_context_persistent_cache_disabled_without_results_schema(): + """Persistent cache gracefully disabled when results_schema is None.""" + ibis = pytest.importorskip("ibis") + backend = ibis.duckdb.connect() + + ctx = make_execution_context( + backend=backend, + cdm_schema="main", + concept_sets={}, + results_schema=None, + use_persistent_cache=True, + ) + + assert ctx.codeset_resolver._use_persistent_cache is False From b4d9419e16f46c52a67b6c71f1bd714cfbf90876 Mon Sep 17 00:00:00 2001 From: egillax Date: Wed, 6 May 2026 15:42:30 +0200 Subject: [PATCH 56/62] fix(execution): make ERA collapse ordering deterministic --- circe/execution/engine/collapse.py | 13 +++++-- .../execution/test_end_strategy_censoring.py | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/circe/execution/engine/collapse.py b/circe/execution/engine/collapse.py index c98619d2..b6d0cc39 100644 --- a/circe/execution/engine/collapse.py +++ b/circe/execution/engine/collapse.py @@ -27,7 +27,11 @@ def _apply_censor_window(events, censor_window): def _collapse_era(intervals, era_pad: int): padded = intervals.mutate(_padded_end_date=(intervals.end_date + ibis.interval(days=int(era_pad)))) - ordering = [padded.start_date] + ordering = [ + padded.start_date, + padded._padded_end_date.desc(), + padded.end_date.desc(), + ] ordered_window = ibis.window(group_by=padded.person_id, order_by=ordering) cumulative_window = ibis.cumulative_window(group_by=padded.person_id, order_by=ordering) with_cummax = padded.mutate(_cummax_padded_end=padded._padded_end_date.max().over(cumulative_window)) @@ -44,7 +48,12 @@ def _collapse_era(intervals, era_pad: int): grouping_window = ibis.cumulative_window( group_by=marked.person_id, - order_by=[marked.start_date, marked._is_new_group.desc()], + order_by=[ + marked.start_date, + marked._padded_end_date.desc(), + marked.end_date.desc(), + marked._is_new_group.desc(), + ], ) group_index = marked._is_new_group.sum().over(grouping_window) grouped = marked.mutate(_group_idx=group_index) diff --git a/tests/execution/test_end_strategy_censoring.py b/tests/execution/test_end_strategy_censoring.py index 67f7912e..12b3b0c2 100644 --- a/tests/execution/test_end_strategy_censoring.py +++ b/tests/execution/test_end_strategy_censoring.py @@ -283,6 +283,40 @@ def test_collapse_settings_era_merges_tied_start_dates_into_one_group(): assert str(result.iloc[0]["end_date"])[:10] == "2020-01-05" +def test_collapse_settings_era_merges_contained_intervals_after_tied_start_dates(): + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1, 1, 1], + "condition_occurrence_id": [100, 101, 102], + "condition_concept_id": [111, 111, 111], + "condition_start_date": ["2020-01-01", "2020-01-01", "2020-01-10"], + "condition_end_date": ["2020-01-02", "2020-02-01", "2020-01-15"], + "visit_occurrence_id": [10, 10, 10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 111)], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=DateOffsetStrategy(offset=0, date_field="end_date"), + collapse_settings=CollapseSettings(era_pad=0), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-01-01" + assert str(result.iloc[0]["end_date"])[:10] == "2020-02-01" + + def test_apply_end_strategy_rejects_invalid_date_field_and_preserves_fallback_semantics(): ibis_mod = pytest.importorskip("ibis") _ = pytest.importorskip("duckdb") From 72799aa2b5f36f004f739915ed153840ab0b9fed Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Thu, 28 May 2026 10:32:43 -0700 Subject: [PATCH 57/62] Added sqlrender functionality --- circe/sqlrender/__init__.py | 11 + circe/sqlrender/patterns.py | 102 ++ circe/sqlrender/renderer.py | 220 +++ circe/sqlrender/replacementPatterns.csv | 1447 ++++++++++++++++++ circe/sqlrender/splitter.py | 44 + circe/sqlrender/tokenizer.py | 94 ++ circe/sqlrender/translator.py | 371 +++++ tests/test_sqlrender_csv_format.py | 51 + tests/test_sqlrender_render.py | 166 ++ tests/test_sqlrender_split.py | 69 + tests/test_sqlrender_translate.py | 37 + tests/test_sqlrender_translate_duckdb.py | 228 +++ tests/test_sqlrender_translate_postgresql.py | 274 ++++ 13 files changed, 3114 insertions(+) create mode 100644 circe/sqlrender/__init__.py create mode 100644 circe/sqlrender/patterns.py create mode 100644 circe/sqlrender/renderer.py create mode 100644 circe/sqlrender/replacementPatterns.csv create mode 100644 circe/sqlrender/splitter.py create mode 100644 circe/sqlrender/tokenizer.py create mode 100644 circe/sqlrender/translator.py create mode 100644 tests/test_sqlrender_csv_format.py create mode 100644 tests/test_sqlrender_render.py create mode 100644 tests/test_sqlrender_split.py create mode 100644 tests/test_sqlrender_translate.py create mode 100644 tests/test_sqlrender_translate_duckdb.py create mode 100644 tests/test_sqlrender_translate_postgresql.py diff --git a/circe/sqlrender/__init__.py b/circe/sqlrender/__init__.py new file mode 100644 index 00000000..7e5fdfc7 --- /dev/null +++ b/circe/sqlrender/__init__.py @@ -0,0 +1,11 @@ +from .renderer import render +from .splitter import split_sql +from .translator import generate_session_id, set_replacement_patterns_path, translate + +__all__ = [ + "render", + "split_sql", + "translate", + "generate_session_id", + "set_replacement_patterns_path", +] diff --git a/circe/sqlrender/patterns.py b/circe/sqlrender/patterns.py new file mode 100644 index 00000000..e473cc66 --- /dev/null +++ b/circe/sqlrender/patterns.py @@ -0,0 +1,102 @@ +import random +import string + +_target_to_patterns: dict[str, list[tuple[str, str]]] | None = None +_global_session_id: str | None = None +_PATTERNS_PATH: str | None = None + +SESSION_ID_LENGTH = 8 +MAX_TABLE_NAME_LENGTH = 63 + + +def generate_session_id() -> str: + chars = string.ascii_lowercase + "0123456789" + first = random.choice(string.ascii_lowercase) + rest = "".join(random.choice(chars) for _ in range(SESSION_ID_LENGTH - 1)) + return first + rest + + +def get_global_session_id() -> str: + global _global_session_id + if _global_session_id is None: + _global_session_id = generate_session_id() + return _global_session_id + + +def set_replacement_patterns_path(path: str | None) -> None: + global _target_to_patterns, _PATTERNS_PATH + _target_to_patterns = None + _PATTERNS_PATH = path + + +def _safe_split(line: str, delimiter: str = ",") -> list[str]: + result: list[str] = [] + literal = False + escape = False + startpos = 0 + i = 0 + while i < len(line): + ch = line[i] + if ch == '"' and not escape: + literal = not literal + if not literal and ch == delimiter and not escape: + result.append(line[startpos:i]) + startpos = i + 1 + escape = not escape if ch == "\\" else False + i += 1 + result.append(line[startpos:i]) + return result + + +def _clean_column(col: str) -> str: + if col.startswith('"') and col.endswith('"') and len(col) > 1: + col = col[1:-1] + col = col.replace('\\"', '"') + col = col.replace("\\n", "\n") + return col + + +def load_patterns() -> dict[str, list[tuple[str, str]]]: + global _target_to_patterns + if _target_to_patterns is not None: + return _target_to_patterns + + _target_to_patterns = {} + + if _PATTERNS_PATH is not None: + import pathlib + + path = pathlib.Path(_PATTERNS_PATH) + f = path.open("r", encoding="utf-8") + else: + from importlib.resources import files + + f = files("circe.sqlrender").joinpath("replacementPatterns.csv").open("r", encoding="utf-8") + + try: + first = True + for line in f: + line = line.rstrip("\n").rstrip("\r") + if first: + first = False + continue + if not line: + continue + columns = _safe_split(line, ",") + if len(columns) < 3: + continue + target = _clean_column(columns[0]).strip() + pattern = _clean_column(columns[1]) + replacement = _clean_column(columns[2]) + pattern = pattern.replace("@", "@@") + replacement = replacement.replace("@", "@@") + _target_to_patterns.setdefault(target, []).append((pattern, replacement)) + finally: + f.close() + + return _target_to_patterns + + +def get_supported_dialects() -> list[str]: + patterns = load_patterns() + return sorted(patterns.keys()) diff --git a/circe/sqlrender/renderer.py b/circe/sqlrender/renderer.py new file mode 100644 index 00000000..7518c915 --- /dev/null +++ b/circe/sqlrender/renderer.py @@ -0,0 +1,220 @@ +import re +from typing import Any + + +class SqlRenderError(RuntimeError): + pass + + +def _evaluate_condition(condition: str, params: dict[str, Any]) -> bool: + condition = condition.strip() + + if condition.lower() == "true": + return True + if condition.lower() == "false": + return False + + if condition.startswith("!"): + return not _evaluate_condition(condition[1:].strip(), params) + + m = re.match(r"\((.+)\)", condition) + if m: + return _evaluate_condition(m.group(1).strip(), params) + + m = re.match(r"(.+?)\s+(!=|<>)+\s+(.+)", condition) + if m: + left = m.group(1).strip() + right = m.group(3).strip() + lval = _resolve_value(left, params) + rval = _resolve_value(right, params) + return str(lval) != str(rval) + + m = re.match(r"(.+?)\s*==\s*(.+)", condition) + if m: + left = m.group(1).strip() + right = m.group(2).strip() + lval = _resolve_value(left, params) + rval = _resolve_value(right, params) + return str(lval) == str(rval) + + m = re.match(r"([\d.]+|\w+)\s+IN\s+\((.+)\)", condition, re.IGNORECASE | re.DOTALL) + if m: + val = _resolve_value(m.group(1).strip(), params) + in_list_raw = m.group(2).strip() + in_list = [] + for item in re.split(r",\s*", in_list_raw): + item = item.strip() + is_param_ref = item.startswith("@") and item[1:] in params + resolved = _resolve_value(item, params) if is_param_ref else item + if isinstance(resolved, list): + in_list.extend(str(x) for x in resolved) + else: + in_list.append(str(resolved)) + return str(val) in in_list + + m = re.match(r"(.+?)\s*&\s*(.+)", condition) + if m: + return _evaluate_condition(m.group(1).strip(), params) and _evaluate_condition( + m.group(2).strip(), params + ) + + m = re.match(r"(.+?)\s*\|\s*(.+)", condition) + if m: + return _evaluate_condition(m.group(1).strip(), params) or _evaluate_condition( + m.group(2).strip(), params + ) + + if condition.startswith("@"): + param_name = condition[1:] + val = params.get(param_name) + return val is not None and ( + (isinstance(val, bool) and val) or (isinstance(val, str) and val.lower() == "true") + ) + + raise SqlRenderError(f"Invalid boolean logic: {condition}") + + +def _resolve_value(expr: str, params: dict[str, Any]) -> Any: + expr = expr.strip() + if expr.startswith("@") and len(expr) > 1: + param_name = expr[1:] + return params.get(param_name, expr) + if expr.startswith("'") and expr.endswith("'"): + return expr[1:-1] + return expr + + +def render(sql: str, **params: Any) -> str: + has_unused_params = any(re.search(r"@" + re.escape(k) + r"\b", sql) is None for k in params) + if has_unused_params: + import warnings as _warnings + + _warnings.warn("Parameter name mismatch in render call", stacklevel=2) + + sql = _apply_defaults(sql, params) + sql = _process_conditionals(sql, params) + sql = _substitute_params(sql, params) + return sql + + +DEFAULT_PATTERN = re.compile(r"\{DEFAULT\s+@(\w+)\s*=\s*([^}]+)\}") + + +def _apply_defaults(sql: str, params: dict[str, Any]) -> dict[str, Any]: + def extract_default(m: re.Match) -> str: + name = m.group(1) + value = m.group(2).strip() + if name not in params: + if value.startswith("'") and value.endswith("'"): + params[name] = value[1:-1] + else: + params[name] = value + return "" + + sql = DEFAULT_PATTERN.sub(extract_default, sql) + return sql + + +def _find_matching_brace(s: str, start: int) -> int: + depth = 0 + in_single = False + in_double = False + i = start + while i < len(s): + ch = s[i] + if ch == "'" and not in_double: + in_single = not in_single + elif ch == '"' and not in_single: + in_double = not in_double + if in_single or in_double: + i += 1 + continue + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + +def _extract_braced_block(s: str, start: int) -> tuple[str, int]: + end = _find_matching_brace(s, start) + if end == -1: + return "", start + return s[start + 1 : end], end + + +def _process_conditionals(sql: str, params: dict[str, Any]) -> str: + result = sql + + for _pass in range(100): + i = 0 + modified = False + while i < len(result): + ch = result[i] + if ch == "{": + close = _find_matching_brace(result, i) + if close == -1: + i += 1 + continue + + inner = result[i + 1 : close] + rest_after_close = close + 1 + + if inner.startswith("DEFAULT "): + pass + + elif rest_after_close < len(result) and result[rest_after_close] == "?": + condition_text = inner + after_q = rest_after_close + 1 + + if after_q < len(result) and result[after_q] == "{": + then_block, then_end = _extract_braced_block(result, after_q) + then_text = then_block + after_then = then_end + 1 + + else_text = "" + if after_then < len(result) and result[after_then] == ":": + after_colon = after_then + 1 + if after_colon < len(result) and result[after_colon] == "{": + else_block, else_end = _extract_braced_block(result, after_colon) + else_text = else_block + after_else = else_end + 1 + else: + after_else = after_colon + else: + after_else = after_then + + cond_result = _evaluate_condition(condition_text, params) + + replacement = then_text if cond_result else else_text + result = result[:i] + replacement + result[after_else:] + modified = True + break + + else: + pass + + i += 1 + + if not modified: + break + + return result + + +def _substitute_params(sql: str, params: dict[str, Any]) -> str: + def repl(m: re.Match) -> str: + name = m.group(1) + if name in params: + val = params[name] + if isinstance(val, list): + return ", ".join(str(v) for v in val) + if isinstance(val, bool): + return str(val).lower() + return str(val) + return m.group(0) + + return re.sub(r"@(\w+)", repl, sql) diff --git a/circe/sqlrender/replacementPatterns.csv b/circe/sqlrender/replacementPatterns.csv new file mode 100644 index 00000000..fc733bc9 --- /dev/null +++ b/circe/sqlrender/replacementPatterns.csv @@ -0,0 +1,1447 @@ +To,Pattern,Replacement +oracle,...@([0-9]+|y)a,xxx@a +oracle,"AS drvd(@a)","AS drvd(@a)" +oracle,"@a, @b)","@a, @b)" +oracle,"","NULL AS " +oracle,"FROM (VALUES @a) AS drvd","FROM (@a) AS drvd" +oracle,"@a, @b)","@a UNION ALL @b)" +oracle,"(@a)","SELECT @a" +oracle,"FROM (SELECT @a) AS drvd(@b)","FROM (SELECT @b WHERE (0 = 1) UNION ALL SELECT @a) AS values_table" +oracle,TRY_CAST(@a),CAST(@a) +oracle,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +oracle,"CAST('@a' AS DATE)","TO_DATE('@a', 'YYYYMMDD')" +oracle,"CAST('@a' + @b AS DATE)","TO_DATE('@a' + @b, 'YYYYMMDD')" +oracle,"CAST(@a + '@b' AS DATE)","TO_DATE(@a + '@b', 'YYYYMMDD')" +oracle,"CAST(CONCAT(@a) AS DATE)","TO_DATE(CONCAT(@a), 'YYYYMMDD')" +oracle,"INSERT INTO @table (@columns) VALUES (@values1),(@values2)", INSERT INTO @table (@columns) VALUES (INTO @table @columns VALUES @values1\n INTO @table @columns VALUES @values2\n) +oracle,INTO @table @columns VALUES INTO @table @columns VALUES,INTO @table @columns VALUES +oracle, @a INSERT INTO @table (@columns) VALUES (@b),INSERT ALL\n@bSELECT * FROM dual +oracle,,( +oracle,,) +oracle,EXCEPT,MINUS +oracle,"CONCAT(@a, @b, @c)","CONCAT(@a, CONCAT(@b, @c))" +oracle,"DATEADD(second,@seconds,@datetime)","(@date + NUMTODSINTERVAL(@seconds, 'second'))" +oracle,"DATEADD(minute,@minutes,@datetime)","(@date + NUMTODSINTERVAL(@minutes, 'minute'))" +oracle,"DATEADD(hour,@hours,@datetime)","(@date + NUMTODSINTERVAL(@hours, 'hour'))" +oracle,"DATEADD(d,@days,@date)","(@date + NUMTODSINTERVAL(@days, 'day'))" +oracle,"DATEADD(dd,@days,@date)","(@date + NUMTODSINTERVAL(@days, 'day'))" +oracle,"DATEADD(day,@days,@date)","(@date + NUMTODSINTERVAL(@days, 'day'))" +oracle,"DATEADD(month,@months,@date)","ADD_MONTHS(@date, @months)" +oracle,"DATEADD(mm,@months,@date)","ADD_MONTHS(@date, @months)" +oracle,"DATEADD(m,@months,@date)","ADD_MONTHS(@date, @months)" +oracle,"DATEADD(year,@years,@date)","ADD_MONTHS(@date, 12 * @years)" +oracle,"DATEADD(yyyy,@years,@date)","ADD_MONTHS(@date, 12 * @years)" +oracle,"DATEADD(yy,@years,@date)","ADD_MONTHS(@date, 12 * @years)" +oracle,"DATEDIFF(second,@start, @end)","EXTRACT(SECOND FROM (@end - @start))" +oracle,"DATEDIFF(minute,@start, @end)","EXTRACT(MINUTE FROM (@end - @start))" +oracle,"DATEDIFF(hour,@start, @end)","EXTRACT(HOUR FROM (@end - @start))" +oracle,"DATEDIFF(day,@start, @end)",CEIL(CAST(@end AS DATE) - CAST(@start AS DATE)) +oracle,"DATEDIFF(dd,@start, @end)",CEIL(CAST(@end AS DATE) - CAST(@start AS DATE)) +oracle,"DATEDIFF(d,@start, @end)",CEIL(CAST(@end AS DATE) - CAST(@start AS DATE)) +oracle,"DATEDIFF(year,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) +oracle,"DATEDIFF(yyyy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) +oracle,"DATEDIFF(yy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) +oracle,"DATEDIFF(month,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" +oracle,"DATEDIFF(mm,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" +oracle,"DATEDIFF(m,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" +oracle,"CONVERT(VARCHAR,@date,112)","TO_CHAR(@date, 'YYYYMMDD')" +oracle,GETDATE(),SYSDATE +oracle,+ '@a',|| '@a' +oracle,'@a' +,'@a' || +oracle,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || +oracle,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) +oracle,CAST(@a AS varchar) +,CAST(@a AS varchar) || +oracle,+ CAST(@a AS varchar),|| CAST(@a AS varchar) +oracle,"CONVERT(DATE, @a)","TO_DATE(@a, 'YYYYMMDD')" +oracle,CAST(@a AS VARCHAR),TO_CHAR(@a) +oracle,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00'), 'YYYY-MM-DD')" +oracle,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00')||' '||TO_CHAR(@hour,'00')||':'||TO_CHAR(@minute,'00')||':'||TO_CHAR(@second,'00'), 'YYYY-MM-DD HH24:MI:SS')" +oracle,EOMONTH(@date),"TO_DATE(to_char(last_day(@date),'YYYY-MM-DD')||' 23:59:59','YYYY-MM-DD HH24:MI:SS')" +oracle,STDEV(@a),STDDEV(@a) +oracle,VAR(@a),VARIANCE(@a) +oracle,RAND(),DBMS_RANDOM.VALUE +oracle,CEILING(@a),CEIL(@a) +oracle,"HASHBYTES('MD5',@a)","DBMS_CRYPTO.HASH(@a,2)" +oracle,LEN(@a),LENGTH(@a) +oracle,"LEFT(@str,@chars)","SUBSTR(@str,0,@chars)" +oracle,"RIGHT(@str,@chars)","SUBSTR(@str,-@chars)" +oracle,"LOG(@expression,@base)","(@base,@expression)" +oracle,LOG(@expression),"LOG(2.718281828459,@expression)" +oracle,,LOG +oracle,LOG10(@expression),"LOG(10,@expression)" +oracle,"ISNULL(@a,@b)","NVL(@a,@b)" +oracle,ISNUMERIC(@a),"CASE WHEN (LENGTH(TRIM(TRANSLATE(@a, ' +-.0123456789',' '))) IS NULL) THEN 1 ELSE 0 END" +oracle,COUNT_BIG(@a),COUNT(@a) +oracle,SQUARE(@a),((@a)*(@a)) +oracle,PI(),3.141592654 +oracle,NEWID(),SYS_GUID() +oracle,"CHARINDEX(@a,@b)","INSTR(@b,@a)" +oracle,SELECT @a WHERE @b;,SELECT @a FROM DUAL WHERE @b; +oracle,(SELECT @a WHERE @b),(SELECT @a FROM DUAL WHERE @b) +oracle,SELECT @a WHERE @b UNION,SELECT @a FROM DUAL WHERE @b UNION +oracle,SELECT @a;,SELECT @a FROM DUAL; +oracle,(SELECT @a),(SELECT @a FROM DUAL) +oracle,SELECT @a UNION,SELECT @a FROM DUAL UNION +oracle,FROM DUAL FROM DUAL, FROM DUAL +oracle,FROM @a UNION @b FROM DUAL UNION,FROM @a UNION @b FROM DUAL FROM DUAL UNION +oracle,FROM @a FROM DUAL UNION,FROM @a UNION +oracle,FROM @a UNION @b FROM DUAL;,FROM @a UNION @b FROM DUAL FROM DUAL; +oracle,FROM @a FROM DUAL;,FROM @a; +oracle,FROM @a UNION @b FROM DUAL WHERE,FROM @a UNION @b FROM DUAL FROM DUAL WHERE +oracle,FROM @a FROM DUAL WHERE,FROM @a WHERE +oracle,FROM @a UNION @b FROM DUAL),FROM @a UNION @b FROM DUAL FROM DUAL) +oracle,FROM @b FROM DUAL),FROM @b) +oracle,, +oracle,SELECT @a CASE @b COUNT(@c) @d END @e;,SELECT @a CASE @b COUNT(@c) @d END @e GROUP BY 1; +oracle,(SELECT @a CASE @b COUNT(@c) @d END @e),(SELECT @a CASE @b COUNT(@c) @d END @e GROUP BY 1) +oracle,GROUP BY @a GROUP BY 1,GROUP BY @a +oracle,GROUP BY @a GROUP BY 1,GROUP BY @a +oracle,YEAR(@date),EXTRACT(YEAR FROM @date) +oracle,MONTH(@date),EXTRACT(MONTH FROM @date) +oracle,DAY(@date),EXTRACT(DAY FROM @date) +oracle,"DATEPART(YEAR, @date)",EXTRACT(YEAR FROM @date) +oracle,"DATEPART(MONTH, @date)",EXTRACT(MONTH FROM @date) +oracle,"DATEPART(DAY, @date)",EXTRACT(DAY FROM @date) +oracle,USE @schema;,ALTER SESSION SET current_schema = @schema; +oracle,.dbo.,. +oracle,CREATE CLUSTERED INDEX,CREATE INDEX +oracle,CREATE UNIQUE INDEX @name ON @table (@variable);,BEGIN\n EXECUTE IMMEDIATE 'CREATE UNIQUE INDEX @name ON @table (@variable)';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -1408 THEN\n RAISE;\n END IF;\nEND; +oracle,CREATE UNIQUE CLUSTERED INDEX @name ON @table (@variable);,BEGIN\n EXECUTE IMMEDIATE 'CREATE UNIQUE INDEX @name ON @table (@variable)';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -1408 THEN\n RAISE;\n END IF;\nEND; +oracle,PRIMARY KEY NONCLUSTERED,PRIMARY KEY +oracle,DATETIME,TIMESTAMP +oracle,DATETIME2,TIMESTAMP +oracle,BIGINT,NUMBER(19) +oracle,VARCHAR(MAX),VARCHAR2(1024) +oracle,"NOT NULL DEFAULT @a,","DEFAULT @a NOT NULL," +oracle,"(@x NOT NULL DEFAULT @a)","(@x DEFAULT @a NOT NULL)" +oracle,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +oracle,WITH @a AS @b INSERT INTO @c SELECT @d;,INSERT INTO @c WITH @a AS @b SELECT @d; +oracle,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; +oracle,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; +oracle,##, +oracle,CREATE TABLE #@([^\s]+)table,DROP TABLE IF EXISTS %temp_prefix%%session_id%@table;\nCREATE TABLE %temp_prefix%%session_id%@table +oracle,#@([^\s]+)table.@([^\s]+)field,%session_id%@table.@field +oracle,"DROP TABLE IF EXISTS #@table;",BEGIN\n EXECUTE IMMEDIATE 'TRUNCATE TABLE %temp_prefix%%session_id%@table';\n EXECUTE IMMEDIATE 'DROP TABLE %temp_prefix%%session_id%@table';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN\n RAISE;\n END IF;\nEND; +oracle,#,%temp_prefix%%session_id% +oracle,"CREATE INDEX @a ON @b (@c,@d) WHERE @e;","CREATE INDEX @a ON @b (CASE WHEN @e THEN @c END, CASE WHEN @e THEN @d END);" +oracle,,## +oracle,SELECT TOP @([0-9]+)rows @a;,SELECT @a FETCH FIRST @rows ROWS ONLY; +oracle,(SELECT TOP @([0-9]+)rows @a),(SELECT @a FETCH FIRST @rows ROWS ONLY) +oracle,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a FETCH FIRST @rows ROWS ONLY; +oracle,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a FETCH FIRST @rows ROWS ONLY) +oracle,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",BEGIN\n EXECUTE IMMEDIATE 'CREATE TABLE @table (@definition)';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -955 THEN\n RAISE;\n END IF;\nEND; +oracle,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE #@table;",BEGIN\n EXECUTE IMMEDIATE 'TRUNCATE TABLE %temp_prefix%%session_id%@table';\n EXECUTE IMMEDIATE 'DROP TABLE %temp_prefix%%session_id%@table';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN\n RAISE;\n END IF;\nEND; +oracle,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",BEGIN\n EXECUTE IMMEDIATE 'TRUNCATE TABLE @table';\n EXECUTE IMMEDIATE 'DROP TABLE @table';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN\n RAISE;\n END IF;\nEND; +oracle,"DROP TABLE IF EXISTS @table;",BEGIN\n EXECUTE IMMEDIATE 'TRUNCATE TABLE @table';\n EXECUTE IMMEDIATE 'DROP TABLE @table';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN\n RAISE;\n END IF;\nEND; +oracle,"FROM @a AS @b;","FROM @a NESTED @b;" +oracle,"NESTED @a AS @b;","@a @b;" +oracle,"NESTED","" +oracle,"FROM @a AS @b WHERE","FROM @a @b WHERE" +oracle,"FROM @a AS @b)","FROM @a @b)" +oracle,"JOIN @a AS @b ON","JOIN @a @b ON" +oracle,UPDATE STATISTICS @a;,-- ANALYZE should not be used to collect optimizer statistics +oracle,"CONVERT(VARBINARY, @a, 1)","TO_NUMBER(@a, RPAD('X', LENGTH(@a), 'X'))" +oracle,"SELECT *, @a FROM (@c) @b WHERE","SELECT @b.*, @a FROM (@c) @b WHERE" +oracle,"SELECT *, @a FROM (@c) @b ORDER BY","SELECT @b.*, @a FROM (@c) @b ORDER BY" +oracle,"SELECT *, @a FROM (@c) @b FETCH FIRST","SELECT @b.*, @a FROM (@c) @b FETCH FIRST" +oracle,"(SELECT *, @a FROM (@c) @b)","(SELECT @b.*, @a FROM (@c) @b)" +oracle,"SELECT *, @a FROM (@c) @b;","SELECT @b.*, @a FROM (@c) @b;" +oracle,"SELECT *, @a FROM @b WHERE","SELECT @b.*, @a FROM @b WHERE" +oracle,"SELECT *, @a FROM @b ORDER BY","SELECT @b.*, @a FROM @b ORDER BY" +oracle,"SELECT *, @a FROM @b FETCH FIRST","SELECT @b.*, @a FROM @b FETCH FIRST" +oracle,"(SELECT *, @a FROM @b)","(SELECT @b.*, @a FROM @b)" +oracle,"SELECT *, @a FROM @b;","SELECT @b.*, @a FROM @b;" +oracle,"SELECT @a, * FROM @b WHERE","SELECT @a, @b.* FROM @b WHERE" +oracle,"SELECT @a, * FROM @b ORDER BY","SELECT @a, @b.* FROM @b ORDER BY" +oracle,"SELECT @a, * FROM @b FETCH FIRST","SELECT @a, @b.* FROM @b FETCH FIRST" +oracle,"(SELECT @a, * FROM @b)","(SELECT @a, @b.* FROM @b)" +oracle,"SELECT @a, * FROM @b;","SELECT @a, @b.* FROM @b;" +oracle,(@a & @b),"BITAND(@a, @b)" +postgresql,...@([0-9]+|y)a,xxx@a +postgresql,TRY_CAST(@a),CAST(@a) +postgresql,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +postgresql,"ROUND(@a,@b)","ROUND(CAST(@a AS NUMERIC),@b)" +postgresql,"HASHBYTES('MD5',@a)","MD5(@a)" +postgresql,"CONVERT(VARBINARY, @a, 1)","CAST(CONCAT('x', @a) AS BIT(32))" +postgresql,"CONVERT(DATE, @a)","TO_DATE(@a, 'yyyymmdd')" +postgresql,"DATEADD(second,@seconds,@datetime)",(@datetime + @seconds*INTERVAL'1 second') +postgresql,"DATEADD(minute,@minutes,@datetime)",(@datetime + @minutes*INTERVAL'1 minute') +postgresql,"DATEADD(hour,@hours,@datetime)",(@datetime + @hours*INTERVAL'1 hour') +postgresql,"DATEADD(d,@days,@date)",(@date + @days*INTERVAL'1 day') +postgresql,"DATEADD(dd,@days,@date)",(@date + @days*INTERVAL'1 day') +postgresql,"DATEADD(day,@days,@date)",(@date + @days*INTERVAL'1 day') +postgresql,"DATEADD(m,@months,@date)",(@date + @months*INTERVAL'1 month') +postgresql,"DATEADD(mm,@months,@date)",(@date + @months*INTERVAL'1 month') +postgresql,"DATEADD(month,@months,@date)",(@date + @months*INTERVAL'1 month') +postgresql,"DATEADD(yy,@years,@date)",(@date + @years*INTERVAL'1 year') +postgresql,"DATEADD(yyyy,@years,@date)",(@date + @years*INTERVAL'1 year') +postgresql,"DATEADD(year,@years,@date)",(@date + @years*INTERVAL'1 year') +postgresql,"DATEDIFF(second,@start, @end)",EXTRACT(EPOCH FROM (@end - @start)) +postgresql,"DATEDIFF(minute,@start, @end)",(EXTRACT(EPOCH FROM (@end - @start)) / 60) +postgresql,"DATEDIFF(hour,@start, @end)",(EXTRACT(EPOCH FROM (@end - @start)) / 3600) +postgresql,"DATEDIFF(d,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) +postgresql,"DATEDIFF(dd,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) +postgresql,"DATEDIFF(day,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) +postgresql,"DATEDIFF(year,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) +postgresql,"DATEDIFF(yyyy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) +postgresql,"DATEDIFF(yy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) +postgresql,"DATEDIFF(month,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" +postgresql,"DATEDIFF(mm,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" +postgresql,"DATEDIFF(m,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" +postgresql,"CONVERT(VARCHAR,@date,112)","TO_CHAR(@date, 'YYYYMMDD')" +postgresql,GETDATE(),CURRENT_DATE +postgresql,+ '@a',|| '@a' +postgresql,'@a' +,'@a' || +postgresql,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || +postgresql,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) +postgresql,CAST(@a AS varchar) +,CAST(@a AS varchar) || +postgresql,+ CAST(@a AS varchar),|| CAST(@a AS varchar) +postgresql,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00'), 'YYYY-MM-DD')" +postgresql,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00')||' '||TO_CHAR(@hour,'00')||':'||TO_CHAR(@minute,'00')||':'||TO_CHAR(@second,'00'), 'YYYY-MM-DD HH24:MI:SS')" +postgresql,YEAR(@date),EXTRACT(YEAR FROM @date) +postgresql,MONTH(@date),EXTRACT(MONTH FROM @date) +postgresql,DAY(@date),EXTRACT(DAY FROM @date) +postgresql,"DATEPART(YEAR, @date)",EXTRACT(YEAR FROM @date) +postgresql,"DATEPART(MONTH, @date)",EXTRACT(MONTH FROM @date) +postgresql,"DATEPART(DAY, @date)",EXTRACT(DAY FROM @date) +postgresql,EOMONTH(@date),"(DATE_TRUNC('MONTH', @date) + INTERVAL '1 MONTH - 1 day')::DATE" +postgresql,STDEV(@a),STDDEV(@a) +postgresql,VAR(@a),VARIANCE(@a) +postgresql,RAND(),RANDOM() +postgresql,LEN(@a),CHAR_LENGTH(@a) +postgresql,"CHARINDEX(@a,@b)","STRPOS(@b,@a)" +postgresql,"LOG(@expression,@base)","(CAST((@base) AS NUMERIC),CAST((@expression) AS NUMERIC))" +postgresql,LOG(@expression),LN(CAST((@expression) AS REAL)) +postgresql,,LOG +postgresql,LOG10(@expression),"LOG(10,CAST((@expression) AS NUMERIC))" +postgresql,"ISNULL(@a,@b)","COALESCE(@a,@b)" +postgresql,"ISNUMERIC(@a)","CASE WHEN (CAST(@a AS VARCHAR) ~ '^([0-9]+\.?[0-9]*|\.[0-9]+)$') THEN 1 ELSE 0 END" +postgresql,COUNT_BIG(@a),COUNT(@a) +postgresql,SQUARE(@a),((@a)*(@a)) +postgresql,NEWID(),MD5(RANDOM()::TEXT || CLOCK_TIMESTAMP()::TEXT) +postgresql,USE @schema;,SET search_path TO @schema; +postgresql,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +postgresql,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +postgresql,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +postgresql,.dbo.,. +postgresql,CREATE TABLE #@table (@definition),CREATE TEMP TABLE @table (@definition) +postgresql,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE INDEX @index_name ON @table (@variable);\nCLUSTER @table USING @index_name; +postgresql,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable);\nCLUSTER @table USING @index_name; +postgresql,PRIMARY KEY NONCLUSTERED,PRIMARY KEY +postgresql,DATETIME,TIMESTAMP +postgresql,DATETIME2,TIMESTAMP +postgresql,VARCHAR(MAX),TEXT +postgresql,FLOAT,NUMERIC +postgresql,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nANALYZE @d; +postgresql,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +postgresql,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a\nFROM\n@c;\nANALYZE @b; +postgresql,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; +postgresql,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; +postgresql,#, +postgresql,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; +postgresql,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) +postgresql,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; +postgresql,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) +postgresql,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as TEXT) @s2)" +postgresql,UPDATE STATISTICS @a;,ANALYZE @a; +postgresql,"ALTER TABLE @table ALTER COLUMN @([^ ]+)a @b;","ALTER TABLE @table ALTER COLUMN @a TYPE @b;" +postgresql,"ALTER TABLE @table ADD @a, @b;","ALTER TABLE @table @a, @b;" +postgresql," @b, @c;"," @b, @c;" +postgresql,"ALTER TABLE @table ADD @a;","ALTER TABLE @table @a;" +postgresql,"",ADD COLUMN +postgresql,"",ADD COLUMN +postgresql,ADD COLUMN COLUMN,ADD COLUMN +postgresql,ADD COLUMN CONSTRAINT,ADD CONSTRAINT +redshift,...@([0-9]+|y)a,xxx@a +redshift,"AS drvd(@a)","AS drvd(@a)" +redshift,"@a, @b)","@a, @b)" +redshift,"","NULL AS " +redshift,"FROM (VALUES @a) AS drvd","FROM (@a) AS drvd" +redshift,"@a, @b)","@a UNION ALL @b)" +redshift,"(@a)","SELECT @a" +redshift,"FROM (SELECT @a) AS drvd(@b)","FROM (SELECT @b WHERE (0 = 1) UNION ALL SELECT @a) AS values_table" +redshift,TRY_CAST(@a),CAST(@a) +redshift,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +redshift,CREATE INDEX @index_name ON @table (@variable);,-- redshift does not support indexes +redshift,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,-- redshift does not support indexes +redshift,"OVER (@a ORDER BY @b DESC)","OVER (@a O*D*R B* @b DESC ROWS UNBOUNDED PRECEDING)" +redshift,"OVER (@a ORDER BY @b ASC)","OVER (@a O*D*R B* @b ASC ROWS UNBOUNDED PRECEDING)" +redshift,"OVER (@a ORDER BY @((?!.*ROWS).*)b)","OVER (@a O*D*R B* @b ROWS UNBOUNDED PRECEDING)" +redshift,"ROW_NUMBER() OVER (@a ROWS UNBOUNDED PRECEDING)","ROW_NUMBER() OVER (@a)" +redshift,"CUME_DIST() OVER (@a ROWS UNBOUNDED PRECEDING)","CUME_DIST() OVER (@a)" +redshift,"DENSE_RANK() OVER (@a ROWS UNBOUNDED PRECEDING)","DENSE_RANK() OVER (@a)" +redshift,"PERCENT_RANK() OVER (@a ROWS UNBOUNDED PRECEDING)","PERCENT_RANK() OVER (@a)" +redshift,"RANK() OVER (@a ROWS UNBOUNDED PRECEDING)","RANK() OVER (@a)" +redshift,"LAG(@x) OVER (@a ROWS UNBOUNDED PRECEDING)","LAG(@x) OVER (@a)" +redshift,"LEAD(@x) OVER (@a ROWS UNBOUNDED PRECEDING)","LEAD(@x) OVER (@a)" +redshift,"NTILE(@x) OVER (@a ROWS UNBOUNDED PRECEDING)","NTILE(@x) OVER (@a)" +redshift,"O*D*R B*","ORDER BY" +redshift,"ROUND(@a,@b)","ROUND(CAST(@a AS FLOAT),@b)" +redshift,"ROUND(@expression,@length,@trunc)","case when @trunc = 0 then ROUND(@expression,@length) else TRUNC(@expression,@length) end" +redshift,"DATEADD(dd,@days,@date)","DATEADD(day,@days,@date)" +redshift,"DATEADD(m,@months,@date)","DATEADD(month,@months,@date)" +redshift,"DATEADD(mm,@months,@date)","DATEADD(month,@months,@date)" +redshift,"DATEADD(yyyy,@years,@date)","DATEADD(year,@years,@date)" +redshift,"DATEADD(yy,@years,@date)","DATEADD(year,@years,@date)" +redshift,"DATEADD(qq,@n,@date)","DATEADD(quarter,@n,@date)" +redshift,"DATEADD(q,@n,@date)","DATEADD(quarter,@n,@date)" +redshift,"DATEADD(wk,@n,@date)","DATEADD(week,@n,@date)" +redshift,"DATEADD(ww,@n,@date)","DATEADD(week,@n,@date)" +redshift,"DATEADD(hh,@n,@date)","DATEADD(hour,@n,@date)" +redshift,"DATEADD(mi,@n,@date)","DATEADD(minute,@n,@date)" +redshift,"DATEADD(n,@n,@date)","DATEADD(minute,@n,@date)" +redshift,"DATEADD(ss,@n,@date)","DATEADD(second,@n,@date)" +redshift,"DATEADD(mcs,@n,@date)","DATEADD(microsecond,@n,@date)" +redshift,"DATEADD(@part,@n,@date)","DATEADD(@part,CAST(@n as int),@date)" +redshift,"DATEDIFF(dd,@start,@end)","DATEDIFF(day,@start,@end)" +redshift,"DATEDIFF(m,@start,@end)","DATEDIFF(month,@start,@end)" +redshift,"DATEDIFF(mm,@start,@end)","DATEDIFF(month,@start,@end)" +redshift,"DATEDIFF(yyyy,@start,@end)","DATEDIFF(year,@start,@end)" +redshift,"DATEDIFF(yy,@start,@end)","DATEDIFF(year,@start,@end)" +redshift,"DATEDIFF(qq,@start,@end)","DATEDIFF(quarter,@start,@end)" +redshift,"DATEDIFF(q,@start,@end)","DATEDIFF(quarter,@start,@end)" +redshift,"DATEDIFF(wk,@start,@end)","DATEDIFF(week,@start,@end)" +redshift,"DATEDIFF(ww,@start,@end)","DATEDIFF(week,@start,@end)" +redshift,"DATEDIFF(hh,@start,@end)","DATEDIFF(hour,@start,@end)" +redshift,"DATEDIFF(mi,@start,@end)","DATEDIFF(minute,@start,@end)" +redshift,"DATEDIFF(n,@start,@end)","DATEDIFF(minute,@start,@end)" +redshift,"DATEDIFF(ss,@start,@end)","DATEDIFF(second,@start,@end)" +redshift,"DATEDIFF(mcs,@start,@end)","DATEDIFF(microsecond,@start,@end)" +redshift,"DATEDIFF_BIG(dd,@start,@end)","DATEDIFF(day,@start,@end)" +redshift,"DATEDIFF_BIG(day,@start,@end)","DATEDIFF(day,@start,@end)" +redshift,"DATEDIFF_BIG(m,@start,@end)","DATEDIFF(month,@start,@end)" +redshift,"DATEDIFF_BIG(mm,@start,@end)","DATEDIFF(month,@start,@end)" +redshift,"DATEDIFF_BIG(yyyy,@start,@end)","DATEDIFF(year,@start,@end)" +redshift,"DATEDIFF_BIG(yy,@start,@end)","DATEDIFF(year,@start,@end)" +redshift,"DATEDIFF_BIG(qq,@start,@end)","DATEDIFF(quarter,@start,@end)" +redshift,"DATEDIFF_BIG(q,@start,@end)","DATEDIFF(quarter,@start,@end)" +redshift,"DATEDIFF_BIG(wk,@start,@end)","DATEDIFF(week,@start,@end)" +redshift,"DATEDIFF_BIG(ww,@start,@end)","DATEDIFF(week,@start,@end)" +redshift,"DATEDIFF_BIG(hh,@start,@end)","DATEDIFF(hour,@start,@end)" +redshift,"DATEDIFF_BIG(hour,@start,@end)","DATEDIFF(hour,@start,@end)" +redshift,"DATEDIFF_BIG(mi,@start,@end)","DATEDIFF(minute,@start,@end)" +redshift,"DATEDIFF_BIG(minute,@start,@end)","DATEDIFF(minute,@start,@end)" +redshift,"DATEDIFF_BIG(n,@start,@end)","DATEDIFF(minute,@start,@end)" +redshift,"DATEDIFF_BIG(ss,@start,@end)","DATEDIFF(second,@start,@end)" +redshift,"DATEDIFF_BIG(second,@start,@end)","DATEDIFF(second,@start,@end)" +redshift,"DATEDIFF_BIG(mcs,@start,@end)","DATEDIFF(microsecond,@start,@end)" +redshift,"DATEPART(dd,@date)","DATEPART(day,@date)" +redshift,"DATEPART(m,@date)","DATEPART(month,@date)" +redshift,"DATEPART(mm,@date)","DATEPART(month,@date)" +redshift,"DATEPART(yyyy,@date)","DATEPART(year,@date)" +redshift,"DATEPART(yy,@date)","DATEPART(year,@date)" +redshift,"DATEPART(qq,@date)","DATEPART(quarter,@date)" +redshift,"DATEPART(q,@date)","DATEPART(quarter,@date)" +redshift,"DATEPART(wk,@date)","DATEPART(week,@date)" +redshift,"DATEPART(ww,@date)","DATEPART(week,@date)" +redshift,"DATEPART(hh,@date)","DATEPART(hour,@date)" +redshift,"DATEPART(mi,@date)","DATEPART(minute,@date)" +redshift,"DATEPART(n,@date)","DATEPART(minute,@date)" +redshift,"DATEPART(ss,@date)","DATEPART(second,@date)" +redshift,"DATEPART(mcs,@date)","DATEPART(microsecond,@date)" +redshift,"CONVERT(VARCHAR,@date,112)","TO_CHAR(@date, 'YYYYMMDD')" +redshift,GETDATE(),CURRENT_DATE +redshift,+ '@a',|| '@a' +redshift,'@a' +,'@a' || +redshift,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || +redshift,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) +redshift,CAST(@a AS varchar) +,CAST(@a AS varchar) || +redshift,+ CAST(@a AS varchar),|| CAST(@a AS varchar) +redshift,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM'), 'YYYY-MM-DD')" +redshift,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM')||':'||TO_CHAR(@second,'00FM')||'.'||TO_CHAR(@ms,'000FM') as TIMESTAMP)" +redshift,YEAR(@date),EXTRACT(YEAR FROM @date) +redshift,MONTH(@date),EXTRACT(MONTH FROM @date) +redshift,DAY(@date),EXTRACT(DAY FROM @date) +redshift,EOMONTH(@date),LAST_DAY(@date) +redshift,VAR(@a),VARIANCE(@a) +redshift,STDEV(@a),STDDEV(@a) +redshift,RAND(),RANDOM() +redshift,"HASHBYTES('MD5',@a)",MD5(@a) +redshift,"CONVERT(VARBINARY, @a, 1)","STRTOL(LEFT(@a, 15), 16)" +redshift,LEN(@a),CHAR_LENGTH(@a) +redshift,"LOG(@expression,@base)",(LN(CAST((@expression) AS REAL))/LN(CAST((@base) AS REAL))) +redshift,LOG(@expression),LN(CAST((@expression) AS REAL)) +redshift,LOG10(@expression),LOG(CAST((@expression) AS REAL)) +redshift,"ISNULL(@a,@b)","COALESCE(@a,@b)" +redshift,COUNT_BIG(@a),COUNT(@a) +redshift,SQUARE(@a),((@a) * (@a)) +redshift,TEXT,VARCHAR(max) +redshift,NTEXT,VARCHAR(max) +redshift,NEWID(),MD5(RANDOM()::TEXT || GETDATE()::TEXT) +redshift,USE @schema;,SET search_path TO @schema; +redshift,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +redshift,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +redshift,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE @table;","DROP TABLE IF EXISTS #@table;" +redshift,.dbo.,. +redshift,"HINT DISTRIBUTE_ON_KEY(@key) @hint CREATE TABLE @table (@definition);",HINT DISTRIBUTE_ON_KEY(@key) @hint\nCREATE TABLE @table (@definition)\nDISTKEY(@key); +redshift,"HINT DISTRIBUTE_ON_RANDOM @hint CREATE TABLE @table (@definition);",HINT DISTRIBUTE_ON_RANDOM @hint\nCREATE TABLE @table (@definition)\nDISTSTYLE EVEN; +redshift,"HINT @hint SORT_ON_KEY(@type:@key) CREATE TABLE @table (@definition) @options;",HINT @hint SORT_ON_KEY(@type:@key)\nCREATE TABLE @table (@definition)\n@options\n@type SORTKEY(@key); +redshift,"CREATE TABLE @table (@a1 person_id @a2);",CREATE TABLE @table (@a1 person_id @a2)\nDISTKEY(person_id); +redshift,"CREATE TABLE @table (@a1 subject_id @a2);",CREATE TABLE @table (@a1 subject_id @a2)\nDISTKEY(subject_id); +redshift,"CREATE TABLE @table (@a1 analysis_id @a2);",CREATE TABLE @table (@a1 analysis_id @a2)\nDISTKEY(analysis_id); +redshift,"CREATE TABLE @table (@definition);",CREATE TABLE @table (@definition)\nDISTSTYLE ALL; +redshift,HINT DISTRIBUTE_ON_KEY(@key) WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_KEY(@key)\nCREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +redshift,HINT DISTRIBUTE_ON_RANDOM WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_RANDOM\nCREATE TABLE @d\nDISTSTYLE EVEN\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +redshift,HINT SORT_ON_KEY(@type:@sortkey) WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @d\n@type SORTKEY(@sortkey)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +redshift,HINT DISTRIBUTE_ON_KEY(@key) SORT_ON_KEY(@type:@sortkey) WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_KEY(@key) SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @d\nDISTKEY(@key)\n@type SORTKEY(@sortkey)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +redshift,HINT DISTRIBUTE_ON_RANDOM SORT_ON_KEY(@type:@sortkey) WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_RANDOM SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @d\nDISTSTYLE EVEN\n@type SORTKEY(@sortkey)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +redshift,WITH @a INSERT INTO @b SELECT @c;,INSERT INTO @b WITH @a SELECT @c; +redshift,WITH @a AS @b SELECT @c1 person_id as @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 person_id as @key @c2\nFROM\n@e; +redshift,WITH @a AS @b SELECT @c1 person_id @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 person_id @key @c2\nFROM\n@e; +redshift,WITH @a AS @b SELECT @c1 person_id @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(person_id)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 person_id @c2\nFROM\n@e; +redshift,WITH @a AS @b SELECT @c1 subject_id as @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 subject_id as @key @c2\nFROM\n@e; +redshift,WITH @a AS @b SELECT @c1 subject_id @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 subject_id @key @c2\nFROM\n@e; +redshift,WITH @a AS @b SELECT @c1 subject_id @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(subject_id)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 subject_id @c2\nFROM\n@e; +redshift,WITH @a AS @b SELECT @c1 analysis_id as @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 analysis_id as @key @c2\nFROM\n@e; +redshift,WITH @a AS @b SELECT @c1 analysis_id @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 analysis_id @key @c2\nFROM\n@e; +redshift,WITH @a AS @b SELECT @c1 analysis_id @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(analysis_id)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 analysis_id @c2\nFROM\n@e; +redshift,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d DISTSTYLE ALL\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c\nFROM\n@e; +redshift,HINT DISTRIBUTE_ON_KEY(@key) SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a\nFROM\n@c; +redshift,HINT DISTRIBUTE_ON_RANDOM SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_RANDOM \nCREATE TABLE @b\nDISTSTYLE EVEN\nAS\nSELECT\n@a\nFROM\n@c; +redshift,HINT SORT_ON_KEY(@type:@sortkey) SELECT @a INTO @b FROM @c;,HINT SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @b\n@type SORTKEY(@sortkey)\nAS\nSELECT\n@a\nFROM\n@c; +redshift,HINT DISTRIBUTE_ON_KEY(@key) SORT_ON_KEY(@type:@sortkey) SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_KEY(@key) SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @b\nDISTKEY(@key)\n@type SORTKEY(@sortkey)\nAS\nSELECT\n@a\nFROM\n@c; +redshift,HINT DISTRIBUTE_ON_RANDOM SORT_ON_KEY(@type:@sortkey) SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_RANDOM SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @b\nDISTSTYLE EVEN\n@type SORTKEY(@sortkey)\nAS\nSELECT\n@a\nFROM\n@c; +redshift,SELECT @a1 person_id as @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 person_id as @key @a2\nFROM\n@c; +redshift,SELECT @a1 person_id @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 person_id @key @a2\nFROM\n@c; +redshift,SELECT @a1 person_id @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(person_id)\nAS\nSELECT\n@a1 person_id @a2\nFROM\n@c; +redshift,SELECT @a1 subject_id as @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 subject_id as @key @a2\nFROM\n@c; +redshift,SELECT @a1 subject_id @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 subject_id @key @a2\nFROM\n@c; +redshift,SELECT @a1 subject_id @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(subject_id)\nAS\nSELECT\n@a1 subject_id @a2\nFROM\n@c; +redshift,SELECT @a1 analysis_id as @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 analysis_id as @key @a2\nFROM\n@c; +redshift,SELECT @a1 analysis_id @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 analysis_id @key @a2\nFROM\n@c; +redshift,SELECT @a1 analysis_id @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(analysis_id)\nAS\nSELECT\n@a1 analysis_id @a2\nFROM\n@c; +redshift,SELECT @a INTO @b FROM @c;,CREATE TABLE @b DISTSTYLE ALL\nAS\nSELECT\n@a\nFROM\n@c; +redshift,SELECT @a INTO @b;,CREATE TABLE @b DISTSTYLE ALL\nAS\nSELECT\n@a; +redshift,[ person_id ],[person_id] +redshift,[ subject_id ],[subject_id] +redshift,[ analysis_id ],[analysis_id] +redshift,PRIMARY KEY NONCLUSTERED,PRIMARY KEY +redshift,DATETIME,TIMESTAMP +redshift,SELECT DISTINCT TOP @([0-9]+)rows,SELECT TOP @rows DISTINCT +redshift,BIT,BOOLEAN +redshift,MONEY,"DECIMAL(19, 4)" +redshift,SMALLMONEY,"DECIMAL(10, 4)" +redshift,TINYINT,SMALLINT +redshift,FLOAT(@s),FLOAT +redshift,DATETIME2(@p),TIMESTAMP +redshift,DATETIME2,TIMESTAMP +redshift,DATETIME,TIMESTAMP +redshift,DATETIMEOFFSET(@p),TIMESTAMPTZ +redshift,DATETIMEOFFSET,TIMESTAMPTZ +redshift,SMALLDATETIME,TIMESTAMP +redshift,UNIQUEIDENTIFIER,CHAR(36) +redshift,STDEVP(@a),STDDEV_POP(@a) +redshift,VARP(@a),VAR_POP(@a) +redshift,"DATETIME2FROMPARTS(@year,@month,@day,@hour,@minute,@seconds,0,0)","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM')||':'||TO_CHAR(@seconds,'00FM') as TIMESTAMP)" +redshift,"DATETIME2FROMPARTS(@year,@month,@day,@hour,@minute,@seconds,@fractions,@precision)","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM')||':'||TO_CHAR(@seconds,'00FM')||'.'||TO_CHAR(@fractions,repeat('0', @precision) || 'FM') as TIMESTAMP)" +redshift,"DATETIMEOFFSETFROMPARTS (@year,@month,@day,@hour,@minute,@seconds,0,@h_offset,@m_offset,0 )","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM')||':'||TO_CHAR(@seconds,'00FM')||case when @h_offset >= 0 then '+' else '-' end ||TO_CHAR(ABS(@h_offset),'00FM')||':'||TO_CHAR(ABS(@m_offset),'00FM') as TIMESTAMPTZ)" +redshift,"DATETIMEOFFSETFROMPARTS (@year,@month,@day,@hour,@minute,@seconds,@fractions,@h_offset,@m_offset,@precision )","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM')||':'||TO_CHAR(@seconds,'00FM')||'.'||TO_CHAR(@fractions,repeat('0',@precision) || 'FM')||case when @h_offset >= 0 then '+' else '-' end ||TO_CHAR(ABS(@h_offset),'00FM')||':'||TO_CHAR(ABS(@m_offset),'00FM') as TIMESTAMPTZ)" +redshift,GETUTCDATE(),CURRENT_TIMESTAMP +redshift,"SMALLDATETIMEFROMPARTS(@year,@month,@day,@hour,@minute )","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM') as TIMESTAMP)" +redshift,SYSUTCDATETIME(),CURRENT_TIMESTAMP +redshift,"TODATETIMEOFFSET(@expression,@timezone)","CAST(TO_CHAR(CAST(@expression as TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS.US') ||@timezone as TIMESTAMPTZ)" +redshift,"ATN2(@a,@b)","ATAN2(@a,@b)" +redshift,"CHARINDEX(@expression,@in,@start)","case when CHARINDEX(@expression, SUBSTRING(@in,@start)) > 0 then (CHARINDEX(@expression, SUBSTRING(@in,@start)) +@start - 1) else 0 end" +redshift,QUOTENAME(@a),QUOTE_IDENT(@a) +redshift,"SPACE(@n)","REPEAT(' ',@n)" +redshift,"STUFF(@expression,@start,@length,@replace)","SUBSTRING(@expression, 0,@start)||@replace||SUBSTRING(@expression,@start +@length)" +redshift,"CONCAT(@a,@b,@tail)","CONCAT(@a,CONCAT(@b,@tail))" +redshift,"ISDATE(@s)","REGEXP_INSTR(@s, '^(\\d{4}[/\-]?[01]\\d[/\-]?[0123]\\d)([ T]([0-1][0-9]|[2][0-3]):([0-5][0-9])(:[0-5][0-9](.\\d+)?)?)?$')" +redshift,"ISNUMERIC(@s)","REGEXP_INSTR(@s, '^[\-\+]?(\\d*\\.)?\\d+([Ee][\-\+]?\\d+)?$')" +redshift,"PATINDEX(@pattern,@expression)","REGEXP_INSTR(@expression, case when LEFT(@pattern,1)<>'%' and RIGHT(@pattern,1)='%' then '^' else '' end||TRIM('%' FROM REPLACE(@pattern,'_','.'))||case when LEFT(@pattern,1)='%' and RIGHT(@pattern,1)<>'%' then '$' else '' end)" +redshift,^,# +redshift,"CONVERT(DATE, @a)","CAST(@a as DATE)" +redshift,"CONVERT(TIMESTAMPTZ, @a)","CONVERT(TIMESTAMP WITH TIME ZONE, @a)" +redshift,UPDATE STATISTICS @a;,ANALYZE @a; +pdw,...@([0-9]+|y)a,xxx@a +pdw,CREATE INDEX @index_name ON #@table (@variable);,-- PDW does not support non-clustered index on temp tables. +pdw,VARCHAR(MAX),VARCHAR(1000) +pdw,HINT DISTRIBUTE_ON_KEY(@key) @hint WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_KEY(@key) @hint\nCREATE TABLE @d WITH (DISTRIBUTION = HASH(@key))\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +pdw,HINT DISTRIBUTE_ON_RANDOM @hint WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_RANDOM @hint\nCREATE TABLE @d WITH (DISTRIBUTION = ROUND_ROBIN)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +pdw,"WITH @a AS @b SELECT @c1 subject_id, @c2 INTO @d FROM @e;","CREATE TABLE @d WITH (DISTRIBUTION = HASH(subject_id))\nAS\nWITH @a AS @b SELECT\n@c1 subject_id, @c2\nFROM\n@e;" +pdw,"WITH @a AS @b SELECT @c1 person_id, @c2 INTO @d FROM @e;","CREATE TABLE @d WITH (DISTRIBUTION = HASH(person_id))\nAS\nWITH @a AS @b SELECT\n@c1 person_id, @c2\nFROM\n@e;" +pdw,"WITH @a AS @b SELECT @c1 analysis_id, @c2 INTO @d FROM @e;","CREATE TABLE @d WITH (DISTRIBUTION = HASH(analysis_id))\nAS\nWITH @a AS @b SELECT\n@c1 analysis_id, @c2\nFROM\n@e;" +pdw,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d WITH (DISTRIBUTION = REPLICATE)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +pdw,HINT DISTRIBUTE_ON_KEY(@key) @hint SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_KEY(@key) @hint\nCREATE TABLE @b WITH (DISTRIBUTION = HASH(@key))\nAS\nSELECT\n@a\nFROM\n@c; +pdw,HINT DISTRIBUTE_ON_RANDOM @hint SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_RANDOM @hint\nCREATE TABLE @b WITH (DISTRIBUTION = ROUND_ROBIN)\nAS\nSELECT\n@a\nFROM\n@c; +pdw,"SELECT @a1 subject_id, @a2 INTO @b FROM @c;","CREATE TABLE @b WITH (DISTRIBUTION = HASH(subject_id))\nAS\nSELECT\n@a1 subject_id, @a2\nFROM\n@c;" +pdw,"SELECT @a1 person_id, @a2 INTO @b FROM @c;","CREATE TABLE @b WITH (DISTRIBUTION = HASH(person_id))\nAS\nSELECT\n@a1 person_id, @a2\nFROM\n@c;" +pdw,"SELECT @a1 analysis_id, @a2 INTO @b FROM @c;","CREATE TABLE @b WITH (DISTRIBUTION = HASH(analysis_id))\nAS\nSELECT\n@a1 analysis_id, @a2\nFROM\n@c;" +pdw,SELECT @a INTO @b FROM @c;,CREATE TABLE @b WITH (DISTRIBUTION = REPLICATE)\nAS\nSELECT\n@a\nFROM\n@c; +pdw,SELECT @a INTO @b;,CREATE TABLE @b WITH (DISTRIBUTION = REPLICATE)\nAS\nSELECT\n@a; +pdw,CREATE TABLE #@a WITH (DISTRIBUTION = @b) AS,"CREATE TABLE #@a WITH (LOCATION = USER_DB, DISTRIBUTION = @b) AS" +pdw,HINT DISTRIBUTE_ON_KEY(@key) @hint CREATE TABLE @table (@definition);,HINT DISTRIBUTE_ON_KEY(@key) @hint\nCREATE TABLE @table (@definition)\nWITH (DISTRIBUTION = HASH(@key)); +pdw,HINT DISTRIBUTE_ON_RANDOM @hint CREATE TABLE @table (@definition);,HINT DISTRIBUTE_ON_RANDOM @hint\nCREATE TABLE @table (@definition)\nWITH (DISTRIBUTION = ROUND_ROBIN); +pdw,CREATE TABLE @table (@definition_part1 subject_id @definition_part2);,CREATE TABLE @table (@definition_part1 subject_id @definition_part2)\nWITH (DISTRIBUTION = HASH(subject_id)); +pdw,CREATE TABLE @table (@definition_part1 person_id @definition_part2);,CREATE TABLE @table (@definition_part1 person_id @definition_part2)\nWITH (DISTRIBUTION = HASH(person_id)); +pdw,CREATE TABLE @table (@definition_part1 analysis_id @definition_part2);,CREATE TABLE @table (@definition_part1 analysis_id @definition_part2)\nWITH (DISTRIBUTION = HASH(analysis_id)); +pdw,CREATE TABLE @table (@definition);,CREATE TABLE @table (@definition)\nWITH (DISTRIBUTION = REPLICATE); +pdw,CREATE TABLE #@table (@definition) WITH (DISTRIBUTION = @distribution);,"CREATE TABLE #@table (@definition)\nWITH (LOCATION = USER_DB, DISTRIBUTION = @distribution);" +pdw,[ person_id ],[person_id] +pdw,[ subject_id ],[subject_id] +pdw,[ analysis_id ],[analysis_id] +pdw,TRUNCATE TABLE ,IF XACT_STATE() = 1 COMMIT; TR*NC*T* TABLE +pdw,DROP TABLE ,IF XACT_STATE() = 1 COMMIT; DR*P TABLE +pdw,CREATE TABLE ,IF XACT_STATE() = 1 COMMIT; CR**T* TABLE +pdw,TR*NC*T*,TRUNCATE +pdw,DR*P,DROP +pdw,CR**T*,CREATE +pdw,IF OBJECT_ID(@a) IS NOT NULL IF XACT_STATE() = 1 COMMIT;,IF XACT_STATE() = 1 COMMIT; IF OBJECT_ID(@a) IS NOT NULL +pdw,IF OBJECT_ID(@a) IS NULL IF XACT_STATE() = 1 COMMIT;,IF XACT_STATE() = 1 COMMIT; IF OBJECT_ID(@a) IS NULL +pdw,"CONSTRAINT @a DEFAULT GETDATE()","" +pdw,"DEFAULT GETDATE()","" +pdw,CREATE INDEX @index_name ON @table (@variable) WHERE @b;,CREATE INDEX @index_name ON @table (@variable); +impala,...@([0-9]+|y)a,xxx@a +impala,TRY_CAST(@a),CAST(@a) +impala,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +impala,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,-- impala does not support indexes +impala,CREATE INDEX @index_name ON @table (@variable);,-- impala does not support indexes +impala,"CHARINDEX(@a,@b)","INSTR(@b,@a)" +impala,COUNT_BIG(@a),COUNT(@a) +impala,"LEFT(@str,@chars)","SUBSTR(@str,1,@chars)" +impala,LEN(@a),LENGTH(@a) +impala,LOG(@expression),LN(@expression) +impala,NEWID(),UUID() +impala,"RIGHT(@str,@chars)","SUBSTR(@str,-@chars)" +impala,"ROUND(@a,@b)","ROUND(CAST(@a AS DOUBLE),@b)" +impala,SQUARE(@a),((@a)*(@a)) +impala,STDEV(@a),STDDEV(@a) +impala,VAR(@a),VARIANCE(@a) +impala,"DATEADD(d,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" +impala,"DATEADD(dd,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" +impala,"DATEADD(day,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" +impala,"DATEADD(month,@months,CAST(@date AS DATE))","ADD_MONTHS(@date, @months)" +impala,"DATEADD(mm,@months,CAST(@date AS DATE))","ADD_MONTHS(@date, @months)" +impala,"DATEADD(m,@months,CAST(@date AS DATE))","ADD_MONTHS(@date, @months)" +impala,"DATEADD(year,@years,CAST(@date AS DATE))","ADD_MONTHS(@date, 12 * @years)" +impala,"DATEADD(yyyy,@years,CAST(@date AS DATE))","ADD_MONTHS(@date, 12 * @years)" +impala,"DATEADD(yy,@years,CAST(@date AS DATE))","ADD_MONTHS(@date, 12 * @years)" +impala,"DATEADD(d,@days,@date)","DATE_ADD(CAST(@date AS DATE), @days)" +impala,"DATEADD(dd,@days,@date)","DATE_ADD(CAST(@date AS DATE), @days)" +impala,"DATEADD(day,@days,@date)","DATE_ADD(CAST(@date AS DATE), @days)" +impala,"DATEADD(month,@months,@date)","ADD_MONTHS(CAST(@date AS DATE), @months)" +impala,"DATEADD(mm,@months,@date)","ADD_MONTHS(CAST(@date AS DATE), @months)" +impala,"DATEADD(m,@months,@date)","ADD_MONTHS(CAST(@date AS DATE), @months)" +impala,"DATEADD(year,@years,@date)","ADD_MONTHS(CAST(@date AS DATE), 12 * @years)" +impala,"DATEADD(yyyy,@years,@date)","ADD_MONTHS(CAST(@date AS DATE), 12 * @years)" +impala,"DATEADD(yy,@years,@date)","ADD_MONTHS(CAST(@date AS DATE), 12 * @years)" +impala,"DATEDIFF(d,@start, @end)","DATEDIFF(CAST(@end AS DATE), CAST(@start AS DATE))" +impala,"DATEDIFF(dd,@start, @end)","DATEDIFF(CAST(@end AS DATE), CAST(@start AS DATE))" +impala,"DATEDIFF(day,@start, @end)","DATEDIFF(CAST(@end AS DATE), CAST(@start AS DATE))" +impala,"DATEDIFF(year,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) +impala,"DATEDIFF(yyyy,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) +impala,"DATEDIFF(yy,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) +impala,"DATEDIFF(month,@start, @end)","INT_MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" +impala,"DATEDIFF(mm,@start, @end)","INT_MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" +impala,"DATEDIFF(m,@start, @end)","INT_MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" +impala,"DATEFROMPARTS(@year,@month,@day)","to_timestamp(CONCAT(CAST(@year AS VARCHAR),'-',CAST(@month AS VARCHAR),'-',CAST(@day AS VARCHAR)), 'yyyy-M-d')" +impala,"eomonth(@date)","days_sub(add_months(trunc(CAST(@date AS TIMESTAMP), 'MM'),1),1)" +impala,DAY(@date),DAY(CAST(@date AS DATE)) +impala,GETDATE(),NOW() +impala,MONTH(@date),MONTH(CAST(@date AS DATE)) +impala,YEAR(@date),YEAR(CAST(@date AS DATE)) +impala,"DATEPART(YEAR, @date)",YEAR(CAST(@date AS DATE)) +impala,"DATEPART(MONTH, @date)",MONTH(CAST(@date AS DATE)) +impala,"DATEPART(DAY, @date)",DAY(CAST(@date AS DATE)) +impala,CAST(@a AS DATE),"CASE TYPEOF(@a) WHEN 'TIMESTAMP' THEN CAST(@a AS TIMESTAMP) ELSE TO_UTC_TIMESTAMP(CONCAT_WS('-', SUBSTR(CAST(@a AS STRING), 1, 4), SUBSTR(CAST(@a AS STRING), 5, 2), SUBSTR(CAST(@a AS STRING), 7, 2)), 'UTC') END" +impala,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +impala,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +impala,(SELECT @a UNION SELECT @b) ORDER BY,SELECT * FROM\n(SELECT @a\nUNION\nSELECT @b)\nAS t1 ORDER BY +impala,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d STORED AS PARQUET\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\n UPDATE STATISTICS @d; +impala,SELECT @a INTO @b FROM @c;,CREATE TABLE @b STORED AS PARQUET AS\nSELECT\n@a\nFROM\n@c;\n UPDATE STATISTICS @b; +impala,SELECT @a INTO @b;,CREATE TABLE @b STORED AS PARQUET AS\nSELECT\n@a;\n UPDATE STATISTICS @b; +impala,SELECT DISTINCT @a FROM @b INTERSECT SELECT DISTINCT @a FROM @c;,SELECT t1.@a FROM (SELECT DISTINCT @a FROM @b UNION ALL SELECT DISTINCT @a FROM @c) AS t1 GROUP BY @a HAVING COUNT(*) >= 2; +impala,(SELECT DISTINCT @a FROM @b INTERSECT SELECT DISTINCT @a FROM @c),(SELECT t1.@a FROM (SELECT DISTINCT @a FROM @b UNION ALL SELECT DISTINCT @a FROM @c) AS t1 GROUP BY @a HAVING COUNT(*) >= 2) +impala,DELETE FROM @a WHERE @b;,INSERT OVERWRITE TABLE @a SELECT * FROM @a WHERE NOT(@b); +impala,DELETE FROM @a;,TRUNCATE TABLE @a; +impala,.dbo.,. +impala,##, +impala,#@([^\s]+)table.@([^\s]+)field,%session_id%@table.@field +impala,#,%temp_prefix%%session_id% +impala,,## +impala,.location,.`location` +impala,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; +impala,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) +impala,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; +impala,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) +impala,DATE,TIMESTAMP +impala,DATETIME,TIMESTAMP +impala,DATETIME2,TIMESTAMP +impala,BIGINT NOT NULL,BIGINT +impala,BOOLEAN NOT NULL,BOOLEAN +impala,CHAR NOT NULL,CHAR +impala,DECIMAL NOT NULL,DECIMAL +impala,DOUBLE PRECISION,DOUBLE +impala,DOUBLE NOT NULL,DOUBLE +impala,FLOAT NOT NULL,FLOAT +impala,INT NOT NULL,INT +impala,INTEGER NOT NULL,INT +impala,REAL NOT NULL,REAL +impala,SMALLINT NOT NULL,SMALLINT +impala,STRING NOT NULL,STRING +impala,TIMESTAMP NOT NULL,TIMESTAMP +impala,TINYINT NOT NULL,TINYINT +impala,VARCHAR(@a) NOT NULL,VARCHAR(@a) +impala,BIGINT NULL,BIGINT +impala,BOOLEAN NULL,BOOLEAN +impala,CHAR NULL,CHAR +impala,DECIMAL NULL,DECIMAL +impala,DOUBLE NULL,DOUBLE +impala,FLOAT NULL,FLOAT +impala,INT NULL,INT +impala,REAL NULL,REAL +impala,SMALLINT NULL,SMALLINT +impala,STRING NULL,STRING +impala,TIMESTAMP NULL,TIMESTAMP +impala,TINYINT NULL,TINYINT +impala,VARCHAR(@a) NULL,VARCHAR(@a) +impala,"CHAR,","CHAR(1)," +impala,"CHAR\n+","CHAR(1)\n" +impala,"CHAR)","CHAR(1))" +impala,"CONSTRAINT @a DEFAULT NOW()","" +impala,"DEFAULT NOW()","" +impala,stats,_stats +impala,UPDATE STATISTICS @a;,COMPUTE STATS @a; +impala,"ISNUMERIC(@a)","case when regexp_like(@a,'^([0-9]+\.?[0-9]*|\.[0-9]+)$') then 1 else 0 end" +impala,"HASHBYTES('MD5',@a)","fnv_hash(@a)" +impala,"CONVERT(VARBINARY, @a, 1)","cast(conv(@a, 16, 10) as int)" +netezza,...@([0-9]+|y)a,xxx@a +netezza,TRY_CAST(@a),CAST(@a) +netezza,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +netezza,HINT DISTRIBUTE_ON_KEY(@key)\n@statement;,HINT DISTRIBUTE_ON_KEY(@key)\n@statement\nDISTRIBUTE ON (@key); +netezza,HINT DISTRIBUTE_ON_RANDOM\n@statement;,HINT DISTRIBUTE_ON_RANDOM\n@statement\nDISTRIBUTE ON RANDOM; +netezza,CREATE TABLE #@table (@definition);,CREATE TEMP TABLE @table (@definition); +netezza,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +netezza,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +netezza,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a\nFROM\n@c; +netezza,SELECT @a INTO @b FROM @c;,CREATE TABLE @b\nAS\nSELECT\n@a\nFROM\n@c; +netezza,SELECT @a INTO #@b;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a; +netezza,SELECT @a INTO @b;,CREATE TABLE @b\nAS\nSELECT @a; +netezza,"ROUND(@a,@b)","ROUND(CAST(@a AS NUMERIC),@b)" +netezza,"CAST('@a' AS DATE)","TO_DATE('@a', 'yyyymmdd')" +netezza,"CAST('@a' + @b AS DATE)","TO_DATE('@a' + @b, 'yyyymmdd')" +netezza,"CAST(@a + '@b' AS DATE)","TO_DATE(@a + '@b', 'yyyymmdd')" +netezza,"CAST(CONCAT(@a) AS DATE)","TO_DATE(CONCAT(@a), 'yyyymmdd')" +netezza,CAST(@a AS INT),CAST(@a AS INTEGER) +netezza,CAST(@a AS VARCHAR),CAST(@a AS VARCHAR(1000)) +netezza,"CONVERT(VARCHAR,@date,112)","TO_CHAR(@date, 'YYYYMMDD')" +netezza,"DATEADD(d,@days,@date)",(@date + @days) +netezza,"DATEADD(dd,@days,@date)",(@date + @days) +netezza,"DATEADD(day,@days,@date)",(@date + @days) +netezza,"DATEADD(m,@months,@date)",CAST((@date + @months*INTERVAL'1 month') AS DATE) +netezza,"DATEADD(mm,@months,@date)",CAST((@date + @months*INTERVAL'1 month') AS DATE) +netezza,"DATEADD(month,@months,@date)",CAST((@date + @months*INTERVAL'1 month') AS DATE) +netezza,"DATEADD(yy,@years,@date)",CAST((@date + @years*INTERVAL'1 year') AS DATE) +netezza,"DATEADD(yyyy,@years,@date)",CAST((@date + @years*INTERVAL'1 year') AS DATE) +netezza,"DATEADD(year,@years,@date)",CAST((@date + @years*INTERVAL'1 year') AS DATE) +netezza,"DATEDIFF(d,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) +netezza,"DATEDIFF(dd,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) +netezza,"DATEDIFF(day,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) +netezza,"DATEDIFF(year,@start, @end)","(DATE_PART('YEAR', CAST(@end AS DATE)) - DATE_PART('YEAR', CAST(@start AS DATE)))" +netezza,"DATEDIFF(yyyy,@start, @end)","(DATE_PART('YEAR', CAST(@end AS DATE)) - DATE_PART('YEAR', CAST(@start AS DATE)))" +netezza,"DATEDIFF(yy,@start, @end)","(DATE_PART('YEAR', CAST(@end AS DATE)) - DATE_PART('YEAR', CAST(@start AS DATE)))" +netezza,"DATEDIFF(month,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" +netezza,"DATEDIFF(mm,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" +netezza,"DATEDIFF(m,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" +netezza,GETDATE(),CURRENT_DATE +netezza,+ '@a',|| '@a' +netezza,'@a' +,'@a' || +netezza,YEAR(@variable),"DATE_PART('YEAR', @variable)" +netezza,MONTH(@variable),"DATE_PART('MONTH', @variable)" +netezza,DAY(@variable),"DATE_PART('DAY', @variable)" +netezza,"DATEPART(YEAR, @date)","DATEPART('YEAR', @date)" +netezza,"DATEPART(MONTH, @date)","DATEPART('MONTH', @date)" +netezza,"DATEPART(DAY, @date)","DATEPART('DAY', @date)" +netezza,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || +netezza,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) +netezza,STDEV(@a),STDDEV(@a) +netezza,LEN(@a),CHAR_LENGTH(@a) +netezza,"LOG(@expression,@base)",(LN(@expression)/LN(@base)) +netezza,LOG(@expression),LN(@expression) +netezza,LOG10(@expression),LOG(@expression) +netezza,"ISNULL(@a,@b)","COALESCE(@a,@b)" +netezza,COUNT_BIG(@a),COUNT(@a) +netezza,USE @schema;,SET search_path TO @schema; +netezza,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition) DISTRIBUTE ON RANDOM; +netezza,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE @table IF EXISTS; +netezza,.dbo.,. +netezza,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,-- netezza does not support indexes +netezza,CREATE INDEX @index_name ON @table (@variable);,-- netezza does not support indexes +netezza,PRIMARY KEY NONCLUSTERED,PRIMARY KEY +netezza,VARCHAR(MAX),VARCHAR(1000) +netezza,FLOAT,"FLOAT(6)" +netezza,#, +netezza,"LEFT(@variable,@b)","SUBSTR(@variable, 1, @b)" +netezza,"RIGHT(@variable,@b)","SUBSTR(@variable, LENGTH(@variable)-@b+1, @b)" +netezza,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; +netezza,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) +netezza,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; +netezza,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) +netezza,"CONCAT(@a, @b, @c)","CONCAT(@a, CONCAT(@b, @c))" +netezza,"CONCAT(@a,@b)","@a || @b" +netezza,"POWER(@a,@b)","POW(@a,@b)" +netezza,EOMONTH(@date),LAST_DAY(@date) +netezza,ROWCOUNT(), ROW_NUMBER() +netezza,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00'), 'YYYY-MM-DD')" +netezza,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00')||' '||TO_CHAR(@hour,'00')||':'||TO_CHAR(@minute,'00')||':'||TO_CHAR(@second,'00'), 'YYYY-MM-DD HH24:MI:SS')" +netezza,WITH @a INSERT INTO @b SELECT @c;,INSERT INTO @b WITH @a SELECT @c; +netezza,UPDATE STATISTICS @a;,GENERATE STATISTICS ON @a; +netezza,DATETIME,TIMESTAMP +netezza,DATETIME2,TIMESTAMP +netezza,"ISNUMERIC(@a)","CASE WHEN translate(@a,'0123456789','') in ('','.','-','-.') THEN 1 ELSE 0 END" +netezza,"HASHBYTES('MD5',@a)","hash(@a)" +netezza,"CONVERT(VARBINARY, @a, 1)","hex_to_binary(@a)" +netezza,RAND(),RANDOM() +netezza,DROP TABLE IF EXISTS #@table;,DROP TABLE @table IF EXISTS; +netezza,DROP TABLE IF EXISTS @table;,DROP TABLE @table IF EXISTS; +bigquery,...@([0-9]+|y)a,xxx@a +bigquery,"AS drvd(@a)","AS drvd(@a)" +bigquery,"@a, @b)","@a, @b)" +bigquery,"","NULL AS " +bigquery,"FROM (VALUES @a) AS drvd","FROM (@a) AS drvd" +bigquery,"@a, @b)","@a UNION ALL @b)" +bigquery,"(@a)","SELECT @a" +bigquery,"FROM (SELECT @a) AS drvd(@b)","FROM (SELECT @b UNION ALL SELECT @a LIMIT 999999 OFFSET 1) AS values_table" +bigquery,TRY_CAST(@a),CAST(@a) +bigquery,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +bigquery,"CONVERT(VARBINARY, @a, 1)","safe_cast(concat('0x', @a) as int64)" +bigquery,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; +bigquery,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) +bigquery,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; +bigquery,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) +bigquery,"DATEDIFF(d,@start, @end)","DATE_DIFF(cast(@end as date), cast(@start as date), DAY)" +bigquery,"DATEDIFF(dd,@start, @end)","DATE_DIFF(cast(@end as date), cast(@start as date), DAY)" +bigquery,"DATEDIFF(day,@start, @end)","DATE_DIFF(cast(@end as date), cast(@start as date), DAY)" +bigquery,"DATEDIFF(year,@start, @end)",(EXTRACT(YEAR from CAST(@end AS DATE)) - EXTRACT(YEAR from CAST(@start AS DATE))) +bigquery,"DATEDIFF(yyyy,@start, @end)",(EXTRACT(YEAR from CAST(@end AS DATE)) - EXTRACT(YEAR from CAST(@start AS DATE))) +bigquery,"DATEDIFF(yy,@start, @end)",(EXTRACT(YEAR from CAST(@end AS DATE)) - EXTRACT(YEAR from CAST(@start AS DATE))) +bigquery,"DATEDIFF(month,@start, @end)","((12 * EXTRACT(YEAR FROM CAST(@end AS DATE)) + EXTRACT(MONTH FROM CAST(@end AS DATE))) - (12 * EXTRACT(YEAR FROM CAST(@start AS DATE)) + EXTRACT(MONTH FROM CAST(@start AS DATE))) + IF(EXTRACT(DAY FROM CAST(@end AS DATE)) >= EXTRACT(DAY FROM CAST(@start AS DATE)), 0, -1))" +bigquery,"DATEDIFF(mm,@start, @end)","((12 * EXTRACT(YEAR FROM CAST(@end AS DATE)) + EXTRACT(MONTH FROM CAST(@end AS DATE))) - (12 * EXTRACT(YEAR FROM CAST(@start AS DATE)) + EXTRACT(MONTH FROM CAST(@start AS DATE))) + IF(EXTRACT(DAY FROM CAST(@end AS DATE)) >= EXTRACT(DAY FROM CAST(@start AS DATE)), 0, -1))" +bigquery,"DATEDIFF(m,@start, @end)","((12 * EXTRACT(YEAR FROM CAST(@end AS DATE)) + EXTRACT(MONTH FROM CAST(@end AS DATE))) - (12 * EXTRACT(YEAR FROM CAST(@start AS DATE)) + EXTRACT(MONTH FROM CAST(@start AS DATE))) + IF(EXTRACT(DAY FROM CAST(@end AS DATE)) >= EXTRACT(DAY FROM CAST(@start AS DATE)), 0, -1))" +bigquery,"DATEADD(d,@days,@date)","DATE_ADD(cast(@date as date), INTERVAL @days DAY)" +bigquery,"DATEADD(dd,@days,@date)","DATE_ADD(cast(@date as date), INTERVAL @days DAY)" +bigquery,"DATEADD(day,@days,@date)","DATE_ADD(cast(@date as date), INTERVAL @days DAY)" +bigquery,"DATEADD(m,@months,@date)","DATE_ADD(cast(@date as date), INTERVAL @months MONTH)" +bigquery,"DATEADD(mm,@months,@date)","DATE_ADD(cast(@date as date), INTERVAL @months MONTH)" +bigquery,"DATEADD(month,@months,@date)","DATE_ADD(cast(@date as date), INTERVAL @months MONTH)" +bigquery,"DATEADD(yy,@years,@date)","DATE_ADD(@date, INTERVAL @years YEAR)" +bigquery,"DATEADD(yyyy,@years,@date)","DATE_ADD(@date, INTERVAL @years YEAR)" +bigquery,"DATEADD(year,@years,@date)","DATE_ADD(@date, INTERVAL @years YEAR)" +bigquery,INTERVAL @(-?[0-9]+)a.0,INTERVAL @a +bigquery,CAST(@a AS VARCHAR) + @b(@c),"CONCAT(CAST(@a AS VARCHAR), @b(@c))" +bigquery,@([a-z]+)a(@b) + CAST(@c AS VARCHAR),"CONCAT(@a(@b), CAST(@c AS VARCHAR))" +bigquery,CAST(@a AS VARCHAR(@n)) + @b(@c),"CONCAT(CAST(@a AS VARCHAR(@n)), @b(@c))" +bigquery,@([a-z]+)a(@b) + CAST(@c AS VARCHAR(@n)),"CONCAT(@a(@b), CAST(@c AS VARCHAR(@n)))" +bigquery,'@a' + @b(@c),"CONCAT('@a', @b(@c))" +bigquery,@([a-z]+)a(@b) + '@c',"CONCAT(@a(@b), '@c')" +bigquery,'@a' + @b FROM,"CONCAT('@a', @b) FROM" +bigquery,@([a-z0-9_]+)a + '@b',"CONCAT(@a, '@b')" +bigquery,CAST(@a AS VARCHAR) + @b FROM,"CONCAT(CAST(@a AS VARCHAR), @b) FROM" +bigquery,@([a-z0-9_]+)a + CAST(@b AS VARCHAR),"CONCAT(@a, CAST(@b AS VARCHAR))" +bigquery,CAST(@a AS VARCHAR(@n)) + @b FROM,"CONCAT(CAST(@a AS VARCHAR(@n)), @b) FROM" +bigquery,@([a-z0-9_]+)a + CAST(@b AS VARCHAR(@n)),"CONCAT(@a, CAST(@b AS VARCHAR(@n)))" +bigquery,CONCAT(@a) + @b(@c),"CONCAT(@a, @b(@c))" +bigquery,@([a-z]+)a(@b) + CONCAT(@c),"CONCAT(@a(@b), @c)" +bigquery,CONCAT(@a) + @b FROM,"CONCAT(@a, @b) FROM" +bigquery,@([a-z0-9_]+)a + CONCAT(@b),"CONCAT(@a, @b)" +bigquery,"CONCAT(@a, CONCAT(@b, @c))","CONCAT(@a, @b, @c)" +bigquery,"CONCAT(CONCAT(@a, @b), @c)","CONCAT(@a, @b, @c)" +bigquery,"CONCAT(CONCAT(@a, @b, @c))","CONCAT(@a, @b, @c)" +bigquery,"STDEV(@a)","STDDEV(@a)" +bigquery,"HASHBYTES('MD5',@a)","md5(@a)" +bigquery,"LEN(@a)","LENGTH(@a)" +bigquery,"COUNT_BIG(@a)","COUNT(@a)" +bigquery,"cast(@a % @b as int)","CAST(MOD(@a, @b) AS INT64)" +bigquery,"cast((@a % @b) as int)","CAST(MOD(@a, @b) AS INT64)" +bigquery,"cast(@a) % @([0-9]+)b","MOD(CAST(@a), @b)" +bigquery,"cast(@a) % cast(@b)","MOD(CAST(@a), CAST(@b))" +bigquery,"CAST(@a as:string)","CAST(@a as string)" +bigquery,"CAST(@a as:integer)","CAST(@a as int64)" +bigquery,"CAST(@a as:float)","CAST(@a as float64)" +bigquery,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +bigquery,WITH @a SELECT @b INTO @c FROM @d;,CREATE TABLE @c AS WITH @a SELECT @b FROM @d; +bigquery,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; +bigquery,SELECT @a INTO @b;,CREATE TABLE @b\nAS\nSELECT\n@a; +bigquery,WITH @a INSERT INTO @b SELECT @c;,INSERT INTO @b WITH @a SELECT @c; +bigquery,"LEFT(@str,@chars)","SUBSTR(@str,0,@chars)" +bigquery,"RIGHT(@str,@chars)","SUBSTR(@str,-@chars)" +bigquery,"cast(@a as float)","cast(@a as float64)" +bigquery,"cast(@a as bigint)","cast(@a as int64)" +bigquery,"cast(@a as int)","cast(@a as int64)" +bigquery,date(@a),cast(@a as date) +bigquery,"cast(concat(@a) as date)","parse_date('%Y%m%d', concat(@a))" +bigquery,"cast(@a as date)","IF(SAFE_CAST(@a AS DATE) IS NULL,PARSE_DATE('%Y%m%d', cast(@a AS STRING)),SAFE_CAST(@a AS DATE))" +bigquery,"YEAR(@date)","EXTRACT(YEAR from @date)" +bigquery,"MONTH(@date)","EXTRACT(MONTH from @date)" +bigquery,"DAY(@date)","EXTRACT(DAY from @date)" +bigquery,"DATEPART(YEAR, @date)",EXTRACT(YEAR FROM @date) +bigquery,"DATEPART(MONTH, @date)",EXTRACT(MONTH FROM @date) +bigquery,"DATEPART(DAY, @date)",EXTRACT(DAY FROM @date) +bigquery,"union select","union distinct select" +bigquery,INTERSECT,INTERSECT DISTINCT +bigquery,"ISNULL(@a,@b)","IFNULL(@a,@b)" +bigquery,as \" @a \",as @a +bigquery,"coalesce(@([0-9]+)a, @b)","coalesce(@a, cast(@b as int64))" +bigquery,"coalesce(@a, @([0-9]+)b)","coalesce(cast(@a as int64), @b)" +bigquery,"cast(@a as decimal(@b))","cast(@a as float64)" +bigquery,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +bigquery,"int","INT64" +bigquery,"DATETIME2","datetime" +bigquery,"INTEGER","INT64" +bigquery,"bigint","INT64" +bigquery,"float","FLOAT64" +bigquery,VARCHAR(@a),STRING +bigquery,VARCHAR,STRING +bigquery,CHAR(@a),STRING +bigquery,CHAR,STRING +bigquery,STRING NULL,STRING +bigquery,DATE NULL,DATE +bigquery,DATETIME NULL,DATETIME +bigquery,INT64 NULL,INT64 +bigquery,FLOAT64 NULL,FLOAT64 +bigquery,NUMERIC NULL,NUMERIC +bigquery,"DOUBLE PRECISION","FLOAT64" +bigquery,"GETDATE()","CURRENT_DATE()" +bigquery,"CONSTRAINT @a DEFAULT CURRENT_DATE()","" +bigquery,DEFAULT @([0-9]+)a,"" +bigquery,DEFAULT \"@a\","" +bigquery,"DEFAULT CURRENT_DATE()","" +bigquery,TRUNCATE TABLE @a;,DELETE FROM @a WHERE True; +bigquery,CREATE TABLE #@([^\s]+)table,DROP TABLE IF EXISTS %temp_prefix%%session_id%@table;\nCREATE TABLE %temp_prefix%%session_id%@table +bigquery,#@([^\s]+)table.@([^\s]+)field,%session_id%@table.@field +bigquery,#,%temp_prefix%%session_id% +bigquery,CREATE INDEX @index_name ON @table_col_cond;,-- bigquery does not support indexes +bigquery,DROP INDEX @index_name;,-- bigquery does not support indexes +bigquery,"DATEFROMPARTS(@year,@month,@day)","DATE(@year, @month, @day)" +bigquery,EOMONTH(@date),"DATE_SUB(DATE_TRUNC(DATE_ADD(@date, INTERVAL 1 MONTH), MONTH), INTERVAL 1 DAY)" +bigquery,"ISNUMERIC(@a)","CASE WHEN SAFE_CAST(@a AS FLOAT64) IS NULL THEN 0 ELSE 1 END" +bigquery,UPDATE STATISTICS @a;,-- big query does not support such functionality +bigquery,NEWID(),GENERATE_UUID() +bigquery,"AS @(q[0-9]+)a","AS val_@a" +bigquery,"(@(q[0-9]+)a","(val_@a" +bigquery,"CHARINDEX(@a,@b)","STRPOS(@b,@a)" +bigquery,"\"","`" +sqlite,...@([0-9]+|y)a,xxx@a +sqlite,"AS drvd(@a)","AS drvd(@a)" +sqlite,"@a, @b)","@a, @b)" +sqlite,"","NULL AS " +sqlite,"FROM (VALUES @a) AS drvd(@b)","FROM (SELECT @b WHERE (0 = 1) UNION ALL VALUES @a) AS values_table" +sqlite,TRY_CAST(@a),CAST(@a) +sqlite,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +sqlite,"ROUND(@a,@b)","ROUND(CAST(@a AS REAL),@b)" +sqlite,DATETIME,REAL +sqlite,DATETIME2,REAL +sqlite,"CONVERT(DATE, @a)","CAST(STRFTIME('%s', SUBSTR(CAST(@a AS TEXT), 1, 4) || '-' || SUBSTR(CAST(@a AS TEXT), 5, 2) || '-' || SUBSTR(CAST(@a AS TEXT), 7)) AS REAL)" +sqlite,CAST(@a AS DATE),"CAST(STRFTIME('%s', SUBSTR(CAST(@a AS TEXT), 1, 4) || '-' || SUBSTR(CAST(@a AS TEXT), 5, 2) || '-' || SUBSTR(CAST(@a AS TEXT), 7)) AS REAL)" +sqlite,"DATEADD(second,@seconds,@datetime)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@seconds)||' seconds')) AS REAL)" +sqlite,"DATEADD(minute,@minutes,@datetime)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@minutes)||' minutes')) AS REAL)" +sqlite,"DATEADD(hour,@hours,@datetime)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@hours)||' hours')) AS REAL)" +sqlite,"DATEADD(d,@days,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@days)||' days')) AS REAL)" +sqlite,"DATEADD(dd,@days,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@days)||' days')) AS REAL)" +sqlite,"DATEADD(day,@days,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@days)||' days')) AS REAL)" +sqlite,"DATEADD(m,@months,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@months)||' months')) AS REAL)" +sqlite,"DATEADD(mm,@months,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@months)||' months')) AS REAL)" +sqlite,"DATEADD(month,@months,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@months)||' months')) AS REAL)" +sqlite,"DATEADD(yy,@years,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@years)||' years')) AS REAL)" +sqlite,"DATEADD(yyyy,@years,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@years)||' years')) AS REAL)" +sqlite,"DATEADD(year,@years,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@years)||' years')) AS REAL)" +sqlite,"DATEDIFF(d,@start,@end)","(JULIANDAY(@end, 'unixepoch') - JULIANDAY(@start, 'unixepoch'))" +sqlite,"DATEDIFF(dd,@start,@end)","(JULIANDAY(@end, 'unixepoch') - JULIANDAY(@start, 'unixepoch'))" +sqlite,"DATEDIFF(day,@start,@end)","(JULIANDAY(@end, 'unixepoch') - JULIANDAY(@start, 'unixepoch'))" +sqlite,"DATEDIFF(year,@start, @end)","(STRFTIME('%Y', @end, 'unixepoch') - STRFTIME('%Y', @start, 'unixepoch'))" +sqlite,"DATEDIFF(yyyy,@start, @end)","(STRFTIME('%Y', @end, 'unixepoch') - STRFTIME('%Y', @start, 'unixepoch'))" +sqlite,"DATEDIFF(yy,@start, @end)","(STRFTIME('%Y', @end, 'unixepoch') - STRFTIME('%Y', @start, 'unixepoch'))" +sqlite,"DATEDIFF(MONTH,@start, @end)","((STRFTIME('%Y', @end, 'unixepoch')*12 + STRFTIME('%m', @end, 'unixepoch')) - (STRFTIME('%Y', @start, 'unixepoch')*12 + STRFTIME('%m', @start, 'unixepoch')) + (CASE WHEN STRFTIME('%d', @end, 'unixepoch') >= STRFTIME('%d', @start, 'unixepoch') then 0 else -1 end))" +sqlite,"JULIANDAY('@literal', 'unixepoch')","JULIANDAY(CAST(STRFTIME('%s', SUBSTR(CAST('@literal' AS TEXT), 1, 4) || '-' || SUBSTR(CAST('@literal' AS TEXT), 5, 2) || '-' || SUBSTR(CAST('@literal' AS TEXT), 7)) AS REAL), 'unixepoch')" +sqlite,"STRFTIME('%Y', '@literal', 'unixepoch')","CAST(SUBSTR('@literal', 1, 4) AS REAL)" +sqlite,"STRFTIME('%m', '@literal', 'unixepoch')","CAST(SUBSTR('@literal', 5, 2) AS REAL)" +sqlite,"STRFTIME('%d', '@literal', 'unixepoch')","CAST(SUBSTR('@literal', 7, 2) AS REAL)" +sqlite,"CONVERT(VARCHAR,@date,112)","CAST(STRFTIME('%Y%m%d', @date) AS REAL)" +sqlite,GETDATE(),"STRFTIME('%s','now')" +sqlite,+ '@a',|| '@a' +sqlite,'@a' +,'@a' || +sqlite,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || +sqlite,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) +sqlite,CAST(@a AS varchar) +,CAST(@a AS varchar) || +sqlite,+ CAST(@a AS varchar),|| CAST(@a AS varchar) +sqlite,"DATEFROMPARTS(@year,@month,@day)","STRFTIME('%s', SUBSTR(CAST('0000'||CAST(@year AS INT) AS TEXT),-4) || '-' || SUBSTR(CAST('00'||CAST(@month AS INT) AS TEXT),-2) || '-' || SUBSTR(CAST('00'||CAST(@day AS INT) AS TEXT),-2))" +sqlite,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","STRFTIME('%s', SUBSTR(CAST('0000'||CAST(@year AS INT) AS TEXT),-4) || '-' || SUBSTR(CAST('00'||CAST(@month AS INT) AS TEXT),-2) || '-' || SUBSTR(CAST('00'||CAST(@day AS INT) AS TEXT),-2) || ' ' || SUBSTR(CAST('00'||CAST(@hour AS INT) AS TEXT),-2) || ':' || SUBSTR(CAST('00'||CAST(@minute AS INT) AS TEXT),-2) || ':' || SUBSTR(CAST('00'||CAST(@second AS INT) AS TEXT),-2) || '.' || SUBSTR(CAST('000'||CAST(@ms AS INT) AS TEXT),-3))" +sqlite,YEAR(@date),"CAST(STRFTIME('%Y', @date, 'unixepoch') AS INT)" +sqlite,MONTH(@date),"CAST(STRFTIME('%m', @date, 'unixepoch') AS INT)" +sqlite,DAY(@date),"CAST(STRFTIME('%d', @date, 'unixepoch') AS INT)" +sqlite,"DATEPART(YEAR, @date)","CAST(STRFTIME('%Y', @date, 'unixepoch') AS INT)" +sqlite,"DATEPART(MONTH, @date)","CAST(STRFTIME('%m', @date, 'unixepoch') AS INT)" +sqlite,"DATEPART(DAY, @date)","CAST(STRFTIME('%d', @date, 'unixepoch') AS INT)" +sqlite,EOMONTH(@date),"STRFTIME('%s', DATETIME(@date, 'unixepoch', 'start of month', '+1 month', '-1 day'))" +sqlite,VAR(@a),VARIANCE(@a) +sqlite,RAND(),((RANDOM()+9223372036854775808) / 18446744073709551615) +sqlite,LEN(@a),LENGTH(@a) +sqlite,"LOG(@expression,@base)","(LOG(@expression)/LOG(@base))" +sqlite,"ISNULL(@a,@b)","COALESCE(@a,@b)" +sqlite,"ISNUMERIC(@a)","CASE WHEN @a GLOB '[0-9]*' OR @a GLOB '[0-9]*.[0-9]*' OR @a GLOB '.[0-9]*' THEN 1 ELSE 0 END" +sqlite,COUNT_BIG(@a),COUNT(@a) +sqlite,NEWID(),RANDOM() +sqlite,"RIGHT(@a,@b)","SUBSTR(CAST(@a AS TEXT),-@b)" +sqlite,"LEFT(@str,@chars)","SUBSTR(CAST(@str AS TEXT),1,@chars)" +sqlite,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +sqlite,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +sqlite,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +sqlite,.dbo.,. +sqlite,CREATE TABLE #@table (@definition),CREATE TEMP TABLE @table (@definition) +sqlite,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE INDEX @index_name ON @table (@variable); +sqlite,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable); +sqlite,CREATE INDEX @index_name ON @schema.@table (@variable);,CREATE INDEX @index_name ON @table (@variable); +sqlite,CREATE UNIQUE INDEX @index_name ON @schema.@table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable); +sqlite,DROP INDEX @schema.@index_name;,DROP INDEX @index_name; +sqlite,PRIMARY KEY NONCLUSTERED,PRIMARY KEY +sqlite,VARCHAR(@a),TEXT +sqlite,VARCHAR,TEXT +sqlite,FLOAT,REAL +sqlite,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nANALYZE @d; +sqlite,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +sqlite,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a\nFROM\n@c;\nANALYZE @b; +sqlite,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; +sqlite,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; +sqlite,#@([^\s]+)table.@([^\s]+)field,@table.@field +sqlite,#,temp. +sqlite,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; +sqlite,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) +sqlite,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; +sqlite,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) +sqlite,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as TEXT) @s2)" +sqlite,UPDATE STATISTICS @a;,ANALYZE @a; +sqlite,TRUNCATE TABLE @a;,DELETE FROM @a; +sqlite,"CONCAT(@a, @b, @c)","CONCAT(@a, CONCAT(@b, @c))" +sqlite,"CONCAT(@a, @b)","@a || @b" +sqlite,CEILING(@a),CEIL(@a) +sqlite,IN (SELECT @a) UNION,IN ((SELECT @a)) UNION +sqlite,(SELECT @a) UNION,SELECT @a UNION +sqlite,UNION (@a),UNION @a +sqlite,UNION ALL (@a),UNION ALL @a +sqlite,"ALTER TABLE @table ALTER COLUMN @a BIGINT;","SELECT 0;" +sqlite,"ALTER TABLE @table ADD @a, @b;","ALTER TABLE @table ADD @a; ALTER TABLE @table ADD @b;" +hive,...@([0-9]+|y)a,xxx@a +hive,TRY_CAST(@a),CAST(@a) +hive,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +hive,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,-- hive does not support indexes +hive,CREATE INDEX @index_name ON @table (@variable);,-- hive does not support indexes +hive,CREATE INDEX @index_name ON @table (@variable) WHERE @c;,-- hive does not support indexes +hive,"CHARINDEX(@a,@b)","INSTR(@b,@a)" +hive,COUNT_BIG(@a),COUNT(@a) +hive,"LEFT(@str,@chars)","SUBSTR(@str,1,@chars)" +hive,LEN(@a),LENGTH(@a) +hive,LOG(@expression),LN(@expression) +hive,NEWID(),"reflect('java.util.UUID','randomUUID')" +hive,"RIGHT(@str,@chars)","SUBSTR(@str,-@chars)" +hive,"ROUND(@a,@b)","ROUND(CAST(@a AS DOUBLE),@b)" +hive,SQUARE(@a),((@a)*(@a)) +hive,STDEV(@a),STDDEV_POP(@a) +hive,VAR(@a),VARIANCE(@a) +hive,"DATEADD(d,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" +hive,"DATEADD(dd,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" +hive,"DATEADD(day,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" +hive,"DATEADD(month,@months,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, @months) AS TIMESTAMP)" +hive,"DATEADD(mm,@months,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, @months) AS TIMESTAMP)" +hive,"DATEADD(m,@months,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, @months) AS TIMESTAMP)" +hive,"DATEADD(year,@years,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, 12 * @years) AS TIMESTAMP)" +hive,"DATEADD(yyyy,@years,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, 12 * @years) AS TIMESTAMP)" +hive,"DATEADD(yy,@years,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, 12 * @years) AS TIMESTAMP)" +hive,"DATEADD(d,@days,@date)","DATE_ADD(CAST(@date AS TIMESTAMP), @days)" +hive,"DATEADD(dd,@days,@date)","DATE_ADD(CAST(@date AS TIMESTAMP), @days)" +hive,"DATEADD(day,@days,@date)","DATE_ADD(CAST(@date AS TIMESTAMP), @days)" +hive,"DATEADD(month,@months,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), @months) AS TIMESTAMP)" +hive,"DATEADD(mm,@months,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), @months) AS TIMESTAMP)" +hive,"DATEADD(m,@months,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), @months) AS TIMESTAMP)" +hive,"DATEADD(year,@years,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), 12 * @years) AS TIMESTAMP)" +hive,"DATEADD(yyyy,@years,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), 12 * @years) AS TIMESTAMP)" +hive,"DATEADD(yy,@years,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), 12 * @years) AS TIMESTAMP)" +hive,"DATEDIFF(d,@start, @end)","day(CAST(@end AS TIMESTAMP) - CAST(@start AS TIMESTAMP))" +hive,"DATEDIFF(dd,@start, @end)","day(CAST(@end AS TIMESTAMP) - CAST(@start AS TIMESTAMP))" +hive,"DATEDIFF(day,@start, @end)","day(CAST(@end AS TIMESTAMP) - CAST(@start AS TIMESTAMP))" +hive,"DATEDIFF(year,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) +hive,"DATEDIFF(yyyy,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) +hive,"DATEDIFF(yy,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) +hive,"DATEDIFF(month,@start, @end)","CAST(MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE)) AS INT)" +hive,"DATEDIFF(mm,@start, @end)","CAST(MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE)) AS INT)" +hive,"DATEDIFF(m,@start, @end)","CAST(MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE)) AS INT)" +hive,"DATEFROMPARTS(@year,@month,@day)","CAST(CONCAT(CAST(@year AS STRING),'-',CAST(@month AS STRING),'-',CAST(@day AS STRING)) AS TIMESTAMP)" +hive,"eomonth(@date)","CAST(last_day(@date) AS TIMESTAMP)" +hive,GETDATE(),unix_timestamp() +hive,year(unix_timestamp()),year(from_unixtime(unix_timestamp())) +hive,"DATEPART(YEAR, @date)",year(from_unixtime(unix_timestamp())) +hive,"DATEPART(MONTH, @date)",month(from_unixtime(unix_timestamp())) +hive,"DATEPART(DAY, @date)",day(from_unixtime(unix_timestamp())) +hive,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +hive,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +hive,"HINT PARTITION(@p) @before CREATE TABLE IF NOT EXISTS @table(@pid,@fields) @after;",partitioned table \n@before CREATE TABLE IF NOT EXISTS @table (@fields) @after \n PARTITIONED BY(@p); +hive,"HINT BUCKET(@bucket,@size) @before CREATE TABLE IF NOT EXISTS @table(@fields) @after;",table with bucket \n@before CREATE TABLE IF NOT EXISTS @table (@fields) @after \n CLUSTERED by (@bucket) into @size BUCKETS; +hive,"HINT PARTITION(@p) @before CREATE TABLE @table(@pid,@fields) @after;",partitioned table \n@before CREATE TABLE @table (@fields) @after \n PARTITIONED BY(@p); +hive,"HINT BUCKET(@bucket,@size) @before CREATE TABLE @table(@fields) @after;",table with bucket \n@before CREATE TABLE @table (@fields) @after \n CLUSTERED by (@bucket) into @size BUCKETS; +hive,(SELECT @a UNION SELECT @b) ORDER BY,SELECT * FROM\n(SELECT @a\nUNION\nSELECT @b)\nAS t1 ORDER BY +hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f) SELECT @i INTO #@j FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nCREATE TEMPORARY TABLE @j AS SELECT @i FROM" +hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f), @g AS (@h) SELECT @i INTO #@j FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e; DROP TABLE IF EXISTS @g;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nCREATE TEMPORARY TABLE @g AS @h;\nCREATE TEMPORARY TABLE @j AS SELECT @i FROM" +hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f), @g AS (@h), @i AS (@j) SELECT @k INTO #@l FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e; DROP TABLE IF EXISTS @g; DROP TABLE IF EXISTS @i;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nCREATE TEMPORARY TABLE @g AS @h;\nCREATE TEMPORARY TABLE @i AS @j;\nCREATE TEMPORARY TABLE @l AS SELECT @k FROM" +hive,WITH @a AS @b SELECT @c INTO #@d FROM,DROP TABLE IF EXISTS @a;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @d AS SELECT @c FROM +hive,SELECT @a INTO #@b FROM @c;,CREATE TEMPORARY TABLE IF NOT EXISTS @b AS\nSELECT\n@a\nFROM\n@c; +hive,SELECT @a INTO #@b;,CREATE TEMPORARY TABLE IF NOT EXISTS @b AS\nSELECT\n@a; +hive,CREATE TABLE #@table (@definition),CREATE TEMPORARY TABLE IF NOT EXISTS @table (@definition) +hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f) SELECT @i INTO @j FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nSELECT @i INTO @j FROM" +hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f), @g AS (@h) SELECT @i INTO @j FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e; DROP TABLE IF EXISTS @g;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nCREATE TEMPORARY TABLE @g AS @h;\nSELECT @i INTO @j FROM" +hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f), @g AS (@h), @i AS (@j) SELECT @k INTO @l FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e; DROP TABLE IF EXISTS @g; DROP TABLE IF EXISTS @i;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nCREATE TEMPORARY TABLE @g AS @h;\nCREATE TEMPORARY TABLE @i AS @j;\nSELECT @k INTO @l FROM" +hive,WITH @a AS @b SELECT @c INTO @d FROM,DROP TABLE IF EXISTS @a;\n\nCREATE TEMPORARY TABLE @a AS @b;\nSELECT @c INTO @d FROM +hive,"CREATE TEMPORARY TABLE @a AS (@b), @c (@d) as (@e)\n;","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; CREATE TEMPORARY TABLE @a AS (@b)\n;\nCREATE TEMPORARY TABLE @c AS (@e)\n;" +hive,"CREATE TEMPORARY TABLE @a AS (@b), @c as (@d)\n;","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; CREATE TEMPORARY TABLE @a AS (@b)\n;\nCREATE TEMPORARY TABLE @c AS (@d)\n;" +hive,DROP TABLE IF EXISTS @a (@b),DROP TABLE IF EXISTS @a +hive,SELECT @a INTO @b FROM @c;,CREATE TABLE IF NOT EXISTS @b AS\nSELECT\n@a\nFROM\n@c; +hive,SELECT @a INTO @b;,CREATE TABLE IF NOT EXISTS @b AS\nSELECT\n@a; +hive,SELECT DISTINCT @a FROM @b INTERSECT SELECT DISTINCT @a FROM @c;,SELECT t1.@a FROM (SELECT DISTINCT @a FROM @b UNION ALL SELECT DISTINCT @a FROM @c) AS t1 GROUP BY @a HAVING COUNT(*) >= 2; +hive,(SELECT DISTINCT @a FROM @b INTERSECT SELECT DISTINCT @a FROM @c),(SELECT t1.@a FROM (SELECT DISTINCT @a FROM @b UNION ALL SELECT DISTINCT @a FROM @c) AS t1 GROUP BY @a HAVING COUNT(*) >= 2) +hive,.dbo.,. +hive,##, +hive,#, +hive,#.@table (,@table ( +hive,#.@table;,@table; +hive,#.@table),@table) +hive,,## +hive,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; +hive,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) +hive,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; +hive,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) +hive,DATE,TIMESTAMP +hive,DATETIME,TIMESTAMP +hive,DATETIME2,TIMESTAMP +hive,BIGINT NOT NULL,BIGINT +hive,BOOLEAN NOT NULL,BOOLEAN +hive,CHAR NOT NULL,CHAR +hive,DECIMAL NOT NULL,DECIMAL +hive,DOUBLE NOT NULL,DOUBLE +hive,FLOAT NOT NULL,FLOAT +hive,INT NOT NULL,INT +hive,REAL NOT NULL,FLOAT +hive,SMALLINT NOT NULL,SMALLINT +hive,STRING NOT NULL,VARCHAR +hive,TIMESTAMP NOT NULL,TIMESTAMP +hive,TINYINT NOT NULL,TINYINT +hive,VARCHAR(@a) NOT NULL,VARCHAR(@a) +hive,BIGINT NULL,BIGINT +hive,BOOLEAN NULL,BOOLEAN +hive,CHAR NULL,CHAR +hive,DECIMAL NULL,DECIMAL +hive,DOUBLE NULL,DOUBLE +hive,FLOAT NULL,FLOAT +hive,INT NULL,INT +hive,REAL NULL,FLOAT +hive,SMALLINT NULL,SMALLINT +hive,STRING NULL,VARCHAR +hive,TIMESTAMP NULL,TIMESTAMP +hive,TINYINT NULL,TINYINT +hive,VARCHAR(@a) NULL,VARCHAR(@a) +hive,"CHAR,","CHAR(1)," +hive,"CHAR\n+","CHAR(1)\n" +hive,"CHAR)","CHAR(1))" +hive,"CONSTRAINT @a DEFAULT unix_timestamp()","" +hive,"DEFAULT unix_timestamp()","" +hive,stats,_stats +hive,UPDATE STATISTICS @a;,-- hive does not support COMPUTE STATS +hive,CAST(@a AS VARCHAR),CAST(@a AS VARCHAR(1000)) +hive,"ISNULL(@a,@b)","COALESCE(@a,@b)" +hive,(@a) AS select,(@a) AS select +hive,"TABLE @cte_name (@a) AS (","TABLE @cte_name AS (" +hive,"TABLE @cte_name (@a) AS select","TABLE @cte_name AS select" +hive,"WHEN .@digits * ","WHEN 0.@digits * " +hive,">= .@digits * ",">= 0.@digits * " +hive, _stats, stats +hive,"ISNUMERIC(@a)","case when cast(@a as double) is not null then 1 else 0 end" +hive,as \"@a\",as @a +hive,"HASHBYTES('MD5',@a)","hash(@a)" +hive,"CONVERT(VARBINARY, @a, 1)","@a" +spark,...@([0-9]+|y)a,xxx@a +spark,TRY_CAST(@a),CAST(@a) +spark,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +spark,tempdb..#@table +,%temp_prefix%%session_id% || +spark,#@([^\s]+)table.@([^\s]+)field,%session_id%@table.@field +spark,"--HINT BUCKET(@a, @b)","" +spark,"--HINT PARTITION(@a @b)","" +spark,"HINT DISTRIBUTE_ON_KEY(@key) CREATE TABLE IF NOT EXISTS @table\nUSING DELTA\nAS\n@definition;","HINT DISTRIBUTE_ON_KEY(@key)\nCREATE TABLE IF NOT EXISTS @table\nUSING DELTA\nAS\n@definition;\nOPTIMIZE @table ZORDER BY @key;" +spark,"HINT DISTRIBUTE_ON_KEY(@key) IF OBJECT_ID('@d', 'U') IS NULL WITH @a AS @b SELECT @c INTO @d FROM @e;",HINT DISTRIBUTE_ON_KEY(@key)\nCREATE TABLE IF NOT EXISTS @d\nUSING DELTA\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nOPTIMIZE @d ZORDER BY @key; +spark,"HINT DISTRIBUTE_ON_KEY(@key) IF OBJECT_ID('@b', 'U') IS NULL SELECT @a INTO @b FROM @c;",HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE IF NOT EXISTS @b\nUSING DELTA\nAS\nSELECT\n@a\nFROM\n@c;\nOPTIMIZE @b ZORDER BY @key; +spark,"HINT DISTRIBUTE_ON_KEY(@key) IF OBJECT_ID('@b', 'U') IS NULL SELECT @a INTO @b WHERE @c;",HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE IF NOT EXISTS @b\nUSING DELTA\nAS\nSELECT\n@a WHERE @c;\nOPTIMIZE @b ZORDER BY @key; +spark,"HINT DISTRIBUTE_ON_KEY(@key) IF OBJECT_ID('@b', 'U') IS NULL SELECT @a INTO @b GROUP BY @c;",HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE IF NOT EXISTS @b\nUSING DELTA\nAS\nSELECT\n@a\nGROUP BY\n@c;\nOPTIMIZE @b ZORDER BY @key; +spark,HINT DISTRIBUTE_ON_KEY(@key) WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_KEY(@key)\nCREATE TABLE @d\nUSING DELTA\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nOPTIMIZE @d ZORDER BY @key; +spark,HINT DISTRIBUTE_ON_KEY(@key) SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE @b\nUSING DELTA\nAS\nSELECT\n@a\nFROM\n@c;\nOPTIMIZE @b ZORDER BY @key; +spark,HINT DISTRIBUTE_ON_KEY(@key) SELECT @a INTO @b WHERE @c;,HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE @b\nUSING DELTA\nAS\nSELECT\n@a WHERE @c;\nOPTIMIZE @b ZORDER BY @key; +spark,HINT DISTRIBUTE_ON_KEY(@key) SELECT @a INTO @b GROUP BY @c;,HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE @b\nUSING DELTA\nAS\nSELECT\n@a\nGROUP BY\n@c;\nOPTIMIZE @b ZORDER BY @key; +spark,WITH @with SELECT @c INTO @d FROM @e;,@with\n CREATE TABLE @d\nUSING DELTA\nAS\n(SELECT\n@c\nFROM\n@e); +spark,"@a (@columns) AS (@b),",@a AS (@b) +spark,"@a AS (@b),",@a AS (@b) +spark,@a (@columns) AS (@b),@a AS (@b) +spark,"@a AS (@b)",DROP VIEW IF EXISTS @a; CREATE TEMPORARY VIEW @a AS (@b);\n +spark,SELECT @a INTO @b FROM @c;,CREATE TABLE @b\nUSING DELTA\nAS\nSELECT\n@a\nFROM\n@c; +spark,SELECT @a INTO @b WHERE @c;,CREATE TABLE @b\nUSING DELTA\n AS\nSELECT\n@a WHERE @c; +spark,SELECT @a INTO @b GROUP BY @c;,CREATE TABLE @b\nUSING DELTA\n AS\nSELECT\n@a GROUP BY @c; +spark,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table\nUSING DELTA\nAS\n@definition;",CREATE TABLE IF NOT EXISTS @table\nUSING DELTA\nAS\n@definition; +spark,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +spark,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +spark,CREATE TABLE #@([^\s]+)table,DROP TABLE IF EXISTS %temp_prefix%%session_id%@table;\nCREATE TABLE %temp_prefix%%session_id%@table +spark,#,%temp_prefix%%session_id% +spark,\"@a\",`@a` +spark,+ '@a',|| '@a' +spark,'@a' +,'@a' || +spark,CREATE INDEX @index_name ON @table (@variable);,-- spark does not support indexes +spark,"ROUND(@a,@b)","ROUND(CAST(@a AS DOUBLE),@b)" +spark,"HASHBYTES('MD5',@a)","MD5(@a)" +spark,"CONVERT(VARBINARY, CONCAT('0x', @a), 1)","CAST(CONCAT('x', @a) AS BIT(32))" +spark,"CONVERT(DATE, @a)","TO_DATE(@a, 'yyyy-MM-dd')" +spark,"DATEPART(@part, @date)","DATE_PART('@part', @date)" +spark,"DATEADD(d,@days,@date)","DATEADD(day,@days,@date)" +spark,"DATEADD(dd,@days,@date)","DATEADD(day,@days,@date)" +spark,"DATEADD(m,@months,@date)","DATEADD(month,@months,@date)" +spark,"DATEADD(mm,@months,@date)","DATEADD(month,@months,@date)" +spark,"DATEADD(yy,@years,@date)","DATEADD(year,@years,@date)" +spark,"DATEADD(yyyy,@years,@date)","DATEADD(year,@years,@date)" +spark,"DATEADD(@part,@(-?[0-9]+)a.0,@date)","DATEADD(@part,@a,@date)" +spark,INTERVAL @(-?[0-9]+)a.0,INTERVAL @a +spark,"DATEDIFF(d,@start, @end)","datediff(day,@start,@end)" +spark,"DATEDIFF(dd,@start, @end)","datediff(day,@start,@end)" +spark,"CONVERT(VARCHAR,@date,112)","@date" +spark,GETDATE(),CURRENT_DATE +spark,CAST(@a AS varchar(@b)) +,"SUBSTRING(CAST(@a AS string), 0, @b) ||" +spark,+ CAST(@a AS varchar(@b)),"|| SUBSTRING(CAST(@a AS string), 0, @b)" +spark,CAST(@a AS varchar) +,CAST(@a AS string) || +spark,+ CAST(@a AS varchar),|| CAST(@a AS string) +spark,"DATEFROMPARTS(@year,@month,@day)","to_date(cast(@year as string) || '-' || cast(@month as string) || '-' || cast(@day as string))" +spark,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)",to_timestamp(cast(@year as string) || '-' || cast(@month as string) || '-' || cast(@day as string) || ' ' || cast(@hour as string) || ':' || cast(@minute as string) || ':' || cast(@second as string) || '.' || cast(@ms as string)) +spark,EOMONTH(@date),last_day(@date) +spark,STDEV(@a),STDDEV(@a) +spark,VAR(@a),VARIANCE(@a) +spark,LEN(@a),LENGTH(@a) +spark,"CHARINDEX(@a,@b)","INSTR(@b,@a)" +spark,"LOG(@expression,@base)","(@base,@expression)" +spark,LOG(@expression),LN(@expression) +spark,,LOG +spark,LOG10(@expression),"LOG(10,@expression)" +spark,"ISNULL(@a,@b)","COALESCE(@a,@b)" +spark,"ISNUMERIC(@a)","CASE WHEN CAST(@a AS DOUBLE) IS NOT NULL THEN 1 ELSE 0 END" +spark,COUNT_BIG(@a),COUNT(@a) +spark,SQUARE(@a),((@a)*(@a)) +spark,NEWID(),UUID() +spark,.dbo.,. +spark,CREATE CLUSTERED INDEX @index_name ON @table (@variable);, +spark,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);, +spark,PRIMARY KEY NONCLUSTERED, +spark,DATETIME,TIMESTAMP +spark,DATETIME2,TIMESTAMP +spark,VARCHAR(MAX),STRING +spark,VARCHAR(@a),STRING +spark,VARCHAR,STRING +spark,DOUBLE PRECISION,DOUBLE +spark,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; +spark,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) +spark,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; +spark,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) +spark,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as STRING) @s2)" +spark,UPDATE STATISTICS @a;, +spark,"SELECT @columns FROM (@a) @x,(@b) @y;","SELECT @columns FROM (@a) @x cross join (@b) @y;" +spark,"ALTER TABLE @table ADD COLUMN @([\w_-]+)column @type DEFAULT @default;","ALTER TABLE @table ADD COLUMN @column @type; \nALTER TABLE @table SET TBLPROPERTIES('delta.feature.allowColumnDefaults' = 'supported'); \nALTER TABLE @table ALTER COLUMN @column SET DEFAULT @default;" +spark,"CAST(@a AS DATE)","IF(try_cast(@a AS DATE) IS NULL, to_date(cast(@a AS STRING), 'yyyyMMdd'), try_cast(@a AS DATE))" +spark,"DATEADD(@part,@amount,@([0-9a-zA-Z_]+_date)date)","CAST(DATEA##(@part,@amount,@date) AS DATE)" +spark,DATEA##,DATEADD +spark,FLOAT,DOUBLE +sqlite extended,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +sqlite extended,"ROUND(@a,@b)","ROUND(CAST(@a AS REAL),@b)" +sqlite extended,+ '@a',|| '@a' +sqlite extended,'@a' +,'@a' || +sqlite extended,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || +sqlite extended,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) +sqlite extended,CAST(@a AS varchar) +,CAST(@a AS varchar) || +sqlite extended,+ CAST(@a AS varchar),|| CAST(@a AS varchar) +sqlite extended,VAR(@a),VARIANCE(@a) +sqlite extended,RAND(),RANDOM() +sqlite extended,LEN(@a),LENGTH(@a) +sqlite extended,"LOG(@expression,@base)","(LOG(@expression)/LOG(@base))" +sqlite extended,"ISNULL(@a,@b)","COALESCE(@a,@b)" +sqlite extended,"ISNUMERIC(@a)","CASE WHEN @a GLOB '[0-9]*' OR @a GLOB '[0-9]*.[0-9]*' OR @a GLOB '.[0-9]*' THEN 1 ELSE 0 END" +sqlite extended,COUNT_BIG(@a),COUNT(@a) +sqlite extended,NEWID(),RANDOM() +sqlite extended,"RIGHT(@a,@b)","SUBSTR(@a,-@b)" +sqlite extended,"LEFT(@str,@chars)","SUBSTR(@str,1,@chars)" +sqlite extended,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +sqlite extended,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +sqlite extended,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +sqlite extended,.dbo.,. +sqlite extended,CREATE TABLE #@table (@definition),CREATE TEMP TABLE @table (@definition) +sqlite extended,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE INDEX @index_name ON @table (@variable); +sqlite extended,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable); +sqlite extended,CREATE INDEX @index_name ON @schema.@table (@variable);,CREATE INDEX @index_name ON @table (@variable); +sqlite extended,CREATE UNIQUE INDEX @index_name ON @schema.@table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable); +sqlite extended,DROP INDEX @schema.@index_name;,DROP INDEX @index_name; +sqlite extended,PRIMARY KEY NONCLUSTERED,PRIMARY KEY +sqlite extended,VARCHAR(@a),TEXT +sqlite extended,VARCHAR,TEXT +sqlite extended,FLOAT,REAL +sqlite extended,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nANALYZE @d; +sqlite extended,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +sqlite extended,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a\nFROM\n@c;\nANALYZE @b; +sqlite extended,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; +sqlite extended,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; +sqlite extended,#,temp. +sqlite extended,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; +sqlite extended,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) +sqlite,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; +sqlite,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) +sqlite extended,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as TEXT) @s2)" +sqlite extended,UPDATE STATISTICS @a;,ANALYZE @a; +sqlite extended,TRUNCATE TABLE @a;,DELETE FROM @a; +sqlite extended,"CONCAT(@a, @b, @c)","CONCAT(@a, CONCAT(@b, @c))" +sqlite extended,"CONCAT(@a, @b)","@a || @b" +duckdb,...@([0-9]+|y)a,xxx@a +duckdb,TRY_CAST(@a),CAST(@a) +duckdb,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +duckdb,"ROUND(@a,@b)","ROUND(CAST(@a AS NUMERIC),@b)" +duckdb,"HASHBYTES('MD5',@a)","MD5(@a)" +duckdb,"DATEADD(second,@seconds,@datetime)",(@datetime + TO_SECONDS(CAST(@seconds AS INTEGER))) +duckdb,"DATEADD(minute,@minutes,@datetime)",(@datetime + TO_MINUTES(CAST(@minutes AS INTEGER))) +duckdb,"DATEADD(hour,@hours,@datetime)",(@datetime + TO_HOURS(CAST(@hours AS INTEGER))) +duckdb,"DATEADD(d,@days,@date)",(@date + TO_DAYS(CAST(@days AS INTEGER))) +duckdb,"DATEADD(dd,@days,@date)",(@date + TO_DAYS(CAST(@days AS INTEGER))) +duckdb,"DATEADD(day,@days,@date)",(@date + TO_DAYS(CAST(@days AS INTEGER))) +duckdb,"DATEADD(m,@months,@date)",(@date + TO_MONTHS(CAST(@months AS INTEGER))) +duckdb,"DATEADD(mm,@months,@date)",(@date + TO_MONTHS(CAST(@months AS INTEGER))) +duckdb,"DATEADD(month,@months,@date)",(@date + TO_MONTHS(CAST(@months AS INTEGER))) +duckdb,"DATEADD(yy,@years,@date)",(@date + TO_YEARS(CAST(@years AS INTEGER))) +duckdb,"DATEADD(yyyy,@years,@date)",(@date + TO_YEARS(CAST(@years AS INTEGER))) +duckdb,"DATEADD(year,@years,@date)",(@date + TO_YEARS(CAST(@years AS INTEGER))) +duckdb,INTERVAL'@(-?[0-9]+)a.0 @b',INTERVAL'@a @b' +duckdb,"DATEDIFF(d,@start, @end)","(CONVERT(DATE, @end) - CAST(@start AS DATE))" +duckdb,"DATEDIFF(dd,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) +duckdb,"DATEDIFF(day,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) +duckdb,"DATEDIFF(year,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) +duckdb,"DATEDIFF(yyyy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) +duckdb,"DATEDIFF(yy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) +duckdb,"DATEDIFF(month,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" +duckdb,"DATEDIFF(mm,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" +duckdb,"DATEDIFF(m,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" +duckdb,"CONVERT(VARCHAR,@date,112)","STRFTIME(@date, '%Y%m%d')" +duckdb,GETDATE(),CURRENT_DATE +duckdb,"CONVERT(DATE, @a)","CAST(@a AS DATE)" +duckdb,"CAST('@a' AS DATE)","CAST(strptime('@a', '%Y%m%d') AS DATE)" +duckdb,"CAST('@a' + @b AS DATE)","CAST(strptime('@a' + @b, '%Y%m%d') AS DATE)" +duckdb,"CAST(@a + '@b' AS DATE)","CAST(strptime(@a + '@b', '%Y%m%d') AS DATE)" +duckdb,"CAST(CONCAT(@a) AS DATE)","CAST(strptime(CONCAT(@a), '%Y%m%d') AS DATE)" +duckdb,+ '@a',|| '@a' +duckdb,'@a' +,'@a' || +duckdb,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || +duckdb,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) +duckdb,CAST(@a AS varchar) +,CAST(@a AS varchar) || +duckdb,+ CAST(@a AS varchar),|| CAST(@a AS varchar) +duckdb,"DATEFROMPARTS(@year,@month,@day)","(CAST(@year AS VARCHAR) || '-' || CAST(@month AS VARCHAR) || '-' || CAST(@day AS VARCHAR)) :: DATE" +duckdb,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","(CAST(@year AS VARCHAR) || '-' || CAST(@month AS VARCHAR) || '-' || CAST(@day AS VARCHAR) || '-' || CAST(@hour AS VARCHAR) || '-' || CAST(@minute AS VARCHAR) || '-' || CAST(@second AS VARCHAR)) :: DATE" +duckdb,YEAR(@date),YEAR(CAST(@date AS DATE)) +duckdb,MONTH(@date),MONTH(CAST(@date AS DATE)) +duckdb,DAY(@date),DAY(CAST(@date AS DATE)) +duckdb,"DATEPART(YEAR, @date)",YEAR(CAST(@date AS DATE)) +duckdb,"DATEPART(MONTH, @date)",MONTH(CAST(@date AS DATE)) +duckdb,"DATEPART(DAY, @date)",DAY(CAST(@date AS DATE)) +duckdb,EOMONTH(@date),"(DATE_TRUNC('MONTH', @date) + INTERVAL '1 MONTH' - INTERVAL '1 day')::DATE" +duckdb,STDEV(@a),STDDEV(@a) +duckdb,VAR(@a),VARIANCE(@a) +duckdb,RAND(),RANDOM() +duckdb,LEN(@a),LENGTH(@a) +duckdb,"CHARINDEX(@a,@b)","STRPOS(@b,@a)" +duckdb,"LOG(@expression,@base)",(LN(CAST((@expression) AS REAL))/LN(CAST((@base) AS REAL))) +duckdb,LOG(@expression),LN(CAST((@expression) AS REAL)) +duckdb,,LOG +duckdb,LOG10(@expression),"LOG(@expression)" +duckdb,"ISNULL(@a,@b)","COALESCE(@a,@b)" +duckdb,"ISNUMERIC(@a)","CASE WHEN (CAST(@a AS VARCHAR) ~ '^([0-9]+\.?[0-9]*|\.[0-9]+)$') THEN 1 ELSE 0 END" +duckdb,COUNT_BIG(@a),COUNT(@a) +duckdb,SQUARE(@a),((@a)*(@a)) +duckdb,NEWID(),uuid() +duckdb,USE @schema;,SET search_path TO @schema; +duckdb,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +duckdb,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +duckdb,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +duckdb,.dbo.,. +duckdb,CREATE TABLE #@table (@definition),CREATE TEMP TABLE @table (@definition) +duckdb,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE INDEX @index_name ON @table (@variable); +duckdb,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable); +duckdb,PRIMARY KEY NONCLUSTERED,PRIMARY KEY +duckdb,DATETIME,TIMESTAMP +duckdb,DATETIME2,TIMESTAMP +duckdb,VARCHAR(MAX),TEXT +duckdb,FLOAT,NUMERIC +duckdb,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nANALYZE @d; +duckdb,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +duckdb,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a\nFROM\n@c;\nANALYZE @b; +duckdb,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; +duckdb,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; +duckdb,#, +duckdb,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; +duckdb,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) +duckdb,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; +duckdb,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) +duckdb,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as TEXT) @s2)" +duckdb,UPDATE STATISTICS @a;,ANALYZE @a; +duckdb,TRUNCATE TABLE @a;,DELETE FROM @a; +duckdb,ALTER TABLE @a DROP CONSTRAINT @b;,CREATE TABLE @a_new AS SELECT * FROM @a;DROP TABLE @a;ALTER TABLE @a_new RENAME TO @a; +duckdb,"ALTER TABLE @table ADD @a, @b;","ALTER TABLE @table ADD @a; ALTER TABLE @table ADD @b;" +duckdb,"ALTER TABLE @table ALTER COLUMN @([0-9a-z_]+)a @b;","ALTER TABLE @table ALTER @a TYPE @b;" +snowflake,.@([0-9a-z_]+)a...@([0-9]+|y)b,x@a...@b +snowflake,.@([0-9a-z_]+)a...@([0-9]+|y)b,x@axxx@b +snowflake,...@([0-9]+|y)a,xxx@a +snowflake,"AS drvd(@a)","AS values_table(@a)" +snowflake,TRY_CAST(@a),CAST(@a) +snowflake,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +snowflake,"HASHBYTES('MD5',@a)","MD5(@a)" +snowflake,"CONVERT(VARBINARY, @a, 1)","CAST(CONCAT('x', @a) AS BIT(32))" +snowflake,"CONVERT(DATE, @a)","TO_DATE(@a, 'yyyymmdd')" +snowflake,"CONVERT(VARCHAR,@date,112)","TO_CHAR(@date, 'YYYYMMDD')" +snowflake,"CAST('@a' AS DATE)","TO_DATE('@a', 'YYYYMMDD')" +snowflake,"CAST('@a' + @b AS DATE)","TO_DATE('@a' + @b, 'YYYYMMDD')" +snowflake,"CAST(@a + '@b' AS DATE)","TO_DATE(@a + '@b', 'YYYYMMDD')" +snowflake,"CAST(CONCAT(@a) AS DATE)","TO_DATE(CONCAT(@a), 'YYYYMMDD')" +snowflake,GETDATE(),CURRENT_DATE +snowflake,+ '@a',|| '@a' +snowflake,'@a' +,'@a' || +snowflake,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || +snowflake,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) +snowflake,CAST(@a AS varchar) +,CAST(@a AS varchar) || +snowflake,+ CAST(@a AS varchar),|| CAST(@a AS varchar) +snowflake,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'FM0000')||'-'||TO_CHAR(@month,'FM00')||'-'||TO_CHAR(@day,'FM00'), 'YYYY-MM-DD')" +snowflake,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","TO_DATE(TO_CHAR(@year,'FM0000')||'-'||TO_CHAR(@month,'FM00')||'-'||TO_CHAR(@day,'FM00')||' '||TO_CHAR(@hour,'FM00')||':'||TO_CHAR(@minute,'FM00')||':'||TO_CHAR(@second,'FM00'), 'YYYY-MM-DD HH24:MI:SS')" +snowflake,YEAR(@date),EXTRACT(YEAR FROM @date) +snowflake,MONTH(@date),EXTRACT(MONTH FROM @date) +snowflake,DAY(@date),EXTRACT(DAY FROM @date) +snowflake,"DATEPART(YEAR, @date)",EXTRACT(YEAR FROM @date) +snowflake,"DATEPART(MONTH, @date)",EXTRACT(MONTH FROM @date) +snowflake,"DATEPART(DAY, @date)",EXTRACT(DAY FROM @date) +snowflake,EOMONTH(@date),last_day(@date) +snowflake,STDEV(@a),STDDEV(@a) +snowflake,VAR(@a),VARIANCE(@a) +snowflake,RAND(),RANDOM() +snowflake,CEILING(@a),CEIL(@a) +snowflake,"LOG(@expression,@base)","(@base,@expression)" +snowflake,LOG(@expression),LN(CAST((@expression) AS REAL)) +snowflake,,LOG +snowflake,LOG10(@expression),"LOG(10,@expression)" +snowflake,"ISNULL(@a,@b)","COALESCE(@a,@b)" +snowflake,"ISNUMERIC(@a)","IS_REAL(TRY_TO_NUMERIC(@a))" +snowflake,CAST(@a AS INTEGER),TRY_CAST(CAST(@a AS TEXT) AS INTEGER) +snowflake,CAST(@a AS int),TRY_CAST(CAST(@a AS TEXT) AS int) +snowflake,COUNT_BIG(@a),COUNT(@a) +snowflake,NEWID(),UUID_STRING() +snowflake,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +snowflake,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +snowflake,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE #@table;",DROP TABLE IF EXISTS #@table; +snowflake,.dbo.,. +snowflake,CREATE TABLE #@table (@definition),CREATE TEMP TABLE #@table (@definition) +snowflake,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,-- snowflake does not support indexes +snowflake,CREATE INDEX @index_name ON @table (@variable);,-- snowflake does not support indexes +snowflake,CREATE INDEX @index_name ON @table (@variable) WHERE @c;,-- snowflake does not support indexes +snowflake,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);,-- snowflake does not support indexes +snowflake,DROP INDEX @index_name;,-- snowflake does not support indexes +snowflake,PRIMARY KEY NONCLUSTERED,PRIMARY KEY +snowflake,DATETIME,TIMESTAMP +snowflake,DATETIME2,TIMESTAMP +snowflake,VARCHAR(MAX),TEXT +snowflake,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE #@d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +snowflake,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; +snowflake,WITH @a INSERT INTO @b SELECT @c;,INSERT INTO @b WITH @a SELECT @c; +snowflake,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE #@b\nAS\nSELECT\n@a\nFROM\n@c; +snowflake,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; +snowflake,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; +snowflake,SELECT @a INTO #@b;,CREATE TEMP TABLE #@b AS\nSELECT\n@a; +snowflake,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; +snowflake,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) +snowflake,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; +snowflake,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) +snowflake,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as TEXT) @s2)" +snowflake,UPDATE STATISTICS @a;, +snowflake,"--HINT BUCKET(@a, @b)", +snowflake,"--HINT PARTITION(@a @b)", +snowflake,"--HINT DISTRIBUTE_ON_KEY(@key)", +snowflake,#,%temp_prefix%%session_id% +snowflake,(@a & @b),"BITAND(@a, @b)" +synapse,...@([0-9]+|y)a,xxx@a +synapse,VARCHAR(MAX),VARCHAR(8000) +synapse,CREATE INDEX @index_name ON #@table (@variable);,-- synapse does not support non-clustered index on temp tables. +synapse,"CONSTRAINT @a DEFAULT GETDATE()","" +synapse,"DEFAULT GETDATE()","" +synapse,CREATE INDEX @index_name ON @table (@variable) WHERE @b;,CREATE INDEX @index_name ON @table (@variable); +synapse,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +synapse,DROP TABLE IF EXISTS #@table;,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE #@table;" +synapse,DROP TABLE IF EXISTS @table;,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;" +synapse,CREATE TABLE IF NOT EXISTS @table (@definition);,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);" +sql server,.@([^\s]+)p2...@([0-9]+)p3,x@p2...@p3 +sql server,.@([^\s]+)p2...@([0-9]+)p3,x@p2...@p3 +sql server,...@([0-9]+|y)a,xxx@a +sql server,DROP TABLE IF EXISTS #@table;,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE #@table;" +sql server,DROP TABLE IF EXISTS @table;,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;" +sql server,CREATE TABLE IF NOT EXISTS @table (@definition);,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);" +iris,...@([0-9]+)a,xxx@a --??? +iris,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" +iris,TRY_CAST(@a),CAST(@a) +iris,+ '@a',|| '@a' +iris,'@a' +,'@a' || +iris,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || +iris,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) +iris,CAST(@a AS varchar) +,CAST(@a AS varchar) || +iris,+ CAST(@a AS varchar),|| CAST(@a AS varchar) +iris,COUNT_BIG(@a),COUNT(@a) +iris,.dbo.,. +iris,CREATE TABLE #@table (@definition),DROP TABLE IF EXISTS #@table; CREATE GLOBAL TEMPORARY TABLE #@table (@definition) +iris,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); +iris,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; +iris,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE #@table;",DROP TABLE IF EXISTS #@table; +iris,PRIMARY KEY NONCLUSTERED,PRIMARY KEY +iris,"AS drvd(@a)","AS drvd(@a)" +iris,"@a, @b)","@a, @b)" +iris,"","NULL AS " +iris,"FROM (VALUES @a) AS drvd(@b)","FROM ((SELECT @b WHERE (0 = 1)) UNION ALL VALUES @a) AS values_table" +iris,"UNION ALL VALUES (@a), (@b)","UNION ALL (SELECT @a) UNION ALL VALUES (@b)" +iris,"UNION ALL VALUES (@a)","UNION ALL (SELECT @a)" +iris,SELECT @a INTO #@b FROM @c;,CREATE GLOBAL TEMPORARY TABLE #@b AS SELECT @a FROM @c; +iris,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS SELECT @a FROM @c; +iris,SELECT @a INTO @b;,CREATE TABLE @b AS SELECT @a; +iris,SELECT @a INTO #@b;,CREATE GLOBAL TEMPORARY TABLE #@b AS SELECT @a; +iris,"WITH @cte CREATE TABLE #@table AS @select;","CREATE GLOBAL TEMPORARY TABLE #@table AS WITH @cte @select;" +iris,"WITH @cte CREATE GLOBAL TEMPORARY TABLE @table AS @select;","CREATE GLOBAL TEMPORARY TABLE @table AS WITH @cte @select;" +iris,"WITH @cte CREATE TABLE @table AS @select;","CREATE TABLE @table AS WITH @cte @select;" +iris,#,%temp_prefix%%session_id% +iris,UPDATE STATISTICS @a;,TUNE TABLE @a; +iris,"--HINT BUCKET(@a, @b)", "-- haven't looked into this yet, skip it for now" +iris,"--HINT PARTITION(@a @b)", -- "haven't looked into this yet, skip it for now"" +iris,"--HINT DISTRIBUTE_ON_KEY(@key)", -- "haven't looked into this yet, skip it for now"" +iris,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'FM0000')||'-'||TO_CHAR(@month,'FM00')||'-'||TO_CHAR(@day,'FM00'), 'YYYY-MM-DD')" +iris,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","TO_TIMESTAMP(TO_CHAR(@year,'FM0000')||'-'||TO_CHAR(@month,'FM00')||'-'||TO_CHAR(@day,'FM00')||' '||TO_CHAR(@hour,'FM00')||':'||TO_CHAR(@minute,'FM00')||':'||TO_CHAR(@second,'FM00')||'.'||TO_CHAR(@ms,'FM000'), 'YYYY-MM-DD HH24:MI:SS.FF')" +iris," DATEADD(d, @a, @b) AS"," TO_DATE(DATEADD(d, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" +iris," DATEADD(dd, @a, @b) AS"," TO_DATE(DATEADD(dd, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" +iris," DATEADD(day, @a, @b) AS"," TO_DATE(DATEADD(day, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" +iris," DATEADD(m, @a, @b) AS"," TO_DATE(DATEADD(m, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" +iris," DATEADD(mm, @a, @b) AS"," TO_DATE(DATEADD(mm, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" +iris," DATEADD(yy, @a, @b) AS"," TO_DATE(DATEADD(yy, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" +iris," DATEADD(yyyy, @a, @b) AS"," TO_DATE(DATEADD(yyyy, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" +iris," COALESCE(p.birth_datetime", COALESCE(CAST(p.birth_datetime AS DATE) +iris,"COALESCE(@a, DATEADD(day,@b,@c))","COALESCE(@a, CAST (DATEADD(day,@b,@c) AS DATE))" +iris,"COALESCE(@a, DATEADD(day,@b,@c), DATEADD(day,@d,@e))","COALESCE(@a, CAST (DATEADD(day,@b,@c) AS DATE), CAST (DATEADD(day,@d,@e) AS DATE))" +iris,"case when DATEADD(day,@a,@b) > op_end_date then op_end_date else DATEADD(day,@a,@b) end as end_date","case when CAST (DATEADD(day,@a,@b) AS DATE) > op_end_date then op_end_date else CAST (DATEADD(day,@a,@b) AS DATE) end as end_date" +iris,"select @a as cohort_definition_id, person_id, start_date, end_date","select @a as cohort_definition_id, person_id, CAST (start_date AS DATE), CAST (end_date AS DATE)" +iris,"select @a as design_hash, person_id, start_date, end_date","select @a as design_hash, person_id, CAST (start_date AS DATE), CAST (end_date AS DATE)" +iris,"select cohort_definition_id, subject_id, cohort_start_date, cohort_end_date, @a as adjusted_start_date, @b as adjusted_end_date","select cohort_definition_id, subject_id, cohort_start_date, cohort_end_date, CAST(@a AS DATE) as adjusted_start_date, CAST(@b AS DATE) as adjusted_end_date" +iris,"CONCAT(p.year_of_birth, @b, @c)",p.year_of_birth||'-'||@b||'-'|| @c +iris,"CONCAT(@a, @b,","@a || CONCAT(@b," +iris,"CONCAT(@a,@b)",@a || @b +iris,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE INDEX @index_name ON @table (@variable); +iris,CREATE INDEX @index_name ON @table (@variable) WHERE @b;,CREATE INDEX @index_name ON @table (@variable); +iris,AS MIN,AS "MIN" +iris,AS MAX,AS "MAX" +iris,AS COUNT,AS "COUNT" +iris,STDEV(@a),STDDEV(@a) +iris,STDEV_POP(@a),STDDEV_POP(@a) +iris,STDEV_SAMP(@a),STDDEV_SAMP(@a) +iris,EOMONTH(@date),LAST_DAY(@date) +iris,.DOMAIN ,."DOMAIN" +iris,NEWID(),$TSQL_NEWID() +iris,RAND(),$TSQL_NEWID() +iris,CREATE TABLE @a AS (@b) ORDER BY @c;,CREATE TABLE @a AS @b ORDER BY @c; +iris,CREATE TABLE @a AS (@b ORDER BY @c);,CREATE TABLE @a AS @b ORDER BY @c; diff --git a/circe/sqlrender/splitter.py b/circe/sqlrender/splitter.py new file mode 100644 index 00000000..61290a6f --- /dev/null +++ b/circe/sqlrender/splitter.py @@ -0,0 +1,44 @@ +from .tokenizer import tokenize_sql + + +def split_sql(sql: str) -> list[str]: + tokens = tokenize_sql(sql.lower()) + nest_stack: list[str] = [] + last_pop = "" + start = 0 + cursor = 0 + quote = False + bracket = False + quote_text = "" + parts: list[str] = [] + + for cursor in range(len(tokens)): + token = tokens[cursor] + + if quote: + if token.text == quote_text: + quote = False + elif bracket: + if token.text == "]": + bracket = False + elif token.text in ("'", '"'): + quote = True + quote_text = token.text + elif token.text == "[": + bracket = True + elif token.text in ("begin", "case"): + nest_stack.append(token.text) + elif token.text == "end" and (cursor == len(tokens) - 1 or tokens[cursor + 1].text != "if"): + if nest_stack: + last_pop = nest_stack.pop() + elif len(nest_stack) == 0 and token.text == ";": + if cursor == 0 or (tokens[cursor - 1].text == "end" and last_pop == "begin"): + parts.append(sql[tokens[start].start : token.end]) + else: + parts.append(sql[tokens[start].start : token.end - 1]) + start = cursor + 1 + + if start < cursor + 1: + parts.append(sql[tokens[start].start : tokens[cursor].end]) + + return parts diff --git a/circe/sqlrender/tokenizer.py b/circe/sqlrender/tokenizer.py new file mode 100644 index 00000000..e216b299 --- /dev/null +++ b/circe/sqlrender/tokenizer.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass + +HINT_KEY_WORD = "hint" + + +@dataclass +class Token: + start: int = 0 + end: int = 0 + text: str = "" + in_quotes: bool = False + + +def tokenize_sql(sql: str) -> list[Token]: + tokens: list[Token] = [] + start = 0 + cursor = 0 + comment_type1 = False + comment_type2 = False + in_single_quotes = False + in_double_quotes = False + + while cursor < len(sql): + ch = sql[cursor] + + if comment_type1: + if ch == "\n": + comment_type1 = False + start = cursor + 1 + cursor += 1 + continue + + if comment_type2: + if ch == "/" and cursor > 0 and sql[cursor - 1] == "*": + comment_type2 = False + start = cursor + 1 + cursor += 1 + continue + + if not (ch.isalnum() or ch == "_" or ch == "@"): + if cursor > start: + token = Token( + start=start, + end=cursor, + text=sql[start:cursor], + in_quotes=in_single_quotes or in_double_quotes, + ) + tokens.append(token) + + if ( + ch == "-" + and cursor + 1 < len(sql) + and sql[cursor + 1] == "-" + and not in_single_quotes + and not in_double_quotes + and (len(sql) - cursor < 6 or sql[cursor + 2 : cursor + 6].lower() != HINT_KEY_WORD) + ): + comment_type1 = True + elif ( + ch == "/" + and cursor + 1 < len(sql) + and sql[cursor + 1] == "*" + and not in_single_quotes + and not in_double_quotes + ): + comment_type2 = True + elif not ch.isspace(): + token = Token( + start=cursor, + end=cursor + 1, + text=sql[cursor], + in_quotes=in_single_quotes or in_double_quotes, + ) + tokens.append(token) + if ch == "'" and not in_double_quotes: + in_single_quotes = not in_single_quotes + if ch == '"' and not in_single_quotes: + in_double_quotes = not in_double_quotes + + start = cursor + 1 + cursor += 1 + else: + cursor += 1 + + if cursor > start and not comment_type1 and not comment_type2: + token = Token( + start=start, + end=cursor, + text=sql[start:cursor], + in_quotes=in_single_quotes or in_double_quotes, + ) + tokens.append(token) + + return tokens diff --git a/circe/sqlrender/translator.py b/circe/sqlrender/translator.py new file mode 100644 index 00000000..1f057020 --- /dev/null +++ b/circe/sqlrender/translator.py @@ -0,0 +1,371 @@ +import re +from dataclasses import dataclass, field + +from .patterns import ( + MAX_TABLE_NAME_LENGTH, + get_global_session_id, + load_patterns, +) +from .tokenizer import tokenize_sql + + +class SqlTranslateError(RuntimeError): + pass + + +@dataclass +class Block: + start: int = 0 + end: int = 0 + text: str = "" + in_quotes: bool = False + is_variable: bool = False + reg_ex: str | None = None + + +@dataclass +class MatchedPattern: + start: int = -1 + end: int = -1 + start_token: int = -1 + variable_to_value: dict[str, str] = field(default_factory=dict) + + +def parse_search_pattern(pattern: str) -> list[Block]: + tokens = tokenize_sql(pattern.lower()) + blocks: list[Block] = [] + i = 0 + while i < len(tokens): + block = Block( + start=tokens[i].start, + end=tokens[i].end, + text=tokens[i].text, + in_quotes=tokens[i].in_quotes, + ) + + if len(block.text) > 2 and block.text[0] == "@": + block.is_variable = True + + if block.text == "@@" and i < len(tokens) - 2 and tokens[i + 1].text == "(": + escape = False + nesting = 0 + for j in range(i + 2, len(tokens)): + if escape: + escape = False + elif tokens[j].text == "\\": + escape = True + elif not escape and tokens[j].text == "(": + nesting += 1 + elif not escape and tokens[j].text == ")": + if nesting == 0: + block.text = "@@" + tokens[j + 1].text + block.reg_ex = pattern[tokens[i + 1].end : tokens[j].start] + block.end = tokens[j + 1].end + block.is_variable = True + i = j + 1 + break + nesting -= 1 + blocks.append(block) + i += 1 + continue + + blocks.append(block) + i += 1 + + if blocks and blocks[0].is_variable and blocks[0].reg_ex is None: + raise SqlTranslateError( + "Error in search pattern: pattern cannot start or end with a non-regex variable: " + pattern + ) + if blocks and blocks[-1].is_variable and blocks[-1].reg_ex is None: + raise SqlTranslateError( + "Error in search pattern: pattern cannot start or end with a non-regex variable: " + pattern + ) + + return blocks + + +def _matches(regex: str, string: str) -> bool: + return bool(re.match(regex, string, re.DOTALL | re.MULTILINE | re.IGNORECASE)) + + +def _matches_end(regex: str, string: str) -> int: + stripped = re.sub(r"\s+$", "", string) + pattern = re.compile(regex, re.DOTALL | re.MULTILINE | re.IGNORECASE) + start = -1 + for m in pattern.finditer(stripped): + if m.end() == len(stripped): + start = m.start() + return start + + +def search(sql: str, parsed_pattern: list[Block], start_token: int = 0) -> MatchedPattern: + lowercase_sql = sql.lower() + tokens = tokenize_sql(lowercase_sql) + match_count = 0 + var_start = 0 + nest_stack: list[str] = [] + in_pattern_quote = False + matched = MatchedPattern() + + cursor = start_token + while cursor < len(tokens): + token = tokens[cursor] + + if parsed_pattern[match_count].is_variable: + block = parsed_pattern[match_count] + + if block.reg_ex is not None and ( + match_count == len(parsed_pattern) - 1 or parsed_pattern[match_count + 1].is_variable + ): + pat = re.compile(block.reg_ex, re.DOTALL | re.MULTILINE | re.IGNORECASE) + m = pat.match(sql[token.start :]) + if m and m.start() == 0: + if match_count == 0: + matched.start = token.start + matched.start_token = cursor + matched.variable_to_value[block.text] = sql[token.start : token.start + m.end()] + match_count += 1 + if match_count == len(parsed_pattern): + matched.end = token.start + m.end() + return matched + elif parsed_pattern[match_count].is_variable: + var_start = token.start + m.end() + while cursor < len(tokens) and tokens[cursor].start < token.start + m.end(): + cursor += 1 + cursor -= 1 + else: + match_count = 0 + cursor += 1 + continue + + if ( + len(nest_stack) == 0 + and match_count < len(parsed_pattern) - 1 + and token.text == parsed_pattern[match_count + 1].text + ): + if block.reg_ex is not None and match_count == 0: + s = _matches_end(block.reg_ex, sql[var_start : token.start]) + if s != -1: + matched.variable_to_value[block.text] = sql[var_start + s : token.start] + matched.start = var_start + s + matched.start_token = cursor + match_count += 2 + if match_count == len(parsed_pattern): + matched.end = token.end + return matched + elif parsed_pattern[match_count].is_variable: + var_start = tokens[cursor + 1].start if cursor < len(tokens) - 1 else -1 + if token.text in ("'", '"'): + in_pattern_quote = not in_pattern_quote + else: + match_count = 0 + cursor = matched.start_token + elif block.reg_ex is not None and not _matches(block.reg_ex, sql[var_start : token.start]): + match_count = 0 + cursor = matched.start_token + else: + matched.variable_to_value[block.text] = sql[var_start : token.start] + match_count += 2 + if match_count == len(parsed_pattern): + matched.end = token.end + return matched + elif parsed_pattern[match_count].is_variable: + var_start = tokens[cursor + 1].start if cursor < len(tokens) - 1 else -1 + if token.text in ("'", '"'): + in_pattern_quote = not in_pattern_quote + cursor += 1 + continue + + if ( + match_count != 0 + and len(nest_stack) == 0 + and not in_pattern_quote + and token.text in (";", ")") + ): + match_count = 0 + cursor = matched.start_token + cursor += 1 + continue + + if nest_stack: + top = nest_stack[-1] + if top in ('"', "'"): + if token.text == top: + nest_stack.pop() + else: + if token.text in ('"', "'") or not in_pattern_quote and token.text == "(": + nest_stack.append(token.text) + elif not in_pattern_quote and nest_stack and token.text == ")" and nest_stack[-1] == "(": + nest_stack.pop() + else: + if token.text in ('"', "'") or not in_pattern_quote and token.text == "(": + nest_stack.append(token.text) + elif not in_pattern_quote and nest_stack and token.text == ")" and nest_stack[-1] == "(": + nest_stack.pop() + cursor += 1 + continue + + if token.text == parsed_pattern[match_count].text and (match_count != 0 or not token.in_quotes): + if match_count == 0: + matched.start = token.start + matched.start_token = cursor + match_count += 1 + if match_count == len(parsed_pattern): + matched.end = token.end + return matched + elif parsed_pattern[match_count].is_variable: + var_start = tokens[cursor + 1].start if cursor < len(tokens) - 1 else -1 + if token.text in ("'", '"'): + in_pattern_quote = not in_pattern_quote + elif match_count != 0: + match_count = 0 + cursor = matched.start_token + + cursor += 1 + + if match_count != 0 and cursor >= len(tokens): + match_count = 0 + cursor = matched.start_token + 1 + + matched.start = -1 + return matched + + +def search_and_replace(sql: str, parsed_pattern: list[Block], replace_pattern: str) -> str: + matched = search(sql, parsed_pattern, 0) + while matched.start != -1: + replacement = replace_pattern + for var_name, var_value in matched.variable_to_value.items(): + replacement = replacement.replace(var_name, var_value) + sql = sql[: matched.start] + replacement + sql[matched.end :] + + delta = 1 + repl_tokens = tokenize_sql(replacement) + if len(repl_tokens) == 0: + delta = 0 + if ( + delta > 0 + and replace_pattern.startswith("@@") + and replacement.lower().strip().startswith(parsed_pattern[0].text) + ): + delta = 0 + matched = search(sql, parsed_pattern, matched.start_token + delta) + return sql + + +def _strip_blank_lines(sql: str) -> str: + return re.sub(r"(?m)^[ \t]*\r?\n", "", sql) + + +def translate( + sql: str, + target_dialect: str, + session_id: str | None = None, + temp_emulation_schema: str | None = None, +) -> str: + patterns = load_patterns() + + if session_id is None: + session_id = get_global_session_id() + else: + if len(session_id) != 8: + raise SqlTranslateError(f"Session ID has length {len(session_id)}, should be 8") + if not session_id[0].isalpha(): + raise SqlTranslateError("Session ID does not start with a letter") + for ch in session_id[1:]: + if not ch.isalnum(): + raise SqlTranslateError(f"Illegal character in session ID: {ch}") + + oracle_temp_prefix = "" + if temp_emulation_schema is not None: + oracle_temp_prefix = temp_emulation_schema + "." + + replacement_patterns = patterns.get(target_dialect) + if replacement_patterns is None: + supported = ", ".join(sorted(patterns.keys())) + raise SqlTranslateError( + f"Don't know how to translate to {target_dialect}. Valid target dialects are {supported}" + ) + + for pattern, replacement in replacement_patterns: + replacement = replacement.replace("%session_id%", session_id) + replacement = replacement.replace("%temp_prefix%", oracle_temp_prefix) + parsed = parse_search_pattern(pattern) + sql = _strip_blank_lines(search_and_replace(sql, parsed, replacement)) + + sql = _strip_blank_lines(sql) + + lower = target_dialect.lower() + if lower in ("impala", "bigquery", "spark"): + sql = _replace_with_concat(sql) + + return sql + + +def _replace_with_concat(val: str) -> str: + pattern = re.compile(r"(? str: + inner = s[1:-1] + parts = inner.split("''") + concat_parts = [] + for part in parts: + if part == "": + concat_parts.append("'\\047'") + else: + escaped = part.replace("\\", "\\\\").replace('"', "\\042").replace("/", "\\/") + concat_parts.append("'" + escaped + "'") + return "CONCAT(" + ",".join(concat_parts) + ")" + + +def check(sql: str, target_dialect: str) -> list[str]: + warnings: list[str] = [] + pattern = re.compile(r"#[0-9a-zA-Z_]+") + long_temp_names: set[str] = set() + for m in pattern.finditer(sql): + name = m.group() + if len(name) > MAX_TABLE_NAME_LENGTH - 8 - 1: + long_temp_names.add(name) + for name in sorted(long_temp_names): + warnings.append( + f"Temp table name '{name}' is too long. Temp table names should be shorter than " + f"{MAX_TABLE_NAME_LENGTH - 8} characters to prevent some DMBSs from throwing an error." + ) + + pattern2 = re.compile(r"(create|drop|truncate)\s+table\s+[0-9a-zA-Z_]+", re.IGNORECASE) + long_names: set[str] = set() + for m in pattern2.finditer(sql): + name = m.group().split()[-1] + if len(name) > MAX_TABLE_NAME_LENGTH and "#" + name not in long_temp_names: + long_names.add(name) + for name in sorted(long_names): + warnings.append( + f"Table name '{name}' is too long. Table names should be shorter than " + f"{MAX_TABLE_NAME_LENGTH} characters to prevent some DMBSs from throwing an error." + ) + return warnings + + +def generate_session_id() -> str: + from .patterns import generate_session_id as _gen + + return _gen() + + +def set_replacement_patterns_path(path: str | None) -> None: + from .patterns import set_replacement_patterns_path as _set + + _set(path) diff --git a/tests/test_sqlrender_csv_format.py b/tests/test_sqlrender_csv_format.py new file mode 100644 index 00000000..d9d62403 --- /dev/null +++ b/tests/test_sqlrender_csv_format.py @@ -0,0 +1,51 @@ +"""Test replacementPatterns.csv format - ported from OHDSI SqlRender test-replacement-patterns-file-format.R""" + +import pytest + +from circe.sqlrender import translate +from circe.sqlrender.patterns import _safe_split + + +class TestCsvFormat: + + def test_csv_has_valid_format(self): + from importlib.resources import files + + f = files("circe.sqlrender").joinpath("replacementPatterns.csv").open("r", encoding="utf-8") + content = f.read() + + lines = content.splitlines() + assert len(lines) > 1, "CSV should have header row plus at least one pattern" + + for i, line in enumerate(lines): + columns = _safe_split(line, ",") + if i == 0: + assert columns[0] == "To" + assert columns[1] == "Pattern" + assert columns[2] == "Replacement" + continue + assert len(columns) >= 3, ( + f"Row {i} has {len(columns)} columns (expected at least 3): {columns}" + ) + + def test_all_patterns_can_be_parsed(self): + from circe.sqlrender.translator import parse_search_pattern + from circe.sqlrender.patterns import load_patterns + + patterns = load_patterns() + for dialect, pairs in patterns.items(): + for pattern, replacement in pairs: + try: + parse_search_pattern(pattern) + except Exception as e: + pytest.fail( + f"Failed to parse pattern for dialect '{dialect}': " + f"pattern={pattern!r}, error={e}" + ) + + def test_duckdb_and_postgresql_can_translate_simple_sql(self): + sql = "SELECT * FROM table;" + for dialect in ("duckdb", "postgresql"): + result = translate(sql, dialect) + assert "SELECT" in result + assert "table" in result diff --git a/tests/test_sqlrender_render.py b/tests/test_sqlrender_render.py new file mode 100644 index 00000000..d9b41c4f --- /dev/null +++ b/tests/test_sqlrender_render.py @@ -0,0 +1,166 @@ +"""Test parameter rendering - ported from OHDSI SqlRender test-renderSql.R""" + +import warnings + +import pytest + +from circe.sqlrender import render + + +class TestRender: + S = "{DEFAULT @a = '123'} SELECT * FROM table WHERE x = @a AND {@b == 'blaat'}?{y = 1234}:{x = 1};" + + def test_parameter_substitution(self): + sql = render(self.S, a="abc") + assert sql == " SELECT * FROM table WHERE x = abc AND x = 1;" + + def test_empty_parameter(self): + sql = render(self.S, a="abc", b="") + assert sql == " SELECT * FROM table WHERE x = abc AND x = 1;" + + def test_default(self): + sql = render(self.S, b="1") + assert sql == " SELECT * FROM table WHERE x = 123 AND x = 1;" + + def test_if_then_else_then(self): + sql = render(self.S, b="blaat") + assert sql == " SELECT * FROM table WHERE x = 123 AND y = 1234;" + + def test_if_then_else_else(self): + sql = render(self.S, b="bla") + assert sql == " SELECT * FROM table WHERE x = 123 AND x = 1;" + + def test_boolean_param_true(self): + sql = render("SELECT * FROM table {@a}?{WHERE x = 1}", a=True) + assert sql == "SELECT * FROM table WHERE x = 1" + + def test_boolean_param_false(self): + sql = render("SELECT * FROM table {@a}?{WHERE x = 1}", a=False) + assert sql == "SELECT * FROM table " + + def test_in_pattern_true(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + sql = render("{1 IN (@a)}?{SELECT * FROM table}", a=[1, 2, 3, 4]) + assert sql == "SELECT * FROM table" + + def test_in_pattern_false(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + sql = render("{1 IN (@a)}?{SELECT * FROM table}", a=[2, 3, 4]) + assert sql == "" + + def test_in_pattern_space_start_true(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + sql = render("{ 1 IN (@a)}?{SELECT * FROM table}", a=[1, 2, 3, 4]) + assert sql == "SELECT * FROM table" + + def test_in_pattern_space_start_false(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + sql = render("{ 1 IN (@a)}?{SELECT * FROM table}", a=[2, 3, 4]) + assert sql == "" + + def test_and_operator_true(self): + sql = render("{true & true}?{true}:{false}") + assert sql == "true" + + def test_and_operator_false(self): + sql = render("{true & false}?{true}:{false}") + assert sql == "false" + + def test_or_operator_true_1(self): + sql = render("{true | false}?{true}:{false}") + assert sql == "true" + + def test_or_operator_true_2(self): + sql = render("{true | true}?{true}:{false}") + assert sql == "true" + + def test_or_operator_false(self): + sql = render("{false | false}?{true}:{false}") + assert sql == "false" + + def test_nested_in_boolean(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + sql = render("{true & (true & (true & 4 IN (@a)))}?{true}:{false}", a=[1, 2, 3]) + assert sql == "false" + + def test_nested_if_then_else_true(self): + sql = render("{true}?{{true}?{double true}:{true false}}:{false}") + assert sql == "double true" + + def test_nested_if_then_else_false(self): + sql = render("{false}?{{true}?{double true}:{true false}}:{false}") + assert sql == "false" + + def test_simple_negation(self): + sql = render("{!false}?{true}:{false}") + assert sql == "true" + + def test_negation_of_param(self): + sql = render("{!@a}?{true}:{false}", a="true") + assert sql == "false" + + def test_not_equals_operator_1(self): + sql = render("{123 != 123}?{true}:{false}") + assert sql == "false" + + def test_not_equals_operator_2(self): + sql = render("{123 != 234}?{true}:{false}") + assert sql == "true" + + def test_not_equals_operator_3(self): + sql = render("{123 <> 123}?{true}:{false}") + assert sql == "false" + + def test_not_equals_operator_4(self): + sql = render("{123 <> 234}?{true}:{false}") + assert sql == "true" + + def test_nested_in_evaluates_true(self): + sql = render("{TRUE & (FALSE | 1 IN (1,2,3))}?{true}:{false}") + assert sql == "true" + + def test_nested_in_evaluates_false(self): + sql = render("{TRUE & (FALSE | 4 IN (1,2,3))}?{true}:{false}") + assert sql == "false" + + def test_backslash_in_parameter(self): + sql = render("SELECT * FROM table WHERE name = '@name';", name="NA\\joe") + assert sql == "SELECT * FROM table WHERE name = 'NA\\joe';" + + def test_dollar_in_parameter(self): + sql = render("SELECT * FROM table WHERE name = '@name';", name="NA$joe") + assert sql == "SELECT * FROM table WHERE name = 'NA$joe';" + + def test_error_on_bad_boolean_syntax(self): + from circe.sqlrender.renderer import SqlRenderError + + with pytest.raises(SqlRenderError): + render("{true = true}?{true}:{false}") + + def test_warning_on_parameter_name_mismatch(self): + with pytest.warns(UserWarning): + render("SELECT * FROM @my_table", a_table="x") + + def test_no_problem_missing_parameters(self): + assert render("SELECT * FROM @my_table") == "SELECT * FROM @my_table" + + def test_warning_on_old_function(self): + with pytest.warns(UserWarning): + render("SELECT * FROM @my_table", x="y") + + def test_inline_simple(self): + sql = render("{1 == 1}?{yes}:{no}") + assert sql == "yes" + + def test_inline_false(self): + sql = render("{1 == 2}?{yes}:{no}") + assert sql == "no" + + def test_inline_no_else(self): + sql = render("{false}?{hide}") + assert sql == "" diff --git a/tests/test_sqlrender_split.py b/tests/test_sqlrender_split.py new file mode 100644 index 00000000..c5c233a1 --- /dev/null +++ b/tests/test_sqlrender_split.py @@ -0,0 +1,69 @@ +"""Test SQL splitting - ported from OHDSI SqlRender test-splitSql.R""" + +import pytest + +from circe.sqlrender import split_sql + + +class TestSplitSql: + + def test_split_simple_statements(self): + parts = split_sql("SELECT * INTO a FROM b; USE x; DROP TABLE c;") + assert parts == ["SELECT * INTO a FROM b", "USE x", "DROP TABLE c"] + + def test_split_with_begin_end(self): + parts = split_sql("BEGIN\nSELECT * INTO a FROM b;\nEND;\nUSE x;") + assert parts == ["BEGIN\nSELECT * INTO a FROM b;\nEND;", "USE x"] + + def test_split_with_case_end(self): + parts = split_sql( + "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END AS x INTO a FROM b;\nUSE x;" + ) + assert parts == [ + "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END AS x INTO a FROM b", + "USE x", + ] + + def test_split_with_end_in_quoted_text(self): + parts = split_sql( + "insert into a (x) values ('end');\n insert into a (x) values ('begin');" + ) + assert parts == [ + "insert into a (x) values ('end')", + "insert into a (x) values ('begin')", + ] + + def test_split_with_case_end_at_end(self): + sql = "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END FROM a GROUP BY CASE WHEN x=1 THEN 0 ELSE 1 END;" + parts = split_sql(sql) + assert parts == [ + "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END FROM a GROUP BY CASE WHEN x=1 THEN 0 ELSE 1 END" + ] + + def test_split_with_reserved_word_end_as_field(self): + sql = "INSERT INTO t (data_source, start, [end]) VALUES ('hes', '1990-01-01', '2014-12-31');" + parts = split_sql(sql) + assert parts == [ + "INSERT INTO t (data_source, start, [end]) VALUES ('hes', '1990-01-01', '2014-12-31')" + ] + + def test_split_with_comment_last_line_no_eol(self): + parts = split_sql("SELECT * FROM table;\n-- end") + assert parts == ["SELECT * FROM table"] + + def test_split_with_hint_at_start(self): + parts = split_sql( + "--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist" + ) + assert parts == [ + "--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist" + ] + + def test_split_with_hint_in_second_statement(self): + parts = split_sql( + "DROP TABLE blah;\n--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist;" + ) + assert parts == [ + "DROP TABLE blah", + "--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist", + ] diff --git a/tests/test_sqlrender_translate.py b/tests/test_sqlrender_translate.py new file mode 100644 index 00000000..a56bca37 --- /dev/null +++ b/tests/test_sqlrender_translate.py @@ -0,0 +1,37 @@ +"""Test general translation behavior - ported from OHDSI SqlRender test-translateSql.R""" + +import pytest + +from circe.sqlrender import translate +from circe.sqlrender.translator import SqlTranslateError, check + + +def setup_function(): + global _target_to_patterns + _target_to_patterns = None + + +class TestGeneralTranslate: + def test_invalid_target_dialect(self): + with pytest.raises(SqlTranslateError, match="Don't know how to translate to pwd"): + translate("SELECT * FROM a;", target_dialect="pwd") + + def test_table_name_too_long_warning(self): + warnings = check( + "DROP TABLE abcdefghijklmnopqrstuvwxyz1234567890123456789012345678901234567890", + "pdw", + ) + assert len(warnings) > 0 + assert "too long" in warnings[0].lower() + + def test_no_warning_for_short_table_name(self): + warnings = check("DROP TABLE short_name;", "pdw") + assert len(warnings) == 0 + + def test_list_supported_dialects(self): + from circe.sqlrender.patterns import load_patterns + + patterns = load_patterns() + # Should have at least ome common dialects + for d in ("duckdb", "postgresql", "oracle", "bigquery"): + assert d in patterns, f"Missing dialect: {d}" diff --git a/tests/test_sqlrender_translate_duckdb.py b/tests/test_sqlrender_translate_duckdb.py new file mode 100644 index 00000000..e88e0d1f --- /dev/null +++ b/tests/test_sqlrender_translate_duckdb.py @@ -0,0 +1,228 @@ +"""Test DuckDB translation - ported from OHDSI SqlRender test-translate-duckdb.R""" + +import re + +from circe.sqlrender import translate + +# Force fresh pattern load for each test module + + +def setup_function(): + global _target_to_patterns + _target_to_patterns = None + + +def normalize_sql(s: str) -> str: + s = re.sub(r"([;()'+\-/|*\n])", r" \1 ", s) + s = re.sub(r" +", " ", s) + return s.strip() + + +def assert_sql_equal(actual: str, expected: str): + assert normalize_sql(actual) == normalize_sql(expected), f"\nExpected: {expected}\nGot: {actual}" + + +class TestDuckDBTranslation: + def test_string_concat_1(self): + sql = translate("'x' + b ( 'x' + b)", "duckdb") + assert_sql_equal(sql, "'x' || b ( 'x' || b)") + + def test_string_concat_2(self): + sql = translate("a + ';b'", "duckdb") + assert_sql_equal(sql, "a || ';b'") + + def test_string_concat_3(self): + sql = translate("a + ';('", "duckdb") + assert_sql_equal(sql, "a || ';('") + + def test_add_months(self): + sql = translate("DATEADD(mm,2,date)", "duckdb") + assert_sql_equal(sql, "(date + TO_MONTHS(CAST(2 AS INTEGER)))") + + def test_add_years(self): + sql = translate("DATEADD(yy,2,date)", "duckdb") + assert_sql_equal(sql, "(date + TO_YEARS(CAST(2 AS INTEGER)))") + + def test_cte_select_into(self): + sql = translate( + "WITH cte1 AS (SELECT a FROM b) SELECT c INTO d FROM cte1;", + "duckdb", + ) + expected = "CREATE TABLE d \nAS\nWITH cte1 AS (SELECT a FROM b) SELECT\nc \nFROM\ncte1;" + assert_sql_equal(sql, expected) + + def test_select_into(self): + sql = translate("SELECT c INTO d;", "duckdb") + expected = "CREATE TABLE d AS\nSELECT\nc ;" + assert_sql_equal(sql, expected) + + def test_cte_insert_into_select(self): + sql = translate( + "WITH cte1 AS (SELECT a FROM b) INSERT INTO c (d int) SELECT e FROM cte1;", + "duckdb", + ) + expected = "WITH cte1 AS (SELECT a FROM b) INSERT INTO c (d int) SELECT e FROM cte1;" + assert_sql_equal(sql, expected) + + def test_create_table_if_not_exists(self): + sql = translate( + "IF OBJECT_ID('cohort', 'U') IS NULL\n CREATE TABLE cohort\n(cohort_definition_id INT);", + "duckdb", + ) + expected = "CREATE TABLE IF NOT EXISTS cohort\n (cohort_definition_id INT);" + assert_sql_equal(sql, expected) + + def test_select_random_row(self): + sql = translate( + "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY RAND()) AS rn FROM table) tmp WHERE rn <= 1", + "duckdb", + ) + expected = "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY RANDOM()) AS rn FROM table) tmp WHERE rn <= 1" + assert_sql_equal(sql, expected) + + def test_temp_table(self): + sql = translate("SELECT * FROM #my_temp;", "duckdb") + assert_sql_equal(sql, "SELECT * FROM my_temp;") + + def test_top(self): + sql = translate("SELECT TOP 10 * FROM my_table WHERE a = b;", "duckdb") + assert_sql_equal(sql, "SELECT * FROM my_table WHERE a = b LIMIT 10;") + + def test_top_subquery(self): + sql = translate( + "SELECT name FROM (SELECT TOP 1 name FROM my_table WHERE a = b);", + "duckdb", + ) + expected = "SELECT name FROM (SELECT name FROM my_table WHERE a = b LIMIT 1);" + assert_sql_equal(sql, expected) + + def test_convert_varchar_date_112(self): + sql = translate("CONVERT(VARCHAR,start_date,112) FROM table;", "duckdb") + assert_sql_equal(sql, "STRFTIME(start_date, '%Y%m%d') FROM table;") + + def test_convert_date(self): + sql = translate("CONVERT(DATE, '20000101');", "duckdb") + assert_sql_equal(sql, "CAST(strptime('20000101', '%Y%m%d') AS DATE);") + + def test_cast_date(self): + sql = translate("CAST('20000101' AS DATE);", "duckdb") + assert_sql_equal(sql, "CAST(strptime('20000101', '%Y%m%d') AS DATE);") + + def test_log_any_base(self): + sql = translate("SELECT LOG(number, base) FROM table", "duckdb") + expected = "SELECT (LN(CAST((number) AS REAL))/LN(CAST((base) AS REAL))) FROM table" + assert_sql_equal(sql, expected) + + def test_isnumeric(self): + sql = translate("SELECT CASE WHEN ISNUMERIC(a) = 1 THEN a ELSE b FROM c;", "duckdb") + expected = ( + "SELECT CASE WHEN CASE WHEN (CAST(a AS VARCHAR) ~ '^([0-9]+\\.?[0-9]*|\\.[0-9]+)$')" + " THEN 1 ELSE 0 END = 1 THEN a ELSE b FROM c;" + ) + assert_sql_equal(sql, expected) + + def test_isnumeric_where(self): + sql = translate("SELECT a FROM table WHERE ISNUMERIC(a) = 1", "duckdb") + expected = ( + "SELECT a FROM table WHERE CASE WHEN (CAST(a AS VARCHAR) ~ '^([0-9]+\\.?[0-9]*|\\.[0-9]+)$')" + " THEN 1 ELSE 0 END = 1" + ) + assert_sql_equal(sql, expected) + + def test_update_statistics(self): + sql = translate("UPDATE STATISTICS results_schema.heracles_results;", "duckdb") + assert_sql_equal(sql, "ANALYZE results_schema.heracles_results;") + + def test_datetime_types(self): + sql = translate("CREATE TABLE x (a DATETIME2, b DATETIME);", "duckdb") + assert_sql_equal(sql, "CREATE TABLE x (a TIMESTAMP, b TIMESTAMP);") + + def test_getdate(self): + sql = translate("GETDATE()", "duckdb") + assert_sql_equal(sql, "CURRENT_DATE") + + def test_create_index(self): + sql = translate("CREATE INDEX idx_1 ON main.person (person_id);", "duckdb") + assert_sql_equal(sql, "CREATE INDEX idx_1 ON main.person (person_id);") + + def test_datediff_with_literals(self): + sql = translate("SELECT DATEDIFF(DAY, '20000131', '20000101');", "duckdb") + expected = ( + "SELECT (CAST(strptime('20000101', '%Y%m%d') AS DATE)" + " - CAST(strptime('20000131', '%Y%m%d') AS DATE));" + ) + assert_sql_equal(sql, expected) + + def test_datediff_date_fields(self): + sql = translate("SELECT DATEDIFF(DAY, date1, date2);", "duckdb") + expected = "SELECT (CAST(date2 AS DATE) - CAST(date1 AS DATE));" + assert_sql_equal(sql, expected) + + def test_datediff_year_literals(self): + sql = translate("SELECT DATEDIFF(YEAR, '20010131', '20000101');", "duckdb") + expected = ( + "SELECT (EXTRACT(YEAR FROM CAST(strptime('20000101', '%Y%m%d') AS DATE))" + " - EXTRACT(YEAR FROM CAST(strptime('20010131', '%Y%m%d') AS DATE)));" + ) + assert_sql_equal(sql, expected) + + def test_datediff_year_fields(self): + sql = translate("SELECT DATEDIFF(YEAR, date1, date2);", "duckdb") + expected = "SELECT (EXTRACT(YEAR FROM CAST(date2 AS DATE)) - EXTRACT(YEAR FROM CAST(date1 AS DATE)));" + assert_sql_equal(sql, expected) + + def test_datediff_month_literals(self): + sql = translate("SELECT DATEDIFF(MONTH, '20000115', '20010116');", "duckdb") + expected = ( + "SELECT (extract(year from age(CAST(strptime('20010116', '%Y%m%d') AS DATE)," + " CAST(strptime('20000115', '%Y%m%d') AS DATE)))*12" + " + extract(month from age(CAST(strptime('20010116', '%Y%m%d') AS DATE)," + " CAST(strptime('20000115', '%Y%m%d') AS DATE))));" + ) + assert_sql_equal(sql, expected) + + def test_datediff_month_fields(self): + sql = translate("SELECT DATEDIFF(MONTH, date1, date2);", "duckdb") + expected = ( + "SELECT (extract(year from age(CAST(date2 AS DATE), CAST(date1 AS DATE)))*12" + " + extract(month from age(CAST(date2 AS DATE), CAST(date1 AS DATE))));" + ) + assert_sql_equal(sql, expected) + + def test_ceiling(self): + sql = translate("SELECT CEILING(0.1);", "duckdb") + assert_sql_equal(sql, "SELECT CEILING(0.1);") + + def test_drop_table_if_exists(self): + sql = translate("DROP TABLE IF EXISTS test;", "duckdb") + assert_sql_equal(sql, "DROP TABLE IF EXISTS test;") + + def test_iif(self): + sql = translate("SELECT IIF(a>b, 1, b) AS max_val FROM table;", "duckdb") + expected = "SELECT CASE WHEN a>b THEN 1 ELSE b END AS max_val FROM table ;" + assert_sql_equal(sql, expected) + + def test_add_days_with_period(self): + sql = translate("DATEADD(DAY, -2.0, date)", "duckdb") + assert_sql_equal(sql, "(date + TO_DAYS(CAST(-2.0 AS INTEGER)))") + + def test_newid(self): + sql = translate("SELECT NEWID()", "duckdb") + assert_sql_equal(sql, "SELECT uuid()") + + def test_cast_concat_date(self): + sql = translate("CAST(CONCAT('2000', '0101') AS DATE);", "duckdb") + assert_sql_equal(sql, "CAST(strptime(CONCAT('2000', '0101'), '%Y%m%d') AS DATE);") + + def test_alter_table_add_single(self): + sql = translate("ALTER TABLE my_table ADD a INT;", "duckdb") + assert_sql_equal(sql, "ALTER TABLE my_table ADD a INT;") + + def test_alter_table_add_multiple(self): + sql = translate("ALTER TABLE my_table ADD a INT, b INT, c VARCHAR(255);", "duckdb") + expected = "ALTER TABLE my_table ADD a INT; ALTER TABLE my_table ADD b INT; ALTER TABLE my_table ADD c VARCHAR(255);" + assert_sql_equal(sql, expected) + + def test_alter_table_alter_column(self): + sql = translate("ALTER TABLE my_table ALTER COLUMN a BIGINT;", "duckdb") + assert_sql_equal(sql, "ALTER TABLE my_table ALTER a TYPE BIGINT;") diff --git a/tests/test_sqlrender_translate_postgresql.py b/tests/test_sqlrender_translate_postgresql.py new file mode 100644 index 00000000..30da8b53 --- /dev/null +++ b/tests/test_sqlrender_translate_postgresql.py @@ -0,0 +1,274 @@ +"""Test PostgreSQL translation - ported from OHDSI SqlRender test-translate-postgresql.R""" + +import re + +from circe.sqlrender import translate + + +def setup_function(): + global _target_to_patterns + _target_to_patterns = None + + +def normalize_sql(s: str) -> str: + s = re.sub(r"([;()'+\-/|*\n])", r" \1 ", s) + s = re.sub(r" +", " ", s) + return s.strip() + + +def assert_sql_equal(actual: str, expected: str): + assert normalize_sql(actual) == normalize_sql(expected), f"\nExpected: {expected}\nGot: {actual}" + + +class TestPostgreSQLTranslation: + def test_use(self): + sql = translate("USE vocabulary;", "postgresql") + assert_sql_equal(sql, "SET search_path TO vocabulary;") + + def test_string_concat_1(self): + sql = translate("'x' + b ( 'x' + b)", "postgresql") + assert_sql_equal(sql, "'x' || b ( 'x' || b)") + + def test_string_concat_2(self): + sql = translate("a + ';b'", "postgresql") + assert_sql_equal(sql, "a || ';b'") + + def test_string_concat_3(self): + sql = translate("a + ';('", "postgresql") + assert_sql_equal(sql, "a || ';('") + + def test_dateadd_month(self): + sql = translate("DATEADD(mm,1,date)", "postgresql") + assert_sql_equal(sql, "(date + 1*INTERVAL'1 month')") + + def test_datediff_month(self): + sql = translate( + "SELECT DATEDIFF(month,drug_era_start_date,drug_era_end_date) FROM drug_era;", + "postgresql", + ) + expected = ( + "SELECT (extract(year from age(CAST(drug_era_end_date AS DATE)," + " CAST(drug_era_start_date AS DATE)))*12" + " + extract(month from age(CAST(drug_era_end_date AS DATE)," + " CAST(drug_era_start_date AS DATE)))) FROM drug_era;" + ) + assert_sql_equal(sql, expected) + + def test_datediff_hour(self): + sql = translate( + "SELECT DATEDIFF(hour,drug_exposure_start_datetime,drug_exposure_end_datetime) FROM drug_exposure;", + "postgresql", + ) + expected = ( + "SELECT (EXTRACT(EPOCH FROM (drug_exposure_end_datetime" + " - drug_exposure_start_datetime)) / 3600) FROM drug_exposure;" + ) + assert_sql_equal(sql, expected) + + def test_datediff_minute(self): + sql = translate( + "SELECT DATEDIFF(minute,drug_exposure_start_datetime,drug_exposure_end_datetime) FROM drug_exposure;", + "postgresql", + ) + expected = ( + "SELECT (EXTRACT(EPOCH FROM (drug_exposure_end_datetime" + " - drug_exposure_start_datetime)) / 60) FROM drug_exposure;" + ) + assert_sql_equal(sql, expected) + + def test_datediff_second(self): + sql = translate( + "SELECT DATEDIFF(second,drug_exposure_start_datetime,drug_exposure_end_datetime) FROM drug_exposure;", + "postgresql", + ) + expected = ( + "SELECT EXTRACT(EPOCH FROM (drug_exposure_end_datetime" + " - drug_exposure_start_datetime)) FROM drug_exposure;" + ) + assert_sql_equal(sql, expected) + + def test_with_select(self): + sql = translate("WITH cte1 AS (SELECT a FROM b) SELECT c FROM cte1;", "postgresql") + assert_sql_equal(sql, "WITH cte1 AS (SELECT a FROM b) SELECT c FROM cte1;") + + def test_with_select_into(self): + sql = translate("WITH cte1 AS (SELECT a FROM b) SELECT c INTO d FROM cte1;", "postgresql") + expected = "CREATE TABLE d \nAS\nWITH cte1 AS (SELECT a FROM b) SELECT\nc \nFROM\ncte1;" + assert_sql_equal(sql, expected) + + def test_select_into_without_from(self): + sql = translate("SELECT c INTO d;", "postgresql") + expected = "CREATE TABLE d AS\nSELECT\nc ;" + assert_sql_equal(sql, expected) + + def test_with_insert_into_select(self): + sql = translate( + "WITH cte1 AS (SELECT a FROM b) INSERT INTO c (d int) SELECT e FROM cte1;", + "postgresql", + ) + expected = "WITH cte1 AS (SELECT a FROM b) INSERT INTO c (d int) SELECT e FROM cte1;" + assert_sql_equal(sql, expected) + + def test_create_table_if_not_exists(self): + sql = translate( + "IF OBJECT_ID('cohort', 'U') IS NULL\n CREATE TABLE cohort\n(cohort_definition_id INT);", + "postgresql", + ) + expected = "CREATE TABLE IF NOT EXISTS cohort\n (cohort_definition_id INT);" + assert_sql_equal(sql, expected) + + def test_select_random_row(self): + sql = translate( + "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY RAND()) AS rn FROM table) tmp WHERE rn <= 1", + "postgresql", + ) + expected = "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY RANDOM()) AS rn FROM table) tmp WHERE rn <= 1" + assert_sql_equal(sql, expected) + + def test_hashbytes_md5(self): + sql = translate( + "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY HASHBYTES('MD5',CAST(person_id AS varchar))) tmp WHERE rn <= 1", + "postgresql", + ) + expected = "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY MD5(CAST(person_id AS varchar))) tmp WHERE rn <= 1" + assert_sql_equal(sql, expected) + + def test_convert_varbinary(self): + sql = translate( + "SELECT ROW_NUMBER() OVER CONVERT(VARBINARY, val, 1) rn WHERE rn <= 1", + "postgresql", + ) + expected = "SELECT ROW_NUMBER() OVER CAST(CONCAT('x', val) AS BIT(32)) rn WHERE rn <= 1" + assert_sql_equal(sql, expected) + + def test_top(self): + sql = translate("SELECT TOP 10 * FROM my_table WHERE a = b;", "postgresql") + assert_sql_equal(sql, "SELECT * FROM my_table WHERE a = b LIMIT 10;") + + def test_top_subquery(self): + sql = translate( + "SELECT name FROM (SELECT TOP 1 name FROM my_table WHERE a = b);", + "postgresql", + ) + expected = "SELECT name FROM (SELECT name FROM my_table WHERE a = b LIMIT 1);" + assert_sql_equal(sql, expected) + + def test_convert_varchar_date(self): + sql = translate("CONVERT(VARCHAR,start_date,112) FROM table;", "postgresql") + assert_sql_equal(sql, "TO_CHAR(start_date, 'YYYYMMDD') FROM table;") + + def test_log(self): + sql = translate("SELECT LOG(number) FROM table", "postgresql") + assert_sql_equal(sql, "SELECT LN(CAST((number) AS REAL)) FROM table") + + def test_log10(self): + sql = translate("SELECT LOG10(number) FROM table;", "postgresql") + assert_sql_equal(sql, "SELECT LOG(10,CAST((number) AS NUMERIC)) FROM table;") + + def test_log_any_base(self): + sql = translate("SELECT LOG(number, base) FROM table", "postgresql") + expected = "SELECT LOG(CAST((base) AS NUMERIC),CAST((number) AS NUMERIC)) FROM table" + assert_sql_equal(sql, expected) + + def test_isnumeric(self): + sql = translate("SELECT CASE WHEN ISNUMERIC(a) = 1 THEN a ELSE b FROM c;", "postgresql") + expected = ( + "SELECT CASE WHEN CASE WHEN (CAST(a AS VARCHAR) ~ '^([0-9]+\\.?[0-9]*|\\.[0-9]+)$')" + " THEN 1 ELSE 0 END = 1 THEN a ELSE b FROM c;" + ) + assert_sql_equal(sql, expected) + + sql = translate("SELECT a FROM table WHERE ISNUMERIC(a) = 1", "postgresql") + expected = ( + "SELECT a FROM table WHERE CASE WHEN (CAST(a AS VARCHAR) ~ '^([0-9]+\\.?[0-9]*|\\.[0-9]+)$')" + " THEN 1 ELSE 0 END = 1" + ) + assert_sql_equal(sql, expected) + + sql = translate("SELECT a FROM table WHERE ISNUMERIC(a) = 0", "postgresql") + expected = ( + "SELECT a FROM table WHERE CASE WHEN (CAST(a AS VARCHAR) ~ '^([0-9]+\\.?[0-9]*|\\.[0-9]+)$')" + " THEN 1 ELSE 0 END = 0" + ) + assert_sql_equal(sql, expected) + + def test_cte_string_literal_cast(self): + sql = translate( + "WITH expression AS(SELECT 'my literal', col1, CAST('other literal' as VARCHAR(MAX)), col2 FROM table WHERE a = b) SELECT * FROM expression ORDER BY 1, 2, 3, 4;", + "postgresql", + ) + expected = ( + "WITH expression AS (SELECT CAST('my literal' as TEXT), col1, CAST('other literal' as TEXT)," + " col2 FROM table WHERE a = b) SELECT * FROM expression ORDER BY 1, 2, 3, 4;" + ) + assert_sql_equal(sql, expected) + + def test_update_statistics(self): + sql = translate("UPDATE STATISTICS results_schema.heracles_results;", "postgresql") + assert_sql_equal(sql, "ANALYZE results_schema.heracles_results;") + + def test_datetime_types(self): + sql = translate("CREATE TABLE x (a DATETIME2, b DATETIME);", "postgresql") + assert_sql_equal(sql, "CREATE TABLE x (a TIMESTAMP, b TIMESTAMP);") + + def test_drop_table_if_exists(self): + sql = translate("DROP TABLE IF EXISTS test;", "postgresql") + assert_sql_equal(sql, "DROP TABLE IF EXISTS test;") + + def test_comments_in_quotes_1(self): + sql = ( + "WITH cte_all\nAS (\nSELECT * FROM my_table\n\nUNION ALL\n\n" + "SELECT '(--12 hours fasting)' AS check_description\n)\n" + "INSERT INTO cdm.main\nSELECT *\nFROM cte_all;" + ) + result = translate(sql, "postgresql") + expected = ( + "WITH cte_all\n AS (SELECT * FROM my_table\nUNION ALL\n" + "SELECT CAST('(--12 hours fasting)' as TEXT) AS check_description\n)\n" + "INSERT INTO cdm.main\nSELECT *\nFROM cte_all;" + ) + assert_sql_equal(result, expected) + + def test_comments_in_quotes_2(self): + sql = ( + "WITH cte_all\nAS (\nSELECT * FROM my_table\n\nUNION ALL\n\n" + "SELECT '(/*12 hours fasting)' AS check_description\n)\n" + "INSERT INTO cdm.main\nSELECT *\nFROM cte_all;" + ) + result = translate(sql, "postgresql") + expected = ( + "WITH cte_all\n AS (SELECT * FROM my_table\nUNION ALL\n" + "SELECT CAST('(/*12 hours fasting)' as TEXT) AS check_description\n)\n" + "INSERT INTO cdm.main\nSELECT *\nFROM cte_all;" + ) + assert_sql_equal(result, expected) + + def test_iif(self): + sql = translate("SELECT IIF(a>b, 1, b) AS max_val FROM table;", "postgresql") + expected = "SELECT CASE WHEN a>b THEN 1 ELSE b END AS max_val FROM table ;" + assert_sql_equal(sql, expected) + + def test_alter_table_add_single(self): + sql = translate("ALTER TABLE my_table ADD a INT;", "postgresql") + assert_sql_equal(sql, "ALTER TABLE my_table ADD COLUMN a INT;") + + def test_alter_table_add_multiple(self): + sql = translate("ALTER TABLE my_table ADD a INT, b INT, c VARCHAR(255);", "postgresql") + expected = "ALTER TABLE my_table ADD COLUMN a INT, ADD COLUMN b INT, ADD COLUMN c VARCHAR(255);" + assert_sql_equal(sql, expected) + + def test_alter_table_add_column(self): + sql = translate("ALTER TABLE my_table ADD COLUMN a INT;", "postgresql") + assert_sql_equal(sql, "ALTER TABLE my_table ADD COLUMN a INT;") + + def test_alter_table_add_constraint(self): + sql = translate( + "ALTER TABLE cdm.MEASUREMENT ADD CONSTRAINT xpk_MEASUREMENT PRIMARY KEY NONCLUSTERED (measurement_id);", + "postgresql", + ) + expected = "ALTER TABLE cdm.MEASUREMENT ADD CONSTRAINT xpk_MEASUREMENT PRIMARY KEY (measurement_id);" + assert_sql_equal(sql, expected) + + def test_alter_table_alter_column(self): + sql = translate("ALTER TABLE my_table ALTER COLUMN a BIGINT;", "postgresql") + assert_sql_equal(sql, "ALTER TABLE my_table ALTER COLUMN a TYPE BIGINT;") From 3cc306e069f3bcca487e9112d8994327d3d71143 Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Thu, 28 May 2026 11:41:00 -0700 Subject: [PATCH 58/62] Basic sql glot builder classes and parity tests --- .../sqlglot_builders/__init__.py | 9 + .../cohortdefinition/sqlglot_builders/base.py | 29 + .../sqlglot_builders/codesets.py | 117 +++ .../sqlglot_builders/condition_occurrence.py | 272 ++++++ .../sqlglot_builders/drug_exposure.py | 310 +++++++ .../sqlglot_builders/primitives.py | 245 ++++++ .../sqlglot_builders/visit_occurrence.py | 235 ++++++ tests/test_sqlglot_builder_equivalence.py | 793 ++++++++++++++++++ tests/test_sqlglot_primitives.py | 469 +++++++++++ tests/test_sqlrender_csv_format.py | 12 +- tests/test_sqlrender_split.py | 19 +- tests/test_utils_db.py | 4 + 12 files changed, 2491 insertions(+), 23 deletions(-) create mode 100644 circe/cohortdefinition/sqlglot_builders/__init__.py create mode 100644 circe/cohortdefinition/sqlglot_builders/base.py create mode 100644 circe/cohortdefinition/sqlglot_builders/codesets.py create mode 100644 circe/cohortdefinition/sqlglot_builders/condition_occurrence.py create mode 100644 circe/cohortdefinition/sqlglot_builders/drug_exposure.py create mode 100644 circe/cohortdefinition/sqlglot_builders/primitives.py create mode 100644 circe/cohortdefinition/sqlglot_builders/visit_occurrence.py create mode 100644 tests/test_sqlglot_builder_equivalence.py create mode 100644 tests/test_sqlglot_primitives.py diff --git a/circe/cohortdefinition/sqlglot_builders/__init__.py b/circe/cohortdefinition/sqlglot_builders/__init__.py new file mode 100644 index 00000000..07980c36 --- /dev/null +++ b/circe/cohortdefinition/sqlglot_builders/__init__.py @@ -0,0 +1,9 @@ +from .condition_occurrence import ConditionOccurrenceGlotBuilder +from .drug_exposure import DrugExposureGlotBuilder +from .visit_occurrence import VisitOccurrenceGlotBuilder + +__all__ = [ + "ConditionOccurrenceGlotBuilder", + "DrugExposureGlotBuilder", + "VisitOccurrenceGlotBuilder", +] diff --git a/circe/cohortdefinition/sqlglot_builders/base.py b/circe/cohortdefinition/sqlglot_builders/base.py new file mode 100644 index 00000000..4d9ddfe8 --- /dev/null +++ b/circe/cohortdefinition/sqlglot_builders/base.py @@ -0,0 +1,29 @@ +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +from sqlglot import exp as sge + +from ..builders.utils import CriteriaColumn +from ..criteria import Criteria + +T = TypeVar("T", bound=Criteria) + + +class CriteriaColumnMap(ABC): + """Maps CriteriaColumn enum values to sqlglot column expressions.""" + + @abstractmethod + def get_column(self, column: CriteriaColumn) -> sge.Expression: + pass + + +class SqlGlotCriteriaBuilder(ABC, Generic[T]): + """Abstract base for sqlglot AST-based criteria builders.""" + + @abstractmethod + def build_select(self, criteria: T) -> sge.Select: + pass + + def compile(self, criteria: T, dialect: str = "duckdb") -> str: + sel = self.build_select(criteria) + return sel.sql(dialect=dialect) diff --git a/circe/cohortdefinition/sqlglot_builders/codesets.py b/circe/cohortdefinition/sqlglot_builders/codesets.py new file mode 100644 index 00000000..08ee201b --- /dev/null +++ b/circe/cohortdefinition/sqlglot_builders/codesets.py @@ -0,0 +1,117 @@ +from sqlglot import exp as sge + +from ...vocabulary.concept import ConceptSet, ConceptSetExpression, ConceptSetItem + + +def build_codeset_query(concept_sets: list[ConceptSet]) -> sge.Union | None: + if not concept_sets: + return None + + union_parts: list[sge.Select] = [] + for cs in concept_sets: + if hasattr(cs, "id") and hasattr(cs, "expression"): + sub = _build_concept_set_select(cs.id, cs.expression) + if sub is not None: + union_parts.append(sub) + + if not union_parts: + return None + + result = union_parts[0] + for part in union_parts[1:]: + result = sge.Union(this=result, expression=part, distinct=False) + return result + + +def _build_concept_set_select( + codeset_id: int, + expression: ConceptSetExpression, +) -> sge.Select | None: + items = expression.items if expression and expression.items else [] + if not items: + return None + + union_parts: list[sge.Select] = [] + for item in items: + sel = _build_item_select(codeset_id, item) + if sel is not None: + union_parts.append(sel) + + if not union_parts: + union_parts.append( + sge.Select() + .select( + sge.Literal.number(codeset_id).as_("codeset_id"), + sge.Literal.number(0).as_("concept_id"), + ) + .where(sge.false()) + ) + + result = union_parts[0] + for part in union_parts[1:]: + result = sge.Union(this=result, expression=part, distinct=False) + return result + + +def _build_item_select(codeset_id: int, item: ConceptSetItem) -> sge.Select | None: + if item.concept is None or item.concept.concept_id is None: + return None + + concept_id = item.concept.concept_id + + base_select = sge.Select().select( + sge.Literal.number(codeset_id).as_("codeset_id"), + sge.column("c.concept_id"), + ) + + if item.include_descendants: + base_select = ( + base_select.from_(sge.Table(this="concept_ancestor", alias="ca")) + .join( + sge.Table(this="concept", alias="c"), + on=sge.EQ( + this=sge.column("ca.descendant_concept_id"), + expression=sge.column("c.concept_id"), + ), + kind="INNER JOIN", + ) + .where( + sge.EQ( + this=sge.column("ca.ancestor_concept_id"), + expression=sge.Literal.number(concept_id), + ) + ) + ) + elif item.include_mapped: + base_select = ( + base_select.from_(sge.Table(this="concept_relationship", alias="cr")) + .join( + sge.Table(this="concept", alias="c"), + on=sge.EQ( + this=sge.column("cr.concept_id_2"), + expression=sge.column("c.concept_id"), + ), + kind="INNER JOIN", + ) + .where( + sge.EQ( + this=sge.column("cr.concept_id_1"), + expression=sge.Literal.number(concept_id), + ) + ) + .where( + sge.EQ( + this=sge.column("c.standard_concept"), + expression=sge.Literal.string("S"), + ) + ) + ) + else: + base_select = base_select.from_(sge.Table(this="concept", alias="c")).where( + sge.EQ( + this=sge.column("c.concept_id"), + expression=sge.Literal.number(concept_id), + ) + ) + + return base_select diff --git a/circe/cohortdefinition/sqlglot_builders/condition_occurrence.py b/circe/cohortdefinition/sqlglot_builders/condition_occurrence.py new file mode 100644 index 00000000..6c6f8d59 --- /dev/null +++ b/circe/cohortdefinition/sqlglot_builders/condition_occurrence.py @@ -0,0 +1,272 @@ +from sqlglot import exp as sge + +from ..builders.utils import BuilderUtils +from ..criteria import ConditionOccurrence +from .base import SqlGlotCriteriaBuilder +from .primitives import ( + alias_expr, + build_date_range_clause, + build_in_clause, + build_numeric_range_clause, + build_text_filter_clause, + coalesce, + codeset_in, + column_ref, + date_add, + row_number_expr, + year_of, +) + + +class ConditionOccurrenceGlotBuilder(SqlGlotCriteriaBuilder[ConditionOccurrence]): + def build_select(self, criteria: ConditionOccurrence) -> sge.Select: + inner = sge.Select() + cols = [ + column_ref("co", "person_id"), + column_ref("co", "condition_occurrence_id"), + column_ref("co", "condition_concept_id"), + column_ref("co", "visit_occurrence_id"), + ] + + if criteria.condition_type is not None and len(criteria.condition_type) > 0: + cols.append(column_ref("co", "condition_type_concept_id")) + if criteria.condition_type_cs is not None: + cols.append(column_ref("co", "condition_type_concept_id")) + if criteria.stop_reason is not None: + cols.append(column_ref("co", "stop_reason")) + if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: + cols.append(column_ref("co", "provider_id")) + if criteria.provider_specialty_cs is not None: + cols.append(column_ref("co", "provider_id")) + if criteria.condition_status is not None and len(criteria.condition_status) > 0: + cols.append(column_ref("co", "condition_status_concept_id")) + if criteria.condition_status_cs is not None: + cols.append(column_ref("co", "condition_status_concept_id")) + + if criteria.date_adjustment is not None: + start_col = ( + column_ref("co", "condition_start_date") + if criteria.date_adjustment.start_with == "start_date" + else coalesce( + column_ref("co", "condition_end_date"), + date_add("day", 1, column_ref("co", "condition_start_date")), + ) + ) + end_col = ( + column_ref("co", "condition_start_date") + if criteria.date_adjustment.end_with == "start_date" + else coalesce( + column_ref("co", "condition_end_date"), + date_add("day", 1, column_ref("co", "condition_start_date")), + ) + ) + start_date_expr = date_add("day", criteria.date_adjustment.start_offset, start_col) + end_date_expr = date_add("day", criteria.date_adjustment.end_offset, end_col) + else: + start_date_expr = column_ref("co", "condition_start_date") + end_date_expr = coalesce( + column_ref("co", "condition_end_date"), + date_add("day", 1, column_ref("co", "condition_start_date")), + ) + + cols.append(alias_expr(start_date_expr, "start_date")) + cols.append(alias_expr(end_date_expr, "end_date")) + + inner = inner.select(*cols).from_(sge.Table(this="CONDITION_OCCURRENCE", alias="co")) + + if criteria.codeset_id is not None: + cs_table = sge.Table(this="#Codesets", alias="cs") + cs_on = sge.And( + this=sge.EQ( + this=column_ref("co", "condition_concept_id"), expression=column_ref("cs", "concept_id") + ), + expression=sge.EQ( + this=column_ref("cs", "codeset_id"), expression=sge.Literal.number(criteria.codeset_id) + ), + ) + inner = inner.join(cs_table, on=cs_on, kind="INNER JOIN", append=True) + if criteria.condition_source_concept is not None: + cns_table = sge.Table(this="#Codesets", alias="cns") + cns_on = sge.And( + this=sge.EQ( + this=column_ref("co", "condition_source_concept_id"), + expression=column_ref("cns", "concept_id"), + ), + expression=sge.EQ( + this=column_ref("cns", "codeset_id"), + expression=sge.Literal.number(criteria.condition_source_concept), + ), + ) + inner = inner.join(cns_table, on=cns_on, kind="INNER JOIN", append=True) + + if criteria.first: + inner = inner.select( + alias_expr( + row_number_expr( + [column_ref("co", "person_id")], + [ + column_ref("co", "condition_start_date"), + column_ref("co", "condition_occurrence_id"), + ], + ), + "ordinal", + ) + ) + + outer_cols = [ + alias_expr(column_ref("C", "person_id"), "person_id"), + alias_expr(column_ref("C", "condition_occurrence_id"), "event_id"), + column_ref("C", "start_date"), + column_ref("C", "end_date"), + column_ref("C", "visit_occurrence_id"), + alias_expr(column_ref("C", "start_date"), "sort_date"), + ] + + outer = sge.Select().select(*outer_cols).from_(inner.subquery().as_("C")) + + if ( + criteria.age is not None + or (criteria.gender is not None and len(criteria.gender) > 0) + or criteria.gender_cs is not None + ): + outer = outer.join( + sge.Table(this="PERSON", alias="P"), + on=sge.EQ(this=column_ref("C", "person_id"), expression=column_ref("P", "person_id")), + kind="JOIN", + append=True, + ) + + if ( + criteria.visit_type is not None and len(criteria.visit_type) > 0 + ) or criteria.visit_type_cs is not None: + outer = outer.join( + sge.Table(this="VISIT_OCCURRENCE", alias="V"), + on=sge.And( + this=sge.EQ( + this=column_ref("C", "visit_occurrence_id"), + expression=column_ref("V", "visit_occurrence_id"), + ), + expression=sge.EQ( + this=column_ref("C", "person_id"), expression=column_ref("V", "person_id") + ), + ), + kind="JOIN", + append=True, + ) + + if ( + criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs is not None: + outer = outer.join( + sge.Table(this="PROVIDER", alias="PR"), + on=sge.EQ(this=column_ref("C", "provider_id"), expression=column_ref("PR", "provider_id")), + kind="LEFT JOIN", + append=True, + ) + + wheres = [] + + if criteria.occurrence_start_date is not None: + clause = build_date_range_clause(column_ref("C", "start_date"), criteria.occurrence_start_date) + if clause is not None: + wheres.append(clause) + + if criteria.occurrence_end_date is not None: + clause = build_date_range_clause(column_ref("C", "end_date"), criteria.occurrence_end_date) + if clause is not None: + wheres.append(clause) + + if criteria.condition_type is not None and len(criteria.condition_type) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.condition_type) + if concept_ids: + wheres.append( + build_in_clause( + column_ref("C", "condition_type_concept_id"), + concept_ids, + exclude=criteria.condition_type_exclude, + ) + ) + + if criteria.condition_type_cs is not None: + clause = codeset_in( + column_ref("C", "condition_type_concept_id"), + criteria.condition_type_cs.codeset_id, + exclude=criteria.condition_type_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.stop_reason is not None: + clause = build_text_filter_clause(column_ref("C", "stop_reason"), criteria.stop_reason) + if clause is not None: + wheres.append(clause) + + if criteria.age is not None: + age_expr = sge.Sub( + this=year_of(column_ref("C", "start_date")), + expression=column_ref("P", "year_of_birth"), + ) + clause = build_numeric_range_clause(age_expr, criteria.age) + if clause is not None: + wheres.append(clause) + + if criteria.gender is not None and len(criteria.gender) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) + if concept_ids: + wheres.append(build_in_clause(column_ref("P", "gender_concept_id"), concept_ids)) + + if criteria.gender_cs is not None: + clause = codeset_in( + column_ref("P", "gender_concept_id"), + criteria.gender_cs.codeset_id, + exclude=criteria.gender_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) + if concept_ids: + wheres.append(build_in_clause(column_ref("PR", "specialty_concept_id"), concept_ids)) + + if criteria.provider_specialty_cs is not None: + clause = codeset_in( + column_ref("PR", "specialty_concept_id"), + criteria.provider_specialty_cs.codeset_id, + exclude=criteria.provider_specialty_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.visit_type is not None and len(criteria.visit_type) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) + if concept_ids: + wheres.append(build_in_clause(column_ref("V", "visit_concept_id"), concept_ids)) + + if criteria.visit_type_cs is not None: + clause = codeset_in( + column_ref("V", "visit_concept_id"), + criteria.visit_type_cs.codeset_id, + exclude=criteria.visit_type_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.condition_status is not None and len(criteria.condition_status) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.condition_status) + if concept_ids: + wheres.append(build_in_clause(column_ref("C", "condition_status_concept_id"), concept_ids)) + + if criteria.condition_status_cs is not None: + clause = codeset_in( + column_ref("C", "condition_status_concept_id"), + criteria.condition_status_cs.codeset_id, + exclude=criteria.condition_status_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + for w in wheres: + outer = outer.where(w) + + return outer diff --git a/circe/cohortdefinition/sqlglot_builders/drug_exposure.py b/circe/cohortdefinition/sqlglot_builders/drug_exposure.py new file mode 100644 index 00000000..b1b9c1d2 --- /dev/null +++ b/circe/cohortdefinition/sqlglot_builders/drug_exposure.py @@ -0,0 +1,310 @@ +from sqlglot import exp as sge + +from ..builders.utils import BuilderUtils +from ..criteria import DrugExposure +from .base import SqlGlotCriteriaBuilder +from .primitives import ( + alias_expr, + build_date_range_clause, + build_in_clause, + build_numeric_range_clause, + build_text_filter_clause, + coalesce, + codeset_in, + column_ref, + date_add, + row_number_expr, + year_of, +) + + +class DrugExposureGlotBuilder(SqlGlotCriteriaBuilder[DrugExposure]): + def build_select(self, criteria: DrugExposure) -> sge.Select: + inner = sge.Select() + cols = [ + column_ref("de", "person_id"), + column_ref("de", "drug_exposure_id"), + column_ref("de", "drug_concept_id"), + column_ref("de", "visit_occurrence_id"), + column_ref("de", "days_supply"), + column_ref("de", "quantity"), + column_ref("de", "refills"), + ] + + if criteria.drug_type is not None and len(criteria.drug_type) > 0: + cols.append(column_ref("de", "drug_type_concept_id")) + if criteria.drug_type_cs is not None: + cols.append(column_ref("de", "drug_type_concept_id")) + if criteria.stop_reason is not None: + cols.append(column_ref("de", "stop_reason")) + if criteria.route_concept is not None and len(criteria.route_concept) > 0: + cols.append(column_ref("de", "route_concept_id")) + if criteria.route_concept_cs is not None: + cols.append(column_ref("de", "route_concept_id")) + if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: + cols.append(column_ref("de", "provider_id")) + if criteria.provider_specialty_cs is not None: + cols.append(column_ref("de", "provider_id")) + if criteria.dose_unit is not None and len(criteria.dose_unit) > 0: + cols.append(column_ref("de", "dose_unit_concept_id")) + if criteria.dose_unit_cs is not None: + cols.append(column_ref("de", "dose_unit_concept_id")) + if criteria.lot_number is not None: + cols.append(column_ref("de", "lot_number")) + + if criteria.date_adjustment is not None: + start_col = ( + column_ref("de", "drug_exposure_start_date") + if criteria.date_adjustment.start_with == "start_date" + else column_ref("de", "drug_exposure_end_date") + ) + end_col = ( + column_ref("de", "drug_exposure_start_date") + if criteria.date_adjustment.end_with == "start_date" + else column_ref("de", "drug_exposure_end_date") + ) + start_date_expr = date_add("day", criteria.date_adjustment.start_offset, start_col) + end_date_expr = date_add("day", criteria.date_adjustment.end_offset, end_col) + else: + start_date_expr = column_ref("de", "drug_exposure_start_date") + end_date_expr = coalesce( + column_ref("de", "drug_exposure_end_date"), + date_add( + "day", column_ref("de", "days_supply"), column_ref("de", "drug_exposure_start_date") + ), + date_add("day", 1, column_ref("de", "drug_exposure_start_date")), + ) + + cols.append(alias_expr(start_date_expr, "start_date")) + cols.append(alias_expr(end_date_expr, "end_date")) + + inner = inner.select(*cols).from_(sge.Table(this="DRUG_EXPOSURE", alias="de")) + + if criteria.codeset_id is not None: + cs_table = sge.Table(this="#Codesets", alias="cs") + cs_on = sge.And( + this=sge.EQ( + this=column_ref("de", "drug_concept_id"), expression=column_ref("cs", "concept_id") + ), + expression=sge.EQ( + this=column_ref("cs", "codeset_id"), expression=sge.Literal.number(criteria.codeset_id) + ), + ) + inner = inner.join(cs_table, on=cs_on, kind="INNER JOIN", append=True) + if criteria.drug_source_concept is not None: + cns_table = sge.Table(this="#Codesets", alias="cns") + cns_on = sge.And( + this=sge.EQ( + this=column_ref("de", "drug_source_concept_id"), + expression=column_ref("cns", "concept_id"), + ), + expression=sge.EQ( + this=column_ref("cns", "codeset_id"), + expression=sge.Literal.number(criteria.drug_source_concept), + ), + ) + inner = inner.join(cns_table, on=cns_on, kind="INNER JOIN", append=True) + + if criteria.first: + inner = inner.select( + alias_expr( + row_number_expr( + [column_ref("de", "person_id")], + [column_ref("de", "drug_exposure_start_date"), column_ref("de", "drug_exposure_id")], + ), + "ordinal", + ) + ) + + outer_cols = [ + alias_expr(column_ref("C", "person_id"), "person_id"), + alias_expr(column_ref("C", "drug_exposure_id"), "event_id"), + column_ref("C", "start_date"), + column_ref("C", "end_date"), + column_ref("C", "visit_occurrence_id"), + alias_expr(column_ref("C", "start_date"), "sort_date"), + ] + + outer = sge.Select().select(*outer_cols).from_(inner.subquery().as_("C")) + + if ( + criteria.age is not None + or (criteria.gender is not None and len(criteria.gender) > 0) + or criteria.gender_cs is not None + ): + outer = outer.join( + sge.Table(this="PERSON", alias="P"), + on=sge.EQ(this=column_ref("C", "person_id"), expression=column_ref("P", "person_id")), + kind="JOIN", + append=True, + ) + + if ( + criteria.visit_type is not None and len(criteria.visit_type) > 0 + ) or criteria.visit_type_cs is not None: + outer = outer.join( + sge.Table(this="VISIT_OCCURRENCE", alias="V"), + on=sge.And( + this=sge.EQ( + this=column_ref("C", "visit_occurrence_id"), + expression=column_ref("V", "visit_occurrence_id"), + ), + expression=sge.EQ( + this=column_ref("C", "person_id"), expression=column_ref("V", "person_id") + ), + ), + kind="JOIN", + append=True, + ) + + if ( + criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs is not None: + outer = outer.join( + sge.Table(this="PROVIDER", alias="PR"), + on=sge.EQ(this=column_ref("C", "provider_id"), expression=column_ref("PR", "provider_id")), + kind="LEFT JOIN", + append=True, + ) + + wheres = [] + + if criteria.occurrence_start_date is not None: + clause = build_date_range_clause(column_ref("C", "start_date"), criteria.occurrence_start_date) + if clause is not None: + wheres.append(clause) + + if criteria.occurrence_end_date is not None: + clause = build_date_range_clause(column_ref("C", "end_date"), criteria.occurrence_end_date) + if clause is not None: + wheres.append(clause) + + if criteria.drug_type is not None and len(criteria.drug_type) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.drug_type) + if concept_ids: + wheres.append( + build_in_clause( + column_ref("C", "drug_type_concept_id"), + concept_ids, + exclude=criteria.drug_type_exclude, + ) + ) + + if criteria.drug_type_cs is not None: + clause = codeset_in( + column_ref("C", "drug_type_concept_id"), + criteria.drug_type_cs.codeset_id, + exclude=criteria.drug_type_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.stop_reason is not None: + clause = build_text_filter_clause(column_ref("C", "stop_reason"), criteria.stop_reason) + if clause is not None: + wheres.append(clause) + + if criteria.route_concept is not None and len(criteria.route_concept) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.route_concept) + if concept_ids: + wheres.append(build_in_clause(column_ref("C", "route_concept_id"), concept_ids)) + + if criteria.route_concept_cs is not None: + clause = codeset_in( + column_ref("C", "route_concept_id"), + criteria.route_concept_cs.codeset_id, + exclude=criteria.route_concept_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.dose_unit is not None and len(criteria.dose_unit) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.dose_unit) + if concept_ids: + wheres.append(build_in_clause(column_ref("C", "dose_unit_concept_id"), concept_ids)) + + if criteria.dose_unit_cs is not None: + clause = codeset_in( + column_ref("C", "dose_unit_concept_id"), + criteria.dose_unit_cs.codeset_id, + exclude=criteria.dose_unit_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.lot_number is not None: + clause = build_text_filter_clause(column_ref("C", "lot_number"), criteria.lot_number) + if clause is not None: + wheres.append(clause) + + if criteria.refills is not None: + clause = build_numeric_range_clause(column_ref("C", "refills"), criteria.refills) + if clause is not None: + wheres.append(clause) + + if criteria.quantity is not None: + clause = build_numeric_range_clause(column_ref("C", "quantity"), criteria.quantity) + if clause is not None: + wheres.append(clause) + + if criteria.days_supply is not None: + clause = build_numeric_range_clause(column_ref("C", "days_supply"), criteria.days_supply) + if clause is not None: + wheres.append(clause) + + if criteria.age is not None: + age_expr = sge.Sub( + this=year_of(column_ref("C", "start_date")), + expression=column_ref("P", "year_of_birth"), + ) + clause = build_numeric_range_clause(age_expr, criteria.age) + if clause is not None: + wheres.append(clause) + + if criteria.gender is not None and len(criteria.gender) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) + if concept_ids: + wheres.append(build_in_clause(column_ref("P", "gender_concept_id"), concept_ids)) + + if criteria.gender_cs is not None: + clause = codeset_in( + column_ref("P", "gender_concept_id"), + criteria.gender_cs.codeset_id, + exclude=criteria.gender_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) + if concept_ids: + wheres.append(build_in_clause(column_ref("PR", "specialty_concept_id"), concept_ids)) + + if criteria.provider_specialty_cs is not None: + clause = codeset_in( + column_ref("PR", "specialty_concept_id"), + criteria.provider_specialty_cs.codeset_id, + exclude=criteria.provider_specialty_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.visit_type is not None and len(criteria.visit_type) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) + if concept_ids: + wheres.append(build_in_clause(column_ref("V", "visit_concept_id"), concept_ids)) + + if criteria.visit_type_cs is not None: + clause = codeset_in( + column_ref("V", "visit_concept_id"), + criteria.visit_type_cs.codeset_id, + exclude=criteria.visit_type_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + for w in wheres: + if w is not None: + outer = outer.where(w) + + return outer diff --git a/circe/cohortdefinition/sqlglot_builders/primitives.py b/circe/cohortdefinition/sqlglot_builders/primitives.py new file mode 100644 index 00000000..92509fa7 --- /dev/null +++ b/circe/cohortdefinition/sqlglot_builders/primitives.py @@ -0,0 +1,245 @@ +from typing import Optional + +from sqlglot import exp as sge + +from ..core import DateRange, NumericRange + + +def column_ref(table_alias: str, col: str) -> sge.Column: + return sge.Column(this=col, table=table_alias) + + +def alias_expr(expr, alias: str) -> sge.Alias: + return sge.Alias(this=expr, alias=sge.to_identifier(alias)) + + +def date_add(unit: str, n, expr) -> sge.DateAdd: + if isinstance(n, int): + return sge.DateAdd(this=expr, expression=sge.Literal.number(n), unit=sge.Var(this=unit)) + return sge.DateAdd(this=expr, expression=n, unit=sge.Var(this=unit)) + + +def coalesce(*exprs) -> sge.Coalesce | None: + if not exprs: + return None + result = sge.Coalesce(this=exprs[0]) + result.args.setdefault("expressions", []) + for e in exprs[1:]: + result.args["expressions"].append(e) + return result + + +def year_of(expr) -> sge.Year: + return sge.Year(this=expr) + + +def datediff(unit: str, start, end) -> sge.DateDiff: + start_ts = sge.TimeStrToTime(this=start) if isinstance(start, sge.Column) else start + end_ts = sge.TimeStrToTime(this=end) if isinstance(end, sge.Column) else end + return sge.DateDiff(this=end_ts, expression=start_ts, unit=sge.Var(this=unit)) + + +def date_from_parts(year, month, day) -> sge.DateFromParts: + return sge.DateFromParts( + year=sge.Literal.number(year), + month=sge.Literal.number(month), + day=sge.Literal.number(day), + ) + + +def row_number_expr(partition_by: list, order_by: list) -> sge.Window: + orders = [ + sge.Ordered(this=col, desc=False, nulls_first=True) + if isinstance(col, sge.Column) + else sge.Ordered(this=col, desc=False) + for col in order_by + ] + order = sge.Order(expressions=orders) + window = sge.Window( + this=sge.RowNumber(), + partition_by=partition_by, + order=order, + ) + return window + + +def codeset_join( + codeset_table: str, + concept_column: sge.Column, + codeset_id: int, + alias: str = "cs", +) -> sge.Join: + return sge.Join( + this=sge.Table(this=codeset_table, alias=alias), + kind="INNER JOIN", + on=sge.And( + this=sge.EQ(this=concept_column, expression=column_ref(alias, "concept_id")), + expression=sge.EQ( + this=column_ref(alias, "codeset_id"), + expression=sge.Literal.number(codeset_id), + ), + ), + ) + + +def codeset_in(column: sge.Column, codeset_id: int, exclude: bool = False) -> sge.In | sge.Not | None: + subq = ( + sge.Select() + .select(sge.column("concept_id")) + .from_(sge.Table(this="#Codesets")) + .where( + sge.EQ( + this=sge.column("codeset_id"), + expression=sge.Literal.number(codeset_id), + ) + ) + ) + result: sge.In = sge.In(this=column, expressions=[sge.Subquery(this=subq)]) + if exclude: + return sge.Not(this=result) + return result + + +def build_date_range_clause( + column: sge.Column, + date_range: Optional[DateRange], +) -> Optional[sge.Expression]: + if date_range is None or date_range.op is None: + return None + op = date_range.op.lower() + + if op.endswith("bt"): + negation = op.startswith("!") + if date_range.value is None: + return None + lo = date_string_to_expr(date_range.value) + hi = date_string_to_expr(date_range.extent) if date_range.extent else None + if hi is None: + return None + result = sge.And( + this=sge.GTE(this=column, expression=lo), + expression=sge.LTE(this=column, expression=hi), + ) + if negation: + return sge.Not(this=result) + return result + + if date_range.value is None: + return None + val = date_string_to_expr(date_range.value) + sql_op = _get_sql_operator(op) + if sql_op == "=": + return sge.EQ(this=column, expression=val) + elif sql_op == "<>": + return sge.NEQ(this=column, expression=val) + elif sql_op == ">": + return sge.GT(this=column, expression=val) + elif sql_op == ">=": + return sge.GTE(this=column, expression=val) + elif sql_op == "<": + return sge.LT(this=column, expression=val) + elif sql_op == "<=": + return sge.LTE(this=column, expression=val) + return None + + +def _get_sql_operator(op: str) -> str: + operators = { + "lt": "<", + "lte": "<=", + "eq": "=", + "ne": "<>", + "!eq": "<>", + "gt": ">", + "gte": ">=", + } + return operators.get(op, "=") + + +def date_string_to_expr(date_str: str) -> sge.DateFromParts: + parts = date_str.split("-") + year = int(parts[0]) + month = int(parts[1]) + day = int(parts[2]) + return date_from_parts(year, month, day) + + +def build_numeric_range_clause( + column, + numeric_range: Optional[NumericRange], +) -> Optional[sge.Expression]: + if numeric_range is None or numeric_range.op is None: + return None + op = numeric_range.op.lower() + + if op.endswith("bt"): + if numeric_range.value is None or numeric_range.extent is None: + return None + negation = op.startswith("!") + lo = sge.Literal.number(int(numeric_range.value)) + hi = sge.Literal.number(int(numeric_range.extent)) + result = sge.And( + this=sge.GTE(this=column, expression=lo), expression=sge.LTE(this=column, expression=hi) + ) + if negation: + return sge.Not(this=result) + return result + + if numeric_range.value is None: + return None + val = sge.Literal.number(int(numeric_range.value)) + sql_op = _get_sql_operator(op) + if sql_op == "=": + return sge.EQ(this=column, expression=val) + elif sql_op == "<>": + return sge.NEQ(this=column, expression=val) + elif sql_op == ">": + return sge.GT(this=column, expression=val) + elif sql_op == ">=": + return sge.GTE(this=column, expression=val) + elif sql_op == "<": + return sge.LT(this=column, expression=val) + elif sql_op == "<=": + return sge.LTE(this=column, expression=val) + return None + + +def build_text_filter_clause( + column: sge.Column, + text_filter, +) -> Optional[sge.Expression]: + if text_filter is None: + return None + if isinstance(text_filter, str): + return sge.Like( + this=column, + expression=sge.Literal.string(f"%{text_filter}%"), + ) + text = getattr(text_filter, "text", None) + op = getattr(text_filter, "op", "contains") + if text is None: + return None + escaped = text.replace("'", "''") + if op == "eq": + return sge.EQ(this=column, expression=sge.Literal.string(escaped)) + elif op == "!eq": + return sge.NEQ(this=column, expression=sge.Literal.string(escaped)) + elif op == "startsWith": + return sge.Like(this=column, expression=sge.Literal.string(f"{escaped}%")) + elif op == "endsWith": + return sge.Like(this=column, expression=sge.Literal.string(f"%{escaped}")) + elif op == "!contains": + return sge.Not(this=sge.Like(this=column, expression=sge.Literal.string(f"%{escaped}%"))) + else: + return sge.Like(this=column, expression=sge.Literal.string(f"%{escaped}%")) + + +def build_in_clause(column: sge.Column, values: list[int], exclude: bool = False) -> sge.In | sge.Not: + sorted_vals = sorted(set(values)) + in_expr: sge.In = sge.In( + this=column, + expressions=[sge.Literal.number(v) for v in sorted_vals], + ) + if exclude: + return sge.Not(this=in_expr) + return in_expr diff --git a/circe/cohortdefinition/sqlglot_builders/visit_occurrence.py b/circe/cohortdefinition/sqlglot_builders/visit_occurrence.py new file mode 100644 index 00000000..e98c014e --- /dev/null +++ b/circe/cohortdefinition/sqlglot_builders/visit_occurrence.py @@ -0,0 +1,235 @@ +from sqlglot import exp as sge + +from ..builders.utils import BuilderUtils +from ..criteria import VisitOccurrence +from .base import SqlGlotCriteriaBuilder +from .primitives import ( + alias_expr, + build_date_range_clause, + build_in_clause, + build_numeric_range_clause, + codeset_in, + column_ref, + date_add, + datediff, + row_number_expr, + year_of, +) + + +class VisitOccurrenceGlotBuilder(SqlGlotCriteriaBuilder[VisitOccurrence]): + def build_select(self, criteria: VisitOccurrence) -> sge.Select: + inner = sge.Select() + cols = [ + column_ref("vo", "person_id"), + column_ref("vo", "visit_occurrence_id"), + column_ref("vo", "visit_concept_id"), + ] + + if ( + criteria.visit_type is not None and len(criteria.visit_type) > 0 + ) or criteria.visit_type_cs is not None: + cols.append(column_ref("vo", "visit_type_concept_id")) + if ( + criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0 + ) or criteria.provider_specialty_cs is not None: + cols.append(column_ref("vo", "provider_id")) + if ( + criteria.place_of_service is not None and len(criteria.place_of_service) > 0 + ) or criteria.place_of_service_cs is not None: + cols.append(column_ref("vo", "care_site_id")) + + if criteria.date_adjustment is not None: + start_col = ( + column_ref("vo", "visit_start_date") + if criteria.date_adjustment.start_with == "START_DATE" + else column_ref("vo", "visit_end_date") + ) + end_col = ( + column_ref("vo", "visit_start_date") + if criteria.date_adjustment.end_with == "START_DATE" + else column_ref("vo", "visit_end_date") + ) + start_date_expr = date_add("day", criteria.date_adjustment.start_offset, start_col) + end_date_expr = date_add("day", criteria.date_adjustment.end_offset, end_col) + else: + start_date_expr = column_ref("vo", "visit_start_date") + end_date_expr = column_ref("vo", "visit_end_date") + + cols.append(alias_expr(start_date_expr, "start_date")) + cols.append(alias_expr(end_date_expr, "end_date")) + + inner = inner.select(*cols).from_(sge.Table(this="VISIT_OCCURRENCE", alias="vo")) + + if criteria.codeset_id is not None: + cs_table = sge.Table(this="#Codesets", alias="cs") + cs_on = sge.And( + this=sge.EQ( + this=column_ref("vo", "visit_concept_id"), expression=column_ref("cs", "concept_id") + ), + expression=sge.EQ( + this=column_ref("cs", "codeset_id"), expression=sge.Literal.number(criteria.codeset_id) + ), + ) + inner = inner.join(cs_table, on=cs_on, kind="INNER JOIN", append=True) + if criteria.visit_source_concept is not None: + cns_table = sge.Table(this="#Codesets", alias="cns") + cns_on = sge.And( + this=sge.EQ( + this=column_ref("vo", "visit_source_concept_id"), + expression=column_ref("cns", "concept_id"), + ), + expression=sge.EQ( + this=column_ref("cns", "codeset_id"), + expression=sge.Literal.number(criteria.visit_source_concept), + ), + ) + inner = inner.join(cns_table, on=cns_on, kind="INNER JOIN", append=True) + + if criteria.first: + inner = inner.select( + alias_expr( + row_number_expr( + [column_ref("vo", "person_id")], + [column_ref("vo", "visit_start_date"), column_ref("vo", "visit_occurrence_id")], + ), + "ordinal", + ) + ) + + outer_cols = [ + alias_expr(column_ref("C", "person_id"), "person_id"), + alias_expr(column_ref("C", "visit_occurrence_id"), "event_id"), + column_ref("C", "start_date"), + column_ref("C", "end_date"), + column_ref("C", "visit_occurrence_id"), + alias_expr(column_ref("C", "start_date"), "sort_date"), + ] + + outer = sge.Select().select(*outer_cols).from_(inner.subquery().as_("C")) + + if ( + criteria.age is not None + or (criteria.gender is not None and len(criteria.gender) > 0) + or (criteria.gender_cs is not None and criteria.gender_cs.codeset_id) + ): + outer = outer.join( + sge.Table(this="PERSON", alias="P"), + on=sge.EQ(this=column_ref("C", "person_id"), expression=column_ref("P", "person_id")), + kind="JOIN", + append=True, + ) + + if ( + (criteria.place_of_service is not None and len(criteria.place_of_service) > 0) + or (criteria.place_of_service_cs is not None and criteria.place_of_service_cs.codeset_id) + or criteria.place_of_service_location is not None + ): + outer = outer.join( + sge.Table(this="CARE_SITE", alias="CS"), + on=sge.EQ(this=column_ref("C", "care_site_id"), expression=column_ref("CS", "care_site_id")), + kind="JOIN", + append=True, + ) + + if (criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0) or ( + criteria.provider_specialty_cs is not None and criteria.provider_specialty_cs.codeset_id + ): + outer = outer.join( + sge.Table(this="PROVIDER", alias="PR"), + on=sge.EQ(this=column_ref("C", "provider_id"), expression=column_ref("PR", "provider_id")), + kind="LEFT JOIN", + append=True, + ) + + wheres = [] + + if criteria.occurrence_start_date is not None: + clause = build_date_range_clause(column_ref("C", "start_date"), criteria.occurrence_start_date) + if clause is not None: + wheres.append(clause) + + if criteria.occurrence_end_date is not None: + clause = build_date_range_clause(column_ref("C", "end_date"), criteria.occurrence_end_date) + if clause is not None: + wheres.append(clause) + + if criteria.visit_type is not None and len(criteria.visit_type) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) + if concept_ids: + exclude = criteria.visit_type_exclude if hasattr(criteria, "visit_type_exclude") else False + wheres.append( + build_in_clause(column_ref("C", "visit_type_concept_id"), concept_ids, exclude=exclude) + ) + + if criteria.visit_type_cs is not None and criteria.visit_type_cs.codeset_id: + clause = codeset_in( + column_ref("C", "visit_type_concept_id"), + criteria.visit_type_cs.codeset_id, + exclude=criteria.visit_type_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.visit_length is not None: + len_expr = datediff("day", column_ref("C", "start_date"), column_ref("C", "end_date")) + clause = build_numeric_range_clause(len_expr, criteria.visit_length) + if clause is not None: + wheres.append(clause) + + if criteria.age is not None: + age_expr = sge.Sub( + this=year_of(column_ref("C", "start_date")), + expression=column_ref("P", "year_of_birth"), + ) + clause = build_numeric_range_clause(age_expr, criteria.age) + if clause is not None: + wheres.append(clause) + + if criteria.gender is not None and len(criteria.gender) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) + if concept_ids: + wheres.append(build_in_clause(column_ref("P", "gender_concept_id"), concept_ids)) + + if criteria.gender_cs is not None and criteria.gender_cs.codeset_id: + clause = codeset_in( + column_ref("P", "gender_concept_id"), + criteria.gender_cs.codeset_id, + exclude=criteria.gender_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) + if concept_ids: + wheres.append(build_in_clause(column_ref("PR", "specialty_concept_id"), concept_ids)) + + if criteria.provider_specialty_cs is not None and criteria.provider_specialty_cs.codeset_id: + clause = codeset_in( + column_ref("PR", "specialty_concept_id"), + criteria.provider_specialty_cs.codeset_id, + exclude=criteria.provider_specialty_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + if criteria.place_of_service is not None and len(criteria.place_of_service) > 0: + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.place_of_service) + if concept_ids: + wheres.append(build_in_clause(column_ref("CS", "place_of_service_concept_id"), concept_ids)) + + if criteria.place_of_service_cs is not None and criteria.place_of_service_cs.codeset_id: + clause = codeset_in( + column_ref("CS", "place_of_service_concept_id"), + criteria.place_of_service_cs.codeset_id, + exclude=criteria.place_of_service_cs.is_exclusion, + ) + if clause is not None: + wheres.append(clause) + + for w in wheres: + if w is not None: + outer = outer.where(w) + + return outer diff --git a/tests/test_sqlglot_builder_equivalence.py b/tests/test_sqlglot_builder_equivalence.py new file mode 100644 index 00000000..c481422c --- /dev/null +++ b/tests/test_sqlglot_builder_equivalence.py @@ -0,0 +1,793 @@ +"""Equivalence tests: string-template + SqlRender vs sqlglot builders. + +For each criteria scenario, we build SQL via both paths, execute both +in DuckDB, and assert the result row sets are identical. +""" + +import datetime +from typing import Any + +import pytest + +from circe.cohortdefinition import ( + ConditionOccurrence, + DateAdjustment, + DrugExposure, + NumericRange, + VisitOccurrence, +) +from circe.cohortdefinition.builders.condition_occurrence import ( + ConditionOccurrenceSqlBuilder, +) +from circe.cohortdefinition.builders.drug_exposure import DrugExposureSqlBuilder +from circe.cohortdefinition.builders.visit_occurrence import ( + VisitOccurrenceSqlBuilder, +) +from circe.cohortdefinition.sqlglot_builders import ( + ConditionOccurrenceGlotBuilder, + DrugExposureGlotBuilder, + VisitOccurrenceGlotBuilder, +) +from tests.test_utils_db import DuckDBTestHelper + + +@pytest.fixture(scope="module") +def db(): + helper = DuckDBTestHelper() + _create_test_schema(helper) + return helper + + +def _create_test_schema(helper: DuckDBTestHelper): + con = helper.con + + con.execute("DROP TABLE IF EXISTS person") + con.execute(""" + CREATE TABLE person ( + person_id INTEGER, + year_of_birth INTEGER, + gender_concept_id INTEGER, + race_concept_id INTEGER, + ethnicity_concept_id INTEGER + ) + """) + con.execute("INSERT INTO person VALUES (1, 1980, 8507, 0, 0)") + con.execute("INSERT INTO person VALUES (2, 1990, 8532, 0, 0)") + con.execute("INSERT INTO person VALUES (3, 1970, 8507, 0, 0)") + con.execute("INSERT INTO person VALUES (4, 2000, 8532, 0, 0)") + + con.execute("DROP TABLE IF EXISTS condition_occurrence") + con.execute(""" + CREATE TABLE condition_occurrence ( + person_id INTEGER, + condition_occurrence_id INTEGER, + condition_concept_id INTEGER, + condition_start_date DATE, + condition_end_date DATE, + condition_type_concept_id INTEGER, + stop_reason VARCHAR, + condition_status_concept_id INTEGER, + visit_occurrence_id INTEGER, + provider_id INTEGER, + condition_source_concept_id INTEGER + ) + """) + con.execute( + "INSERT INTO condition_occurrence VALUES (1, 101, 10, '2020-01-15', '2020-01-20', 100, NULL, 0, 1, NULL, NULL)" + ) + con.execute( + "INSERT INTO condition_occurrence VALUES (1, 102, 11, '2020-02-01', '2020-02-05', 200, 'resolved', 0, 1, NULL, NULL)" + ) + con.execute( + "INSERT INTO condition_occurrence VALUES (2, 103, 10, '2020-03-01', '2020-03-10', 100, NULL, 0, 2, NULL, NULL)" + ) + con.execute( + "INSERT INTO condition_occurrence VALUES (3, 104, 10, '2020-01-01', '2020-01-10', 100, NULL, 0, NULL, NULL, NULL)" + ) + con.execute( + "INSERT INTO condition_occurrence VALUES (4, 105, 99, '2020-06-01', '2020-06-05', 300, NULL, 0, NULL, NULL, NULL)" + ) + + con.execute("DROP TABLE IF EXISTS drug_exposure") + con.execute(""" + CREATE TABLE drug_exposure ( + person_id INTEGER, + drug_exposure_id INTEGER, + drug_concept_id INTEGER, + drug_exposure_start_date DATE, + drug_exposure_end_date DATE, + drug_type_concept_id INTEGER, + stop_reason VARCHAR, + refills INTEGER, + quantity NUMERIC, + days_supply INTEGER, + route_concept_id INTEGER, + dose_unit_concept_id INTEGER, + provider_id INTEGER, + visit_occurrence_id INTEGER, + drug_source_concept_id INTEGER, + lot_number VARCHAR + ) + """) + con.execute( + "INSERT INTO drug_exposure VALUES (1, 201, 20, '2020-01-10', '2020-01-20', 50, NULL, 2, 10, 10, NULL, NULL, NULL, 1, NULL, NULL)" + ) + con.execute( + "INSERT INTO drug_exposure VALUES (1, 202, 21, '2020-02-15', '2020-02-25', 60, 'stopped', 0, 5, 10, NULL, NULL, NULL, 1, NULL, 'ABC123')" + ) + con.execute( + "INSERT INTO drug_exposure VALUES (2, 203, 20, '2020-03-05', '2020-03-15', 50, NULL, 1, 20, 10, NULL, NULL, NULL, 2, NULL, NULL)" + ) + con.execute( + "INSERT INTO drug_exposure VALUES (3, 204, 20, '2020-01-05', '2020-01-15', 50, NULL, 0, 15, 5, NULL, NULL, NULL, NULL, NULL, NULL)" + ) + con.execute( + "INSERT INTO drug_exposure VALUES (4, 205, 99, '2020-07-01', '2020-07-10', 70, NULL, 0, 5, 30, NULL, NULL, NULL, NULL, NULL, NULL)" + ) + + con.execute("DROP TABLE IF EXISTS visit_occurrence") + con.execute(""" + CREATE TABLE visit_occurrence ( + person_id INTEGER, + visit_occurrence_id INTEGER, + visit_concept_id INTEGER, + visit_start_date DATE, + visit_end_date DATE, + visit_type_concept_id INTEGER, + provider_id INTEGER, + care_site_id INTEGER, + visit_source_concept_id INTEGER + ) + """) + con.execute( + "INSERT INTO visit_occurrence VALUES (1, 1, 30, '2020-01-10', '2020-01-20', 500, NULL, NULL, NULL)" + ) + con.execute( + "INSERT INTO visit_occurrence VALUES (2, 2, 30, '2020-03-01', '2020-03-15', 500, NULL, NULL, NULL)" + ) + con.execute( + "INSERT INTO visit_occurrence VALUES (3, 3, 31, '2020-01-01', '2020-01-10', 600, NULL, NULL, NULL)" + ) + con.execute( + "INSERT INTO visit_occurrence VALUES (4, 4, 99, '2020-06-01', '2020-06-10', 700, NULL, NULL, NULL)" + ) + + con.execute("DROP TABLE IF EXISTS observation_period") + con.execute(""" + CREATE TABLE observation_period ( + person_id INTEGER, + observation_period_start_date DATE, + observation_period_end_date DATE + ) + """) + for pid in range(1, 5): + con.execute(f"INSERT INTO observation_period VALUES ({pid}, '2019-01-01', '2021-12-31')") + + con.execute("DELETE FROM Codesets") + for codeset_id, concept_id in [(1, 10), (2, 20), (3, 30), (4, 99)]: + con.execute(f"INSERT INTO Codesets (codeset_id, concept_id) VALUES ({codeset_id}, {concept_id})") + + con.execute("DROP TABLE IF EXISTS provider") + con.execute(""" + CREATE TABLE provider ( + provider_id INTEGER, + specialty_concept_id INTEGER + ) + """) + + con.execute("DROP TABLE IF EXISTS care_site") + con.execute(""" + CREATE TABLE care_site ( + care_site_id INTEGER, + place_of_service_concept_id INTEGER + ) + """) + + +def _sql_param_replace(sql: str) -> str: + return sql.replace("@cdm_database_schema.", "main.") + + +def _glot_to_duckdb(sql: str) -> str: + return sql.replace("#Codesets", "Codesets").replace("#", "") + + +def _run_glot(db: DuckDBTestHelper, select) -> list[Any]: + sql = select.sql(dialect="duckdb") + sql = _glot_to_duckdb(sql) + return _normalize_dates(db.execute_raw(f"SELECT * FROM ({sql}) C").fetchall()) + + +def _result_set(results: list[Any]) -> set[tuple]: + return {tuple(r) for r in results} + + +def _normalize_dates(results: list[Any]) -> list[list]: + out = [] + for row in results: + r = list(row) + for i, val in enumerate(r): + if isinstance(val, datetime.datetime): + r[i] = val.date() + out.append(r) + return out + + +class TestConditionOccurrenceEquivalence: + builder = ConditionOccurrenceSqlBuilder() + glot = ConditionOccurrenceGlotBuilder() + + def test_simple_codeset_match(self, db: DuckDBTestHelper): + co = ConditionOccurrence(codeset_id=1) + + tsql = self.builder.get_criteria_sql(co) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(co))) + + assert rows_a == rows_b, "Simple codeset match: rows differ" + + def test_date_range_filter(self, db: DuckDBTestHelper): + from circe.cohortdefinition import DateRange + + co = ConditionOccurrence( + codeset_id=1, + occurrence_start_date=DateRange(op="gte", value="2020-02-01"), + ) + + tsql = self.builder.get_criteria_sql(co) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(co))) + + assert rows_a == rows_b, "Date range filter: rows differ" + + def test_age_filter(self, db: DuckDBTestHelper): + co = ConditionOccurrence( + codeset_id=1, + age=NumericRange(op="gte", value=40), + ) + + tsql = self.builder.get_criteria_sql(co) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(co))) + + assert rows_a == rows_b, "Age filter: rows differ" + + def test_first_occurrence(self, db: DuckDBTestHelper): + co = ConditionOccurrence(codeset_id=1, first=True) + + tsql = self.builder.get_criteria_sql(co) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(co))) + + assert rows_a == rows_b, "First occurrence: rows differ" + + def test_date_adjustment(self, db: DuckDBTestHelper): + co = ConditionOccurrence( + codeset_id=1, + date_adjustment=DateAdjustment(start_offset=3, end_offset=0), + ) + + tsql = self.builder.get_criteria_sql(co) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(co))) + + assert rows_a == rows_b, "Date adjustment: rows differ" + + def test_date_adjustment_dates(self, db: DuckDBTestHelper): + """Verify adjusted date values specifically.""" + co = ConditionOccurrence( + codeset_id=1, + date_adjustment=DateAdjustment(start_offset=2, end_offset=1), + ) + + tsql = self.builder.get_criteria_sql(co) + rows_a = db.query(f"SELECT C.start_date, C.end_date FROM ({_sql_param_replace(tsql)}) C") + row_a = rows_a[0] if rows_a else None + + glot_select = self.glot.build_select(co) + glot_sql = glot_select.sql(dialect="duckdb") + glot_sql = _glot_to_duckdb(glot_sql) + rows_b = db.execute_raw(f"SELECT C.start_date, C.end_date FROM ({glot_sql}) C").fetchall() + row_b = rows_b[0] if rows_b else None + + def _to_dates(r): + if r is None: + return None + r = list(r) + for i in range(2): + if isinstance(r[i], datetime.datetime): + r[i] = r[i].date() + return tuple(r) + + row_a, row_b = _to_dates(row_a), _to_dates(row_b) + assert row_a == row_b, f"Date adjustment values differ: {row_a} vs {row_b}" + + +class TestDrugExposureEquivalence: + builder = DrugExposureSqlBuilder() + glot = DrugExposureGlotBuilder() + + def test_simple_codeset_match(self, db: DuckDBTestHelper): + de = DrugExposure(codeset_id=2) + + tsql = self.builder.get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(de))) + + assert rows_a == rows_b, "Drug simple codeset match: rows differ" + + def test_days_supply_filter(self, db: DuckDBTestHelper): + de = DrugExposure(codeset_id=2, days_supply=NumericRange(op="gte", value=8)) + + tsql = self.builder.get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(de))) + + assert rows_a == rows_b, "Drug days_supply filter: rows differ" + + def test_first_drug_exposure(self, db: DuckDBTestHelper): + de = DrugExposure(codeset_id=2, first=True) + + tsql = self.builder.get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(de))) + + assert rows_a == rows_b, "Drug first: rows differ" + + def test_drug_type_exclude(self, db: DuckDBTestHelper): + from circe.vocabulary.concept import Concept + + de = DrugExposure( + codeset_id=2, + drug_type=[Concept(concept_id=70)], + drug_type_exclude=True, + ) + + tsql = self.builder.get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(de))) + + assert rows_a == rows_b, "Drug type exclude: rows differ" + + +class TestVisitOccurrenceEquivalence: + builder = VisitOccurrenceSqlBuilder() + glot = VisitOccurrenceGlotBuilder() + + def test_simple_codeset_match(self, db: DuckDBTestHelper): + vo = VisitOccurrence(codeset_id=3) + + tsql = self.builder.get_criteria_sql(vo) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(vo))) + + assert rows_a == rows_b, "Visit simple codeset match: rows differ" + + def test_visit_length_filter(self, db: DuckDBTestHelper): + from circe.cohortdefinition import NumericRange + + vo = VisitOccurrence( + codeset_id=3, + visit_length=NumericRange(op="gt", value=10), + ) + + tsql = self.builder.get_criteria_sql(vo) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(vo))) + + assert rows_a == rows_b, "Visit length filter: rows differ" + + def test_first_visit(self, db: DuckDBTestHelper): + vo = VisitOccurrence(codeset_id=3, first=True) + + tsql = self.builder.get_criteria_sql(vo) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + + rows_b = _result_set(_run_glot(db, self.glot.build_select(vo))) + + assert rows_a == rows_b, "Visit first: rows differ" + + +class TestCrossCriteriaEquivalence: + """Key test: all three criteria types return same populations.""" + + def test_condition_and_drug_and_visit_all_match(self, db: DuckDBTestHelper): + """Each criteria type should find overlapping patients.""" + co = ConditionOccurrence(codeset_id=1) + de = DrugExposure(codeset_id=2) + vo = VisitOccurrence(codeset_id=3) + + def _get_person_ids(builder, criteria) -> set: + sql = _glot_to_duckdb(builder.build_select(criteria).sql(dialect="duckdb")) + return {r[0] for r in db.execute_raw(f"SELECT * FROM ({sql}) C").fetchall()} + + co_rows = _get_person_ids(ConditionOccurrenceGlotBuilder(), co) + de_rows = _get_person_ids(DrugExposureGlotBuilder(), de) + vo_rows = _get_person_ids(VisitOccurrenceGlotBuilder(), vo) + + assert co_rows == {1, 2, 3}, f"CO should find persons 1,2,3, got {co_rows}" + assert de_rows == {1, 2, 3}, f"DE should find persons 1,2,3, got {de_rows}" + assert vo_rows == {1, 2}, ( + f"VO should find persons 1,2 (patient 3 has concept 31 not 30), got {vo_rows}" + ) + + def test_rejects_non_matching(self, db: DuckDBTestHelper): + """Person 4 should NOT be returned by any criteria since concept 99 doesn't match codesets 1,2,3.""" + co = ConditionOccurrence(codeset_id=1) + de = DrugExposure(codeset_id=2) + vo = VisitOccurrence(codeset_id=3) + + for builder, label, criteria in [ + (ConditionOccurrenceGlotBuilder(), "CO", co), + (DrugExposureGlotBuilder(), "DE", de), + (VisitOccurrenceGlotBuilder(), "VO", vo), + ]: + sql = _glot_to_duckdb(builder.build_select(criteria).sql(dialect="duckdb")) + rows = {r[0] for r in db.execute_raw(f"SELECT * FROM ({sql}) C").fetchall()} + assert 4 not in rows, f"{label} should not return person 4" + + def test_all_paths_produce_same_row_count(self, db: DuckDBTestHelper): + """String-template and sqlglot paths produce same number of rows for each criteria.""" + scenarios = [ + ( + ConditionOccurrence(codeset_id=1), + ConditionOccurrenceSqlBuilder(), + ConditionOccurrenceGlotBuilder(), + ), + (DrugExposure(codeset_id=2), DrugExposureSqlBuilder(), DrugExposureGlotBuilder()), + (VisitOccurrence(codeset_id=3), VisitOccurrenceSqlBuilder(), VisitOccurrenceGlotBuilder()), + ] + + for criteria, str_builder, glot_builder in scenarios: + tsql = str_builder.get_criteria_sql(criteria) + rows_a = _normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C")) + + rows_b = _run_glot(db, glot_builder.build_select(criteria)) + + assert len(rows_a) == len(rows_b), ( + f"{type(criteria).__name__}: row count mismatch: string={len(rows_a)}, glot={len(rows_b)}" + ) + + +class TestComprehensiveConditionOccurrence: + """Exercise all optional CO fields including source concept, condition status/type CS, provider, visit type.""" + + def test_with_source_concept(self, db: DuckDBTestHelper): + db.con.execute("DELETE FROM Codesets") + for codeset_id, concept_id in [(1, 10), (12, 11)]: + db.con.execute( + f"INSERT INTO Codesets (codeset_id, concept_id) VALUES ({codeset_id}, {concept_id})" + ) + + co = ConditionOccurrence( + codeset_id=1, + condition_source_concept=12, + ) + tsql = ConditionOccurrenceSqlBuilder().get_criteria_sql(co) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, ConditionOccurrenceGlotBuilder().build_select(co))) + assert rows_a == rows_b, "CO with source concept: rows differ" + + def test_with_provider_and_visit(self, db: DuckDBTestHelper): + db.con.execute("INSERT INTO provider VALUES (1, 100)") + db.con.execute( + "INSERT INTO visit_occurrence VALUES (5, 5, 30, '2020-05-01', '2020-05-05', 500, 1, NULL, NULL)" + ) + db.con.execute( + "INSERT INTO condition_occurrence VALUES (5, 106, 10, '2020-05-02', '2020-05-04', 100, NULL, 0, 5, 1, NULL)" + ) + db.con.execute("INSERT INTO observation_period VALUES (5, '2019-01-01', '2021-12-31')") + + from circe.cohortdefinition import ConceptSetSelection + + co = ConditionOccurrence( + codeset_id=1, + provider_specialty_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False), + visit_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False), + ) + tsql = ConditionOccurrenceSqlBuilder().get_criteria_sql(co) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, ConditionOccurrenceGlotBuilder().build_select(co))) + assert rows_a == rows_b, "CO with provider and visit CS: rows differ" + + def test_with_condition_status(self, db: DuckDBTestHelper): + from circe.cohortdefinition import ConceptSetSelection + + db.con.execute("UPDATE condition_occurrence SET condition_status_concept_id=555 WHERE person_id=1") + db.con.execute("DELETE FROM Codesets") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (5, 555)") + + co = ConditionOccurrence( + codeset_id=1, + condition_status_cs=ConceptSetSelection(codeset_id=5, is_exclusion=False), + ) + tsql = ConditionOccurrenceSqlBuilder().get_criteria_sql(co) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, ConditionOccurrenceGlotBuilder().build_select(co))) + assert rows_a == rows_b, "CO with condition status CS: rows differ" + + def test_with_condition_type_cs(self, db: DuckDBTestHelper): + from circe.cohortdefinition import ConceptSetSelection + + db.con.execute("DELETE FROM Codesets") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (5, 200)") + + co = ConditionOccurrence( + codeset_id=1, + condition_type_cs=ConceptSetSelection(codeset_id=5, is_exclusion=False), + ) + tsql = ConditionOccurrenceSqlBuilder().get_criteria_sql(co) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, ConditionOccurrenceGlotBuilder().build_select(co))) + assert rows_a == rows_b, "CO with condition type CS: rows differ" + + +class TestComprehensiveDrugExposure: + """Exercise all optional DE fields: route, dose, lot, refills, quantity, days_supply, stop_reason, date adjustment.""" + + def test_with_route_dose_lot(self, db: DuckDBTestHelper): + from circe.cohortdefinition import ConceptSetSelection + + db.con.execute("DELETE FROM Codesets") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (2, 20)") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (7, 777)") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (8, 888)") + db.con.execute( + "INSERT INTO drug_exposure VALUES (5, 206, 20, '2020-05-01', '2020-05-10', 50, NULL, 1, 10, 10, 777, 888, NULL, NULL, NULL, NULL)" + ) + db.con.execute("INSERT INTO observation_period VALUES (5, '2019-01-01', '2021-12-31')") + + de = DrugExposure( + codeset_id=2, + route_concept_cs=ConceptSetSelection(codeset_id=7), + dose_unit_cs=ConceptSetSelection(codeset_id=8), + ) + tsql = DrugExposureSqlBuilder().get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) + assert rows_a == rows_b, "DE with route/dose CS: rows differ" + + def test_with_occurrence_dates_and_route_cs(self, db: DuckDBTestHelper): + from circe.cohortdefinition import ConceptSetSelection, DateRange + + db.con.execute("DELETE FROM Codesets") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (2, 20)") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (7, 777)") + + de = DrugExposure( + codeset_id=2, + occurrence_start_date=DateRange(op="gte", value="2020-02-01"), + occurrence_end_date=DateRange(op="lte", value="2020-06-01"), + route_concept_cs=ConceptSetSelection(codeset_id=7), + ) + tsql = DrugExposureSqlBuilder().get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) + assert rows_a == rows_b, "DE with occurrence dates + route CS: rows differ" + + def test_with_refills_quantity_days_supply(self, db: DuckDBTestHelper): + de = DrugExposure( + codeset_id=2, + refills=NumericRange(op="gte", value=1), + quantity=NumericRange(op="gte", value=5), + days_supply=NumericRange(op="lte", value=15), + ) + tsql = DrugExposureSqlBuilder().get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) + assert rows_a == rows_b, "DE with refills/quantity/days_supply: rows differ" + + def test_with_date_adjustment_alt_start_end(self, db: DuckDBTestHelper): + de = DrugExposure( + codeset_id=2, + date_adjustment=DateAdjustment( + start_offset=2, + end_offset=3, + start_with="end_date", + end_with="start_date", + ), + ) + tsql = DrugExposureSqlBuilder().get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) + assert rows_a == rows_b, "DE with alt date adjustment: rows differ" + + def test_with_all_optional_fields(self, db: DuckDBTestHelper): + from circe.cohortdefinition import ConceptSetSelection, DateRange + from circe.vocabulary.concept import Concept + + db.con.execute("INSERT INTO provider VALUES (3, 300)") + db.con.execute("INSERT INTO observation_period VALUES (7, '2019-01-01', '2021-12-31')") + db.con.execute("INSERT INTO person VALUES (7, 1985, 8507, 0, 0)") + db.con.execute( + "INSERT INTO visit_occurrence VALUES (7, 7, 30, '2020-08-01', '2020-08-10', 500, 3, NULL, NULL)" + ) + db.con.execute( + "INSERT INTO drug_exposure VALUES (7, 209, 20, '2020-08-02', '2020-08-08', 50, 'stopped', 2, 10, 6, 777, 888, 3, 7, NULL, 'LOT001')" + ) + db.con.execute("DELETE FROM Codesets") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (2, 20)") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (10, 999)") + + de = DrugExposure( + codeset_id=2, + drug_source_concept=10, + occurrence_start_date=DateRange(op="gte", value="2020-08-01"), + occurrence_end_date=DateRange(op="lte", value="2020-09-01"), + drug_type=[Concept(concept_id=50)], + drug_type_cs=ConceptSetSelection(codeset_id=2), + route_concept=[Concept(concept_id=777)], + dose_unit=[Concept(concept_id=888)], + provider_specialty=[Concept(concept_id=300)], + visit_type_cs=ConceptSetSelection(codeset_id=2), + refills=NumericRange(op="gte", value=1), + quantity=NumericRange(op="gte", value=5), + days_supply=NumericRange(op="gte", value=5), + age=NumericRange(op="gte", value=18), + ) + tsql = DrugExposureSqlBuilder().get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) + assert rows_a == rows_b, "DE with all optional fields: rows differ" + + def test_with_source_concept_and_drug_type_cs(self, db: DuckDBTestHelper): + from circe.cohortdefinition import ConceptSetSelection + + db.con.execute("DELETE FROM Codesets") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (2, 20)") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (10, 99)") + + de = DrugExposure( + codeset_id=2, + drug_source_concept=10, + drug_type_cs=ConceptSetSelection(codeset_id=2), + ) + tsql = DrugExposureSqlBuilder().get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) + assert rows_a == rows_b, "DE with source concept + type CS: rows differ" + + def test_with_lot_number_text(self, db: DuckDBTestHelper): + from circe.cohortdefinition import TextFilter + + db.con.execute( + "INSERT INTO drug_exposure VALUES (5, 207, 20, '2020-06-01', '2020-06-10', 50, NULL, 0, 5, 10, NULL, NULL, NULL, NULL, NULL, NULL)" + ) + + de = DrugExposure( + codeset_id=2, + lot_number=TextFilter(text="ABC", op="contains"), + stop_reason=TextFilter(text="stopped", op="eq"), + ) + tsql = DrugExposureSqlBuilder().get_criteria_sql(de) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) + assert rows_a == rows_b, "DE with text filters: rows differ" + + +class TestComprehensiveVisitOccurrence: + """Exercise all optional VO fields: visit_type, place_of_service, provider, source concept, date adjustment, visit_length.""" + + def test_with_visit_type_place_of_service(self, db: DuckDBTestHelper): + from circe.cohortdefinition import ConceptSetSelection + + db.con.execute("INSERT INTO care_site VALUES (1, 1000)") + db.con.execute( + "INSERT INTO visit_occurrence VALUES (5, 5, 30, '2020-05-01', '2020-05-10', 500, NULL, 1, NULL)" + ) + db.con.execute("INSERT INTO observation_period VALUES (5, '2019-01-01', '2021-12-31')") + db.con.execute("DELETE FROM Codesets") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (3, 30)") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (6, 500)") + + vo = VisitOccurrence( + codeset_id=3, + visit_type_cs=ConceptSetSelection(codeset_id=6), + place_of_service_cs=ConceptSetSelection(codeset_id=6), + ) + tsql = VisitOccurrenceSqlBuilder().get_criteria_sql(vo) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, VisitOccurrenceGlotBuilder().build_select(vo))) + assert rows_a == rows_b, "VO with visit_type/place_of_service CS: rows differ" + + def test_with_visit_source_concept(self, db: DuckDBTestHelper): + db.con.execute("DELETE FROM Codesets") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (3, 30)") + db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (9, 999)") + db.con.execute( + "INSERT INTO visit_occurrence VALUES (5, 5, 30, '2020-05-01', '2020-05-10', 500, NULL, NULL, NULL)" + ) + + vo = VisitOccurrence( + codeset_id=3, + visit_source_concept=9, + ) + tsql = VisitOccurrenceSqlBuilder().get_criteria_sql(vo) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, VisitOccurrenceGlotBuilder().build_select(vo))) + assert rows_a == rows_b, "VO with visit_source_concept: rows differ" + + def test_with_visit_length_and_date_adjustment(self, db: DuckDBTestHelper): + from circe.cohortdefinition import NumericRange + + vo = VisitOccurrence( + codeset_id=3, + visit_length=NumericRange(op="gt", value=5), + date_adjustment=DateAdjustment( + start_offset=1, end_offset=2, start_with="END_DATE", end_with="START_DATE" + ), + ) + tsql = VisitOccurrenceSqlBuilder().get_criteria_sql(vo) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, VisitOccurrenceGlotBuilder().build_select(vo))) + assert rows_a == rows_b, "VO with visit_length + date_adjustment: rows differ" + + def test_with_provider_specialty_and_gender(self, db: DuckDBTestHelper): + from circe.vocabulary.concept import Concept + + db.con.execute("INSERT INTO provider VALUES (2, 200)") + db.con.execute( + "INSERT INTO visit_occurrence VALUES (6, 6, 30, '2020-07-01', '2020-07-15', 500, 2, NULL, NULL)" + ) + db.con.execute( + "INSERT INTO condition_occurrence VALUES (6, 107, 10, '2020-07-02', '2020-07-05', 100, NULL, 0, 6, 2, NULL)" + ) + db.con.execute("INSERT INTO observation_period VALUES (6, '2019-01-01', '2021-12-31')") + db.con.execute( + "INSERT INTO drug_exposure VALUES (6, 208, 20, '2020-07-03', '2020-07-10', 50, NULL, 0, 5, 7, NULL, NULL, 2, 6, NULL, NULL)" + ) + + co = ConditionOccurrence( + codeset_id=1, + provider_specialty=[Concept(concept_id=200)], + gender=[Concept(concept_id=8532)], + ) + tsql = ConditionOccurrenceSqlBuilder().get_criteria_sql(co) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, ConditionOccurrenceGlotBuilder().build_select(co))) + assert rows_a == rows_b, "CO with provider specialty + gender: rows differ" + + def test_with_place_of_service_and_gender(self, db: DuckDBTestHelper): + from circe.vocabulary.concept import Concept + + db.con.execute("INSERT INTO care_site VALUES (1, 1000)") + db.con.execute( + "INSERT INTO visit_occurrence VALUES (6, 6, 30, '2020-07-01', '2020-07-15', 500, NULL, 1, NULL)" + ) + db.con.execute("INSERT INTO observation_period VALUES (6, '2019-01-01', '2021-12-31')") + + vo = VisitOccurrence( + codeset_id=3, + place_of_service=[Concept(concept_id=1000)], + gender=[Concept(concept_id=8532)], + ) + tsql = VisitOccurrenceSqlBuilder().get_criteria_sql(vo) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, VisitOccurrenceGlotBuilder().build_select(vo))) + assert rows_a == rows_b, "VO with place_of_service + gender: rows differ" + + def test_with_occurrence_dates_and_gender(self, db: DuckDBTestHelper): + from circe.cohortdefinition import DateRange + + vo = VisitOccurrence( + codeset_id=3, + occurrence_start_date=DateRange(op="gte", value="2020-02-01"), + occurrence_end_date=DateRange(op="lte", value="2020-06-01"), + ) + tsql = VisitOccurrenceSqlBuilder().get_criteria_sql(vo) + rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) + rows_b = _result_set(_run_glot(db, VisitOccurrenceGlotBuilder().build_select(vo))) + assert rows_a == rows_b, "VO with occurrence dates: rows differ" diff --git a/tests/test_sqlglot_primitives.py b/tests/test_sqlglot_primitives.py new file mode 100644 index 00000000..77e78165 --- /dev/null +++ b/tests/test_sqlglot_primitives.py @@ -0,0 +1,469 @@ +"""Unit tests for sqlglot builder primitives and codeset builder.""" + +from sqlglot import exp as sge + +from circe.cohortdefinition import ( + ConditionOccurrence, + DrugExposure, + NumericRange, + TextFilter, + VisitOccurrence, +) +from circe.cohortdefinition.core import DateRange +from circe.cohortdefinition.sqlglot_builders import ( + ConditionOccurrenceGlotBuilder, + DrugExposureGlotBuilder, + VisitOccurrenceGlotBuilder, +) +from circe.cohortdefinition.sqlglot_builders.primitives import ( + alias_expr, + build_date_range_clause, + build_in_clause, + build_numeric_range_clause, + build_text_filter_clause, + coalesce, + codeset_in, + codeset_join, + column_ref, + date_add, + date_from_parts, + datediff, + row_number_expr, + year_of, +) + + +class TestPrimitives: + """Direct unit tests on primitive functions.""" + + def test_column_ref(self): + c = column_ref("co", "person_id") + assert c.sql(dialect="duckdb") == "co.person_id" + assert c.sql(dialect="tsql") == "co.person_id" + + def test_alias_expr(self): + a = alias_expr(column_ref("co", "person_id"), "person_id") + assert "person_id AS person_id" in a.sql(dialect="duckdb") + + def test_date_add(self): + da = date_add("day", 3, column_ref("x", "start")) + duck = da.sql(dialect="duckdb") + tsql = da.sql(dialect="tsql") + assert "+ INTERVAL 3 DAY" in duck or "INTERVAL '3 DAY'" in duck + assert "DATEADD" in tsql + + def test_coalesce_non_empty(self): + c = coalesce(column_ref("x", "a"), column_ref("x", "b")) + sql = c.sql(dialect="duckdb") + assert "COALESCE" in sql + assert "x.a" in sql + assert "x.b" in sql + + def test_coalesce_empty(self): + # line 25: empty coalesce returns None + assert coalesce() is None + + def test_year_of(self): + y = year_of(column_ref("co", "start_date")) + assert "YEAR" in y.sql(dialect="duckdb") + + def test_date_diff(self): + dd = datediff("day", column_ref("t", "start"), column_ref("t", "end")) + sql = dd.sql(dialect="duckdb") + assert "DATE_DIFF" in sql or "DATEDIFF" in sql.upper() + + def test_date_from_parts(self): + dfp = date_from_parts(2020, 1, 15) + duck = dfp.sql(dialect="duckdb") + tsql = dfp.sql(dialect="tsql") + assert "MAKE_DATE" in duck.upper() or "DATEFROMPARTS" not in duck + assert "DATEFROMPARTS" in tsql + + def test_row_number_expr(self): + rn = row_number_expr( + [column_ref("co", "person_id")], + [column_ref("co", "start_date"), column_ref("co", "id")], + ) + sql = rn.sql(dialect="duckdb") + assert "ROW_NUMBER" in sql + assert "PARTITION BY co.person_id" in sql or 'PARTITION BY "person_id"' in sql + assert "ORDER BY" in sql + + +class TestPrimitivesDateRange: + """Test all branches of build_date_range_clause.""" + + def _expr(self, col: str = "C.start_date"): + return column_ref("C", "start_date") + + def test_gte(self): + r = DateRange(op="gte", value="2020-01-01") + e = build_date_range_clause(self._expr(), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert ">= MAKE_DATE" in sql or ">= DATE" in sql + + def test_lte(self): + r = DateRange(op="lte", value="2020-12-31") + e = build_date_range_clause(self._expr(), r) + assert e is not None + + def test_gt(self): + r = DateRange(op="gt", value="2020-06-01") + e = build_date_range_clause(self._expr(), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert ">" in sql + + def test_lt(self): + r = DateRange(op="lt", value="2020-06-01") + e = build_date_range_clause(self._expr(), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "<" in sql and ">" not in sql + + def test_eq(self): + r = DateRange(op="eq", value="2020-06-15") + e = build_date_range_clause(self._expr(), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "=" in sql + + def test_neq(self): + r = DateRange(op="ne", value="2020-06-15") + e = build_date_range_clause(self._expr(), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "<>" in sql or "NOT" in sql + + def test_bt(self): + r = DateRange(op="bt", value="2020-01-01", extent="2020-12-31") + e = build_date_range_clause(self._expr(), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "AND" in sql + + def test_not_bt(self): + r = DateRange(op="!bt", value="2020-01-01", extent="2020-12-31") + e = build_date_range_clause(self._expr(), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "NOT" in sql or "<>" in sql + + def test_none_op(self): + assert build_date_range_clause(self._expr(), None) is None + + def test_none_value_bt(self): + r = DateRange(op="bt", value=None, extent="2020-12-31") + assert build_date_range_clause(self._expr(), r) is None + + def test_none_value_single(self): + r = DateRange(op="eq", value=None) + assert build_date_range_clause(self._expr(), r) is None + + +class TestPrimitivesNumericRange: + """Test all branches of build_numeric_range_clause.""" + + def test_gte(self): + r = NumericRange(op="gte", value=18) + e = build_numeric_range_clause(column_ref("C", "age"), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert ">= 18" in sql + + def test_bt(self): + r = NumericRange(op="bt", value=10, extent=20) + e = build_numeric_range_clause(column_ref("C", "age"), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "10" in sql and "20" in sql and "AND" in sql + + def test_not_bt(self): + r = NumericRange(op="!bt", value=5, extent=15) + e = build_numeric_range_clause(column_ref("C", "x"), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "NOT" in sql or "<" in sql + + def test_eq(self): + r = NumericRange(op="eq", value=42) + e = build_numeric_range_clause(column_ref("C", "x"), r) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "= 42" in sql or "42 =" not in sql + + def test_neq(self): + r = NumericRange(op="ne", value=99) + e = build_numeric_range_clause(column_ref("C", "x"), r) + assert e is not None + + def test_lt(self): + r = NumericRange(op="lt", value=50) + e = build_numeric_range_clause(column_ref("C", "x"), r) + assert e is not None + assert "< 50" in e.sql(dialect="duckdb") + + def test_lte(self): + r = NumericRange(op="lte", value=100) + e = build_numeric_range_clause(column_ref("C", "x"), r) + assert e is not None + + def test_gt(self): + r = NumericRange(op="gt", value=0) + e = build_numeric_range_clause(column_ref("C", "x"), r) + assert e is not None + + def test_none_op(self): + assert build_numeric_range_clause(column_ref("C", "x"), None) is None + + def test_none_value(self): + r = NumericRange(op="gt", value=None) + assert build_numeric_range_clause(column_ref("C", "x"), r) is None + + def test_none_value_bt(self): + r = NumericRange(op="bt", value=None, extent=5) + assert build_numeric_range_clause(column_ref("C", "x"), r) is None + + def test_none_extent_bt(self): + r = NumericRange(op="bt", value=1, extent=None) + assert build_numeric_range_clause(column_ref("C", "x"), r) is None + + +class TestPrimitivesTextFilter: + """Test all branches of build_text_filter_clause.""" + + def test_string_input(self): + e = build_text_filter_clause(column_ref("C", "reason"), "stopped") + assert e is not None + sql = e.sql(dialect="duckdb") + assert "LIKE" in sql.upper() + assert "%stopped%" in sql + + def test_eq(self): + e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="exact", op="eq")) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "= 'exact'" in sql or "'exact'" in sql + + def test_neq(self): + e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="bad", op="!eq")) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "<>" in sql or "NOT" in sql + + def test_starts_with(self): + e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="pre", op="startsWith")) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "pre%" in sql + + def test_ends_with(self): + e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="fix", op="endsWith")) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "%fix" in sql + + def test_not_contains(self): + e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="bad", op="!contains")) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "NOT" in sql + + def test_contains_default(self): + e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="hidden", op="unknown_op")) + assert e is not None + sql = e.sql(dialect="duckdb") + assert "%hidden%" in sql + + def test_none(self): + assert build_text_filter_clause(column_ref("C", "x"), None) is None + + def test_empty_text(self): + e = build_text_filter_clause(column_ref("C", "x"), TextFilter(text="", op="eq")) + assert e is not None + + +class TestPrimitivesInClause: + """Test build_in_clause.""" + + def test_basic(self): + e = build_in_clause(column_ref("C", "id"), [1, 2, 3]) + sql = e.sql(dialect="duckdb") + assert "IN" in sql + assert "1" in sql and "2" in sql and "3" in sql + + def test_exclude(self): + e = build_in_clause(column_ref("C", "id"), [5, 6], exclude=True) + sql = e.sql(dialect="duckdb") + assert "NOT" in sql + + def test_single(self): + e = build_in_clause(column_ref("C", "id"), [99]) + sql = e.sql(dialect="duckdb") + assert "99" in sql + + def test_duplicates(self): + e = build_in_clause(column_ref("C", "id"), [1, 1, 2, 2, 3]) + sql = e.sql(dialect="duckdb") + assert "1, 2, 3" in sql or "1" in sql + + +class TestPrimitivesCodesetJoin: + """Test codeset_join and codeset_in.""" + + def test_codeset_join_basic(self): + cj = codeset_join("#Codesets", column_ref("co", "condition_concept_id"), 1) + sql = cj.sql(dialect="duckdb") + assert "Codesets" in sql + assert "condition_concept_id" in sql + + def test_codeset_in(self): + ci = codeset_in(column_ref("C", "type_id"), 5) + sql = ci.sql(dialect="duckdb") + assert "SELECT" in sql + assert "codeset_id" in sql + + def test_codeset_in_exclude(self): + ci = codeset_in(column_ref("C", "type_id"), 5, exclude=True) + sql = ci.sql(dialect="duckdb") + assert "NOT" in sql + + +class TestRowNumberAcrossBuilders: + """Validate ROW_NUMBER output across all three builders for first=True.""" + + def _check_ordinal_col(self, select: sge.Select): + """Ensure the outer SELECT produces a query with ordinal expression.""" + sql = select.sql(dialect="duckdb") + assert "ROW_NUMBER" in sql, f"Missing ROW_NUMBER: {sql}" + + def test_co_ordinal(self): + co = ConditionOccurrence(codeset_id=1, first=True) + s = ConditionOccurrenceGlotBuilder().build_select(co) + self._check_ordinal_col(s) + + def test_de_ordinal(self): + de = DrugExposure(codeset_id=1, first=True) + s = DrugExposureGlotBuilder().build_select(de) + self._check_ordinal_col(s) + + def test_vo_ordinal(self): + vo = VisitOccurrence(codeset_id=1, first=True) + s = VisitOccurrenceGlotBuilder().build_select(vo) + self._check_ordinal_col(s) + + +class TestCodesetsBuilder: + """Test concept set resolution queries.""" + + def test_empty_concept_sets(self): + from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query + + assert build_codeset_query([]) is None + + def test_simple_concept_set(self): + from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query + from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + cs = ConceptSet( + id=1, + name="test", + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(concept_id=123))]), + ) + query = build_codeset_query([cs]) + assert query is not None + sql = query.sql(dialect="duckdb") + assert "123" in sql + assert "concept" in sql.lower() + + def test_concept_set_with_descendants(self): + from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query + from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + cs = ConceptSet( + id=2, + name="test", + expression=ConceptSetExpression( + items=[ConceptSetItem(concept=Concept(concept_id=456), include_descendants=True)] + ), + ) + query = build_codeset_query([cs]) + assert query is not None + sql = query.sql(dialect="duckdb") + assert "descendant" in sql.lower() or "ancestor" in sql.lower() + + def test_concept_set_with_mapped(self): + from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query + from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + cs = ConceptSet( + id=3, + name="test", + expression=ConceptSetExpression( + items=[ConceptSetItem(concept=Concept(concept_id=789), include_mapped=True)] + ), + ) + query = build_codeset_query([cs]) + assert query is not None + sql = query.sql(dialect="duckdb") + assert "relationship" in sql.lower() or "mapped" in sql.lower() + + def test_multiple_concept_sets(self): + from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query + from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + cs1 = ConceptSet( + id=1, expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(concept_id=1))]) + ) + cs2 = ConceptSet( + id=2, expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(concept_id=2))]) + ) + query = build_codeset_query([cs1, cs2]) + assert query is not None + sql = query.sql(dialect="duckdb") + assert "UNION" in sql.upper() + + def test_concept_set_with_no_items(self): + from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query + from circe.vocabulary.concept import ConceptSet, ConceptSetExpression + + cs = ConceptSet(id=1, name="empty", expression=ConceptSetExpression(items=[])) + assert build_codeset_query([cs]) is None + + def test_concept_set_without_expression(self): + from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query + from circe.vocabulary.concept import ConceptSet + + cs = ConceptSet(id=1, name="noexpr") + assert build_codeset_query([cs]) is None + + +class TestCrossDialect: + """Compile each builder to multiple dialects; verify sqlglot can read back.""" + + @staticmethod + def _check_dialect(sel: sge.Select, dialect: str): + sql = sel.sql(dialect=dialect) + assert len(sql) > 0 + clean = sql.replace("#Codesets", "Codesets") + parsed = sge.maybe_parse(clean, dialect=dialect) + assert parsed is not None, f"Cannot parse {dialect} output back" + + def test_co_multiple_dialects(self): + co = ConditionOccurrence(codeset_id=1, age=NumericRange(op="gte", value=18)) + sel = ConditionOccurrenceGlotBuilder().build_select(co) + for d in ("duckdb", "postgres", "tsql", "bigquery"): + self._check_dialect(sel, d) + + def test_de_multiple_dialects(self): + de = DrugExposure(codeset_id=1) + sel = DrugExposureGlotBuilder().build_select(de) + for d in ("duckdb", "postgres", "tsql", "mysql"): + self._check_dialect(sel, d) + + def test_vo_multiple_dialects(self): + vo = VisitOccurrence(codeset_id=1, first=True) + sel = VisitOccurrenceGlotBuilder().build_select(vo) + for d in ("duckdb", "tsql", "bigquery"): + self._check_dialect(sel, d) diff --git a/tests/test_sqlrender_csv_format.py b/tests/test_sqlrender_csv_format.py index d9d62403..77b9a431 100644 --- a/tests/test_sqlrender_csv_format.py +++ b/tests/test_sqlrender_csv_format.py @@ -7,7 +7,6 @@ class TestCsvFormat: - def test_csv_has_valid_format(self): from importlib.resources import files @@ -24,23 +23,20 @@ def test_csv_has_valid_format(self): assert columns[1] == "Pattern" assert columns[2] == "Replacement" continue - assert len(columns) >= 3, ( - f"Row {i} has {len(columns)} columns (expected at least 3): {columns}" - ) + assert len(columns) >= 3, f"Row {i} has {len(columns)} columns (expected at least 3): {columns}" def test_all_patterns_can_be_parsed(self): - from circe.sqlrender.translator import parse_search_pattern from circe.sqlrender.patterns import load_patterns + from circe.sqlrender.translator import parse_search_pattern patterns = load_patterns() for dialect, pairs in patterns.items(): - for pattern, replacement in pairs: + for pattern, _replacement in pairs: try: parse_search_pattern(pattern) except Exception as e: pytest.fail( - f"Failed to parse pattern for dialect '{dialect}': " - f"pattern={pattern!r}, error={e}" + f"Failed to parse pattern for dialect '{dialect}': pattern={pattern!r}, error={e}" ) def test_duckdb_and_postgresql_can_translate_simple_sql(self): diff --git a/tests/test_sqlrender_split.py b/tests/test_sqlrender_split.py index c5c233a1..c5d4a3a4 100644 --- a/tests/test_sqlrender_split.py +++ b/tests/test_sqlrender_split.py @@ -1,12 +1,9 @@ """Test SQL splitting - ported from OHDSI SqlRender test-splitSql.R""" -import pytest - from circe.sqlrender import split_sql class TestSplitSql: - def test_split_simple_statements(self): parts = split_sql("SELECT * INTO a FROM b; USE x; DROP TABLE c;") assert parts == ["SELECT * INTO a FROM b", "USE x", "DROP TABLE c"] @@ -16,18 +13,14 @@ def test_split_with_begin_end(self): assert parts == ["BEGIN\nSELECT * INTO a FROM b;\nEND;", "USE x"] def test_split_with_case_end(self): - parts = split_sql( - "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END AS x INTO a FROM b;\nUSE x;" - ) + parts = split_sql("SELECT CASE WHEN x=1 THEN 0 ELSE 1 END AS x INTO a FROM b;\nUSE x;") assert parts == [ "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END AS x INTO a FROM b", "USE x", ] def test_split_with_end_in_quoted_text(self): - parts = split_sql( - "insert into a (x) values ('end');\n insert into a (x) values ('begin');" - ) + parts = split_sql("insert into a (x) values ('end');\n insert into a (x) values ('begin');") assert parts == [ "insert into a (x) values ('end')", "insert into a (x) values ('begin')", @@ -52,12 +45,8 @@ def test_split_with_comment_last_line_no_eol(self): assert parts == ["SELECT * FROM table"] def test_split_with_hint_at_start(self): - parts = split_sql( - "--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist" - ) - assert parts == [ - "--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist" - ] + parts = split_sql("--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist") + assert parts == ["--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist"] def test_split_with_hint_in_second_statement(self): parts = split_sql( diff --git a/tests/test_utils_db.py b/tests/test_utils_db.py index 5b83e659..79c5398c 100644 --- a/tests/test_utils_db.py +++ b/tests/test_utils_db.py @@ -89,6 +89,10 @@ def execute_query(self, sql: str): return self.con.execute(translated) + def execute_raw(self, sql: str): + """Execute DuckDB SQL directly, no T-SQL transpilation.""" + return self.con.execute(sql) + def query(self, sql: str) -> list[Any]: """Execute and return results.""" return self.execute_query(sql).fetchall() From 12189bfaaa9175a822b6afc911ec5674a64a4767 Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Thu, 28 May 2026 13:23:30 -0700 Subject: [PATCH 59/62] Revert "Basic sql glot builder classes and parity tests" This reverts commit 3cc306e069f3bcca487e9112d8994327d3d71143. --- .../sqlglot_builders/__init__.py | 9 - .../cohortdefinition/sqlglot_builders/base.py | 29 - .../sqlglot_builders/codesets.py | 117 --- .../sqlglot_builders/condition_occurrence.py | 272 ------ .../sqlglot_builders/drug_exposure.py | 310 ------- .../sqlglot_builders/primitives.py | 245 ------ .../sqlglot_builders/visit_occurrence.py | 235 ------ tests/test_sqlglot_builder_equivalence.py | 793 ------------------ tests/test_sqlglot_primitives.py | 469 ----------- tests/test_sqlrender_csv_format.py | 12 +- tests/test_sqlrender_split.py | 19 +- tests/test_utils_db.py | 4 - 12 files changed, 23 insertions(+), 2491 deletions(-) delete mode 100644 circe/cohortdefinition/sqlglot_builders/__init__.py delete mode 100644 circe/cohortdefinition/sqlglot_builders/base.py delete mode 100644 circe/cohortdefinition/sqlglot_builders/codesets.py delete mode 100644 circe/cohortdefinition/sqlglot_builders/condition_occurrence.py delete mode 100644 circe/cohortdefinition/sqlglot_builders/drug_exposure.py delete mode 100644 circe/cohortdefinition/sqlglot_builders/primitives.py delete mode 100644 circe/cohortdefinition/sqlglot_builders/visit_occurrence.py delete mode 100644 tests/test_sqlglot_builder_equivalence.py delete mode 100644 tests/test_sqlglot_primitives.py diff --git a/circe/cohortdefinition/sqlglot_builders/__init__.py b/circe/cohortdefinition/sqlglot_builders/__init__.py deleted file mode 100644 index 07980c36..00000000 --- a/circe/cohortdefinition/sqlglot_builders/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .condition_occurrence import ConditionOccurrenceGlotBuilder -from .drug_exposure import DrugExposureGlotBuilder -from .visit_occurrence import VisitOccurrenceGlotBuilder - -__all__ = [ - "ConditionOccurrenceGlotBuilder", - "DrugExposureGlotBuilder", - "VisitOccurrenceGlotBuilder", -] diff --git a/circe/cohortdefinition/sqlglot_builders/base.py b/circe/cohortdefinition/sqlglot_builders/base.py deleted file mode 100644 index 4d9ddfe8..00000000 --- a/circe/cohortdefinition/sqlglot_builders/base.py +++ /dev/null @@ -1,29 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Generic, TypeVar - -from sqlglot import exp as sge - -from ..builders.utils import CriteriaColumn -from ..criteria import Criteria - -T = TypeVar("T", bound=Criteria) - - -class CriteriaColumnMap(ABC): - """Maps CriteriaColumn enum values to sqlglot column expressions.""" - - @abstractmethod - def get_column(self, column: CriteriaColumn) -> sge.Expression: - pass - - -class SqlGlotCriteriaBuilder(ABC, Generic[T]): - """Abstract base for sqlglot AST-based criteria builders.""" - - @abstractmethod - def build_select(self, criteria: T) -> sge.Select: - pass - - def compile(self, criteria: T, dialect: str = "duckdb") -> str: - sel = self.build_select(criteria) - return sel.sql(dialect=dialect) diff --git a/circe/cohortdefinition/sqlglot_builders/codesets.py b/circe/cohortdefinition/sqlglot_builders/codesets.py deleted file mode 100644 index 08ee201b..00000000 --- a/circe/cohortdefinition/sqlglot_builders/codesets.py +++ /dev/null @@ -1,117 +0,0 @@ -from sqlglot import exp as sge - -from ...vocabulary.concept import ConceptSet, ConceptSetExpression, ConceptSetItem - - -def build_codeset_query(concept_sets: list[ConceptSet]) -> sge.Union | None: - if not concept_sets: - return None - - union_parts: list[sge.Select] = [] - for cs in concept_sets: - if hasattr(cs, "id") and hasattr(cs, "expression"): - sub = _build_concept_set_select(cs.id, cs.expression) - if sub is not None: - union_parts.append(sub) - - if not union_parts: - return None - - result = union_parts[0] - for part in union_parts[1:]: - result = sge.Union(this=result, expression=part, distinct=False) - return result - - -def _build_concept_set_select( - codeset_id: int, - expression: ConceptSetExpression, -) -> sge.Select | None: - items = expression.items if expression and expression.items else [] - if not items: - return None - - union_parts: list[sge.Select] = [] - for item in items: - sel = _build_item_select(codeset_id, item) - if sel is not None: - union_parts.append(sel) - - if not union_parts: - union_parts.append( - sge.Select() - .select( - sge.Literal.number(codeset_id).as_("codeset_id"), - sge.Literal.number(0).as_("concept_id"), - ) - .where(sge.false()) - ) - - result = union_parts[0] - for part in union_parts[1:]: - result = sge.Union(this=result, expression=part, distinct=False) - return result - - -def _build_item_select(codeset_id: int, item: ConceptSetItem) -> sge.Select | None: - if item.concept is None or item.concept.concept_id is None: - return None - - concept_id = item.concept.concept_id - - base_select = sge.Select().select( - sge.Literal.number(codeset_id).as_("codeset_id"), - sge.column("c.concept_id"), - ) - - if item.include_descendants: - base_select = ( - base_select.from_(sge.Table(this="concept_ancestor", alias="ca")) - .join( - sge.Table(this="concept", alias="c"), - on=sge.EQ( - this=sge.column("ca.descendant_concept_id"), - expression=sge.column("c.concept_id"), - ), - kind="INNER JOIN", - ) - .where( - sge.EQ( - this=sge.column("ca.ancestor_concept_id"), - expression=sge.Literal.number(concept_id), - ) - ) - ) - elif item.include_mapped: - base_select = ( - base_select.from_(sge.Table(this="concept_relationship", alias="cr")) - .join( - sge.Table(this="concept", alias="c"), - on=sge.EQ( - this=sge.column("cr.concept_id_2"), - expression=sge.column("c.concept_id"), - ), - kind="INNER JOIN", - ) - .where( - sge.EQ( - this=sge.column("cr.concept_id_1"), - expression=sge.Literal.number(concept_id), - ) - ) - .where( - sge.EQ( - this=sge.column("c.standard_concept"), - expression=sge.Literal.string("S"), - ) - ) - ) - else: - base_select = base_select.from_(sge.Table(this="concept", alias="c")).where( - sge.EQ( - this=sge.column("c.concept_id"), - expression=sge.Literal.number(concept_id), - ) - ) - - return base_select diff --git a/circe/cohortdefinition/sqlglot_builders/condition_occurrence.py b/circe/cohortdefinition/sqlglot_builders/condition_occurrence.py deleted file mode 100644 index 6c6f8d59..00000000 --- a/circe/cohortdefinition/sqlglot_builders/condition_occurrence.py +++ /dev/null @@ -1,272 +0,0 @@ -from sqlglot import exp as sge - -from ..builders.utils import BuilderUtils -from ..criteria import ConditionOccurrence -from .base import SqlGlotCriteriaBuilder -from .primitives import ( - alias_expr, - build_date_range_clause, - build_in_clause, - build_numeric_range_clause, - build_text_filter_clause, - coalesce, - codeset_in, - column_ref, - date_add, - row_number_expr, - year_of, -) - - -class ConditionOccurrenceGlotBuilder(SqlGlotCriteriaBuilder[ConditionOccurrence]): - def build_select(self, criteria: ConditionOccurrence) -> sge.Select: - inner = sge.Select() - cols = [ - column_ref("co", "person_id"), - column_ref("co", "condition_occurrence_id"), - column_ref("co", "condition_concept_id"), - column_ref("co", "visit_occurrence_id"), - ] - - if criteria.condition_type is not None and len(criteria.condition_type) > 0: - cols.append(column_ref("co", "condition_type_concept_id")) - if criteria.condition_type_cs is not None: - cols.append(column_ref("co", "condition_type_concept_id")) - if criteria.stop_reason is not None: - cols.append(column_ref("co", "stop_reason")) - if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: - cols.append(column_ref("co", "provider_id")) - if criteria.provider_specialty_cs is not None: - cols.append(column_ref("co", "provider_id")) - if criteria.condition_status is not None and len(criteria.condition_status) > 0: - cols.append(column_ref("co", "condition_status_concept_id")) - if criteria.condition_status_cs is not None: - cols.append(column_ref("co", "condition_status_concept_id")) - - if criteria.date_adjustment is not None: - start_col = ( - column_ref("co", "condition_start_date") - if criteria.date_adjustment.start_with == "start_date" - else coalesce( - column_ref("co", "condition_end_date"), - date_add("day", 1, column_ref("co", "condition_start_date")), - ) - ) - end_col = ( - column_ref("co", "condition_start_date") - if criteria.date_adjustment.end_with == "start_date" - else coalesce( - column_ref("co", "condition_end_date"), - date_add("day", 1, column_ref("co", "condition_start_date")), - ) - ) - start_date_expr = date_add("day", criteria.date_adjustment.start_offset, start_col) - end_date_expr = date_add("day", criteria.date_adjustment.end_offset, end_col) - else: - start_date_expr = column_ref("co", "condition_start_date") - end_date_expr = coalesce( - column_ref("co", "condition_end_date"), - date_add("day", 1, column_ref("co", "condition_start_date")), - ) - - cols.append(alias_expr(start_date_expr, "start_date")) - cols.append(alias_expr(end_date_expr, "end_date")) - - inner = inner.select(*cols).from_(sge.Table(this="CONDITION_OCCURRENCE", alias="co")) - - if criteria.codeset_id is not None: - cs_table = sge.Table(this="#Codesets", alias="cs") - cs_on = sge.And( - this=sge.EQ( - this=column_ref("co", "condition_concept_id"), expression=column_ref("cs", "concept_id") - ), - expression=sge.EQ( - this=column_ref("cs", "codeset_id"), expression=sge.Literal.number(criteria.codeset_id) - ), - ) - inner = inner.join(cs_table, on=cs_on, kind="INNER JOIN", append=True) - if criteria.condition_source_concept is not None: - cns_table = sge.Table(this="#Codesets", alias="cns") - cns_on = sge.And( - this=sge.EQ( - this=column_ref("co", "condition_source_concept_id"), - expression=column_ref("cns", "concept_id"), - ), - expression=sge.EQ( - this=column_ref("cns", "codeset_id"), - expression=sge.Literal.number(criteria.condition_source_concept), - ), - ) - inner = inner.join(cns_table, on=cns_on, kind="INNER JOIN", append=True) - - if criteria.first: - inner = inner.select( - alias_expr( - row_number_expr( - [column_ref("co", "person_id")], - [ - column_ref("co", "condition_start_date"), - column_ref("co", "condition_occurrence_id"), - ], - ), - "ordinal", - ) - ) - - outer_cols = [ - alias_expr(column_ref("C", "person_id"), "person_id"), - alias_expr(column_ref("C", "condition_occurrence_id"), "event_id"), - column_ref("C", "start_date"), - column_ref("C", "end_date"), - column_ref("C", "visit_occurrence_id"), - alias_expr(column_ref("C", "start_date"), "sort_date"), - ] - - outer = sge.Select().select(*outer_cols).from_(inner.subquery().as_("C")) - - if ( - criteria.age is not None - or (criteria.gender is not None and len(criteria.gender) > 0) - or criteria.gender_cs is not None - ): - outer = outer.join( - sge.Table(this="PERSON", alias="P"), - on=sge.EQ(this=column_ref("C", "person_id"), expression=column_ref("P", "person_id")), - kind="JOIN", - append=True, - ) - - if ( - criteria.visit_type is not None and len(criteria.visit_type) > 0 - ) or criteria.visit_type_cs is not None: - outer = outer.join( - sge.Table(this="VISIT_OCCURRENCE", alias="V"), - on=sge.And( - this=sge.EQ( - this=column_ref("C", "visit_occurrence_id"), - expression=column_ref("V", "visit_occurrence_id"), - ), - expression=sge.EQ( - this=column_ref("C", "person_id"), expression=column_ref("V", "person_id") - ), - ), - kind="JOIN", - append=True, - ) - - if ( - criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0 - ) or criteria.provider_specialty_cs is not None: - outer = outer.join( - sge.Table(this="PROVIDER", alias="PR"), - on=sge.EQ(this=column_ref("C", "provider_id"), expression=column_ref("PR", "provider_id")), - kind="LEFT JOIN", - append=True, - ) - - wheres = [] - - if criteria.occurrence_start_date is not None: - clause = build_date_range_clause(column_ref("C", "start_date"), criteria.occurrence_start_date) - if clause is not None: - wheres.append(clause) - - if criteria.occurrence_end_date is not None: - clause = build_date_range_clause(column_ref("C", "end_date"), criteria.occurrence_end_date) - if clause is not None: - wheres.append(clause) - - if criteria.condition_type is not None and len(criteria.condition_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.condition_type) - if concept_ids: - wheres.append( - build_in_clause( - column_ref("C", "condition_type_concept_id"), - concept_ids, - exclude=criteria.condition_type_exclude, - ) - ) - - if criteria.condition_type_cs is not None: - clause = codeset_in( - column_ref("C", "condition_type_concept_id"), - criteria.condition_type_cs.codeset_id, - exclude=criteria.condition_type_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.stop_reason is not None: - clause = build_text_filter_clause(column_ref("C", "stop_reason"), criteria.stop_reason) - if clause is not None: - wheres.append(clause) - - if criteria.age is not None: - age_expr = sge.Sub( - this=year_of(column_ref("C", "start_date")), - expression=column_ref("P", "year_of_birth"), - ) - clause = build_numeric_range_clause(age_expr, criteria.age) - if clause is not None: - wheres.append(clause) - - if criteria.gender is not None and len(criteria.gender) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - if concept_ids: - wheres.append(build_in_clause(column_ref("P", "gender_concept_id"), concept_ids)) - - if criteria.gender_cs is not None: - clause = codeset_in( - column_ref("P", "gender_concept_id"), - criteria.gender_cs.codeset_id, - exclude=criteria.gender_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) - if concept_ids: - wheres.append(build_in_clause(column_ref("PR", "specialty_concept_id"), concept_ids)) - - if criteria.provider_specialty_cs is not None: - clause = codeset_in( - column_ref("PR", "specialty_concept_id"), - criteria.provider_specialty_cs.codeset_id, - exclude=criteria.provider_specialty_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.visit_type is not None and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) - if concept_ids: - wheres.append(build_in_clause(column_ref("V", "visit_concept_id"), concept_ids)) - - if criteria.visit_type_cs is not None: - clause = codeset_in( - column_ref("V", "visit_concept_id"), - criteria.visit_type_cs.codeset_id, - exclude=criteria.visit_type_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.condition_status is not None and len(criteria.condition_status) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.condition_status) - if concept_ids: - wheres.append(build_in_clause(column_ref("C", "condition_status_concept_id"), concept_ids)) - - if criteria.condition_status_cs is not None: - clause = codeset_in( - column_ref("C", "condition_status_concept_id"), - criteria.condition_status_cs.codeset_id, - exclude=criteria.condition_status_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - for w in wheres: - outer = outer.where(w) - - return outer diff --git a/circe/cohortdefinition/sqlglot_builders/drug_exposure.py b/circe/cohortdefinition/sqlglot_builders/drug_exposure.py deleted file mode 100644 index b1b9c1d2..00000000 --- a/circe/cohortdefinition/sqlglot_builders/drug_exposure.py +++ /dev/null @@ -1,310 +0,0 @@ -from sqlglot import exp as sge - -from ..builders.utils import BuilderUtils -from ..criteria import DrugExposure -from .base import SqlGlotCriteriaBuilder -from .primitives import ( - alias_expr, - build_date_range_clause, - build_in_clause, - build_numeric_range_clause, - build_text_filter_clause, - coalesce, - codeset_in, - column_ref, - date_add, - row_number_expr, - year_of, -) - - -class DrugExposureGlotBuilder(SqlGlotCriteriaBuilder[DrugExposure]): - def build_select(self, criteria: DrugExposure) -> sge.Select: - inner = sge.Select() - cols = [ - column_ref("de", "person_id"), - column_ref("de", "drug_exposure_id"), - column_ref("de", "drug_concept_id"), - column_ref("de", "visit_occurrence_id"), - column_ref("de", "days_supply"), - column_ref("de", "quantity"), - column_ref("de", "refills"), - ] - - if criteria.drug_type is not None and len(criteria.drug_type) > 0: - cols.append(column_ref("de", "drug_type_concept_id")) - if criteria.drug_type_cs is not None: - cols.append(column_ref("de", "drug_type_concept_id")) - if criteria.stop_reason is not None: - cols.append(column_ref("de", "stop_reason")) - if criteria.route_concept is not None and len(criteria.route_concept) > 0: - cols.append(column_ref("de", "route_concept_id")) - if criteria.route_concept_cs is not None: - cols.append(column_ref("de", "route_concept_id")) - if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: - cols.append(column_ref("de", "provider_id")) - if criteria.provider_specialty_cs is not None: - cols.append(column_ref("de", "provider_id")) - if criteria.dose_unit is not None and len(criteria.dose_unit) > 0: - cols.append(column_ref("de", "dose_unit_concept_id")) - if criteria.dose_unit_cs is not None: - cols.append(column_ref("de", "dose_unit_concept_id")) - if criteria.lot_number is not None: - cols.append(column_ref("de", "lot_number")) - - if criteria.date_adjustment is not None: - start_col = ( - column_ref("de", "drug_exposure_start_date") - if criteria.date_adjustment.start_with == "start_date" - else column_ref("de", "drug_exposure_end_date") - ) - end_col = ( - column_ref("de", "drug_exposure_start_date") - if criteria.date_adjustment.end_with == "start_date" - else column_ref("de", "drug_exposure_end_date") - ) - start_date_expr = date_add("day", criteria.date_adjustment.start_offset, start_col) - end_date_expr = date_add("day", criteria.date_adjustment.end_offset, end_col) - else: - start_date_expr = column_ref("de", "drug_exposure_start_date") - end_date_expr = coalesce( - column_ref("de", "drug_exposure_end_date"), - date_add( - "day", column_ref("de", "days_supply"), column_ref("de", "drug_exposure_start_date") - ), - date_add("day", 1, column_ref("de", "drug_exposure_start_date")), - ) - - cols.append(alias_expr(start_date_expr, "start_date")) - cols.append(alias_expr(end_date_expr, "end_date")) - - inner = inner.select(*cols).from_(sge.Table(this="DRUG_EXPOSURE", alias="de")) - - if criteria.codeset_id is not None: - cs_table = sge.Table(this="#Codesets", alias="cs") - cs_on = sge.And( - this=sge.EQ( - this=column_ref("de", "drug_concept_id"), expression=column_ref("cs", "concept_id") - ), - expression=sge.EQ( - this=column_ref("cs", "codeset_id"), expression=sge.Literal.number(criteria.codeset_id) - ), - ) - inner = inner.join(cs_table, on=cs_on, kind="INNER JOIN", append=True) - if criteria.drug_source_concept is not None: - cns_table = sge.Table(this="#Codesets", alias="cns") - cns_on = sge.And( - this=sge.EQ( - this=column_ref("de", "drug_source_concept_id"), - expression=column_ref("cns", "concept_id"), - ), - expression=sge.EQ( - this=column_ref("cns", "codeset_id"), - expression=sge.Literal.number(criteria.drug_source_concept), - ), - ) - inner = inner.join(cns_table, on=cns_on, kind="INNER JOIN", append=True) - - if criteria.first: - inner = inner.select( - alias_expr( - row_number_expr( - [column_ref("de", "person_id")], - [column_ref("de", "drug_exposure_start_date"), column_ref("de", "drug_exposure_id")], - ), - "ordinal", - ) - ) - - outer_cols = [ - alias_expr(column_ref("C", "person_id"), "person_id"), - alias_expr(column_ref("C", "drug_exposure_id"), "event_id"), - column_ref("C", "start_date"), - column_ref("C", "end_date"), - column_ref("C", "visit_occurrence_id"), - alias_expr(column_ref("C", "start_date"), "sort_date"), - ] - - outer = sge.Select().select(*outer_cols).from_(inner.subquery().as_("C")) - - if ( - criteria.age is not None - or (criteria.gender is not None and len(criteria.gender) > 0) - or criteria.gender_cs is not None - ): - outer = outer.join( - sge.Table(this="PERSON", alias="P"), - on=sge.EQ(this=column_ref("C", "person_id"), expression=column_ref("P", "person_id")), - kind="JOIN", - append=True, - ) - - if ( - criteria.visit_type is not None and len(criteria.visit_type) > 0 - ) or criteria.visit_type_cs is not None: - outer = outer.join( - sge.Table(this="VISIT_OCCURRENCE", alias="V"), - on=sge.And( - this=sge.EQ( - this=column_ref("C", "visit_occurrence_id"), - expression=column_ref("V", "visit_occurrence_id"), - ), - expression=sge.EQ( - this=column_ref("C", "person_id"), expression=column_ref("V", "person_id") - ), - ), - kind="JOIN", - append=True, - ) - - if ( - criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0 - ) or criteria.provider_specialty_cs is not None: - outer = outer.join( - sge.Table(this="PROVIDER", alias="PR"), - on=sge.EQ(this=column_ref("C", "provider_id"), expression=column_ref("PR", "provider_id")), - kind="LEFT JOIN", - append=True, - ) - - wheres = [] - - if criteria.occurrence_start_date is not None: - clause = build_date_range_clause(column_ref("C", "start_date"), criteria.occurrence_start_date) - if clause is not None: - wheres.append(clause) - - if criteria.occurrence_end_date is not None: - clause = build_date_range_clause(column_ref("C", "end_date"), criteria.occurrence_end_date) - if clause is not None: - wheres.append(clause) - - if criteria.drug_type is not None and len(criteria.drug_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.drug_type) - if concept_ids: - wheres.append( - build_in_clause( - column_ref("C", "drug_type_concept_id"), - concept_ids, - exclude=criteria.drug_type_exclude, - ) - ) - - if criteria.drug_type_cs is not None: - clause = codeset_in( - column_ref("C", "drug_type_concept_id"), - criteria.drug_type_cs.codeset_id, - exclude=criteria.drug_type_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.stop_reason is not None: - clause = build_text_filter_clause(column_ref("C", "stop_reason"), criteria.stop_reason) - if clause is not None: - wheres.append(clause) - - if criteria.route_concept is not None and len(criteria.route_concept) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.route_concept) - if concept_ids: - wheres.append(build_in_clause(column_ref("C", "route_concept_id"), concept_ids)) - - if criteria.route_concept_cs is not None: - clause = codeset_in( - column_ref("C", "route_concept_id"), - criteria.route_concept_cs.codeset_id, - exclude=criteria.route_concept_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.dose_unit is not None and len(criteria.dose_unit) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.dose_unit) - if concept_ids: - wheres.append(build_in_clause(column_ref("C", "dose_unit_concept_id"), concept_ids)) - - if criteria.dose_unit_cs is not None: - clause = codeset_in( - column_ref("C", "dose_unit_concept_id"), - criteria.dose_unit_cs.codeset_id, - exclude=criteria.dose_unit_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.lot_number is not None: - clause = build_text_filter_clause(column_ref("C", "lot_number"), criteria.lot_number) - if clause is not None: - wheres.append(clause) - - if criteria.refills is not None: - clause = build_numeric_range_clause(column_ref("C", "refills"), criteria.refills) - if clause is not None: - wheres.append(clause) - - if criteria.quantity is not None: - clause = build_numeric_range_clause(column_ref("C", "quantity"), criteria.quantity) - if clause is not None: - wheres.append(clause) - - if criteria.days_supply is not None: - clause = build_numeric_range_clause(column_ref("C", "days_supply"), criteria.days_supply) - if clause is not None: - wheres.append(clause) - - if criteria.age is not None: - age_expr = sge.Sub( - this=year_of(column_ref("C", "start_date")), - expression=column_ref("P", "year_of_birth"), - ) - clause = build_numeric_range_clause(age_expr, criteria.age) - if clause is not None: - wheres.append(clause) - - if criteria.gender is not None and len(criteria.gender) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - if concept_ids: - wheres.append(build_in_clause(column_ref("P", "gender_concept_id"), concept_ids)) - - if criteria.gender_cs is not None: - clause = codeset_in( - column_ref("P", "gender_concept_id"), - criteria.gender_cs.codeset_id, - exclude=criteria.gender_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) - if concept_ids: - wheres.append(build_in_clause(column_ref("PR", "specialty_concept_id"), concept_ids)) - - if criteria.provider_specialty_cs is not None: - clause = codeset_in( - column_ref("PR", "specialty_concept_id"), - criteria.provider_specialty_cs.codeset_id, - exclude=criteria.provider_specialty_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.visit_type is not None and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) - if concept_ids: - wheres.append(build_in_clause(column_ref("V", "visit_concept_id"), concept_ids)) - - if criteria.visit_type_cs is not None: - clause = codeset_in( - column_ref("V", "visit_concept_id"), - criteria.visit_type_cs.codeset_id, - exclude=criteria.visit_type_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - for w in wheres: - if w is not None: - outer = outer.where(w) - - return outer diff --git a/circe/cohortdefinition/sqlglot_builders/primitives.py b/circe/cohortdefinition/sqlglot_builders/primitives.py deleted file mode 100644 index 92509fa7..00000000 --- a/circe/cohortdefinition/sqlglot_builders/primitives.py +++ /dev/null @@ -1,245 +0,0 @@ -from typing import Optional - -from sqlglot import exp as sge - -from ..core import DateRange, NumericRange - - -def column_ref(table_alias: str, col: str) -> sge.Column: - return sge.Column(this=col, table=table_alias) - - -def alias_expr(expr, alias: str) -> sge.Alias: - return sge.Alias(this=expr, alias=sge.to_identifier(alias)) - - -def date_add(unit: str, n, expr) -> sge.DateAdd: - if isinstance(n, int): - return sge.DateAdd(this=expr, expression=sge.Literal.number(n), unit=sge.Var(this=unit)) - return sge.DateAdd(this=expr, expression=n, unit=sge.Var(this=unit)) - - -def coalesce(*exprs) -> sge.Coalesce | None: - if not exprs: - return None - result = sge.Coalesce(this=exprs[0]) - result.args.setdefault("expressions", []) - for e in exprs[1:]: - result.args["expressions"].append(e) - return result - - -def year_of(expr) -> sge.Year: - return sge.Year(this=expr) - - -def datediff(unit: str, start, end) -> sge.DateDiff: - start_ts = sge.TimeStrToTime(this=start) if isinstance(start, sge.Column) else start - end_ts = sge.TimeStrToTime(this=end) if isinstance(end, sge.Column) else end - return sge.DateDiff(this=end_ts, expression=start_ts, unit=sge.Var(this=unit)) - - -def date_from_parts(year, month, day) -> sge.DateFromParts: - return sge.DateFromParts( - year=sge.Literal.number(year), - month=sge.Literal.number(month), - day=sge.Literal.number(day), - ) - - -def row_number_expr(partition_by: list, order_by: list) -> sge.Window: - orders = [ - sge.Ordered(this=col, desc=False, nulls_first=True) - if isinstance(col, sge.Column) - else sge.Ordered(this=col, desc=False) - for col in order_by - ] - order = sge.Order(expressions=orders) - window = sge.Window( - this=sge.RowNumber(), - partition_by=partition_by, - order=order, - ) - return window - - -def codeset_join( - codeset_table: str, - concept_column: sge.Column, - codeset_id: int, - alias: str = "cs", -) -> sge.Join: - return sge.Join( - this=sge.Table(this=codeset_table, alias=alias), - kind="INNER JOIN", - on=sge.And( - this=sge.EQ(this=concept_column, expression=column_ref(alias, "concept_id")), - expression=sge.EQ( - this=column_ref(alias, "codeset_id"), - expression=sge.Literal.number(codeset_id), - ), - ), - ) - - -def codeset_in(column: sge.Column, codeset_id: int, exclude: bool = False) -> sge.In | sge.Not | None: - subq = ( - sge.Select() - .select(sge.column("concept_id")) - .from_(sge.Table(this="#Codesets")) - .where( - sge.EQ( - this=sge.column("codeset_id"), - expression=sge.Literal.number(codeset_id), - ) - ) - ) - result: sge.In = sge.In(this=column, expressions=[sge.Subquery(this=subq)]) - if exclude: - return sge.Not(this=result) - return result - - -def build_date_range_clause( - column: sge.Column, - date_range: Optional[DateRange], -) -> Optional[sge.Expression]: - if date_range is None or date_range.op is None: - return None - op = date_range.op.lower() - - if op.endswith("bt"): - negation = op.startswith("!") - if date_range.value is None: - return None - lo = date_string_to_expr(date_range.value) - hi = date_string_to_expr(date_range.extent) if date_range.extent else None - if hi is None: - return None - result = sge.And( - this=sge.GTE(this=column, expression=lo), - expression=sge.LTE(this=column, expression=hi), - ) - if negation: - return sge.Not(this=result) - return result - - if date_range.value is None: - return None - val = date_string_to_expr(date_range.value) - sql_op = _get_sql_operator(op) - if sql_op == "=": - return sge.EQ(this=column, expression=val) - elif sql_op == "<>": - return sge.NEQ(this=column, expression=val) - elif sql_op == ">": - return sge.GT(this=column, expression=val) - elif sql_op == ">=": - return sge.GTE(this=column, expression=val) - elif sql_op == "<": - return sge.LT(this=column, expression=val) - elif sql_op == "<=": - return sge.LTE(this=column, expression=val) - return None - - -def _get_sql_operator(op: str) -> str: - operators = { - "lt": "<", - "lte": "<=", - "eq": "=", - "ne": "<>", - "!eq": "<>", - "gt": ">", - "gte": ">=", - } - return operators.get(op, "=") - - -def date_string_to_expr(date_str: str) -> sge.DateFromParts: - parts = date_str.split("-") - year = int(parts[0]) - month = int(parts[1]) - day = int(parts[2]) - return date_from_parts(year, month, day) - - -def build_numeric_range_clause( - column, - numeric_range: Optional[NumericRange], -) -> Optional[sge.Expression]: - if numeric_range is None or numeric_range.op is None: - return None - op = numeric_range.op.lower() - - if op.endswith("bt"): - if numeric_range.value is None or numeric_range.extent is None: - return None - negation = op.startswith("!") - lo = sge.Literal.number(int(numeric_range.value)) - hi = sge.Literal.number(int(numeric_range.extent)) - result = sge.And( - this=sge.GTE(this=column, expression=lo), expression=sge.LTE(this=column, expression=hi) - ) - if negation: - return sge.Not(this=result) - return result - - if numeric_range.value is None: - return None - val = sge.Literal.number(int(numeric_range.value)) - sql_op = _get_sql_operator(op) - if sql_op == "=": - return sge.EQ(this=column, expression=val) - elif sql_op == "<>": - return sge.NEQ(this=column, expression=val) - elif sql_op == ">": - return sge.GT(this=column, expression=val) - elif sql_op == ">=": - return sge.GTE(this=column, expression=val) - elif sql_op == "<": - return sge.LT(this=column, expression=val) - elif sql_op == "<=": - return sge.LTE(this=column, expression=val) - return None - - -def build_text_filter_clause( - column: sge.Column, - text_filter, -) -> Optional[sge.Expression]: - if text_filter is None: - return None - if isinstance(text_filter, str): - return sge.Like( - this=column, - expression=sge.Literal.string(f"%{text_filter}%"), - ) - text = getattr(text_filter, "text", None) - op = getattr(text_filter, "op", "contains") - if text is None: - return None - escaped = text.replace("'", "''") - if op == "eq": - return sge.EQ(this=column, expression=sge.Literal.string(escaped)) - elif op == "!eq": - return sge.NEQ(this=column, expression=sge.Literal.string(escaped)) - elif op == "startsWith": - return sge.Like(this=column, expression=sge.Literal.string(f"{escaped}%")) - elif op == "endsWith": - return sge.Like(this=column, expression=sge.Literal.string(f"%{escaped}")) - elif op == "!contains": - return sge.Not(this=sge.Like(this=column, expression=sge.Literal.string(f"%{escaped}%"))) - else: - return sge.Like(this=column, expression=sge.Literal.string(f"%{escaped}%")) - - -def build_in_clause(column: sge.Column, values: list[int], exclude: bool = False) -> sge.In | sge.Not: - sorted_vals = sorted(set(values)) - in_expr: sge.In = sge.In( - this=column, - expressions=[sge.Literal.number(v) for v in sorted_vals], - ) - if exclude: - return sge.Not(this=in_expr) - return in_expr diff --git a/circe/cohortdefinition/sqlglot_builders/visit_occurrence.py b/circe/cohortdefinition/sqlglot_builders/visit_occurrence.py deleted file mode 100644 index e98c014e..00000000 --- a/circe/cohortdefinition/sqlglot_builders/visit_occurrence.py +++ /dev/null @@ -1,235 +0,0 @@ -from sqlglot import exp as sge - -from ..builders.utils import BuilderUtils -from ..criteria import VisitOccurrence -from .base import SqlGlotCriteriaBuilder -from .primitives import ( - alias_expr, - build_date_range_clause, - build_in_clause, - build_numeric_range_clause, - codeset_in, - column_ref, - date_add, - datediff, - row_number_expr, - year_of, -) - - -class VisitOccurrenceGlotBuilder(SqlGlotCriteriaBuilder[VisitOccurrence]): - def build_select(self, criteria: VisitOccurrence) -> sge.Select: - inner = sge.Select() - cols = [ - column_ref("vo", "person_id"), - column_ref("vo", "visit_occurrence_id"), - column_ref("vo", "visit_concept_id"), - ] - - if ( - criteria.visit_type is not None and len(criteria.visit_type) > 0 - ) or criteria.visit_type_cs is not None: - cols.append(column_ref("vo", "visit_type_concept_id")) - if ( - criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0 - ) or criteria.provider_specialty_cs is not None: - cols.append(column_ref("vo", "provider_id")) - if ( - criteria.place_of_service is not None and len(criteria.place_of_service) > 0 - ) or criteria.place_of_service_cs is not None: - cols.append(column_ref("vo", "care_site_id")) - - if criteria.date_adjustment is not None: - start_col = ( - column_ref("vo", "visit_start_date") - if criteria.date_adjustment.start_with == "START_DATE" - else column_ref("vo", "visit_end_date") - ) - end_col = ( - column_ref("vo", "visit_start_date") - if criteria.date_adjustment.end_with == "START_DATE" - else column_ref("vo", "visit_end_date") - ) - start_date_expr = date_add("day", criteria.date_adjustment.start_offset, start_col) - end_date_expr = date_add("day", criteria.date_adjustment.end_offset, end_col) - else: - start_date_expr = column_ref("vo", "visit_start_date") - end_date_expr = column_ref("vo", "visit_end_date") - - cols.append(alias_expr(start_date_expr, "start_date")) - cols.append(alias_expr(end_date_expr, "end_date")) - - inner = inner.select(*cols).from_(sge.Table(this="VISIT_OCCURRENCE", alias="vo")) - - if criteria.codeset_id is not None: - cs_table = sge.Table(this="#Codesets", alias="cs") - cs_on = sge.And( - this=sge.EQ( - this=column_ref("vo", "visit_concept_id"), expression=column_ref("cs", "concept_id") - ), - expression=sge.EQ( - this=column_ref("cs", "codeset_id"), expression=sge.Literal.number(criteria.codeset_id) - ), - ) - inner = inner.join(cs_table, on=cs_on, kind="INNER JOIN", append=True) - if criteria.visit_source_concept is not None: - cns_table = sge.Table(this="#Codesets", alias="cns") - cns_on = sge.And( - this=sge.EQ( - this=column_ref("vo", "visit_source_concept_id"), - expression=column_ref("cns", "concept_id"), - ), - expression=sge.EQ( - this=column_ref("cns", "codeset_id"), - expression=sge.Literal.number(criteria.visit_source_concept), - ), - ) - inner = inner.join(cns_table, on=cns_on, kind="INNER JOIN", append=True) - - if criteria.first: - inner = inner.select( - alias_expr( - row_number_expr( - [column_ref("vo", "person_id")], - [column_ref("vo", "visit_start_date"), column_ref("vo", "visit_occurrence_id")], - ), - "ordinal", - ) - ) - - outer_cols = [ - alias_expr(column_ref("C", "person_id"), "person_id"), - alias_expr(column_ref("C", "visit_occurrence_id"), "event_id"), - column_ref("C", "start_date"), - column_ref("C", "end_date"), - column_ref("C", "visit_occurrence_id"), - alias_expr(column_ref("C", "start_date"), "sort_date"), - ] - - outer = sge.Select().select(*outer_cols).from_(inner.subquery().as_("C")) - - if ( - criteria.age is not None - or (criteria.gender is not None and len(criteria.gender) > 0) - or (criteria.gender_cs is not None and criteria.gender_cs.codeset_id) - ): - outer = outer.join( - sge.Table(this="PERSON", alias="P"), - on=sge.EQ(this=column_ref("C", "person_id"), expression=column_ref("P", "person_id")), - kind="JOIN", - append=True, - ) - - if ( - (criteria.place_of_service is not None and len(criteria.place_of_service) > 0) - or (criteria.place_of_service_cs is not None and criteria.place_of_service_cs.codeset_id) - or criteria.place_of_service_location is not None - ): - outer = outer.join( - sge.Table(this="CARE_SITE", alias="CS"), - on=sge.EQ(this=column_ref("C", "care_site_id"), expression=column_ref("CS", "care_site_id")), - kind="JOIN", - append=True, - ) - - if (criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0) or ( - criteria.provider_specialty_cs is not None and criteria.provider_specialty_cs.codeset_id - ): - outer = outer.join( - sge.Table(this="PROVIDER", alias="PR"), - on=sge.EQ(this=column_ref("C", "provider_id"), expression=column_ref("PR", "provider_id")), - kind="LEFT JOIN", - append=True, - ) - - wheres = [] - - if criteria.occurrence_start_date is not None: - clause = build_date_range_clause(column_ref("C", "start_date"), criteria.occurrence_start_date) - if clause is not None: - wheres.append(clause) - - if criteria.occurrence_end_date is not None: - clause = build_date_range_clause(column_ref("C", "end_date"), criteria.occurrence_end_date) - if clause is not None: - wheres.append(clause) - - if criteria.visit_type is not None and len(criteria.visit_type) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.visit_type) - if concept_ids: - exclude = criteria.visit_type_exclude if hasattr(criteria, "visit_type_exclude") else False - wheres.append( - build_in_clause(column_ref("C", "visit_type_concept_id"), concept_ids, exclude=exclude) - ) - - if criteria.visit_type_cs is not None and criteria.visit_type_cs.codeset_id: - clause = codeset_in( - column_ref("C", "visit_type_concept_id"), - criteria.visit_type_cs.codeset_id, - exclude=criteria.visit_type_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.visit_length is not None: - len_expr = datediff("day", column_ref("C", "start_date"), column_ref("C", "end_date")) - clause = build_numeric_range_clause(len_expr, criteria.visit_length) - if clause is not None: - wheres.append(clause) - - if criteria.age is not None: - age_expr = sge.Sub( - this=year_of(column_ref("C", "start_date")), - expression=column_ref("P", "year_of_birth"), - ) - clause = build_numeric_range_clause(age_expr, criteria.age) - if clause is not None: - wheres.append(clause) - - if criteria.gender is not None and len(criteria.gender) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.gender) - if concept_ids: - wheres.append(build_in_clause(column_ref("P", "gender_concept_id"), concept_ids)) - - if criteria.gender_cs is not None and criteria.gender_cs.codeset_id: - clause = codeset_in( - column_ref("P", "gender_concept_id"), - criteria.gender_cs.codeset_id, - exclude=criteria.gender_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.provider_specialty is not None and len(criteria.provider_specialty) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.provider_specialty) - if concept_ids: - wheres.append(build_in_clause(column_ref("PR", "specialty_concept_id"), concept_ids)) - - if criteria.provider_specialty_cs is not None and criteria.provider_specialty_cs.codeset_id: - clause = codeset_in( - column_ref("PR", "specialty_concept_id"), - criteria.provider_specialty_cs.codeset_id, - exclude=criteria.provider_specialty_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - if criteria.place_of_service is not None and len(criteria.place_of_service) > 0: - concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.place_of_service) - if concept_ids: - wheres.append(build_in_clause(column_ref("CS", "place_of_service_concept_id"), concept_ids)) - - if criteria.place_of_service_cs is not None and criteria.place_of_service_cs.codeset_id: - clause = codeset_in( - column_ref("CS", "place_of_service_concept_id"), - criteria.place_of_service_cs.codeset_id, - exclude=criteria.place_of_service_cs.is_exclusion, - ) - if clause is not None: - wheres.append(clause) - - for w in wheres: - if w is not None: - outer = outer.where(w) - - return outer diff --git a/tests/test_sqlglot_builder_equivalence.py b/tests/test_sqlglot_builder_equivalence.py deleted file mode 100644 index c481422c..00000000 --- a/tests/test_sqlglot_builder_equivalence.py +++ /dev/null @@ -1,793 +0,0 @@ -"""Equivalence tests: string-template + SqlRender vs sqlglot builders. - -For each criteria scenario, we build SQL via both paths, execute both -in DuckDB, and assert the result row sets are identical. -""" - -import datetime -from typing import Any - -import pytest - -from circe.cohortdefinition import ( - ConditionOccurrence, - DateAdjustment, - DrugExposure, - NumericRange, - VisitOccurrence, -) -from circe.cohortdefinition.builders.condition_occurrence import ( - ConditionOccurrenceSqlBuilder, -) -from circe.cohortdefinition.builders.drug_exposure import DrugExposureSqlBuilder -from circe.cohortdefinition.builders.visit_occurrence import ( - VisitOccurrenceSqlBuilder, -) -from circe.cohortdefinition.sqlglot_builders import ( - ConditionOccurrenceGlotBuilder, - DrugExposureGlotBuilder, - VisitOccurrenceGlotBuilder, -) -from tests.test_utils_db import DuckDBTestHelper - - -@pytest.fixture(scope="module") -def db(): - helper = DuckDBTestHelper() - _create_test_schema(helper) - return helper - - -def _create_test_schema(helper: DuckDBTestHelper): - con = helper.con - - con.execute("DROP TABLE IF EXISTS person") - con.execute(""" - CREATE TABLE person ( - person_id INTEGER, - year_of_birth INTEGER, - gender_concept_id INTEGER, - race_concept_id INTEGER, - ethnicity_concept_id INTEGER - ) - """) - con.execute("INSERT INTO person VALUES (1, 1980, 8507, 0, 0)") - con.execute("INSERT INTO person VALUES (2, 1990, 8532, 0, 0)") - con.execute("INSERT INTO person VALUES (3, 1970, 8507, 0, 0)") - con.execute("INSERT INTO person VALUES (4, 2000, 8532, 0, 0)") - - con.execute("DROP TABLE IF EXISTS condition_occurrence") - con.execute(""" - CREATE TABLE condition_occurrence ( - person_id INTEGER, - condition_occurrence_id INTEGER, - condition_concept_id INTEGER, - condition_start_date DATE, - condition_end_date DATE, - condition_type_concept_id INTEGER, - stop_reason VARCHAR, - condition_status_concept_id INTEGER, - visit_occurrence_id INTEGER, - provider_id INTEGER, - condition_source_concept_id INTEGER - ) - """) - con.execute( - "INSERT INTO condition_occurrence VALUES (1, 101, 10, '2020-01-15', '2020-01-20', 100, NULL, 0, 1, NULL, NULL)" - ) - con.execute( - "INSERT INTO condition_occurrence VALUES (1, 102, 11, '2020-02-01', '2020-02-05', 200, 'resolved', 0, 1, NULL, NULL)" - ) - con.execute( - "INSERT INTO condition_occurrence VALUES (2, 103, 10, '2020-03-01', '2020-03-10', 100, NULL, 0, 2, NULL, NULL)" - ) - con.execute( - "INSERT INTO condition_occurrence VALUES (3, 104, 10, '2020-01-01', '2020-01-10', 100, NULL, 0, NULL, NULL, NULL)" - ) - con.execute( - "INSERT INTO condition_occurrence VALUES (4, 105, 99, '2020-06-01', '2020-06-05', 300, NULL, 0, NULL, NULL, NULL)" - ) - - con.execute("DROP TABLE IF EXISTS drug_exposure") - con.execute(""" - CREATE TABLE drug_exposure ( - person_id INTEGER, - drug_exposure_id INTEGER, - drug_concept_id INTEGER, - drug_exposure_start_date DATE, - drug_exposure_end_date DATE, - drug_type_concept_id INTEGER, - stop_reason VARCHAR, - refills INTEGER, - quantity NUMERIC, - days_supply INTEGER, - route_concept_id INTEGER, - dose_unit_concept_id INTEGER, - provider_id INTEGER, - visit_occurrence_id INTEGER, - drug_source_concept_id INTEGER, - lot_number VARCHAR - ) - """) - con.execute( - "INSERT INTO drug_exposure VALUES (1, 201, 20, '2020-01-10', '2020-01-20', 50, NULL, 2, 10, 10, NULL, NULL, NULL, 1, NULL, NULL)" - ) - con.execute( - "INSERT INTO drug_exposure VALUES (1, 202, 21, '2020-02-15', '2020-02-25', 60, 'stopped', 0, 5, 10, NULL, NULL, NULL, 1, NULL, 'ABC123')" - ) - con.execute( - "INSERT INTO drug_exposure VALUES (2, 203, 20, '2020-03-05', '2020-03-15', 50, NULL, 1, 20, 10, NULL, NULL, NULL, 2, NULL, NULL)" - ) - con.execute( - "INSERT INTO drug_exposure VALUES (3, 204, 20, '2020-01-05', '2020-01-15', 50, NULL, 0, 15, 5, NULL, NULL, NULL, NULL, NULL, NULL)" - ) - con.execute( - "INSERT INTO drug_exposure VALUES (4, 205, 99, '2020-07-01', '2020-07-10', 70, NULL, 0, 5, 30, NULL, NULL, NULL, NULL, NULL, NULL)" - ) - - con.execute("DROP TABLE IF EXISTS visit_occurrence") - con.execute(""" - CREATE TABLE visit_occurrence ( - person_id INTEGER, - visit_occurrence_id INTEGER, - visit_concept_id INTEGER, - visit_start_date DATE, - visit_end_date DATE, - visit_type_concept_id INTEGER, - provider_id INTEGER, - care_site_id INTEGER, - visit_source_concept_id INTEGER - ) - """) - con.execute( - "INSERT INTO visit_occurrence VALUES (1, 1, 30, '2020-01-10', '2020-01-20', 500, NULL, NULL, NULL)" - ) - con.execute( - "INSERT INTO visit_occurrence VALUES (2, 2, 30, '2020-03-01', '2020-03-15', 500, NULL, NULL, NULL)" - ) - con.execute( - "INSERT INTO visit_occurrence VALUES (3, 3, 31, '2020-01-01', '2020-01-10', 600, NULL, NULL, NULL)" - ) - con.execute( - "INSERT INTO visit_occurrence VALUES (4, 4, 99, '2020-06-01', '2020-06-10', 700, NULL, NULL, NULL)" - ) - - con.execute("DROP TABLE IF EXISTS observation_period") - con.execute(""" - CREATE TABLE observation_period ( - person_id INTEGER, - observation_period_start_date DATE, - observation_period_end_date DATE - ) - """) - for pid in range(1, 5): - con.execute(f"INSERT INTO observation_period VALUES ({pid}, '2019-01-01', '2021-12-31')") - - con.execute("DELETE FROM Codesets") - for codeset_id, concept_id in [(1, 10), (2, 20), (3, 30), (4, 99)]: - con.execute(f"INSERT INTO Codesets (codeset_id, concept_id) VALUES ({codeset_id}, {concept_id})") - - con.execute("DROP TABLE IF EXISTS provider") - con.execute(""" - CREATE TABLE provider ( - provider_id INTEGER, - specialty_concept_id INTEGER - ) - """) - - con.execute("DROP TABLE IF EXISTS care_site") - con.execute(""" - CREATE TABLE care_site ( - care_site_id INTEGER, - place_of_service_concept_id INTEGER - ) - """) - - -def _sql_param_replace(sql: str) -> str: - return sql.replace("@cdm_database_schema.", "main.") - - -def _glot_to_duckdb(sql: str) -> str: - return sql.replace("#Codesets", "Codesets").replace("#", "") - - -def _run_glot(db: DuckDBTestHelper, select) -> list[Any]: - sql = select.sql(dialect="duckdb") - sql = _glot_to_duckdb(sql) - return _normalize_dates(db.execute_raw(f"SELECT * FROM ({sql}) C").fetchall()) - - -def _result_set(results: list[Any]) -> set[tuple]: - return {tuple(r) for r in results} - - -def _normalize_dates(results: list[Any]) -> list[list]: - out = [] - for row in results: - r = list(row) - for i, val in enumerate(r): - if isinstance(val, datetime.datetime): - r[i] = val.date() - out.append(r) - return out - - -class TestConditionOccurrenceEquivalence: - builder = ConditionOccurrenceSqlBuilder() - glot = ConditionOccurrenceGlotBuilder() - - def test_simple_codeset_match(self, db: DuckDBTestHelper): - co = ConditionOccurrence(codeset_id=1) - - tsql = self.builder.get_criteria_sql(co) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(co))) - - assert rows_a == rows_b, "Simple codeset match: rows differ" - - def test_date_range_filter(self, db: DuckDBTestHelper): - from circe.cohortdefinition import DateRange - - co = ConditionOccurrence( - codeset_id=1, - occurrence_start_date=DateRange(op="gte", value="2020-02-01"), - ) - - tsql = self.builder.get_criteria_sql(co) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(co))) - - assert rows_a == rows_b, "Date range filter: rows differ" - - def test_age_filter(self, db: DuckDBTestHelper): - co = ConditionOccurrence( - codeset_id=1, - age=NumericRange(op="gte", value=40), - ) - - tsql = self.builder.get_criteria_sql(co) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(co))) - - assert rows_a == rows_b, "Age filter: rows differ" - - def test_first_occurrence(self, db: DuckDBTestHelper): - co = ConditionOccurrence(codeset_id=1, first=True) - - tsql = self.builder.get_criteria_sql(co) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(co))) - - assert rows_a == rows_b, "First occurrence: rows differ" - - def test_date_adjustment(self, db: DuckDBTestHelper): - co = ConditionOccurrence( - codeset_id=1, - date_adjustment=DateAdjustment(start_offset=3, end_offset=0), - ) - - tsql = self.builder.get_criteria_sql(co) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(co))) - - assert rows_a == rows_b, "Date adjustment: rows differ" - - def test_date_adjustment_dates(self, db: DuckDBTestHelper): - """Verify adjusted date values specifically.""" - co = ConditionOccurrence( - codeset_id=1, - date_adjustment=DateAdjustment(start_offset=2, end_offset=1), - ) - - tsql = self.builder.get_criteria_sql(co) - rows_a = db.query(f"SELECT C.start_date, C.end_date FROM ({_sql_param_replace(tsql)}) C") - row_a = rows_a[0] if rows_a else None - - glot_select = self.glot.build_select(co) - glot_sql = glot_select.sql(dialect="duckdb") - glot_sql = _glot_to_duckdb(glot_sql) - rows_b = db.execute_raw(f"SELECT C.start_date, C.end_date FROM ({glot_sql}) C").fetchall() - row_b = rows_b[0] if rows_b else None - - def _to_dates(r): - if r is None: - return None - r = list(r) - for i in range(2): - if isinstance(r[i], datetime.datetime): - r[i] = r[i].date() - return tuple(r) - - row_a, row_b = _to_dates(row_a), _to_dates(row_b) - assert row_a == row_b, f"Date adjustment values differ: {row_a} vs {row_b}" - - -class TestDrugExposureEquivalence: - builder = DrugExposureSqlBuilder() - glot = DrugExposureGlotBuilder() - - def test_simple_codeset_match(self, db: DuckDBTestHelper): - de = DrugExposure(codeset_id=2) - - tsql = self.builder.get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(de))) - - assert rows_a == rows_b, "Drug simple codeset match: rows differ" - - def test_days_supply_filter(self, db: DuckDBTestHelper): - de = DrugExposure(codeset_id=2, days_supply=NumericRange(op="gte", value=8)) - - tsql = self.builder.get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(de))) - - assert rows_a == rows_b, "Drug days_supply filter: rows differ" - - def test_first_drug_exposure(self, db: DuckDBTestHelper): - de = DrugExposure(codeset_id=2, first=True) - - tsql = self.builder.get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(de))) - - assert rows_a == rows_b, "Drug first: rows differ" - - def test_drug_type_exclude(self, db: DuckDBTestHelper): - from circe.vocabulary.concept import Concept - - de = DrugExposure( - codeset_id=2, - drug_type=[Concept(concept_id=70)], - drug_type_exclude=True, - ) - - tsql = self.builder.get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(de))) - - assert rows_a == rows_b, "Drug type exclude: rows differ" - - -class TestVisitOccurrenceEquivalence: - builder = VisitOccurrenceSqlBuilder() - glot = VisitOccurrenceGlotBuilder() - - def test_simple_codeset_match(self, db: DuckDBTestHelper): - vo = VisitOccurrence(codeset_id=3) - - tsql = self.builder.get_criteria_sql(vo) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(vo))) - - assert rows_a == rows_b, "Visit simple codeset match: rows differ" - - def test_visit_length_filter(self, db: DuckDBTestHelper): - from circe.cohortdefinition import NumericRange - - vo = VisitOccurrence( - codeset_id=3, - visit_length=NumericRange(op="gt", value=10), - ) - - tsql = self.builder.get_criteria_sql(vo) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(vo))) - - assert rows_a == rows_b, "Visit length filter: rows differ" - - def test_first_visit(self, db: DuckDBTestHelper): - vo = VisitOccurrence(codeset_id=3, first=True) - - tsql = self.builder.get_criteria_sql(vo) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - - rows_b = _result_set(_run_glot(db, self.glot.build_select(vo))) - - assert rows_a == rows_b, "Visit first: rows differ" - - -class TestCrossCriteriaEquivalence: - """Key test: all three criteria types return same populations.""" - - def test_condition_and_drug_and_visit_all_match(self, db: DuckDBTestHelper): - """Each criteria type should find overlapping patients.""" - co = ConditionOccurrence(codeset_id=1) - de = DrugExposure(codeset_id=2) - vo = VisitOccurrence(codeset_id=3) - - def _get_person_ids(builder, criteria) -> set: - sql = _glot_to_duckdb(builder.build_select(criteria).sql(dialect="duckdb")) - return {r[0] for r in db.execute_raw(f"SELECT * FROM ({sql}) C").fetchall()} - - co_rows = _get_person_ids(ConditionOccurrenceGlotBuilder(), co) - de_rows = _get_person_ids(DrugExposureGlotBuilder(), de) - vo_rows = _get_person_ids(VisitOccurrenceGlotBuilder(), vo) - - assert co_rows == {1, 2, 3}, f"CO should find persons 1,2,3, got {co_rows}" - assert de_rows == {1, 2, 3}, f"DE should find persons 1,2,3, got {de_rows}" - assert vo_rows == {1, 2}, ( - f"VO should find persons 1,2 (patient 3 has concept 31 not 30), got {vo_rows}" - ) - - def test_rejects_non_matching(self, db: DuckDBTestHelper): - """Person 4 should NOT be returned by any criteria since concept 99 doesn't match codesets 1,2,3.""" - co = ConditionOccurrence(codeset_id=1) - de = DrugExposure(codeset_id=2) - vo = VisitOccurrence(codeset_id=3) - - for builder, label, criteria in [ - (ConditionOccurrenceGlotBuilder(), "CO", co), - (DrugExposureGlotBuilder(), "DE", de), - (VisitOccurrenceGlotBuilder(), "VO", vo), - ]: - sql = _glot_to_duckdb(builder.build_select(criteria).sql(dialect="duckdb")) - rows = {r[0] for r in db.execute_raw(f"SELECT * FROM ({sql}) C").fetchall()} - assert 4 not in rows, f"{label} should not return person 4" - - def test_all_paths_produce_same_row_count(self, db: DuckDBTestHelper): - """String-template and sqlglot paths produce same number of rows for each criteria.""" - scenarios = [ - ( - ConditionOccurrence(codeset_id=1), - ConditionOccurrenceSqlBuilder(), - ConditionOccurrenceGlotBuilder(), - ), - (DrugExposure(codeset_id=2), DrugExposureSqlBuilder(), DrugExposureGlotBuilder()), - (VisitOccurrence(codeset_id=3), VisitOccurrenceSqlBuilder(), VisitOccurrenceGlotBuilder()), - ] - - for criteria, str_builder, glot_builder in scenarios: - tsql = str_builder.get_criteria_sql(criteria) - rows_a = _normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C")) - - rows_b = _run_glot(db, glot_builder.build_select(criteria)) - - assert len(rows_a) == len(rows_b), ( - f"{type(criteria).__name__}: row count mismatch: string={len(rows_a)}, glot={len(rows_b)}" - ) - - -class TestComprehensiveConditionOccurrence: - """Exercise all optional CO fields including source concept, condition status/type CS, provider, visit type.""" - - def test_with_source_concept(self, db: DuckDBTestHelper): - db.con.execute("DELETE FROM Codesets") - for codeset_id, concept_id in [(1, 10), (12, 11)]: - db.con.execute( - f"INSERT INTO Codesets (codeset_id, concept_id) VALUES ({codeset_id}, {concept_id})" - ) - - co = ConditionOccurrence( - codeset_id=1, - condition_source_concept=12, - ) - tsql = ConditionOccurrenceSqlBuilder().get_criteria_sql(co) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, ConditionOccurrenceGlotBuilder().build_select(co))) - assert rows_a == rows_b, "CO with source concept: rows differ" - - def test_with_provider_and_visit(self, db: DuckDBTestHelper): - db.con.execute("INSERT INTO provider VALUES (1, 100)") - db.con.execute( - "INSERT INTO visit_occurrence VALUES (5, 5, 30, '2020-05-01', '2020-05-05', 500, 1, NULL, NULL)" - ) - db.con.execute( - "INSERT INTO condition_occurrence VALUES (5, 106, 10, '2020-05-02', '2020-05-04', 100, NULL, 0, 5, 1, NULL)" - ) - db.con.execute("INSERT INTO observation_period VALUES (5, '2019-01-01', '2021-12-31')") - - from circe.cohortdefinition import ConceptSetSelection - - co = ConditionOccurrence( - codeset_id=1, - provider_specialty_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False), - visit_type_cs=ConceptSetSelection(codeset_id=1, is_exclusion=False), - ) - tsql = ConditionOccurrenceSqlBuilder().get_criteria_sql(co) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, ConditionOccurrenceGlotBuilder().build_select(co))) - assert rows_a == rows_b, "CO with provider and visit CS: rows differ" - - def test_with_condition_status(self, db: DuckDBTestHelper): - from circe.cohortdefinition import ConceptSetSelection - - db.con.execute("UPDATE condition_occurrence SET condition_status_concept_id=555 WHERE person_id=1") - db.con.execute("DELETE FROM Codesets") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (5, 555)") - - co = ConditionOccurrence( - codeset_id=1, - condition_status_cs=ConceptSetSelection(codeset_id=5, is_exclusion=False), - ) - tsql = ConditionOccurrenceSqlBuilder().get_criteria_sql(co) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, ConditionOccurrenceGlotBuilder().build_select(co))) - assert rows_a == rows_b, "CO with condition status CS: rows differ" - - def test_with_condition_type_cs(self, db: DuckDBTestHelper): - from circe.cohortdefinition import ConceptSetSelection - - db.con.execute("DELETE FROM Codesets") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (1, 10)") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (5, 200)") - - co = ConditionOccurrence( - codeset_id=1, - condition_type_cs=ConceptSetSelection(codeset_id=5, is_exclusion=False), - ) - tsql = ConditionOccurrenceSqlBuilder().get_criteria_sql(co) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, ConditionOccurrenceGlotBuilder().build_select(co))) - assert rows_a == rows_b, "CO with condition type CS: rows differ" - - -class TestComprehensiveDrugExposure: - """Exercise all optional DE fields: route, dose, lot, refills, quantity, days_supply, stop_reason, date adjustment.""" - - def test_with_route_dose_lot(self, db: DuckDBTestHelper): - from circe.cohortdefinition import ConceptSetSelection - - db.con.execute("DELETE FROM Codesets") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (2, 20)") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (7, 777)") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (8, 888)") - db.con.execute( - "INSERT INTO drug_exposure VALUES (5, 206, 20, '2020-05-01', '2020-05-10', 50, NULL, 1, 10, 10, 777, 888, NULL, NULL, NULL, NULL)" - ) - db.con.execute("INSERT INTO observation_period VALUES (5, '2019-01-01', '2021-12-31')") - - de = DrugExposure( - codeset_id=2, - route_concept_cs=ConceptSetSelection(codeset_id=7), - dose_unit_cs=ConceptSetSelection(codeset_id=8), - ) - tsql = DrugExposureSqlBuilder().get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) - assert rows_a == rows_b, "DE with route/dose CS: rows differ" - - def test_with_occurrence_dates_and_route_cs(self, db: DuckDBTestHelper): - from circe.cohortdefinition import ConceptSetSelection, DateRange - - db.con.execute("DELETE FROM Codesets") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (2, 20)") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (7, 777)") - - de = DrugExposure( - codeset_id=2, - occurrence_start_date=DateRange(op="gte", value="2020-02-01"), - occurrence_end_date=DateRange(op="lte", value="2020-06-01"), - route_concept_cs=ConceptSetSelection(codeset_id=7), - ) - tsql = DrugExposureSqlBuilder().get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) - assert rows_a == rows_b, "DE with occurrence dates + route CS: rows differ" - - def test_with_refills_quantity_days_supply(self, db: DuckDBTestHelper): - de = DrugExposure( - codeset_id=2, - refills=NumericRange(op="gte", value=1), - quantity=NumericRange(op="gte", value=5), - days_supply=NumericRange(op="lte", value=15), - ) - tsql = DrugExposureSqlBuilder().get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) - assert rows_a == rows_b, "DE with refills/quantity/days_supply: rows differ" - - def test_with_date_adjustment_alt_start_end(self, db: DuckDBTestHelper): - de = DrugExposure( - codeset_id=2, - date_adjustment=DateAdjustment( - start_offset=2, - end_offset=3, - start_with="end_date", - end_with="start_date", - ), - ) - tsql = DrugExposureSqlBuilder().get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) - assert rows_a == rows_b, "DE with alt date adjustment: rows differ" - - def test_with_all_optional_fields(self, db: DuckDBTestHelper): - from circe.cohortdefinition import ConceptSetSelection, DateRange - from circe.vocabulary.concept import Concept - - db.con.execute("INSERT INTO provider VALUES (3, 300)") - db.con.execute("INSERT INTO observation_period VALUES (7, '2019-01-01', '2021-12-31')") - db.con.execute("INSERT INTO person VALUES (7, 1985, 8507, 0, 0)") - db.con.execute( - "INSERT INTO visit_occurrence VALUES (7, 7, 30, '2020-08-01', '2020-08-10', 500, 3, NULL, NULL)" - ) - db.con.execute( - "INSERT INTO drug_exposure VALUES (7, 209, 20, '2020-08-02', '2020-08-08', 50, 'stopped', 2, 10, 6, 777, 888, 3, 7, NULL, 'LOT001')" - ) - db.con.execute("DELETE FROM Codesets") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (2, 20)") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (10, 999)") - - de = DrugExposure( - codeset_id=2, - drug_source_concept=10, - occurrence_start_date=DateRange(op="gte", value="2020-08-01"), - occurrence_end_date=DateRange(op="lte", value="2020-09-01"), - drug_type=[Concept(concept_id=50)], - drug_type_cs=ConceptSetSelection(codeset_id=2), - route_concept=[Concept(concept_id=777)], - dose_unit=[Concept(concept_id=888)], - provider_specialty=[Concept(concept_id=300)], - visit_type_cs=ConceptSetSelection(codeset_id=2), - refills=NumericRange(op="gte", value=1), - quantity=NumericRange(op="gte", value=5), - days_supply=NumericRange(op="gte", value=5), - age=NumericRange(op="gte", value=18), - ) - tsql = DrugExposureSqlBuilder().get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) - assert rows_a == rows_b, "DE with all optional fields: rows differ" - - def test_with_source_concept_and_drug_type_cs(self, db: DuckDBTestHelper): - from circe.cohortdefinition import ConceptSetSelection - - db.con.execute("DELETE FROM Codesets") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (2, 20)") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (10, 99)") - - de = DrugExposure( - codeset_id=2, - drug_source_concept=10, - drug_type_cs=ConceptSetSelection(codeset_id=2), - ) - tsql = DrugExposureSqlBuilder().get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) - assert rows_a == rows_b, "DE with source concept + type CS: rows differ" - - def test_with_lot_number_text(self, db: DuckDBTestHelper): - from circe.cohortdefinition import TextFilter - - db.con.execute( - "INSERT INTO drug_exposure VALUES (5, 207, 20, '2020-06-01', '2020-06-10', 50, NULL, 0, 5, 10, NULL, NULL, NULL, NULL, NULL, NULL)" - ) - - de = DrugExposure( - codeset_id=2, - lot_number=TextFilter(text="ABC", op="contains"), - stop_reason=TextFilter(text="stopped", op="eq"), - ) - tsql = DrugExposureSqlBuilder().get_criteria_sql(de) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, DrugExposureGlotBuilder().build_select(de))) - assert rows_a == rows_b, "DE with text filters: rows differ" - - -class TestComprehensiveVisitOccurrence: - """Exercise all optional VO fields: visit_type, place_of_service, provider, source concept, date adjustment, visit_length.""" - - def test_with_visit_type_place_of_service(self, db: DuckDBTestHelper): - from circe.cohortdefinition import ConceptSetSelection - - db.con.execute("INSERT INTO care_site VALUES (1, 1000)") - db.con.execute( - "INSERT INTO visit_occurrence VALUES (5, 5, 30, '2020-05-01', '2020-05-10', 500, NULL, 1, NULL)" - ) - db.con.execute("INSERT INTO observation_period VALUES (5, '2019-01-01', '2021-12-31')") - db.con.execute("DELETE FROM Codesets") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (3, 30)") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (6, 500)") - - vo = VisitOccurrence( - codeset_id=3, - visit_type_cs=ConceptSetSelection(codeset_id=6), - place_of_service_cs=ConceptSetSelection(codeset_id=6), - ) - tsql = VisitOccurrenceSqlBuilder().get_criteria_sql(vo) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, VisitOccurrenceGlotBuilder().build_select(vo))) - assert rows_a == rows_b, "VO with visit_type/place_of_service CS: rows differ" - - def test_with_visit_source_concept(self, db: DuckDBTestHelper): - db.con.execute("DELETE FROM Codesets") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (3, 30)") - db.con.execute("INSERT INTO Codesets (codeset_id, concept_id) VALUES (9, 999)") - db.con.execute( - "INSERT INTO visit_occurrence VALUES (5, 5, 30, '2020-05-01', '2020-05-10', 500, NULL, NULL, NULL)" - ) - - vo = VisitOccurrence( - codeset_id=3, - visit_source_concept=9, - ) - tsql = VisitOccurrenceSqlBuilder().get_criteria_sql(vo) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, VisitOccurrenceGlotBuilder().build_select(vo))) - assert rows_a == rows_b, "VO with visit_source_concept: rows differ" - - def test_with_visit_length_and_date_adjustment(self, db: DuckDBTestHelper): - from circe.cohortdefinition import NumericRange - - vo = VisitOccurrence( - codeset_id=3, - visit_length=NumericRange(op="gt", value=5), - date_adjustment=DateAdjustment( - start_offset=1, end_offset=2, start_with="END_DATE", end_with="START_DATE" - ), - ) - tsql = VisitOccurrenceSqlBuilder().get_criteria_sql(vo) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, VisitOccurrenceGlotBuilder().build_select(vo))) - assert rows_a == rows_b, "VO with visit_length + date_adjustment: rows differ" - - def test_with_provider_specialty_and_gender(self, db: DuckDBTestHelper): - from circe.vocabulary.concept import Concept - - db.con.execute("INSERT INTO provider VALUES (2, 200)") - db.con.execute( - "INSERT INTO visit_occurrence VALUES (6, 6, 30, '2020-07-01', '2020-07-15', 500, 2, NULL, NULL)" - ) - db.con.execute( - "INSERT INTO condition_occurrence VALUES (6, 107, 10, '2020-07-02', '2020-07-05', 100, NULL, 0, 6, 2, NULL)" - ) - db.con.execute("INSERT INTO observation_period VALUES (6, '2019-01-01', '2021-12-31')") - db.con.execute( - "INSERT INTO drug_exposure VALUES (6, 208, 20, '2020-07-03', '2020-07-10', 50, NULL, 0, 5, 7, NULL, NULL, 2, 6, NULL, NULL)" - ) - - co = ConditionOccurrence( - codeset_id=1, - provider_specialty=[Concept(concept_id=200)], - gender=[Concept(concept_id=8532)], - ) - tsql = ConditionOccurrenceSqlBuilder().get_criteria_sql(co) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, ConditionOccurrenceGlotBuilder().build_select(co))) - assert rows_a == rows_b, "CO with provider specialty + gender: rows differ" - - def test_with_place_of_service_and_gender(self, db: DuckDBTestHelper): - from circe.vocabulary.concept import Concept - - db.con.execute("INSERT INTO care_site VALUES (1, 1000)") - db.con.execute( - "INSERT INTO visit_occurrence VALUES (6, 6, 30, '2020-07-01', '2020-07-15', 500, NULL, 1, NULL)" - ) - db.con.execute("INSERT INTO observation_period VALUES (6, '2019-01-01', '2021-12-31')") - - vo = VisitOccurrence( - codeset_id=3, - place_of_service=[Concept(concept_id=1000)], - gender=[Concept(concept_id=8532)], - ) - tsql = VisitOccurrenceSqlBuilder().get_criteria_sql(vo) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, VisitOccurrenceGlotBuilder().build_select(vo))) - assert rows_a == rows_b, "VO with place_of_service + gender: rows differ" - - def test_with_occurrence_dates_and_gender(self, db: DuckDBTestHelper): - from circe.cohortdefinition import DateRange - - vo = VisitOccurrence( - codeset_id=3, - occurrence_start_date=DateRange(op="gte", value="2020-02-01"), - occurrence_end_date=DateRange(op="lte", value="2020-06-01"), - ) - tsql = VisitOccurrenceSqlBuilder().get_criteria_sql(vo) - rows_a = _result_set(_normalize_dates(db.query(f"SELECT * FROM ({_sql_param_replace(tsql)}) C"))) - rows_b = _result_set(_run_glot(db, VisitOccurrenceGlotBuilder().build_select(vo))) - assert rows_a == rows_b, "VO with occurrence dates: rows differ" diff --git a/tests/test_sqlglot_primitives.py b/tests/test_sqlglot_primitives.py deleted file mode 100644 index 77e78165..00000000 --- a/tests/test_sqlglot_primitives.py +++ /dev/null @@ -1,469 +0,0 @@ -"""Unit tests for sqlglot builder primitives and codeset builder.""" - -from sqlglot import exp as sge - -from circe.cohortdefinition import ( - ConditionOccurrence, - DrugExposure, - NumericRange, - TextFilter, - VisitOccurrence, -) -from circe.cohortdefinition.core import DateRange -from circe.cohortdefinition.sqlglot_builders import ( - ConditionOccurrenceGlotBuilder, - DrugExposureGlotBuilder, - VisitOccurrenceGlotBuilder, -) -from circe.cohortdefinition.sqlglot_builders.primitives import ( - alias_expr, - build_date_range_clause, - build_in_clause, - build_numeric_range_clause, - build_text_filter_clause, - coalesce, - codeset_in, - codeset_join, - column_ref, - date_add, - date_from_parts, - datediff, - row_number_expr, - year_of, -) - - -class TestPrimitives: - """Direct unit tests on primitive functions.""" - - def test_column_ref(self): - c = column_ref("co", "person_id") - assert c.sql(dialect="duckdb") == "co.person_id" - assert c.sql(dialect="tsql") == "co.person_id" - - def test_alias_expr(self): - a = alias_expr(column_ref("co", "person_id"), "person_id") - assert "person_id AS person_id" in a.sql(dialect="duckdb") - - def test_date_add(self): - da = date_add("day", 3, column_ref("x", "start")) - duck = da.sql(dialect="duckdb") - tsql = da.sql(dialect="tsql") - assert "+ INTERVAL 3 DAY" in duck or "INTERVAL '3 DAY'" in duck - assert "DATEADD" in tsql - - def test_coalesce_non_empty(self): - c = coalesce(column_ref("x", "a"), column_ref("x", "b")) - sql = c.sql(dialect="duckdb") - assert "COALESCE" in sql - assert "x.a" in sql - assert "x.b" in sql - - def test_coalesce_empty(self): - # line 25: empty coalesce returns None - assert coalesce() is None - - def test_year_of(self): - y = year_of(column_ref("co", "start_date")) - assert "YEAR" in y.sql(dialect="duckdb") - - def test_date_diff(self): - dd = datediff("day", column_ref("t", "start"), column_ref("t", "end")) - sql = dd.sql(dialect="duckdb") - assert "DATE_DIFF" in sql or "DATEDIFF" in sql.upper() - - def test_date_from_parts(self): - dfp = date_from_parts(2020, 1, 15) - duck = dfp.sql(dialect="duckdb") - tsql = dfp.sql(dialect="tsql") - assert "MAKE_DATE" in duck.upper() or "DATEFROMPARTS" not in duck - assert "DATEFROMPARTS" in tsql - - def test_row_number_expr(self): - rn = row_number_expr( - [column_ref("co", "person_id")], - [column_ref("co", "start_date"), column_ref("co", "id")], - ) - sql = rn.sql(dialect="duckdb") - assert "ROW_NUMBER" in sql - assert "PARTITION BY co.person_id" in sql or 'PARTITION BY "person_id"' in sql - assert "ORDER BY" in sql - - -class TestPrimitivesDateRange: - """Test all branches of build_date_range_clause.""" - - def _expr(self, col: str = "C.start_date"): - return column_ref("C", "start_date") - - def test_gte(self): - r = DateRange(op="gte", value="2020-01-01") - e = build_date_range_clause(self._expr(), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert ">= MAKE_DATE" in sql or ">= DATE" in sql - - def test_lte(self): - r = DateRange(op="lte", value="2020-12-31") - e = build_date_range_clause(self._expr(), r) - assert e is not None - - def test_gt(self): - r = DateRange(op="gt", value="2020-06-01") - e = build_date_range_clause(self._expr(), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert ">" in sql - - def test_lt(self): - r = DateRange(op="lt", value="2020-06-01") - e = build_date_range_clause(self._expr(), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "<" in sql and ">" not in sql - - def test_eq(self): - r = DateRange(op="eq", value="2020-06-15") - e = build_date_range_clause(self._expr(), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "=" in sql - - def test_neq(self): - r = DateRange(op="ne", value="2020-06-15") - e = build_date_range_clause(self._expr(), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "<>" in sql or "NOT" in sql - - def test_bt(self): - r = DateRange(op="bt", value="2020-01-01", extent="2020-12-31") - e = build_date_range_clause(self._expr(), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "AND" in sql - - def test_not_bt(self): - r = DateRange(op="!bt", value="2020-01-01", extent="2020-12-31") - e = build_date_range_clause(self._expr(), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "NOT" in sql or "<>" in sql - - def test_none_op(self): - assert build_date_range_clause(self._expr(), None) is None - - def test_none_value_bt(self): - r = DateRange(op="bt", value=None, extent="2020-12-31") - assert build_date_range_clause(self._expr(), r) is None - - def test_none_value_single(self): - r = DateRange(op="eq", value=None) - assert build_date_range_clause(self._expr(), r) is None - - -class TestPrimitivesNumericRange: - """Test all branches of build_numeric_range_clause.""" - - def test_gte(self): - r = NumericRange(op="gte", value=18) - e = build_numeric_range_clause(column_ref("C", "age"), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert ">= 18" in sql - - def test_bt(self): - r = NumericRange(op="bt", value=10, extent=20) - e = build_numeric_range_clause(column_ref("C", "age"), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "10" in sql and "20" in sql and "AND" in sql - - def test_not_bt(self): - r = NumericRange(op="!bt", value=5, extent=15) - e = build_numeric_range_clause(column_ref("C", "x"), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "NOT" in sql or "<" in sql - - def test_eq(self): - r = NumericRange(op="eq", value=42) - e = build_numeric_range_clause(column_ref("C", "x"), r) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "= 42" in sql or "42 =" not in sql - - def test_neq(self): - r = NumericRange(op="ne", value=99) - e = build_numeric_range_clause(column_ref("C", "x"), r) - assert e is not None - - def test_lt(self): - r = NumericRange(op="lt", value=50) - e = build_numeric_range_clause(column_ref("C", "x"), r) - assert e is not None - assert "< 50" in e.sql(dialect="duckdb") - - def test_lte(self): - r = NumericRange(op="lte", value=100) - e = build_numeric_range_clause(column_ref("C", "x"), r) - assert e is not None - - def test_gt(self): - r = NumericRange(op="gt", value=0) - e = build_numeric_range_clause(column_ref("C", "x"), r) - assert e is not None - - def test_none_op(self): - assert build_numeric_range_clause(column_ref("C", "x"), None) is None - - def test_none_value(self): - r = NumericRange(op="gt", value=None) - assert build_numeric_range_clause(column_ref("C", "x"), r) is None - - def test_none_value_bt(self): - r = NumericRange(op="bt", value=None, extent=5) - assert build_numeric_range_clause(column_ref("C", "x"), r) is None - - def test_none_extent_bt(self): - r = NumericRange(op="bt", value=1, extent=None) - assert build_numeric_range_clause(column_ref("C", "x"), r) is None - - -class TestPrimitivesTextFilter: - """Test all branches of build_text_filter_clause.""" - - def test_string_input(self): - e = build_text_filter_clause(column_ref("C", "reason"), "stopped") - assert e is not None - sql = e.sql(dialect="duckdb") - assert "LIKE" in sql.upper() - assert "%stopped%" in sql - - def test_eq(self): - e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="exact", op="eq")) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "= 'exact'" in sql or "'exact'" in sql - - def test_neq(self): - e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="bad", op="!eq")) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "<>" in sql or "NOT" in sql - - def test_starts_with(self): - e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="pre", op="startsWith")) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "pre%" in sql - - def test_ends_with(self): - e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="fix", op="endsWith")) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "%fix" in sql - - def test_not_contains(self): - e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="bad", op="!contains")) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "NOT" in sql - - def test_contains_default(self): - e = build_text_filter_clause(column_ref("C", "reason"), TextFilter(text="hidden", op="unknown_op")) - assert e is not None - sql = e.sql(dialect="duckdb") - assert "%hidden%" in sql - - def test_none(self): - assert build_text_filter_clause(column_ref("C", "x"), None) is None - - def test_empty_text(self): - e = build_text_filter_clause(column_ref("C", "x"), TextFilter(text="", op="eq")) - assert e is not None - - -class TestPrimitivesInClause: - """Test build_in_clause.""" - - def test_basic(self): - e = build_in_clause(column_ref("C", "id"), [1, 2, 3]) - sql = e.sql(dialect="duckdb") - assert "IN" in sql - assert "1" in sql and "2" in sql and "3" in sql - - def test_exclude(self): - e = build_in_clause(column_ref("C", "id"), [5, 6], exclude=True) - sql = e.sql(dialect="duckdb") - assert "NOT" in sql - - def test_single(self): - e = build_in_clause(column_ref("C", "id"), [99]) - sql = e.sql(dialect="duckdb") - assert "99" in sql - - def test_duplicates(self): - e = build_in_clause(column_ref("C", "id"), [1, 1, 2, 2, 3]) - sql = e.sql(dialect="duckdb") - assert "1, 2, 3" in sql or "1" in sql - - -class TestPrimitivesCodesetJoin: - """Test codeset_join and codeset_in.""" - - def test_codeset_join_basic(self): - cj = codeset_join("#Codesets", column_ref("co", "condition_concept_id"), 1) - sql = cj.sql(dialect="duckdb") - assert "Codesets" in sql - assert "condition_concept_id" in sql - - def test_codeset_in(self): - ci = codeset_in(column_ref("C", "type_id"), 5) - sql = ci.sql(dialect="duckdb") - assert "SELECT" in sql - assert "codeset_id" in sql - - def test_codeset_in_exclude(self): - ci = codeset_in(column_ref("C", "type_id"), 5, exclude=True) - sql = ci.sql(dialect="duckdb") - assert "NOT" in sql - - -class TestRowNumberAcrossBuilders: - """Validate ROW_NUMBER output across all three builders for first=True.""" - - def _check_ordinal_col(self, select: sge.Select): - """Ensure the outer SELECT produces a query with ordinal expression.""" - sql = select.sql(dialect="duckdb") - assert "ROW_NUMBER" in sql, f"Missing ROW_NUMBER: {sql}" - - def test_co_ordinal(self): - co = ConditionOccurrence(codeset_id=1, first=True) - s = ConditionOccurrenceGlotBuilder().build_select(co) - self._check_ordinal_col(s) - - def test_de_ordinal(self): - de = DrugExposure(codeset_id=1, first=True) - s = DrugExposureGlotBuilder().build_select(de) - self._check_ordinal_col(s) - - def test_vo_ordinal(self): - vo = VisitOccurrence(codeset_id=1, first=True) - s = VisitOccurrenceGlotBuilder().build_select(vo) - self._check_ordinal_col(s) - - -class TestCodesetsBuilder: - """Test concept set resolution queries.""" - - def test_empty_concept_sets(self): - from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query - - assert build_codeset_query([]) is None - - def test_simple_concept_set(self): - from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query - from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem - - cs = ConceptSet( - id=1, - name="test", - expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(concept_id=123))]), - ) - query = build_codeset_query([cs]) - assert query is not None - sql = query.sql(dialect="duckdb") - assert "123" in sql - assert "concept" in sql.lower() - - def test_concept_set_with_descendants(self): - from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query - from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem - - cs = ConceptSet( - id=2, - name="test", - expression=ConceptSetExpression( - items=[ConceptSetItem(concept=Concept(concept_id=456), include_descendants=True)] - ), - ) - query = build_codeset_query([cs]) - assert query is not None - sql = query.sql(dialect="duckdb") - assert "descendant" in sql.lower() or "ancestor" in sql.lower() - - def test_concept_set_with_mapped(self): - from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query - from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem - - cs = ConceptSet( - id=3, - name="test", - expression=ConceptSetExpression( - items=[ConceptSetItem(concept=Concept(concept_id=789), include_mapped=True)] - ), - ) - query = build_codeset_query([cs]) - assert query is not None - sql = query.sql(dialect="duckdb") - assert "relationship" in sql.lower() or "mapped" in sql.lower() - - def test_multiple_concept_sets(self): - from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query - from circe.vocabulary.concept import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem - - cs1 = ConceptSet( - id=1, expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(concept_id=1))]) - ) - cs2 = ConceptSet( - id=2, expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(concept_id=2))]) - ) - query = build_codeset_query([cs1, cs2]) - assert query is not None - sql = query.sql(dialect="duckdb") - assert "UNION" in sql.upper() - - def test_concept_set_with_no_items(self): - from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query - from circe.vocabulary.concept import ConceptSet, ConceptSetExpression - - cs = ConceptSet(id=1, name="empty", expression=ConceptSetExpression(items=[])) - assert build_codeset_query([cs]) is None - - def test_concept_set_without_expression(self): - from circe.cohortdefinition.sqlglot_builders.codesets import build_codeset_query - from circe.vocabulary.concept import ConceptSet - - cs = ConceptSet(id=1, name="noexpr") - assert build_codeset_query([cs]) is None - - -class TestCrossDialect: - """Compile each builder to multiple dialects; verify sqlglot can read back.""" - - @staticmethod - def _check_dialect(sel: sge.Select, dialect: str): - sql = sel.sql(dialect=dialect) - assert len(sql) > 0 - clean = sql.replace("#Codesets", "Codesets") - parsed = sge.maybe_parse(clean, dialect=dialect) - assert parsed is not None, f"Cannot parse {dialect} output back" - - def test_co_multiple_dialects(self): - co = ConditionOccurrence(codeset_id=1, age=NumericRange(op="gte", value=18)) - sel = ConditionOccurrenceGlotBuilder().build_select(co) - for d in ("duckdb", "postgres", "tsql", "bigquery"): - self._check_dialect(sel, d) - - def test_de_multiple_dialects(self): - de = DrugExposure(codeset_id=1) - sel = DrugExposureGlotBuilder().build_select(de) - for d in ("duckdb", "postgres", "tsql", "mysql"): - self._check_dialect(sel, d) - - def test_vo_multiple_dialects(self): - vo = VisitOccurrence(codeset_id=1, first=True) - sel = VisitOccurrenceGlotBuilder().build_select(vo) - for d in ("duckdb", "tsql", "bigquery"): - self._check_dialect(sel, d) diff --git a/tests/test_sqlrender_csv_format.py b/tests/test_sqlrender_csv_format.py index 77b9a431..d9d62403 100644 --- a/tests/test_sqlrender_csv_format.py +++ b/tests/test_sqlrender_csv_format.py @@ -7,6 +7,7 @@ class TestCsvFormat: + def test_csv_has_valid_format(self): from importlib.resources import files @@ -23,20 +24,23 @@ def test_csv_has_valid_format(self): assert columns[1] == "Pattern" assert columns[2] == "Replacement" continue - assert len(columns) >= 3, f"Row {i} has {len(columns)} columns (expected at least 3): {columns}" + assert len(columns) >= 3, ( + f"Row {i} has {len(columns)} columns (expected at least 3): {columns}" + ) def test_all_patterns_can_be_parsed(self): - from circe.sqlrender.patterns import load_patterns from circe.sqlrender.translator import parse_search_pattern + from circe.sqlrender.patterns import load_patterns patterns = load_patterns() for dialect, pairs in patterns.items(): - for pattern, _replacement in pairs: + for pattern, replacement in pairs: try: parse_search_pattern(pattern) except Exception as e: pytest.fail( - f"Failed to parse pattern for dialect '{dialect}': pattern={pattern!r}, error={e}" + f"Failed to parse pattern for dialect '{dialect}': " + f"pattern={pattern!r}, error={e}" ) def test_duckdb_and_postgresql_can_translate_simple_sql(self): diff --git a/tests/test_sqlrender_split.py b/tests/test_sqlrender_split.py index c5d4a3a4..c5c233a1 100644 --- a/tests/test_sqlrender_split.py +++ b/tests/test_sqlrender_split.py @@ -1,9 +1,12 @@ """Test SQL splitting - ported from OHDSI SqlRender test-splitSql.R""" +import pytest + from circe.sqlrender import split_sql class TestSplitSql: + def test_split_simple_statements(self): parts = split_sql("SELECT * INTO a FROM b; USE x; DROP TABLE c;") assert parts == ["SELECT * INTO a FROM b", "USE x", "DROP TABLE c"] @@ -13,14 +16,18 @@ def test_split_with_begin_end(self): assert parts == ["BEGIN\nSELECT * INTO a FROM b;\nEND;", "USE x"] def test_split_with_case_end(self): - parts = split_sql("SELECT CASE WHEN x=1 THEN 0 ELSE 1 END AS x INTO a FROM b;\nUSE x;") + parts = split_sql( + "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END AS x INTO a FROM b;\nUSE x;" + ) assert parts == [ "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END AS x INTO a FROM b", "USE x", ] def test_split_with_end_in_quoted_text(self): - parts = split_sql("insert into a (x) values ('end');\n insert into a (x) values ('begin');") + parts = split_sql( + "insert into a (x) values ('end');\n insert into a (x) values ('begin');" + ) assert parts == [ "insert into a (x) values ('end')", "insert into a (x) values ('begin')", @@ -45,8 +52,12 @@ def test_split_with_comment_last_line_no_eol(self): assert parts == ["SELECT * FROM table"] def test_split_with_hint_at_start(self): - parts = split_sql("--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist") - assert parts == ["--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist"] + parts = split_sql( + "--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist" + ) + assert parts == [ + "--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist" + ] def test_split_with_hint_in_second_statement(self): parts = split_sql( diff --git a/tests/test_utils_db.py b/tests/test_utils_db.py index 79c5398c..5b83e659 100644 --- a/tests/test_utils_db.py +++ b/tests/test_utils_db.py @@ -89,10 +89,6 @@ def execute_query(self, sql: str): return self.con.execute(translated) - def execute_raw(self, sql: str): - """Execute DuckDB SQL directly, no T-SQL transpilation.""" - return self.con.execute(sql) - def query(self, sql: str) -> list[Any]: """Execute and return results.""" return self.execute_query(sql).fetchall() From 7aac88ca3d7ba37d2f321cf004b3f6f1e19ab696 Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Thu, 28 May 2026 13:23:34 -0700 Subject: [PATCH 60/62] Revert "Added sqlrender functionality" This reverts commit 72799aa2b5f36f004f739915ed153840ab0b9fed. --- circe/sqlrender/__init__.py | 11 - circe/sqlrender/patterns.py | 102 -- circe/sqlrender/renderer.py | 220 --- circe/sqlrender/replacementPatterns.csv | 1447 ------------------ circe/sqlrender/splitter.py | 44 - circe/sqlrender/tokenizer.py | 94 -- circe/sqlrender/translator.py | 371 ----- tests/test_sqlrender_csv_format.py | 51 - tests/test_sqlrender_render.py | 166 -- tests/test_sqlrender_split.py | 69 - tests/test_sqlrender_translate.py | 37 - tests/test_sqlrender_translate_duckdb.py | 228 --- tests/test_sqlrender_translate_postgresql.py | 274 ---- 13 files changed, 3114 deletions(-) delete mode 100644 circe/sqlrender/__init__.py delete mode 100644 circe/sqlrender/patterns.py delete mode 100644 circe/sqlrender/renderer.py delete mode 100644 circe/sqlrender/replacementPatterns.csv delete mode 100644 circe/sqlrender/splitter.py delete mode 100644 circe/sqlrender/tokenizer.py delete mode 100644 circe/sqlrender/translator.py delete mode 100644 tests/test_sqlrender_csv_format.py delete mode 100644 tests/test_sqlrender_render.py delete mode 100644 tests/test_sqlrender_split.py delete mode 100644 tests/test_sqlrender_translate.py delete mode 100644 tests/test_sqlrender_translate_duckdb.py delete mode 100644 tests/test_sqlrender_translate_postgresql.py diff --git a/circe/sqlrender/__init__.py b/circe/sqlrender/__init__.py deleted file mode 100644 index 7e5fdfc7..00000000 --- a/circe/sqlrender/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .renderer import render -from .splitter import split_sql -from .translator import generate_session_id, set_replacement_patterns_path, translate - -__all__ = [ - "render", - "split_sql", - "translate", - "generate_session_id", - "set_replacement_patterns_path", -] diff --git a/circe/sqlrender/patterns.py b/circe/sqlrender/patterns.py deleted file mode 100644 index e473cc66..00000000 --- a/circe/sqlrender/patterns.py +++ /dev/null @@ -1,102 +0,0 @@ -import random -import string - -_target_to_patterns: dict[str, list[tuple[str, str]]] | None = None -_global_session_id: str | None = None -_PATTERNS_PATH: str | None = None - -SESSION_ID_LENGTH = 8 -MAX_TABLE_NAME_LENGTH = 63 - - -def generate_session_id() -> str: - chars = string.ascii_lowercase + "0123456789" - first = random.choice(string.ascii_lowercase) - rest = "".join(random.choice(chars) for _ in range(SESSION_ID_LENGTH - 1)) - return first + rest - - -def get_global_session_id() -> str: - global _global_session_id - if _global_session_id is None: - _global_session_id = generate_session_id() - return _global_session_id - - -def set_replacement_patterns_path(path: str | None) -> None: - global _target_to_patterns, _PATTERNS_PATH - _target_to_patterns = None - _PATTERNS_PATH = path - - -def _safe_split(line: str, delimiter: str = ",") -> list[str]: - result: list[str] = [] - literal = False - escape = False - startpos = 0 - i = 0 - while i < len(line): - ch = line[i] - if ch == '"' and not escape: - literal = not literal - if not literal and ch == delimiter and not escape: - result.append(line[startpos:i]) - startpos = i + 1 - escape = not escape if ch == "\\" else False - i += 1 - result.append(line[startpos:i]) - return result - - -def _clean_column(col: str) -> str: - if col.startswith('"') and col.endswith('"') and len(col) > 1: - col = col[1:-1] - col = col.replace('\\"', '"') - col = col.replace("\\n", "\n") - return col - - -def load_patterns() -> dict[str, list[tuple[str, str]]]: - global _target_to_patterns - if _target_to_patterns is not None: - return _target_to_patterns - - _target_to_patterns = {} - - if _PATTERNS_PATH is not None: - import pathlib - - path = pathlib.Path(_PATTERNS_PATH) - f = path.open("r", encoding="utf-8") - else: - from importlib.resources import files - - f = files("circe.sqlrender").joinpath("replacementPatterns.csv").open("r", encoding="utf-8") - - try: - first = True - for line in f: - line = line.rstrip("\n").rstrip("\r") - if first: - first = False - continue - if not line: - continue - columns = _safe_split(line, ",") - if len(columns) < 3: - continue - target = _clean_column(columns[0]).strip() - pattern = _clean_column(columns[1]) - replacement = _clean_column(columns[2]) - pattern = pattern.replace("@", "@@") - replacement = replacement.replace("@", "@@") - _target_to_patterns.setdefault(target, []).append((pattern, replacement)) - finally: - f.close() - - return _target_to_patterns - - -def get_supported_dialects() -> list[str]: - patterns = load_patterns() - return sorted(patterns.keys()) diff --git a/circe/sqlrender/renderer.py b/circe/sqlrender/renderer.py deleted file mode 100644 index 7518c915..00000000 --- a/circe/sqlrender/renderer.py +++ /dev/null @@ -1,220 +0,0 @@ -import re -from typing import Any - - -class SqlRenderError(RuntimeError): - pass - - -def _evaluate_condition(condition: str, params: dict[str, Any]) -> bool: - condition = condition.strip() - - if condition.lower() == "true": - return True - if condition.lower() == "false": - return False - - if condition.startswith("!"): - return not _evaluate_condition(condition[1:].strip(), params) - - m = re.match(r"\((.+)\)", condition) - if m: - return _evaluate_condition(m.group(1).strip(), params) - - m = re.match(r"(.+?)\s+(!=|<>)+\s+(.+)", condition) - if m: - left = m.group(1).strip() - right = m.group(3).strip() - lval = _resolve_value(left, params) - rval = _resolve_value(right, params) - return str(lval) != str(rval) - - m = re.match(r"(.+?)\s*==\s*(.+)", condition) - if m: - left = m.group(1).strip() - right = m.group(2).strip() - lval = _resolve_value(left, params) - rval = _resolve_value(right, params) - return str(lval) == str(rval) - - m = re.match(r"([\d.]+|\w+)\s+IN\s+\((.+)\)", condition, re.IGNORECASE | re.DOTALL) - if m: - val = _resolve_value(m.group(1).strip(), params) - in_list_raw = m.group(2).strip() - in_list = [] - for item in re.split(r",\s*", in_list_raw): - item = item.strip() - is_param_ref = item.startswith("@") and item[1:] in params - resolved = _resolve_value(item, params) if is_param_ref else item - if isinstance(resolved, list): - in_list.extend(str(x) for x in resolved) - else: - in_list.append(str(resolved)) - return str(val) in in_list - - m = re.match(r"(.+?)\s*&\s*(.+)", condition) - if m: - return _evaluate_condition(m.group(1).strip(), params) and _evaluate_condition( - m.group(2).strip(), params - ) - - m = re.match(r"(.+?)\s*\|\s*(.+)", condition) - if m: - return _evaluate_condition(m.group(1).strip(), params) or _evaluate_condition( - m.group(2).strip(), params - ) - - if condition.startswith("@"): - param_name = condition[1:] - val = params.get(param_name) - return val is not None and ( - (isinstance(val, bool) and val) or (isinstance(val, str) and val.lower() == "true") - ) - - raise SqlRenderError(f"Invalid boolean logic: {condition}") - - -def _resolve_value(expr: str, params: dict[str, Any]) -> Any: - expr = expr.strip() - if expr.startswith("@") and len(expr) > 1: - param_name = expr[1:] - return params.get(param_name, expr) - if expr.startswith("'") and expr.endswith("'"): - return expr[1:-1] - return expr - - -def render(sql: str, **params: Any) -> str: - has_unused_params = any(re.search(r"@" + re.escape(k) + r"\b", sql) is None for k in params) - if has_unused_params: - import warnings as _warnings - - _warnings.warn("Parameter name mismatch in render call", stacklevel=2) - - sql = _apply_defaults(sql, params) - sql = _process_conditionals(sql, params) - sql = _substitute_params(sql, params) - return sql - - -DEFAULT_PATTERN = re.compile(r"\{DEFAULT\s+@(\w+)\s*=\s*([^}]+)\}") - - -def _apply_defaults(sql: str, params: dict[str, Any]) -> dict[str, Any]: - def extract_default(m: re.Match) -> str: - name = m.group(1) - value = m.group(2).strip() - if name not in params: - if value.startswith("'") and value.endswith("'"): - params[name] = value[1:-1] - else: - params[name] = value - return "" - - sql = DEFAULT_PATTERN.sub(extract_default, sql) - return sql - - -def _find_matching_brace(s: str, start: int) -> int: - depth = 0 - in_single = False - in_double = False - i = start - while i < len(s): - ch = s[i] - if ch == "'" and not in_double: - in_single = not in_single - elif ch == '"' and not in_single: - in_double = not in_double - if in_single or in_double: - i += 1 - continue - if ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - if depth == 0: - return i - i += 1 - return -1 - - -def _extract_braced_block(s: str, start: int) -> tuple[str, int]: - end = _find_matching_brace(s, start) - if end == -1: - return "", start - return s[start + 1 : end], end - - -def _process_conditionals(sql: str, params: dict[str, Any]) -> str: - result = sql - - for _pass in range(100): - i = 0 - modified = False - while i < len(result): - ch = result[i] - if ch == "{": - close = _find_matching_brace(result, i) - if close == -1: - i += 1 - continue - - inner = result[i + 1 : close] - rest_after_close = close + 1 - - if inner.startswith("DEFAULT "): - pass - - elif rest_after_close < len(result) and result[rest_after_close] == "?": - condition_text = inner - after_q = rest_after_close + 1 - - if after_q < len(result) and result[after_q] == "{": - then_block, then_end = _extract_braced_block(result, after_q) - then_text = then_block - after_then = then_end + 1 - - else_text = "" - if after_then < len(result) and result[after_then] == ":": - after_colon = after_then + 1 - if after_colon < len(result) and result[after_colon] == "{": - else_block, else_end = _extract_braced_block(result, after_colon) - else_text = else_block - after_else = else_end + 1 - else: - after_else = after_colon - else: - after_else = after_then - - cond_result = _evaluate_condition(condition_text, params) - - replacement = then_text if cond_result else else_text - result = result[:i] + replacement + result[after_else:] - modified = True - break - - else: - pass - - i += 1 - - if not modified: - break - - return result - - -def _substitute_params(sql: str, params: dict[str, Any]) -> str: - def repl(m: re.Match) -> str: - name = m.group(1) - if name in params: - val = params[name] - if isinstance(val, list): - return ", ".join(str(v) for v in val) - if isinstance(val, bool): - return str(val).lower() - return str(val) - return m.group(0) - - return re.sub(r"@(\w+)", repl, sql) diff --git a/circe/sqlrender/replacementPatterns.csv b/circe/sqlrender/replacementPatterns.csv deleted file mode 100644 index fc733bc9..00000000 --- a/circe/sqlrender/replacementPatterns.csv +++ /dev/null @@ -1,1447 +0,0 @@ -To,Pattern,Replacement -oracle,...@([0-9]+|y)a,xxx@a -oracle,"AS drvd(@a)","AS drvd(@a)" -oracle,"@a, @b)","@a, @b)" -oracle,"","NULL AS " -oracle,"FROM (VALUES @a) AS drvd","FROM (@a) AS drvd" -oracle,"@a, @b)","@a UNION ALL @b)" -oracle,"(@a)","SELECT @a" -oracle,"FROM (SELECT @a) AS drvd(@b)","FROM (SELECT @b WHERE (0 = 1) UNION ALL SELECT @a) AS values_table" -oracle,TRY_CAST(@a),CAST(@a) -oracle,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -oracle,"CAST('@a' AS DATE)","TO_DATE('@a', 'YYYYMMDD')" -oracle,"CAST('@a' + @b AS DATE)","TO_DATE('@a' + @b, 'YYYYMMDD')" -oracle,"CAST(@a + '@b' AS DATE)","TO_DATE(@a + '@b', 'YYYYMMDD')" -oracle,"CAST(CONCAT(@a) AS DATE)","TO_DATE(CONCAT(@a), 'YYYYMMDD')" -oracle,"INSERT INTO @table (@columns) VALUES (@values1),(@values2)", INSERT INTO @table (@columns) VALUES (INTO @table @columns VALUES @values1\n INTO @table @columns VALUES @values2\n) -oracle,INTO @table @columns VALUES INTO @table @columns VALUES,INTO @table @columns VALUES -oracle, @a INSERT INTO @table (@columns) VALUES (@b),INSERT ALL\n@bSELECT * FROM dual -oracle,,( -oracle,,) -oracle,EXCEPT,MINUS -oracle,"CONCAT(@a, @b, @c)","CONCAT(@a, CONCAT(@b, @c))" -oracle,"DATEADD(second,@seconds,@datetime)","(@date + NUMTODSINTERVAL(@seconds, 'second'))" -oracle,"DATEADD(minute,@minutes,@datetime)","(@date + NUMTODSINTERVAL(@minutes, 'minute'))" -oracle,"DATEADD(hour,@hours,@datetime)","(@date + NUMTODSINTERVAL(@hours, 'hour'))" -oracle,"DATEADD(d,@days,@date)","(@date + NUMTODSINTERVAL(@days, 'day'))" -oracle,"DATEADD(dd,@days,@date)","(@date + NUMTODSINTERVAL(@days, 'day'))" -oracle,"DATEADD(day,@days,@date)","(@date + NUMTODSINTERVAL(@days, 'day'))" -oracle,"DATEADD(month,@months,@date)","ADD_MONTHS(@date, @months)" -oracle,"DATEADD(mm,@months,@date)","ADD_MONTHS(@date, @months)" -oracle,"DATEADD(m,@months,@date)","ADD_MONTHS(@date, @months)" -oracle,"DATEADD(year,@years,@date)","ADD_MONTHS(@date, 12 * @years)" -oracle,"DATEADD(yyyy,@years,@date)","ADD_MONTHS(@date, 12 * @years)" -oracle,"DATEADD(yy,@years,@date)","ADD_MONTHS(@date, 12 * @years)" -oracle,"DATEDIFF(second,@start, @end)","EXTRACT(SECOND FROM (@end - @start))" -oracle,"DATEDIFF(minute,@start, @end)","EXTRACT(MINUTE FROM (@end - @start))" -oracle,"DATEDIFF(hour,@start, @end)","EXTRACT(HOUR FROM (@end - @start))" -oracle,"DATEDIFF(day,@start, @end)",CEIL(CAST(@end AS DATE) - CAST(@start AS DATE)) -oracle,"DATEDIFF(dd,@start, @end)",CEIL(CAST(@end AS DATE) - CAST(@start AS DATE)) -oracle,"DATEDIFF(d,@start, @end)",CEIL(CAST(@end AS DATE) - CAST(@start AS DATE)) -oracle,"DATEDIFF(year,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) -oracle,"DATEDIFF(yyyy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) -oracle,"DATEDIFF(yy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) -oracle,"DATEDIFF(month,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" -oracle,"DATEDIFF(mm,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" -oracle,"DATEDIFF(m,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" -oracle,"CONVERT(VARCHAR,@date,112)","TO_CHAR(@date, 'YYYYMMDD')" -oracle,GETDATE(),SYSDATE -oracle,+ '@a',|| '@a' -oracle,'@a' +,'@a' || -oracle,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || -oracle,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) -oracle,CAST(@a AS varchar) +,CAST(@a AS varchar) || -oracle,+ CAST(@a AS varchar),|| CAST(@a AS varchar) -oracle,"CONVERT(DATE, @a)","TO_DATE(@a, 'YYYYMMDD')" -oracle,CAST(@a AS VARCHAR),TO_CHAR(@a) -oracle,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00'), 'YYYY-MM-DD')" -oracle,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00')||' '||TO_CHAR(@hour,'00')||':'||TO_CHAR(@minute,'00')||':'||TO_CHAR(@second,'00'), 'YYYY-MM-DD HH24:MI:SS')" -oracle,EOMONTH(@date),"TO_DATE(to_char(last_day(@date),'YYYY-MM-DD')||' 23:59:59','YYYY-MM-DD HH24:MI:SS')" -oracle,STDEV(@a),STDDEV(@a) -oracle,VAR(@a),VARIANCE(@a) -oracle,RAND(),DBMS_RANDOM.VALUE -oracle,CEILING(@a),CEIL(@a) -oracle,"HASHBYTES('MD5',@a)","DBMS_CRYPTO.HASH(@a,2)" -oracle,LEN(@a),LENGTH(@a) -oracle,"LEFT(@str,@chars)","SUBSTR(@str,0,@chars)" -oracle,"RIGHT(@str,@chars)","SUBSTR(@str,-@chars)" -oracle,"LOG(@expression,@base)","(@base,@expression)" -oracle,LOG(@expression),"LOG(2.718281828459,@expression)" -oracle,,LOG -oracle,LOG10(@expression),"LOG(10,@expression)" -oracle,"ISNULL(@a,@b)","NVL(@a,@b)" -oracle,ISNUMERIC(@a),"CASE WHEN (LENGTH(TRIM(TRANSLATE(@a, ' +-.0123456789',' '))) IS NULL) THEN 1 ELSE 0 END" -oracle,COUNT_BIG(@a),COUNT(@a) -oracle,SQUARE(@a),((@a)*(@a)) -oracle,PI(),3.141592654 -oracle,NEWID(),SYS_GUID() -oracle,"CHARINDEX(@a,@b)","INSTR(@b,@a)" -oracle,SELECT @a WHERE @b;,SELECT @a FROM DUAL WHERE @b; -oracle,(SELECT @a WHERE @b),(SELECT @a FROM DUAL WHERE @b) -oracle,SELECT @a WHERE @b UNION,SELECT @a FROM DUAL WHERE @b UNION -oracle,SELECT @a;,SELECT @a FROM DUAL; -oracle,(SELECT @a),(SELECT @a FROM DUAL) -oracle,SELECT @a UNION,SELECT @a FROM DUAL UNION -oracle,FROM DUAL FROM DUAL, FROM DUAL -oracle,FROM @a UNION @b FROM DUAL UNION,FROM @a UNION @b FROM DUAL FROM DUAL UNION -oracle,FROM @a FROM DUAL UNION,FROM @a UNION -oracle,FROM @a UNION @b FROM DUAL;,FROM @a UNION @b FROM DUAL FROM DUAL; -oracle,FROM @a FROM DUAL;,FROM @a; -oracle,FROM @a UNION @b FROM DUAL WHERE,FROM @a UNION @b FROM DUAL FROM DUAL WHERE -oracle,FROM @a FROM DUAL WHERE,FROM @a WHERE -oracle,FROM @a UNION @b FROM DUAL),FROM @a UNION @b FROM DUAL FROM DUAL) -oracle,FROM @b FROM DUAL),FROM @b) -oracle,, -oracle,SELECT @a CASE @b COUNT(@c) @d END @e;,SELECT @a CASE @b COUNT(@c) @d END @e GROUP BY 1; -oracle,(SELECT @a CASE @b COUNT(@c) @d END @e),(SELECT @a CASE @b COUNT(@c) @d END @e GROUP BY 1) -oracle,GROUP BY @a GROUP BY 1,GROUP BY @a -oracle,GROUP BY @a GROUP BY 1,GROUP BY @a -oracle,YEAR(@date),EXTRACT(YEAR FROM @date) -oracle,MONTH(@date),EXTRACT(MONTH FROM @date) -oracle,DAY(@date),EXTRACT(DAY FROM @date) -oracle,"DATEPART(YEAR, @date)",EXTRACT(YEAR FROM @date) -oracle,"DATEPART(MONTH, @date)",EXTRACT(MONTH FROM @date) -oracle,"DATEPART(DAY, @date)",EXTRACT(DAY FROM @date) -oracle,USE @schema;,ALTER SESSION SET current_schema = @schema; -oracle,.dbo.,. -oracle,CREATE CLUSTERED INDEX,CREATE INDEX -oracle,CREATE UNIQUE INDEX @name ON @table (@variable);,BEGIN\n EXECUTE IMMEDIATE 'CREATE UNIQUE INDEX @name ON @table (@variable)';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -1408 THEN\n RAISE;\n END IF;\nEND; -oracle,CREATE UNIQUE CLUSTERED INDEX @name ON @table (@variable);,BEGIN\n EXECUTE IMMEDIATE 'CREATE UNIQUE INDEX @name ON @table (@variable)';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -1408 THEN\n RAISE;\n END IF;\nEND; -oracle,PRIMARY KEY NONCLUSTERED,PRIMARY KEY -oracle,DATETIME,TIMESTAMP -oracle,DATETIME2,TIMESTAMP -oracle,BIGINT,NUMBER(19) -oracle,VARCHAR(MAX),VARCHAR2(1024) -oracle,"NOT NULL DEFAULT @a,","DEFAULT @a NOT NULL," -oracle,"(@x NOT NULL DEFAULT @a)","(@x DEFAULT @a NOT NULL)" -oracle,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -oracle,WITH @a AS @b INSERT INTO @c SELECT @d;,INSERT INTO @c WITH @a AS @b SELECT @d; -oracle,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; -oracle,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; -oracle,##, -oracle,CREATE TABLE #@([^\s]+)table,DROP TABLE IF EXISTS %temp_prefix%%session_id%@table;\nCREATE TABLE %temp_prefix%%session_id%@table -oracle,#@([^\s]+)table.@([^\s]+)field,%session_id%@table.@field -oracle,"DROP TABLE IF EXISTS #@table;",BEGIN\n EXECUTE IMMEDIATE 'TRUNCATE TABLE %temp_prefix%%session_id%@table';\n EXECUTE IMMEDIATE 'DROP TABLE %temp_prefix%%session_id%@table';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN\n RAISE;\n END IF;\nEND; -oracle,#,%temp_prefix%%session_id% -oracle,"CREATE INDEX @a ON @b (@c,@d) WHERE @e;","CREATE INDEX @a ON @b (CASE WHEN @e THEN @c END, CASE WHEN @e THEN @d END);" -oracle,,## -oracle,SELECT TOP @([0-9]+)rows @a;,SELECT @a FETCH FIRST @rows ROWS ONLY; -oracle,(SELECT TOP @([0-9]+)rows @a),(SELECT @a FETCH FIRST @rows ROWS ONLY) -oracle,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a FETCH FIRST @rows ROWS ONLY; -oracle,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a FETCH FIRST @rows ROWS ONLY) -oracle,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",BEGIN\n EXECUTE IMMEDIATE 'CREATE TABLE @table (@definition)';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -955 THEN\n RAISE;\n END IF;\nEND; -oracle,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE #@table;",BEGIN\n EXECUTE IMMEDIATE 'TRUNCATE TABLE %temp_prefix%%session_id%@table';\n EXECUTE IMMEDIATE 'DROP TABLE %temp_prefix%%session_id%@table';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN\n RAISE;\n END IF;\nEND; -oracle,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",BEGIN\n EXECUTE IMMEDIATE 'TRUNCATE TABLE @table';\n EXECUTE IMMEDIATE 'DROP TABLE @table';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN\n RAISE;\n END IF;\nEND; -oracle,"DROP TABLE IF EXISTS @table;",BEGIN\n EXECUTE IMMEDIATE 'TRUNCATE TABLE @table';\n EXECUTE IMMEDIATE 'DROP TABLE @table';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN\n RAISE;\n END IF;\nEND; -oracle,"FROM @a AS @b;","FROM @a NESTED @b;" -oracle,"NESTED @a AS @b;","@a @b;" -oracle,"NESTED","" -oracle,"FROM @a AS @b WHERE","FROM @a @b WHERE" -oracle,"FROM @a AS @b)","FROM @a @b)" -oracle,"JOIN @a AS @b ON","JOIN @a @b ON" -oracle,UPDATE STATISTICS @a;,-- ANALYZE should not be used to collect optimizer statistics -oracle,"CONVERT(VARBINARY, @a, 1)","TO_NUMBER(@a, RPAD('X', LENGTH(@a), 'X'))" -oracle,"SELECT *, @a FROM (@c) @b WHERE","SELECT @b.*, @a FROM (@c) @b WHERE" -oracle,"SELECT *, @a FROM (@c) @b ORDER BY","SELECT @b.*, @a FROM (@c) @b ORDER BY" -oracle,"SELECT *, @a FROM (@c) @b FETCH FIRST","SELECT @b.*, @a FROM (@c) @b FETCH FIRST" -oracle,"(SELECT *, @a FROM (@c) @b)","(SELECT @b.*, @a FROM (@c) @b)" -oracle,"SELECT *, @a FROM (@c) @b;","SELECT @b.*, @a FROM (@c) @b;" -oracle,"SELECT *, @a FROM @b WHERE","SELECT @b.*, @a FROM @b WHERE" -oracle,"SELECT *, @a FROM @b ORDER BY","SELECT @b.*, @a FROM @b ORDER BY" -oracle,"SELECT *, @a FROM @b FETCH FIRST","SELECT @b.*, @a FROM @b FETCH FIRST" -oracle,"(SELECT *, @a FROM @b)","(SELECT @b.*, @a FROM @b)" -oracle,"SELECT *, @a FROM @b;","SELECT @b.*, @a FROM @b;" -oracle,"SELECT @a, * FROM @b WHERE","SELECT @a, @b.* FROM @b WHERE" -oracle,"SELECT @a, * FROM @b ORDER BY","SELECT @a, @b.* FROM @b ORDER BY" -oracle,"SELECT @a, * FROM @b FETCH FIRST","SELECT @a, @b.* FROM @b FETCH FIRST" -oracle,"(SELECT @a, * FROM @b)","(SELECT @a, @b.* FROM @b)" -oracle,"SELECT @a, * FROM @b;","SELECT @a, @b.* FROM @b;" -oracle,(@a & @b),"BITAND(@a, @b)" -postgresql,...@([0-9]+|y)a,xxx@a -postgresql,TRY_CAST(@a),CAST(@a) -postgresql,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -postgresql,"ROUND(@a,@b)","ROUND(CAST(@a AS NUMERIC),@b)" -postgresql,"HASHBYTES('MD5',@a)","MD5(@a)" -postgresql,"CONVERT(VARBINARY, @a, 1)","CAST(CONCAT('x', @a) AS BIT(32))" -postgresql,"CONVERT(DATE, @a)","TO_DATE(@a, 'yyyymmdd')" -postgresql,"DATEADD(second,@seconds,@datetime)",(@datetime + @seconds*INTERVAL'1 second') -postgresql,"DATEADD(minute,@minutes,@datetime)",(@datetime + @minutes*INTERVAL'1 minute') -postgresql,"DATEADD(hour,@hours,@datetime)",(@datetime + @hours*INTERVAL'1 hour') -postgresql,"DATEADD(d,@days,@date)",(@date + @days*INTERVAL'1 day') -postgresql,"DATEADD(dd,@days,@date)",(@date + @days*INTERVAL'1 day') -postgresql,"DATEADD(day,@days,@date)",(@date + @days*INTERVAL'1 day') -postgresql,"DATEADD(m,@months,@date)",(@date + @months*INTERVAL'1 month') -postgresql,"DATEADD(mm,@months,@date)",(@date + @months*INTERVAL'1 month') -postgresql,"DATEADD(month,@months,@date)",(@date + @months*INTERVAL'1 month') -postgresql,"DATEADD(yy,@years,@date)",(@date + @years*INTERVAL'1 year') -postgresql,"DATEADD(yyyy,@years,@date)",(@date + @years*INTERVAL'1 year') -postgresql,"DATEADD(year,@years,@date)",(@date + @years*INTERVAL'1 year') -postgresql,"DATEDIFF(second,@start, @end)",EXTRACT(EPOCH FROM (@end - @start)) -postgresql,"DATEDIFF(minute,@start, @end)",(EXTRACT(EPOCH FROM (@end - @start)) / 60) -postgresql,"DATEDIFF(hour,@start, @end)",(EXTRACT(EPOCH FROM (@end - @start)) / 3600) -postgresql,"DATEDIFF(d,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) -postgresql,"DATEDIFF(dd,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) -postgresql,"DATEDIFF(day,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) -postgresql,"DATEDIFF(year,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) -postgresql,"DATEDIFF(yyyy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) -postgresql,"DATEDIFF(yy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) -postgresql,"DATEDIFF(month,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" -postgresql,"DATEDIFF(mm,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" -postgresql,"DATEDIFF(m,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" -postgresql,"CONVERT(VARCHAR,@date,112)","TO_CHAR(@date, 'YYYYMMDD')" -postgresql,GETDATE(),CURRENT_DATE -postgresql,+ '@a',|| '@a' -postgresql,'@a' +,'@a' || -postgresql,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || -postgresql,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) -postgresql,CAST(@a AS varchar) +,CAST(@a AS varchar) || -postgresql,+ CAST(@a AS varchar),|| CAST(@a AS varchar) -postgresql,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00'), 'YYYY-MM-DD')" -postgresql,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00')||' '||TO_CHAR(@hour,'00')||':'||TO_CHAR(@minute,'00')||':'||TO_CHAR(@second,'00'), 'YYYY-MM-DD HH24:MI:SS')" -postgresql,YEAR(@date),EXTRACT(YEAR FROM @date) -postgresql,MONTH(@date),EXTRACT(MONTH FROM @date) -postgresql,DAY(@date),EXTRACT(DAY FROM @date) -postgresql,"DATEPART(YEAR, @date)",EXTRACT(YEAR FROM @date) -postgresql,"DATEPART(MONTH, @date)",EXTRACT(MONTH FROM @date) -postgresql,"DATEPART(DAY, @date)",EXTRACT(DAY FROM @date) -postgresql,EOMONTH(@date),"(DATE_TRUNC('MONTH', @date) + INTERVAL '1 MONTH - 1 day')::DATE" -postgresql,STDEV(@a),STDDEV(@a) -postgresql,VAR(@a),VARIANCE(@a) -postgresql,RAND(),RANDOM() -postgresql,LEN(@a),CHAR_LENGTH(@a) -postgresql,"CHARINDEX(@a,@b)","STRPOS(@b,@a)" -postgresql,"LOG(@expression,@base)","(CAST((@base) AS NUMERIC),CAST((@expression) AS NUMERIC))" -postgresql,LOG(@expression),LN(CAST((@expression) AS REAL)) -postgresql,,LOG -postgresql,LOG10(@expression),"LOG(10,CAST((@expression) AS NUMERIC))" -postgresql,"ISNULL(@a,@b)","COALESCE(@a,@b)" -postgresql,"ISNUMERIC(@a)","CASE WHEN (CAST(@a AS VARCHAR) ~ '^([0-9]+\.?[0-9]*|\.[0-9]+)$') THEN 1 ELSE 0 END" -postgresql,COUNT_BIG(@a),COUNT(@a) -postgresql,SQUARE(@a),((@a)*(@a)) -postgresql,NEWID(),MD5(RANDOM()::TEXT || CLOCK_TIMESTAMP()::TEXT) -postgresql,USE @schema;,SET search_path TO @schema; -postgresql,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -postgresql,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -postgresql,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -postgresql,.dbo.,. -postgresql,CREATE TABLE #@table (@definition),CREATE TEMP TABLE @table (@definition) -postgresql,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE INDEX @index_name ON @table (@variable);\nCLUSTER @table USING @index_name; -postgresql,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable);\nCLUSTER @table USING @index_name; -postgresql,PRIMARY KEY NONCLUSTERED,PRIMARY KEY -postgresql,DATETIME,TIMESTAMP -postgresql,DATETIME2,TIMESTAMP -postgresql,VARCHAR(MAX),TEXT -postgresql,FLOAT,NUMERIC -postgresql,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nANALYZE @d; -postgresql,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -postgresql,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a\nFROM\n@c;\nANALYZE @b; -postgresql,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; -postgresql,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; -postgresql,#, -postgresql,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; -postgresql,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) -postgresql,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; -postgresql,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) -postgresql,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as TEXT) @s2)" -postgresql,UPDATE STATISTICS @a;,ANALYZE @a; -postgresql,"ALTER TABLE @table ALTER COLUMN @([^ ]+)a @b;","ALTER TABLE @table ALTER COLUMN @a TYPE @b;" -postgresql,"ALTER TABLE @table ADD @a, @b;","ALTER TABLE @table @a, @b;" -postgresql," @b, @c;"," @b, @c;" -postgresql,"ALTER TABLE @table ADD @a;","ALTER TABLE @table @a;" -postgresql,"",ADD COLUMN -postgresql,"",ADD COLUMN -postgresql,ADD COLUMN COLUMN,ADD COLUMN -postgresql,ADD COLUMN CONSTRAINT,ADD CONSTRAINT -redshift,...@([0-9]+|y)a,xxx@a -redshift,"AS drvd(@a)","AS drvd(@a)" -redshift,"@a, @b)","@a, @b)" -redshift,"","NULL AS " -redshift,"FROM (VALUES @a) AS drvd","FROM (@a) AS drvd" -redshift,"@a, @b)","@a UNION ALL @b)" -redshift,"(@a)","SELECT @a" -redshift,"FROM (SELECT @a) AS drvd(@b)","FROM (SELECT @b WHERE (0 = 1) UNION ALL SELECT @a) AS values_table" -redshift,TRY_CAST(@a),CAST(@a) -redshift,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -redshift,CREATE INDEX @index_name ON @table (@variable);,-- redshift does not support indexes -redshift,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,-- redshift does not support indexes -redshift,"OVER (@a ORDER BY @b DESC)","OVER (@a O*D*R B* @b DESC ROWS UNBOUNDED PRECEDING)" -redshift,"OVER (@a ORDER BY @b ASC)","OVER (@a O*D*R B* @b ASC ROWS UNBOUNDED PRECEDING)" -redshift,"OVER (@a ORDER BY @((?!.*ROWS).*)b)","OVER (@a O*D*R B* @b ROWS UNBOUNDED PRECEDING)" -redshift,"ROW_NUMBER() OVER (@a ROWS UNBOUNDED PRECEDING)","ROW_NUMBER() OVER (@a)" -redshift,"CUME_DIST() OVER (@a ROWS UNBOUNDED PRECEDING)","CUME_DIST() OVER (@a)" -redshift,"DENSE_RANK() OVER (@a ROWS UNBOUNDED PRECEDING)","DENSE_RANK() OVER (@a)" -redshift,"PERCENT_RANK() OVER (@a ROWS UNBOUNDED PRECEDING)","PERCENT_RANK() OVER (@a)" -redshift,"RANK() OVER (@a ROWS UNBOUNDED PRECEDING)","RANK() OVER (@a)" -redshift,"LAG(@x) OVER (@a ROWS UNBOUNDED PRECEDING)","LAG(@x) OVER (@a)" -redshift,"LEAD(@x) OVER (@a ROWS UNBOUNDED PRECEDING)","LEAD(@x) OVER (@a)" -redshift,"NTILE(@x) OVER (@a ROWS UNBOUNDED PRECEDING)","NTILE(@x) OVER (@a)" -redshift,"O*D*R B*","ORDER BY" -redshift,"ROUND(@a,@b)","ROUND(CAST(@a AS FLOAT),@b)" -redshift,"ROUND(@expression,@length,@trunc)","case when @trunc = 0 then ROUND(@expression,@length) else TRUNC(@expression,@length) end" -redshift,"DATEADD(dd,@days,@date)","DATEADD(day,@days,@date)" -redshift,"DATEADD(m,@months,@date)","DATEADD(month,@months,@date)" -redshift,"DATEADD(mm,@months,@date)","DATEADD(month,@months,@date)" -redshift,"DATEADD(yyyy,@years,@date)","DATEADD(year,@years,@date)" -redshift,"DATEADD(yy,@years,@date)","DATEADD(year,@years,@date)" -redshift,"DATEADD(qq,@n,@date)","DATEADD(quarter,@n,@date)" -redshift,"DATEADD(q,@n,@date)","DATEADD(quarter,@n,@date)" -redshift,"DATEADD(wk,@n,@date)","DATEADD(week,@n,@date)" -redshift,"DATEADD(ww,@n,@date)","DATEADD(week,@n,@date)" -redshift,"DATEADD(hh,@n,@date)","DATEADD(hour,@n,@date)" -redshift,"DATEADD(mi,@n,@date)","DATEADD(minute,@n,@date)" -redshift,"DATEADD(n,@n,@date)","DATEADD(minute,@n,@date)" -redshift,"DATEADD(ss,@n,@date)","DATEADD(second,@n,@date)" -redshift,"DATEADD(mcs,@n,@date)","DATEADD(microsecond,@n,@date)" -redshift,"DATEADD(@part,@n,@date)","DATEADD(@part,CAST(@n as int),@date)" -redshift,"DATEDIFF(dd,@start,@end)","DATEDIFF(day,@start,@end)" -redshift,"DATEDIFF(m,@start,@end)","DATEDIFF(month,@start,@end)" -redshift,"DATEDIFF(mm,@start,@end)","DATEDIFF(month,@start,@end)" -redshift,"DATEDIFF(yyyy,@start,@end)","DATEDIFF(year,@start,@end)" -redshift,"DATEDIFF(yy,@start,@end)","DATEDIFF(year,@start,@end)" -redshift,"DATEDIFF(qq,@start,@end)","DATEDIFF(quarter,@start,@end)" -redshift,"DATEDIFF(q,@start,@end)","DATEDIFF(quarter,@start,@end)" -redshift,"DATEDIFF(wk,@start,@end)","DATEDIFF(week,@start,@end)" -redshift,"DATEDIFF(ww,@start,@end)","DATEDIFF(week,@start,@end)" -redshift,"DATEDIFF(hh,@start,@end)","DATEDIFF(hour,@start,@end)" -redshift,"DATEDIFF(mi,@start,@end)","DATEDIFF(minute,@start,@end)" -redshift,"DATEDIFF(n,@start,@end)","DATEDIFF(minute,@start,@end)" -redshift,"DATEDIFF(ss,@start,@end)","DATEDIFF(second,@start,@end)" -redshift,"DATEDIFF(mcs,@start,@end)","DATEDIFF(microsecond,@start,@end)" -redshift,"DATEDIFF_BIG(dd,@start,@end)","DATEDIFF(day,@start,@end)" -redshift,"DATEDIFF_BIG(day,@start,@end)","DATEDIFF(day,@start,@end)" -redshift,"DATEDIFF_BIG(m,@start,@end)","DATEDIFF(month,@start,@end)" -redshift,"DATEDIFF_BIG(mm,@start,@end)","DATEDIFF(month,@start,@end)" -redshift,"DATEDIFF_BIG(yyyy,@start,@end)","DATEDIFF(year,@start,@end)" -redshift,"DATEDIFF_BIG(yy,@start,@end)","DATEDIFF(year,@start,@end)" -redshift,"DATEDIFF_BIG(qq,@start,@end)","DATEDIFF(quarter,@start,@end)" -redshift,"DATEDIFF_BIG(q,@start,@end)","DATEDIFF(quarter,@start,@end)" -redshift,"DATEDIFF_BIG(wk,@start,@end)","DATEDIFF(week,@start,@end)" -redshift,"DATEDIFF_BIG(ww,@start,@end)","DATEDIFF(week,@start,@end)" -redshift,"DATEDIFF_BIG(hh,@start,@end)","DATEDIFF(hour,@start,@end)" -redshift,"DATEDIFF_BIG(hour,@start,@end)","DATEDIFF(hour,@start,@end)" -redshift,"DATEDIFF_BIG(mi,@start,@end)","DATEDIFF(minute,@start,@end)" -redshift,"DATEDIFF_BIG(minute,@start,@end)","DATEDIFF(minute,@start,@end)" -redshift,"DATEDIFF_BIG(n,@start,@end)","DATEDIFF(minute,@start,@end)" -redshift,"DATEDIFF_BIG(ss,@start,@end)","DATEDIFF(second,@start,@end)" -redshift,"DATEDIFF_BIG(second,@start,@end)","DATEDIFF(second,@start,@end)" -redshift,"DATEDIFF_BIG(mcs,@start,@end)","DATEDIFF(microsecond,@start,@end)" -redshift,"DATEPART(dd,@date)","DATEPART(day,@date)" -redshift,"DATEPART(m,@date)","DATEPART(month,@date)" -redshift,"DATEPART(mm,@date)","DATEPART(month,@date)" -redshift,"DATEPART(yyyy,@date)","DATEPART(year,@date)" -redshift,"DATEPART(yy,@date)","DATEPART(year,@date)" -redshift,"DATEPART(qq,@date)","DATEPART(quarter,@date)" -redshift,"DATEPART(q,@date)","DATEPART(quarter,@date)" -redshift,"DATEPART(wk,@date)","DATEPART(week,@date)" -redshift,"DATEPART(ww,@date)","DATEPART(week,@date)" -redshift,"DATEPART(hh,@date)","DATEPART(hour,@date)" -redshift,"DATEPART(mi,@date)","DATEPART(minute,@date)" -redshift,"DATEPART(n,@date)","DATEPART(minute,@date)" -redshift,"DATEPART(ss,@date)","DATEPART(second,@date)" -redshift,"DATEPART(mcs,@date)","DATEPART(microsecond,@date)" -redshift,"CONVERT(VARCHAR,@date,112)","TO_CHAR(@date, 'YYYYMMDD')" -redshift,GETDATE(),CURRENT_DATE -redshift,+ '@a',|| '@a' -redshift,'@a' +,'@a' || -redshift,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || -redshift,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) -redshift,CAST(@a AS varchar) +,CAST(@a AS varchar) || -redshift,+ CAST(@a AS varchar),|| CAST(@a AS varchar) -redshift,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM'), 'YYYY-MM-DD')" -redshift,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM')||':'||TO_CHAR(@second,'00FM')||'.'||TO_CHAR(@ms,'000FM') as TIMESTAMP)" -redshift,YEAR(@date),EXTRACT(YEAR FROM @date) -redshift,MONTH(@date),EXTRACT(MONTH FROM @date) -redshift,DAY(@date),EXTRACT(DAY FROM @date) -redshift,EOMONTH(@date),LAST_DAY(@date) -redshift,VAR(@a),VARIANCE(@a) -redshift,STDEV(@a),STDDEV(@a) -redshift,RAND(),RANDOM() -redshift,"HASHBYTES('MD5',@a)",MD5(@a) -redshift,"CONVERT(VARBINARY, @a, 1)","STRTOL(LEFT(@a, 15), 16)" -redshift,LEN(@a),CHAR_LENGTH(@a) -redshift,"LOG(@expression,@base)",(LN(CAST((@expression) AS REAL))/LN(CAST((@base) AS REAL))) -redshift,LOG(@expression),LN(CAST((@expression) AS REAL)) -redshift,LOG10(@expression),LOG(CAST((@expression) AS REAL)) -redshift,"ISNULL(@a,@b)","COALESCE(@a,@b)" -redshift,COUNT_BIG(@a),COUNT(@a) -redshift,SQUARE(@a),((@a) * (@a)) -redshift,TEXT,VARCHAR(max) -redshift,NTEXT,VARCHAR(max) -redshift,NEWID(),MD5(RANDOM()::TEXT || GETDATE()::TEXT) -redshift,USE @schema;,SET search_path TO @schema; -redshift,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -redshift,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -redshift,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE @table;","DROP TABLE IF EXISTS #@table;" -redshift,.dbo.,. -redshift,"HINT DISTRIBUTE_ON_KEY(@key) @hint CREATE TABLE @table (@definition);",HINT DISTRIBUTE_ON_KEY(@key) @hint\nCREATE TABLE @table (@definition)\nDISTKEY(@key); -redshift,"HINT DISTRIBUTE_ON_RANDOM @hint CREATE TABLE @table (@definition);",HINT DISTRIBUTE_ON_RANDOM @hint\nCREATE TABLE @table (@definition)\nDISTSTYLE EVEN; -redshift,"HINT @hint SORT_ON_KEY(@type:@key) CREATE TABLE @table (@definition) @options;",HINT @hint SORT_ON_KEY(@type:@key)\nCREATE TABLE @table (@definition)\n@options\n@type SORTKEY(@key); -redshift,"CREATE TABLE @table (@a1 person_id @a2);",CREATE TABLE @table (@a1 person_id @a2)\nDISTKEY(person_id); -redshift,"CREATE TABLE @table (@a1 subject_id @a2);",CREATE TABLE @table (@a1 subject_id @a2)\nDISTKEY(subject_id); -redshift,"CREATE TABLE @table (@a1 analysis_id @a2);",CREATE TABLE @table (@a1 analysis_id @a2)\nDISTKEY(analysis_id); -redshift,"CREATE TABLE @table (@definition);",CREATE TABLE @table (@definition)\nDISTSTYLE ALL; -redshift,HINT DISTRIBUTE_ON_KEY(@key) WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_KEY(@key)\nCREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -redshift,HINT DISTRIBUTE_ON_RANDOM WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_RANDOM\nCREATE TABLE @d\nDISTSTYLE EVEN\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -redshift,HINT SORT_ON_KEY(@type:@sortkey) WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @d\n@type SORTKEY(@sortkey)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -redshift,HINT DISTRIBUTE_ON_KEY(@key) SORT_ON_KEY(@type:@sortkey) WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_KEY(@key) SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @d\nDISTKEY(@key)\n@type SORTKEY(@sortkey)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -redshift,HINT DISTRIBUTE_ON_RANDOM SORT_ON_KEY(@type:@sortkey) WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_RANDOM SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @d\nDISTSTYLE EVEN\n@type SORTKEY(@sortkey)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -redshift,WITH @a INSERT INTO @b SELECT @c;,INSERT INTO @b WITH @a SELECT @c; -redshift,WITH @a AS @b SELECT @c1 person_id as @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 person_id as @key @c2\nFROM\n@e; -redshift,WITH @a AS @b SELECT @c1 person_id @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 person_id @key @c2\nFROM\n@e; -redshift,WITH @a AS @b SELECT @c1 person_id @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(person_id)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 person_id @c2\nFROM\n@e; -redshift,WITH @a AS @b SELECT @c1 subject_id as @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 subject_id as @key @c2\nFROM\n@e; -redshift,WITH @a AS @b SELECT @c1 subject_id @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 subject_id @key @c2\nFROM\n@e; -redshift,WITH @a AS @b SELECT @c1 subject_id @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(subject_id)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 subject_id @c2\nFROM\n@e; -redshift,WITH @a AS @b SELECT @c1 analysis_id as @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 analysis_id as @key @c2\nFROM\n@e; -redshift,WITH @a AS @b SELECT @c1 analysis_id @(\w+\b)key @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(@key)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 analysis_id @key @c2\nFROM\n@e; -redshift,WITH @a AS @b SELECT @c1 analysis_id @c2 INTO @d FROM @e;,CREATE TABLE @d\nDISTKEY(analysis_id)\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c1 analysis_id @c2\nFROM\n@e; -redshift,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d DISTSTYLE ALL\nAS\nWITH\n@a\nAS\n@b\nSELECT\n@c\nFROM\n@e; -redshift,HINT DISTRIBUTE_ON_KEY(@key) SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a\nFROM\n@c; -redshift,HINT DISTRIBUTE_ON_RANDOM SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_RANDOM \nCREATE TABLE @b\nDISTSTYLE EVEN\nAS\nSELECT\n@a\nFROM\n@c; -redshift,HINT SORT_ON_KEY(@type:@sortkey) SELECT @a INTO @b FROM @c;,HINT SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @b\n@type SORTKEY(@sortkey)\nAS\nSELECT\n@a\nFROM\n@c; -redshift,HINT DISTRIBUTE_ON_KEY(@key) SORT_ON_KEY(@type:@sortkey) SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_KEY(@key) SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @b\nDISTKEY(@key)\n@type SORTKEY(@sortkey)\nAS\nSELECT\n@a\nFROM\n@c; -redshift,HINT DISTRIBUTE_ON_RANDOM SORT_ON_KEY(@type:@sortkey) SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_RANDOM SORT_ON_KEY(@type:@sortkey)\nCREATE TABLE @b\nDISTSTYLE EVEN\n@type SORTKEY(@sortkey)\nAS\nSELECT\n@a\nFROM\n@c; -redshift,SELECT @a1 person_id as @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 person_id as @key @a2\nFROM\n@c; -redshift,SELECT @a1 person_id @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 person_id @key @a2\nFROM\n@c; -redshift,SELECT @a1 person_id @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(person_id)\nAS\nSELECT\n@a1 person_id @a2\nFROM\n@c; -redshift,SELECT @a1 subject_id as @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 subject_id as @key @a2\nFROM\n@c; -redshift,SELECT @a1 subject_id @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 subject_id @key @a2\nFROM\n@c; -redshift,SELECT @a1 subject_id @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(subject_id)\nAS\nSELECT\n@a1 subject_id @a2\nFROM\n@c; -redshift,SELECT @a1 analysis_id as @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 analysis_id as @key @a2\nFROM\n@c; -redshift,SELECT @a1 analysis_id @(\w+\b)key @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(@key)\nAS\nSELECT\n@a1 analysis_id @key @a2\nFROM\n@c; -redshift,SELECT @a1 analysis_id @a2 INTO @b FROM @c;,CREATE TABLE @b\nDISTKEY(analysis_id)\nAS\nSELECT\n@a1 analysis_id @a2\nFROM\n@c; -redshift,SELECT @a INTO @b FROM @c;,CREATE TABLE @b DISTSTYLE ALL\nAS\nSELECT\n@a\nFROM\n@c; -redshift,SELECT @a INTO @b;,CREATE TABLE @b DISTSTYLE ALL\nAS\nSELECT\n@a; -redshift,[ person_id ],[person_id] -redshift,[ subject_id ],[subject_id] -redshift,[ analysis_id ],[analysis_id] -redshift,PRIMARY KEY NONCLUSTERED,PRIMARY KEY -redshift,DATETIME,TIMESTAMP -redshift,SELECT DISTINCT TOP @([0-9]+)rows,SELECT TOP @rows DISTINCT -redshift,BIT,BOOLEAN -redshift,MONEY,"DECIMAL(19, 4)" -redshift,SMALLMONEY,"DECIMAL(10, 4)" -redshift,TINYINT,SMALLINT -redshift,FLOAT(@s),FLOAT -redshift,DATETIME2(@p),TIMESTAMP -redshift,DATETIME2,TIMESTAMP -redshift,DATETIME,TIMESTAMP -redshift,DATETIMEOFFSET(@p),TIMESTAMPTZ -redshift,DATETIMEOFFSET,TIMESTAMPTZ -redshift,SMALLDATETIME,TIMESTAMP -redshift,UNIQUEIDENTIFIER,CHAR(36) -redshift,STDEVP(@a),STDDEV_POP(@a) -redshift,VARP(@a),VAR_POP(@a) -redshift,"DATETIME2FROMPARTS(@year,@month,@day,@hour,@minute,@seconds,0,0)","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM')||':'||TO_CHAR(@seconds,'00FM') as TIMESTAMP)" -redshift,"DATETIME2FROMPARTS(@year,@month,@day,@hour,@minute,@seconds,@fractions,@precision)","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM')||':'||TO_CHAR(@seconds,'00FM')||'.'||TO_CHAR(@fractions,repeat('0', @precision) || 'FM') as TIMESTAMP)" -redshift,"DATETIMEOFFSETFROMPARTS (@year,@month,@day,@hour,@minute,@seconds,0,@h_offset,@m_offset,0 )","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM')||':'||TO_CHAR(@seconds,'00FM')||case when @h_offset >= 0 then '+' else '-' end ||TO_CHAR(ABS(@h_offset),'00FM')||':'||TO_CHAR(ABS(@m_offset),'00FM') as TIMESTAMPTZ)" -redshift,"DATETIMEOFFSETFROMPARTS (@year,@month,@day,@hour,@minute,@seconds,@fractions,@h_offset,@m_offset,@precision )","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM')||':'||TO_CHAR(@seconds,'00FM')||'.'||TO_CHAR(@fractions,repeat('0',@precision) || 'FM')||case when @h_offset >= 0 then '+' else '-' end ||TO_CHAR(ABS(@h_offset),'00FM')||':'||TO_CHAR(ABS(@m_offset),'00FM') as TIMESTAMPTZ)" -redshift,GETUTCDATE(),CURRENT_TIMESTAMP -redshift,"SMALLDATETIMEFROMPARTS(@year,@month,@day,@hour,@minute )","CAST(TO_CHAR(@year,'0000FM')||'-'||TO_CHAR(@month,'00FM')||'-'||TO_CHAR(@day,'00FM')||' '||TO_CHAR(@hour,'00FM')||':'||TO_CHAR(@minute,'00FM') as TIMESTAMP)" -redshift,SYSUTCDATETIME(),CURRENT_TIMESTAMP -redshift,"TODATETIMEOFFSET(@expression,@timezone)","CAST(TO_CHAR(CAST(@expression as TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS.US') ||@timezone as TIMESTAMPTZ)" -redshift,"ATN2(@a,@b)","ATAN2(@a,@b)" -redshift,"CHARINDEX(@expression,@in,@start)","case when CHARINDEX(@expression, SUBSTRING(@in,@start)) > 0 then (CHARINDEX(@expression, SUBSTRING(@in,@start)) +@start - 1) else 0 end" -redshift,QUOTENAME(@a),QUOTE_IDENT(@a) -redshift,"SPACE(@n)","REPEAT(' ',@n)" -redshift,"STUFF(@expression,@start,@length,@replace)","SUBSTRING(@expression, 0,@start)||@replace||SUBSTRING(@expression,@start +@length)" -redshift,"CONCAT(@a,@b,@tail)","CONCAT(@a,CONCAT(@b,@tail))" -redshift,"ISDATE(@s)","REGEXP_INSTR(@s, '^(\\d{4}[/\-]?[01]\\d[/\-]?[0123]\\d)([ T]([0-1][0-9]|[2][0-3]):([0-5][0-9])(:[0-5][0-9](.\\d+)?)?)?$')" -redshift,"ISNUMERIC(@s)","REGEXP_INSTR(@s, '^[\-\+]?(\\d*\\.)?\\d+([Ee][\-\+]?\\d+)?$')" -redshift,"PATINDEX(@pattern,@expression)","REGEXP_INSTR(@expression, case when LEFT(@pattern,1)<>'%' and RIGHT(@pattern,1)='%' then '^' else '' end||TRIM('%' FROM REPLACE(@pattern,'_','.'))||case when LEFT(@pattern,1)='%' and RIGHT(@pattern,1)<>'%' then '$' else '' end)" -redshift,^,# -redshift,"CONVERT(DATE, @a)","CAST(@a as DATE)" -redshift,"CONVERT(TIMESTAMPTZ, @a)","CONVERT(TIMESTAMP WITH TIME ZONE, @a)" -redshift,UPDATE STATISTICS @a;,ANALYZE @a; -pdw,...@([0-9]+|y)a,xxx@a -pdw,CREATE INDEX @index_name ON #@table (@variable);,-- PDW does not support non-clustered index on temp tables. -pdw,VARCHAR(MAX),VARCHAR(1000) -pdw,HINT DISTRIBUTE_ON_KEY(@key) @hint WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_KEY(@key) @hint\nCREATE TABLE @d WITH (DISTRIBUTION = HASH(@key))\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -pdw,HINT DISTRIBUTE_ON_RANDOM @hint WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_RANDOM @hint\nCREATE TABLE @d WITH (DISTRIBUTION = ROUND_ROBIN)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -pdw,"WITH @a AS @b SELECT @c1 subject_id, @c2 INTO @d FROM @e;","CREATE TABLE @d WITH (DISTRIBUTION = HASH(subject_id))\nAS\nWITH @a AS @b SELECT\n@c1 subject_id, @c2\nFROM\n@e;" -pdw,"WITH @a AS @b SELECT @c1 person_id, @c2 INTO @d FROM @e;","CREATE TABLE @d WITH (DISTRIBUTION = HASH(person_id))\nAS\nWITH @a AS @b SELECT\n@c1 person_id, @c2\nFROM\n@e;" -pdw,"WITH @a AS @b SELECT @c1 analysis_id, @c2 INTO @d FROM @e;","CREATE TABLE @d WITH (DISTRIBUTION = HASH(analysis_id))\nAS\nWITH @a AS @b SELECT\n@c1 analysis_id, @c2\nFROM\n@e;" -pdw,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d WITH (DISTRIBUTION = REPLICATE)\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -pdw,HINT DISTRIBUTE_ON_KEY(@key) @hint SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_KEY(@key) @hint\nCREATE TABLE @b WITH (DISTRIBUTION = HASH(@key))\nAS\nSELECT\n@a\nFROM\n@c; -pdw,HINT DISTRIBUTE_ON_RANDOM @hint SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_RANDOM @hint\nCREATE TABLE @b WITH (DISTRIBUTION = ROUND_ROBIN)\nAS\nSELECT\n@a\nFROM\n@c; -pdw,"SELECT @a1 subject_id, @a2 INTO @b FROM @c;","CREATE TABLE @b WITH (DISTRIBUTION = HASH(subject_id))\nAS\nSELECT\n@a1 subject_id, @a2\nFROM\n@c;" -pdw,"SELECT @a1 person_id, @a2 INTO @b FROM @c;","CREATE TABLE @b WITH (DISTRIBUTION = HASH(person_id))\nAS\nSELECT\n@a1 person_id, @a2\nFROM\n@c;" -pdw,"SELECT @a1 analysis_id, @a2 INTO @b FROM @c;","CREATE TABLE @b WITH (DISTRIBUTION = HASH(analysis_id))\nAS\nSELECT\n@a1 analysis_id, @a2\nFROM\n@c;" -pdw,SELECT @a INTO @b FROM @c;,CREATE TABLE @b WITH (DISTRIBUTION = REPLICATE)\nAS\nSELECT\n@a\nFROM\n@c; -pdw,SELECT @a INTO @b;,CREATE TABLE @b WITH (DISTRIBUTION = REPLICATE)\nAS\nSELECT\n@a; -pdw,CREATE TABLE #@a WITH (DISTRIBUTION = @b) AS,"CREATE TABLE #@a WITH (LOCATION = USER_DB, DISTRIBUTION = @b) AS" -pdw,HINT DISTRIBUTE_ON_KEY(@key) @hint CREATE TABLE @table (@definition);,HINT DISTRIBUTE_ON_KEY(@key) @hint\nCREATE TABLE @table (@definition)\nWITH (DISTRIBUTION = HASH(@key)); -pdw,HINT DISTRIBUTE_ON_RANDOM @hint CREATE TABLE @table (@definition);,HINT DISTRIBUTE_ON_RANDOM @hint\nCREATE TABLE @table (@definition)\nWITH (DISTRIBUTION = ROUND_ROBIN); -pdw,CREATE TABLE @table (@definition_part1 subject_id @definition_part2);,CREATE TABLE @table (@definition_part1 subject_id @definition_part2)\nWITH (DISTRIBUTION = HASH(subject_id)); -pdw,CREATE TABLE @table (@definition_part1 person_id @definition_part2);,CREATE TABLE @table (@definition_part1 person_id @definition_part2)\nWITH (DISTRIBUTION = HASH(person_id)); -pdw,CREATE TABLE @table (@definition_part1 analysis_id @definition_part2);,CREATE TABLE @table (@definition_part1 analysis_id @definition_part2)\nWITH (DISTRIBUTION = HASH(analysis_id)); -pdw,CREATE TABLE @table (@definition);,CREATE TABLE @table (@definition)\nWITH (DISTRIBUTION = REPLICATE); -pdw,CREATE TABLE #@table (@definition) WITH (DISTRIBUTION = @distribution);,"CREATE TABLE #@table (@definition)\nWITH (LOCATION = USER_DB, DISTRIBUTION = @distribution);" -pdw,[ person_id ],[person_id] -pdw,[ subject_id ],[subject_id] -pdw,[ analysis_id ],[analysis_id] -pdw,TRUNCATE TABLE ,IF XACT_STATE() = 1 COMMIT; TR*NC*T* TABLE -pdw,DROP TABLE ,IF XACT_STATE() = 1 COMMIT; DR*P TABLE -pdw,CREATE TABLE ,IF XACT_STATE() = 1 COMMIT; CR**T* TABLE -pdw,TR*NC*T*,TRUNCATE -pdw,DR*P,DROP -pdw,CR**T*,CREATE -pdw,IF OBJECT_ID(@a) IS NOT NULL IF XACT_STATE() = 1 COMMIT;,IF XACT_STATE() = 1 COMMIT; IF OBJECT_ID(@a) IS NOT NULL -pdw,IF OBJECT_ID(@a) IS NULL IF XACT_STATE() = 1 COMMIT;,IF XACT_STATE() = 1 COMMIT; IF OBJECT_ID(@a) IS NULL -pdw,"CONSTRAINT @a DEFAULT GETDATE()","" -pdw,"DEFAULT GETDATE()","" -pdw,CREATE INDEX @index_name ON @table (@variable) WHERE @b;,CREATE INDEX @index_name ON @table (@variable); -impala,...@([0-9]+|y)a,xxx@a -impala,TRY_CAST(@a),CAST(@a) -impala,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -impala,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,-- impala does not support indexes -impala,CREATE INDEX @index_name ON @table (@variable);,-- impala does not support indexes -impala,"CHARINDEX(@a,@b)","INSTR(@b,@a)" -impala,COUNT_BIG(@a),COUNT(@a) -impala,"LEFT(@str,@chars)","SUBSTR(@str,1,@chars)" -impala,LEN(@a),LENGTH(@a) -impala,LOG(@expression),LN(@expression) -impala,NEWID(),UUID() -impala,"RIGHT(@str,@chars)","SUBSTR(@str,-@chars)" -impala,"ROUND(@a,@b)","ROUND(CAST(@a AS DOUBLE),@b)" -impala,SQUARE(@a),((@a)*(@a)) -impala,STDEV(@a),STDDEV(@a) -impala,VAR(@a),VARIANCE(@a) -impala,"DATEADD(d,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" -impala,"DATEADD(dd,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" -impala,"DATEADD(day,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" -impala,"DATEADD(month,@months,CAST(@date AS DATE))","ADD_MONTHS(@date, @months)" -impala,"DATEADD(mm,@months,CAST(@date AS DATE))","ADD_MONTHS(@date, @months)" -impala,"DATEADD(m,@months,CAST(@date AS DATE))","ADD_MONTHS(@date, @months)" -impala,"DATEADD(year,@years,CAST(@date AS DATE))","ADD_MONTHS(@date, 12 * @years)" -impala,"DATEADD(yyyy,@years,CAST(@date AS DATE))","ADD_MONTHS(@date, 12 * @years)" -impala,"DATEADD(yy,@years,CAST(@date AS DATE))","ADD_MONTHS(@date, 12 * @years)" -impala,"DATEADD(d,@days,@date)","DATE_ADD(CAST(@date AS DATE), @days)" -impala,"DATEADD(dd,@days,@date)","DATE_ADD(CAST(@date AS DATE), @days)" -impala,"DATEADD(day,@days,@date)","DATE_ADD(CAST(@date AS DATE), @days)" -impala,"DATEADD(month,@months,@date)","ADD_MONTHS(CAST(@date AS DATE), @months)" -impala,"DATEADD(mm,@months,@date)","ADD_MONTHS(CAST(@date AS DATE), @months)" -impala,"DATEADD(m,@months,@date)","ADD_MONTHS(CAST(@date AS DATE), @months)" -impala,"DATEADD(year,@years,@date)","ADD_MONTHS(CAST(@date AS DATE), 12 * @years)" -impala,"DATEADD(yyyy,@years,@date)","ADD_MONTHS(CAST(@date AS DATE), 12 * @years)" -impala,"DATEADD(yy,@years,@date)","ADD_MONTHS(CAST(@date AS DATE), 12 * @years)" -impala,"DATEDIFF(d,@start, @end)","DATEDIFF(CAST(@end AS DATE), CAST(@start AS DATE))" -impala,"DATEDIFF(dd,@start, @end)","DATEDIFF(CAST(@end AS DATE), CAST(@start AS DATE))" -impala,"DATEDIFF(day,@start, @end)","DATEDIFF(CAST(@end AS DATE), CAST(@start AS DATE))" -impala,"DATEDIFF(year,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) -impala,"DATEDIFF(yyyy,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) -impala,"DATEDIFF(yy,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) -impala,"DATEDIFF(month,@start, @end)","INT_MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" -impala,"DATEDIFF(mm,@start, @end)","INT_MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" -impala,"DATEDIFF(m,@start, @end)","INT_MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" -impala,"DATEFROMPARTS(@year,@month,@day)","to_timestamp(CONCAT(CAST(@year AS VARCHAR),'-',CAST(@month AS VARCHAR),'-',CAST(@day AS VARCHAR)), 'yyyy-M-d')" -impala,"eomonth(@date)","days_sub(add_months(trunc(CAST(@date AS TIMESTAMP), 'MM'),1),1)" -impala,DAY(@date),DAY(CAST(@date AS DATE)) -impala,GETDATE(),NOW() -impala,MONTH(@date),MONTH(CAST(@date AS DATE)) -impala,YEAR(@date),YEAR(CAST(@date AS DATE)) -impala,"DATEPART(YEAR, @date)",YEAR(CAST(@date AS DATE)) -impala,"DATEPART(MONTH, @date)",MONTH(CAST(@date AS DATE)) -impala,"DATEPART(DAY, @date)",DAY(CAST(@date AS DATE)) -impala,CAST(@a AS DATE),"CASE TYPEOF(@a) WHEN 'TIMESTAMP' THEN CAST(@a AS TIMESTAMP) ELSE TO_UTC_TIMESTAMP(CONCAT_WS('-', SUBSTR(CAST(@a AS STRING), 1, 4), SUBSTR(CAST(@a AS STRING), 5, 2), SUBSTR(CAST(@a AS STRING), 7, 2)), 'UTC') END" -impala,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -impala,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -impala,(SELECT @a UNION SELECT @b) ORDER BY,SELECT * FROM\n(SELECT @a\nUNION\nSELECT @b)\nAS t1 ORDER BY -impala,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d STORED AS PARQUET\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\n UPDATE STATISTICS @d; -impala,SELECT @a INTO @b FROM @c;,CREATE TABLE @b STORED AS PARQUET AS\nSELECT\n@a\nFROM\n@c;\n UPDATE STATISTICS @b; -impala,SELECT @a INTO @b;,CREATE TABLE @b STORED AS PARQUET AS\nSELECT\n@a;\n UPDATE STATISTICS @b; -impala,SELECT DISTINCT @a FROM @b INTERSECT SELECT DISTINCT @a FROM @c;,SELECT t1.@a FROM (SELECT DISTINCT @a FROM @b UNION ALL SELECT DISTINCT @a FROM @c) AS t1 GROUP BY @a HAVING COUNT(*) >= 2; -impala,(SELECT DISTINCT @a FROM @b INTERSECT SELECT DISTINCT @a FROM @c),(SELECT t1.@a FROM (SELECT DISTINCT @a FROM @b UNION ALL SELECT DISTINCT @a FROM @c) AS t1 GROUP BY @a HAVING COUNT(*) >= 2) -impala,DELETE FROM @a WHERE @b;,INSERT OVERWRITE TABLE @a SELECT * FROM @a WHERE NOT(@b); -impala,DELETE FROM @a;,TRUNCATE TABLE @a; -impala,.dbo.,. -impala,##, -impala,#@([^\s]+)table.@([^\s]+)field,%session_id%@table.@field -impala,#,%temp_prefix%%session_id% -impala,,## -impala,.location,.`location` -impala,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; -impala,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) -impala,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; -impala,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) -impala,DATE,TIMESTAMP -impala,DATETIME,TIMESTAMP -impala,DATETIME2,TIMESTAMP -impala,BIGINT NOT NULL,BIGINT -impala,BOOLEAN NOT NULL,BOOLEAN -impala,CHAR NOT NULL,CHAR -impala,DECIMAL NOT NULL,DECIMAL -impala,DOUBLE PRECISION,DOUBLE -impala,DOUBLE NOT NULL,DOUBLE -impala,FLOAT NOT NULL,FLOAT -impala,INT NOT NULL,INT -impala,INTEGER NOT NULL,INT -impala,REAL NOT NULL,REAL -impala,SMALLINT NOT NULL,SMALLINT -impala,STRING NOT NULL,STRING -impala,TIMESTAMP NOT NULL,TIMESTAMP -impala,TINYINT NOT NULL,TINYINT -impala,VARCHAR(@a) NOT NULL,VARCHAR(@a) -impala,BIGINT NULL,BIGINT -impala,BOOLEAN NULL,BOOLEAN -impala,CHAR NULL,CHAR -impala,DECIMAL NULL,DECIMAL -impala,DOUBLE NULL,DOUBLE -impala,FLOAT NULL,FLOAT -impala,INT NULL,INT -impala,REAL NULL,REAL -impala,SMALLINT NULL,SMALLINT -impala,STRING NULL,STRING -impala,TIMESTAMP NULL,TIMESTAMP -impala,TINYINT NULL,TINYINT -impala,VARCHAR(@a) NULL,VARCHAR(@a) -impala,"CHAR,","CHAR(1)," -impala,"CHAR\n+","CHAR(1)\n" -impala,"CHAR)","CHAR(1))" -impala,"CONSTRAINT @a DEFAULT NOW()","" -impala,"DEFAULT NOW()","" -impala,stats,_stats -impala,UPDATE STATISTICS @a;,COMPUTE STATS @a; -impala,"ISNUMERIC(@a)","case when regexp_like(@a,'^([0-9]+\.?[0-9]*|\.[0-9]+)$') then 1 else 0 end" -impala,"HASHBYTES('MD5',@a)","fnv_hash(@a)" -impala,"CONVERT(VARBINARY, @a, 1)","cast(conv(@a, 16, 10) as int)" -netezza,...@([0-9]+|y)a,xxx@a -netezza,TRY_CAST(@a),CAST(@a) -netezza,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -netezza,HINT DISTRIBUTE_ON_KEY(@key)\n@statement;,HINT DISTRIBUTE_ON_KEY(@key)\n@statement\nDISTRIBUTE ON (@key); -netezza,HINT DISTRIBUTE_ON_RANDOM\n@statement;,HINT DISTRIBUTE_ON_RANDOM\n@statement\nDISTRIBUTE ON RANDOM; -netezza,CREATE TABLE #@table (@definition);,CREATE TEMP TABLE @table (@definition); -netezza,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -netezza,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -netezza,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a\nFROM\n@c; -netezza,SELECT @a INTO @b FROM @c;,CREATE TABLE @b\nAS\nSELECT\n@a\nFROM\n@c; -netezza,SELECT @a INTO #@b;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a; -netezza,SELECT @a INTO @b;,CREATE TABLE @b\nAS\nSELECT @a; -netezza,"ROUND(@a,@b)","ROUND(CAST(@a AS NUMERIC),@b)" -netezza,"CAST('@a' AS DATE)","TO_DATE('@a', 'yyyymmdd')" -netezza,"CAST('@a' + @b AS DATE)","TO_DATE('@a' + @b, 'yyyymmdd')" -netezza,"CAST(@a + '@b' AS DATE)","TO_DATE(@a + '@b', 'yyyymmdd')" -netezza,"CAST(CONCAT(@a) AS DATE)","TO_DATE(CONCAT(@a), 'yyyymmdd')" -netezza,CAST(@a AS INT),CAST(@a AS INTEGER) -netezza,CAST(@a AS VARCHAR),CAST(@a AS VARCHAR(1000)) -netezza,"CONVERT(VARCHAR,@date,112)","TO_CHAR(@date, 'YYYYMMDD')" -netezza,"DATEADD(d,@days,@date)",(@date + @days) -netezza,"DATEADD(dd,@days,@date)",(@date + @days) -netezza,"DATEADD(day,@days,@date)",(@date + @days) -netezza,"DATEADD(m,@months,@date)",CAST((@date + @months*INTERVAL'1 month') AS DATE) -netezza,"DATEADD(mm,@months,@date)",CAST((@date + @months*INTERVAL'1 month') AS DATE) -netezza,"DATEADD(month,@months,@date)",CAST((@date + @months*INTERVAL'1 month') AS DATE) -netezza,"DATEADD(yy,@years,@date)",CAST((@date + @years*INTERVAL'1 year') AS DATE) -netezza,"DATEADD(yyyy,@years,@date)",CAST((@date + @years*INTERVAL'1 year') AS DATE) -netezza,"DATEADD(year,@years,@date)",CAST((@date + @years*INTERVAL'1 year') AS DATE) -netezza,"DATEDIFF(d,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) -netezza,"DATEDIFF(dd,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) -netezza,"DATEDIFF(day,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) -netezza,"DATEDIFF(year,@start, @end)","(DATE_PART('YEAR', CAST(@end AS DATE)) - DATE_PART('YEAR', CAST(@start AS DATE)))" -netezza,"DATEDIFF(yyyy,@start, @end)","(DATE_PART('YEAR', CAST(@end AS DATE)) - DATE_PART('YEAR', CAST(@start AS DATE)))" -netezza,"DATEDIFF(yy,@start, @end)","(DATE_PART('YEAR', CAST(@end AS DATE)) - DATE_PART('YEAR', CAST(@start AS DATE)))" -netezza,"DATEDIFF(month,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" -netezza,"DATEDIFF(mm,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" -netezza,"DATEDIFF(m,@start, @end)","MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE))" -netezza,GETDATE(),CURRENT_DATE -netezza,+ '@a',|| '@a' -netezza,'@a' +,'@a' || -netezza,YEAR(@variable),"DATE_PART('YEAR', @variable)" -netezza,MONTH(@variable),"DATE_PART('MONTH', @variable)" -netezza,DAY(@variable),"DATE_PART('DAY', @variable)" -netezza,"DATEPART(YEAR, @date)","DATEPART('YEAR', @date)" -netezza,"DATEPART(MONTH, @date)","DATEPART('MONTH', @date)" -netezza,"DATEPART(DAY, @date)","DATEPART('DAY', @date)" -netezza,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || -netezza,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) -netezza,STDEV(@a),STDDEV(@a) -netezza,LEN(@a),CHAR_LENGTH(@a) -netezza,"LOG(@expression,@base)",(LN(@expression)/LN(@base)) -netezza,LOG(@expression),LN(@expression) -netezza,LOG10(@expression),LOG(@expression) -netezza,"ISNULL(@a,@b)","COALESCE(@a,@b)" -netezza,COUNT_BIG(@a),COUNT(@a) -netezza,USE @schema;,SET search_path TO @schema; -netezza,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition) DISTRIBUTE ON RANDOM; -netezza,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE @table IF EXISTS; -netezza,.dbo.,. -netezza,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,-- netezza does not support indexes -netezza,CREATE INDEX @index_name ON @table (@variable);,-- netezza does not support indexes -netezza,PRIMARY KEY NONCLUSTERED,PRIMARY KEY -netezza,VARCHAR(MAX),VARCHAR(1000) -netezza,FLOAT,"FLOAT(6)" -netezza,#, -netezza,"LEFT(@variable,@b)","SUBSTR(@variable, 1, @b)" -netezza,"RIGHT(@variable,@b)","SUBSTR(@variable, LENGTH(@variable)-@b+1, @b)" -netezza,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; -netezza,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) -netezza,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; -netezza,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) -netezza,"CONCAT(@a, @b, @c)","CONCAT(@a, CONCAT(@b, @c))" -netezza,"CONCAT(@a,@b)","@a || @b" -netezza,"POWER(@a,@b)","POW(@a,@b)" -netezza,EOMONTH(@date),LAST_DAY(@date) -netezza,ROWCOUNT(), ROW_NUMBER() -netezza,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00'), 'YYYY-MM-DD')" -netezza,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","TO_DATE(TO_CHAR(@year,'0000')||'-'||TO_CHAR(@month,'00')||'-'||TO_CHAR(@day,'00')||' '||TO_CHAR(@hour,'00')||':'||TO_CHAR(@minute,'00')||':'||TO_CHAR(@second,'00'), 'YYYY-MM-DD HH24:MI:SS')" -netezza,WITH @a INSERT INTO @b SELECT @c;,INSERT INTO @b WITH @a SELECT @c; -netezza,UPDATE STATISTICS @a;,GENERATE STATISTICS ON @a; -netezza,DATETIME,TIMESTAMP -netezza,DATETIME2,TIMESTAMP -netezza,"ISNUMERIC(@a)","CASE WHEN translate(@a,'0123456789','') in ('','.','-','-.') THEN 1 ELSE 0 END" -netezza,"HASHBYTES('MD5',@a)","hash(@a)" -netezza,"CONVERT(VARBINARY, @a, 1)","hex_to_binary(@a)" -netezza,RAND(),RANDOM() -netezza,DROP TABLE IF EXISTS #@table;,DROP TABLE @table IF EXISTS; -netezza,DROP TABLE IF EXISTS @table;,DROP TABLE @table IF EXISTS; -bigquery,...@([0-9]+|y)a,xxx@a -bigquery,"AS drvd(@a)","AS drvd(@a)" -bigquery,"@a, @b)","@a, @b)" -bigquery,"","NULL AS " -bigquery,"FROM (VALUES @a) AS drvd","FROM (@a) AS drvd" -bigquery,"@a, @b)","@a UNION ALL @b)" -bigquery,"(@a)","SELECT @a" -bigquery,"FROM (SELECT @a) AS drvd(@b)","FROM (SELECT @b UNION ALL SELECT @a LIMIT 999999 OFFSET 1) AS values_table" -bigquery,TRY_CAST(@a),CAST(@a) -bigquery,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -bigquery,"CONVERT(VARBINARY, @a, 1)","safe_cast(concat('0x', @a) as int64)" -bigquery,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; -bigquery,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) -bigquery,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; -bigquery,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) -bigquery,"DATEDIFF(d,@start, @end)","DATE_DIFF(cast(@end as date), cast(@start as date), DAY)" -bigquery,"DATEDIFF(dd,@start, @end)","DATE_DIFF(cast(@end as date), cast(@start as date), DAY)" -bigquery,"DATEDIFF(day,@start, @end)","DATE_DIFF(cast(@end as date), cast(@start as date), DAY)" -bigquery,"DATEDIFF(year,@start, @end)",(EXTRACT(YEAR from CAST(@end AS DATE)) - EXTRACT(YEAR from CAST(@start AS DATE))) -bigquery,"DATEDIFF(yyyy,@start, @end)",(EXTRACT(YEAR from CAST(@end AS DATE)) - EXTRACT(YEAR from CAST(@start AS DATE))) -bigquery,"DATEDIFF(yy,@start, @end)",(EXTRACT(YEAR from CAST(@end AS DATE)) - EXTRACT(YEAR from CAST(@start AS DATE))) -bigquery,"DATEDIFF(month,@start, @end)","((12 * EXTRACT(YEAR FROM CAST(@end AS DATE)) + EXTRACT(MONTH FROM CAST(@end AS DATE))) - (12 * EXTRACT(YEAR FROM CAST(@start AS DATE)) + EXTRACT(MONTH FROM CAST(@start AS DATE))) + IF(EXTRACT(DAY FROM CAST(@end AS DATE)) >= EXTRACT(DAY FROM CAST(@start AS DATE)), 0, -1))" -bigquery,"DATEDIFF(mm,@start, @end)","((12 * EXTRACT(YEAR FROM CAST(@end AS DATE)) + EXTRACT(MONTH FROM CAST(@end AS DATE))) - (12 * EXTRACT(YEAR FROM CAST(@start AS DATE)) + EXTRACT(MONTH FROM CAST(@start AS DATE))) + IF(EXTRACT(DAY FROM CAST(@end AS DATE)) >= EXTRACT(DAY FROM CAST(@start AS DATE)), 0, -1))" -bigquery,"DATEDIFF(m,@start, @end)","((12 * EXTRACT(YEAR FROM CAST(@end AS DATE)) + EXTRACT(MONTH FROM CAST(@end AS DATE))) - (12 * EXTRACT(YEAR FROM CAST(@start AS DATE)) + EXTRACT(MONTH FROM CAST(@start AS DATE))) + IF(EXTRACT(DAY FROM CAST(@end AS DATE)) >= EXTRACT(DAY FROM CAST(@start AS DATE)), 0, -1))" -bigquery,"DATEADD(d,@days,@date)","DATE_ADD(cast(@date as date), INTERVAL @days DAY)" -bigquery,"DATEADD(dd,@days,@date)","DATE_ADD(cast(@date as date), INTERVAL @days DAY)" -bigquery,"DATEADD(day,@days,@date)","DATE_ADD(cast(@date as date), INTERVAL @days DAY)" -bigquery,"DATEADD(m,@months,@date)","DATE_ADD(cast(@date as date), INTERVAL @months MONTH)" -bigquery,"DATEADD(mm,@months,@date)","DATE_ADD(cast(@date as date), INTERVAL @months MONTH)" -bigquery,"DATEADD(month,@months,@date)","DATE_ADD(cast(@date as date), INTERVAL @months MONTH)" -bigquery,"DATEADD(yy,@years,@date)","DATE_ADD(@date, INTERVAL @years YEAR)" -bigquery,"DATEADD(yyyy,@years,@date)","DATE_ADD(@date, INTERVAL @years YEAR)" -bigquery,"DATEADD(year,@years,@date)","DATE_ADD(@date, INTERVAL @years YEAR)" -bigquery,INTERVAL @(-?[0-9]+)a.0,INTERVAL @a -bigquery,CAST(@a AS VARCHAR) + @b(@c),"CONCAT(CAST(@a AS VARCHAR), @b(@c))" -bigquery,@([a-z]+)a(@b) + CAST(@c AS VARCHAR),"CONCAT(@a(@b), CAST(@c AS VARCHAR))" -bigquery,CAST(@a AS VARCHAR(@n)) + @b(@c),"CONCAT(CAST(@a AS VARCHAR(@n)), @b(@c))" -bigquery,@([a-z]+)a(@b) + CAST(@c AS VARCHAR(@n)),"CONCAT(@a(@b), CAST(@c AS VARCHAR(@n)))" -bigquery,'@a' + @b(@c),"CONCAT('@a', @b(@c))" -bigquery,@([a-z]+)a(@b) + '@c',"CONCAT(@a(@b), '@c')" -bigquery,'@a' + @b FROM,"CONCAT('@a', @b) FROM" -bigquery,@([a-z0-9_]+)a + '@b',"CONCAT(@a, '@b')" -bigquery,CAST(@a AS VARCHAR) + @b FROM,"CONCAT(CAST(@a AS VARCHAR), @b) FROM" -bigquery,@([a-z0-9_]+)a + CAST(@b AS VARCHAR),"CONCAT(@a, CAST(@b AS VARCHAR))" -bigquery,CAST(@a AS VARCHAR(@n)) + @b FROM,"CONCAT(CAST(@a AS VARCHAR(@n)), @b) FROM" -bigquery,@([a-z0-9_]+)a + CAST(@b AS VARCHAR(@n)),"CONCAT(@a, CAST(@b AS VARCHAR(@n)))" -bigquery,CONCAT(@a) + @b(@c),"CONCAT(@a, @b(@c))" -bigquery,@([a-z]+)a(@b) + CONCAT(@c),"CONCAT(@a(@b), @c)" -bigquery,CONCAT(@a) + @b FROM,"CONCAT(@a, @b) FROM" -bigquery,@([a-z0-9_]+)a + CONCAT(@b),"CONCAT(@a, @b)" -bigquery,"CONCAT(@a, CONCAT(@b, @c))","CONCAT(@a, @b, @c)" -bigquery,"CONCAT(CONCAT(@a, @b), @c)","CONCAT(@a, @b, @c)" -bigquery,"CONCAT(CONCAT(@a, @b, @c))","CONCAT(@a, @b, @c)" -bigquery,"STDEV(@a)","STDDEV(@a)" -bigquery,"HASHBYTES('MD5',@a)","md5(@a)" -bigquery,"LEN(@a)","LENGTH(@a)" -bigquery,"COUNT_BIG(@a)","COUNT(@a)" -bigquery,"cast(@a % @b as int)","CAST(MOD(@a, @b) AS INT64)" -bigquery,"cast((@a % @b) as int)","CAST(MOD(@a, @b) AS INT64)" -bigquery,"cast(@a) % @([0-9]+)b","MOD(CAST(@a), @b)" -bigquery,"cast(@a) % cast(@b)","MOD(CAST(@a), CAST(@b))" -bigquery,"CAST(@a as:string)","CAST(@a as string)" -bigquery,"CAST(@a as:integer)","CAST(@a as int64)" -bigquery,"CAST(@a as:float)","CAST(@a as float64)" -bigquery,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -bigquery,WITH @a SELECT @b INTO @c FROM @d;,CREATE TABLE @c AS WITH @a SELECT @b FROM @d; -bigquery,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; -bigquery,SELECT @a INTO @b;,CREATE TABLE @b\nAS\nSELECT\n@a; -bigquery,WITH @a INSERT INTO @b SELECT @c;,INSERT INTO @b WITH @a SELECT @c; -bigquery,"LEFT(@str,@chars)","SUBSTR(@str,0,@chars)" -bigquery,"RIGHT(@str,@chars)","SUBSTR(@str,-@chars)" -bigquery,"cast(@a as float)","cast(@a as float64)" -bigquery,"cast(@a as bigint)","cast(@a as int64)" -bigquery,"cast(@a as int)","cast(@a as int64)" -bigquery,date(@a),cast(@a as date) -bigquery,"cast(concat(@a) as date)","parse_date('%Y%m%d', concat(@a))" -bigquery,"cast(@a as date)","IF(SAFE_CAST(@a AS DATE) IS NULL,PARSE_DATE('%Y%m%d', cast(@a AS STRING)),SAFE_CAST(@a AS DATE))" -bigquery,"YEAR(@date)","EXTRACT(YEAR from @date)" -bigquery,"MONTH(@date)","EXTRACT(MONTH from @date)" -bigquery,"DAY(@date)","EXTRACT(DAY from @date)" -bigquery,"DATEPART(YEAR, @date)",EXTRACT(YEAR FROM @date) -bigquery,"DATEPART(MONTH, @date)",EXTRACT(MONTH FROM @date) -bigquery,"DATEPART(DAY, @date)",EXTRACT(DAY FROM @date) -bigquery,"union select","union distinct select" -bigquery,INTERSECT,INTERSECT DISTINCT -bigquery,"ISNULL(@a,@b)","IFNULL(@a,@b)" -bigquery,as \" @a \",as @a -bigquery,"coalesce(@([0-9]+)a, @b)","coalesce(@a, cast(@b as int64))" -bigquery,"coalesce(@a, @([0-9]+)b)","coalesce(cast(@a as int64), @b)" -bigquery,"cast(@a as decimal(@b))","cast(@a as float64)" -bigquery,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -bigquery,"int","INT64" -bigquery,"DATETIME2","datetime" -bigquery,"INTEGER","INT64" -bigquery,"bigint","INT64" -bigquery,"float","FLOAT64" -bigquery,VARCHAR(@a),STRING -bigquery,VARCHAR,STRING -bigquery,CHAR(@a),STRING -bigquery,CHAR,STRING -bigquery,STRING NULL,STRING -bigquery,DATE NULL,DATE -bigquery,DATETIME NULL,DATETIME -bigquery,INT64 NULL,INT64 -bigquery,FLOAT64 NULL,FLOAT64 -bigquery,NUMERIC NULL,NUMERIC -bigquery,"DOUBLE PRECISION","FLOAT64" -bigquery,"GETDATE()","CURRENT_DATE()" -bigquery,"CONSTRAINT @a DEFAULT CURRENT_DATE()","" -bigquery,DEFAULT @([0-9]+)a,"" -bigquery,DEFAULT \"@a\","" -bigquery,"DEFAULT CURRENT_DATE()","" -bigquery,TRUNCATE TABLE @a;,DELETE FROM @a WHERE True; -bigquery,CREATE TABLE #@([^\s]+)table,DROP TABLE IF EXISTS %temp_prefix%%session_id%@table;\nCREATE TABLE %temp_prefix%%session_id%@table -bigquery,#@([^\s]+)table.@([^\s]+)field,%session_id%@table.@field -bigquery,#,%temp_prefix%%session_id% -bigquery,CREATE INDEX @index_name ON @table_col_cond;,-- bigquery does not support indexes -bigquery,DROP INDEX @index_name;,-- bigquery does not support indexes -bigquery,"DATEFROMPARTS(@year,@month,@day)","DATE(@year, @month, @day)" -bigquery,EOMONTH(@date),"DATE_SUB(DATE_TRUNC(DATE_ADD(@date, INTERVAL 1 MONTH), MONTH), INTERVAL 1 DAY)" -bigquery,"ISNUMERIC(@a)","CASE WHEN SAFE_CAST(@a AS FLOAT64) IS NULL THEN 0 ELSE 1 END" -bigquery,UPDATE STATISTICS @a;,-- big query does not support such functionality -bigquery,NEWID(),GENERATE_UUID() -bigquery,"AS @(q[0-9]+)a","AS val_@a" -bigquery,"(@(q[0-9]+)a","(val_@a" -bigquery,"CHARINDEX(@a,@b)","STRPOS(@b,@a)" -bigquery,"\"","`" -sqlite,...@([0-9]+|y)a,xxx@a -sqlite,"AS drvd(@a)","AS drvd(@a)" -sqlite,"@a, @b)","@a, @b)" -sqlite,"","NULL AS " -sqlite,"FROM (VALUES @a) AS drvd(@b)","FROM (SELECT @b WHERE (0 = 1) UNION ALL VALUES @a) AS values_table" -sqlite,TRY_CAST(@a),CAST(@a) -sqlite,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -sqlite,"ROUND(@a,@b)","ROUND(CAST(@a AS REAL),@b)" -sqlite,DATETIME,REAL -sqlite,DATETIME2,REAL -sqlite,"CONVERT(DATE, @a)","CAST(STRFTIME('%s', SUBSTR(CAST(@a AS TEXT), 1, 4) || '-' || SUBSTR(CAST(@a AS TEXT), 5, 2) || '-' || SUBSTR(CAST(@a AS TEXT), 7)) AS REAL)" -sqlite,CAST(@a AS DATE),"CAST(STRFTIME('%s', SUBSTR(CAST(@a AS TEXT), 1, 4) || '-' || SUBSTR(CAST(@a AS TEXT), 5, 2) || '-' || SUBSTR(CAST(@a AS TEXT), 7)) AS REAL)" -sqlite,"DATEADD(second,@seconds,@datetime)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@seconds)||' seconds')) AS REAL)" -sqlite,"DATEADD(minute,@minutes,@datetime)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@minutes)||' minutes')) AS REAL)" -sqlite,"DATEADD(hour,@hours,@datetime)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@hours)||' hours')) AS REAL)" -sqlite,"DATEADD(d,@days,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@days)||' days')) AS REAL)" -sqlite,"DATEADD(dd,@days,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@days)||' days')) AS REAL)" -sqlite,"DATEADD(day,@days,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@days)||' days')) AS REAL)" -sqlite,"DATEADD(m,@months,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@months)||' months')) AS REAL)" -sqlite,"DATEADD(mm,@months,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@months)||' months')) AS REAL)" -sqlite,"DATEADD(month,@months,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@months)||' months')) AS REAL)" -sqlite,"DATEADD(yy,@years,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@years)||' years')) AS REAL)" -sqlite,"DATEADD(yyyy,@years,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@years)||' years')) AS REAL)" -sqlite,"DATEADD(year,@years,@date)","CAST(STRFTIME('%s', DATETIME(@date, 'unixepoch', (@years)||' years')) AS REAL)" -sqlite,"DATEDIFF(d,@start,@end)","(JULIANDAY(@end, 'unixepoch') - JULIANDAY(@start, 'unixepoch'))" -sqlite,"DATEDIFF(dd,@start,@end)","(JULIANDAY(@end, 'unixepoch') - JULIANDAY(@start, 'unixepoch'))" -sqlite,"DATEDIFF(day,@start,@end)","(JULIANDAY(@end, 'unixepoch') - JULIANDAY(@start, 'unixepoch'))" -sqlite,"DATEDIFF(year,@start, @end)","(STRFTIME('%Y', @end, 'unixepoch') - STRFTIME('%Y', @start, 'unixepoch'))" -sqlite,"DATEDIFF(yyyy,@start, @end)","(STRFTIME('%Y', @end, 'unixepoch') - STRFTIME('%Y', @start, 'unixepoch'))" -sqlite,"DATEDIFF(yy,@start, @end)","(STRFTIME('%Y', @end, 'unixepoch') - STRFTIME('%Y', @start, 'unixepoch'))" -sqlite,"DATEDIFF(MONTH,@start, @end)","((STRFTIME('%Y', @end, 'unixepoch')*12 + STRFTIME('%m', @end, 'unixepoch')) - (STRFTIME('%Y', @start, 'unixepoch')*12 + STRFTIME('%m', @start, 'unixepoch')) + (CASE WHEN STRFTIME('%d', @end, 'unixepoch') >= STRFTIME('%d', @start, 'unixepoch') then 0 else -1 end))" -sqlite,"JULIANDAY('@literal', 'unixepoch')","JULIANDAY(CAST(STRFTIME('%s', SUBSTR(CAST('@literal' AS TEXT), 1, 4) || '-' || SUBSTR(CAST('@literal' AS TEXT), 5, 2) || '-' || SUBSTR(CAST('@literal' AS TEXT), 7)) AS REAL), 'unixepoch')" -sqlite,"STRFTIME('%Y', '@literal', 'unixepoch')","CAST(SUBSTR('@literal', 1, 4) AS REAL)" -sqlite,"STRFTIME('%m', '@literal', 'unixepoch')","CAST(SUBSTR('@literal', 5, 2) AS REAL)" -sqlite,"STRFTIME('%d', '@literal', 'unixepoch')","CAST(SUBSTR('@literal', 7, 2) AS REAL)" -sqlite,"CONVERT(VARCHAR,@date,112)","CAST(STRFTIME('%Y%m%d', @date) AS REAL)" -sqlite,GETDATE(),"STRFTIME('%s','now')" -sqlite,+ '@a',|| '@a' -sqlite,'@a' +,'@a' || -sqlite,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || -sqlite,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) -sqlite,CAST(@a AS varchar) +,CAST(@a AS varchar) || -sqlite,+ CAST(@a AS varchar),|| CAST(@a AS varchar) -sqlite,"DATEFROMPARTS(@year,@month,@day)","STRFTIME('%s', SUBSTR(CAST('0000'||CAST(@year AS INT) AS TEXT),-4) || '-' || SUBSTR(CAST('00'||CAST(@month AS INT) AS TEXT),-2) || '-' || SUBSTR(CAST('00'||CAST(@day AS INT) AS TEXT),-2))" -sqlite,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","STRFTIME('%s', SUBSTR(CAST('0000'||CAST(@year AS INT) AS TEXT),-4) || '-' || SUBSTR(CAST('00'||CAST(@month AS INT) AS TEXT),-2) || '-' || SUBSTR(CAST('00'||CAST(@day AS INT) AS TEXT),-2) || ' ' || SUBSTR(CAST('00'||CAST(@hour AS INT) AS TEXT),-2) || ':' || SUBSTR(CAST('00'||CAST(@minute AS INT) AS TEXT),-2) || ':' || SUBSTR(CAST('00'||CAST(@second AS INT) AS TEXT),-2) || '.' || SUBSTR(CAST('000'||CAST(@ms AS INT) AS TEXT),-3))" -sqlite,YEAR(@date),"CAST(STRFTIME('%Y', @date, 'unixepoch') AS INT)" -sqlite,MONTH(@date),"CAST(STRFTIME('%m', @date, 'unixepoch') AS INT)" -sqlite,DAY(@date),"CAST(STRFTIME('%d', @date, 'unixepoch') AS INT)" -sqlite,"DATEPART(YEAR, @date)","CAST(STRFTIME('%Y', @date, 'unixepoch') AS INT)" -sqlite,"DATEPART(MONTH, @date)","CAST(STRFTIME('%m', @date, 'unixepoch') AS INT)" -sqlite,"DATEPART(DAY, @date)","CAST(STRFTIME('%d', @date, 'unixepoch') AS INT)" -sqlite,EOMONTH(@date),"STRFTIME('%s', DATETIME(@date, 'unixepoch', 'start of month', '+1 month', '-1 day'))" -sqlite,VAR(@a),VARIANCE(@a) -sqlite,RAND(),((RANDOM()+9223372036854775808) / 18446744073709551615) -sqlite,LEN(@a),LENGTH(@a) -sqlite,"LOG(@expression,@base)","(LOG(@expression)/LOG(@base))" -sqlite,"ISNULL(@a,@b)","COALESCE(@a,@b)" -sqlite,"ISNUMERIC(@a)","CASE WHEN @a GLOB '[0-9]*' OR @a GLOB '[0-9]*.[0-9]*' OR @a GLOB '.[0-9]*' THEN 1 ELSE 0 END" -sqlite,COUNT_BIG(@a),COUNT(@a) -sqlite,NEWID(),RANDOM() -sqlite,"RIGHT(@a,@b)","SUBSTR(CAST(@a AS TEXT),-@b)" -sqlite,"LEFT(@str,@chars)","SUBSTR(CAST(@str AS TEXT),1,@chars)" -sqlite,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -sqlite,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -sqlite,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -sqlite,.dbo.,. -sqlite,CREATE TABLE #@table (@definition),CREATE TEMP TABLE @table (@definition) -sqlite,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE INDEX @index_name ON @table (@variable); -sqlite,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable); -sqlite,CREATE INDEX @index_name ON @schema.@table (@variable);,CREATE INDEX @index_name ON @table (@variable); -sqlite,CREATE UNIQUE INDEX @index_name ON @schema.@table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable); -sqlite,DROP INDEX @schema.@index_name;,DROP INDEX @index_name; -sqlite,PRIMARY KEY NONCLUSTERED,PRIMARY KEY -sqlite,VARCHAR(@a),TEXT -sqlite,VARCHAR,TEXT -sqlite,FLOAT,REAL -sqlite,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nANALYZE @d; -sqlite,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -sqlite,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a\nFROM\n@c;\nANALYZE @b; -sqlite,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; -sqlite,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; -sqlite,#@([^\s]+)table.@([^\s]+)field,@table.@field -sqlite,#,temp. -sqlite,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; -sqlite,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) -sqlite,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; -sqlite,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) -sqlite,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as TEXT) @s2)" -sqlite,UPDATE STATISTICS @a;,ANALYZE @a; -sqlite,TRUNCATE TABLE @a;,DELETE FROM @a; -sqlite,"CONCAT(@a, @b, @c)","CONCAT(@a, CONCAT(@b, @c))" -sqlite,"CONCAT(@a, @b)","@a || @b" -sqlite,CEILING(@a),CEIL(@a) -sqlite,IN (SELECT @a) UNION,IN ((SELECT @a)) UNION -sqlite,(SELECT @a) UNION,SELECT @a UNION -sqlite,UNION (@a),UNION @a -sqlite,UNION ALL (@a),UNION ALL @a -sqlite,"ALTER TABLE @table ALTER COLUMN @a BIGINT;","SELECT 0;" -sqlite,"ALTER TABLE @table ADD @a, @b;","ALTER TABLE @table ADD @a; ALTER TABLE @table ADD @b;" -hive,...@([0-9]+|y)a,xxx@a -hive,TRY_CAST(@a),CAST(@a) -hive,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -hive,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,-- hive does not support indexes -hive,CREATE INDEX @index_name ON @table (@variable);,-- hive does not support indexes -hive,CREATE INDEX @index_name ON @table (@variable) WHERE @c;,-- hive does not support indexes -hive,"CHARINDEX(@a,@b)","INSTR(@b,@a)" -hive,COUNT_BIG(@a),COUNT(@a) -hive,"LEFT(@str,@chars)","SUBSTR(@str,1,@chars)" -hive,LEN(@a),LENGTH(@a) -hive,LOG(@expression),LN(@expression) -hive,NEWID(),"reflect('java.util.UUID','randomUUID')" -hive,"RIGHT(@str,@chars)","SUBSTR(@str,-@chars)" -hive,"ROUND(@a,@b)","ROUND(CAST(@a AS DOUBLE),@b)" -hive,SQUARE(@a),((@a)*(@a)) -hive,STDEV(@a),STDDEV_POP(@a) -hive,VAR(@a),VARIANCE(@a) -hive,"DATEADD(d,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" -hive,"DATEADD(dd,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" -hive,"DATEADD(day,@days,CAST(@date AS DATE))","DATE_ADD(@date, @days)" -hive,"DATEADD(month,@months,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, @months) AS TIMESTAMP)" -hive,"DATEADD(mm,@months,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, @months) AS TIMESTAMP)" -hive,"DATEADD(m,@months,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, @months) AS TIMESTAMP)" -hive,"DATEADD(year,@years,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, 12 * @years) AS TIMESTAMP)" -hive,"DATEADD(yyyy,@years,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, 12 * @years) AS TIMESTAMP)" -hive,"DATEADD(yy,@years,CAST(@date AS DATE))","CAST(ADD_MONTHS(@date, 12 * @years) AS TIMESTAMP)" -hive,"DATEADD(d,@days,@date)","DATE_ADD(CAST(@date AS TIMESTAMP), @days)" -hive,"DATEADD(dd,@days,@date)","DATE_ADD(CAST(@date AS TIMESTAMP), @days)" -hive,"DATEADD(day,@days,@date)","DATE_ADD(CAST(@date AS TIMESTAMP), @days)" -hive,"DATEADD(month,@months,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), @months) AS TIMESTAMP)" -hive,"DATEADD(mm,@months,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), @months) AS TIMESTAMP)" -hive,"DATEADD(m,@months,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), @months) AS TIMESTAMP)" -hive,"DATEADD(year,@years,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), 12 * @years) AS TIMESTAMP)" -hive,"DATEADD(yyyy,@years,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), 12 * @years) AS TIMESTAMP)" -hive,"DATEADD(yy,@years,@date)","CAST(ADD_MONTHS(CAST(@date AS TIMESTAMP), 12 * @years) AS TIMESTAMP)" -hive,"DATEDIFF(d,@start, @end)","day(CAST(@end AS TIMESTAMP) - CAST(@start AS TIMESTAMP))" -hive,"DATEDIFF(dd,@start, @end)","day(CAST(@end AS TIMESTAMP) - CAST(@start AS TIMESTAMP))" -hive,"DATEDIFF(day,@start, @end)","day(CAST(@end AS TIMESTAMP) - CAST(@start AS TIMESTAMP))" -hive,"DATEDIFF(year,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) -hive,"DATEDIFF(yyyy,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) -hive,"DATEDIFF(yy,@start, @end)",(YEAR(CAST(@end AS DATE)) - YEAR(CAST(@start AS DATE))) -hive,"DATEDIFF(month,@start, @end)","CAST(MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE)) AS INT)" -hive,"DATEDIFF(mm,@start, @end)","CAST(MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE)) AS INT)" -hive,"DATEDIFF(m,@start, @end)","CAST(MONTHS_BETWEEN(CAST(@end AS DATE), CAST(@start AS DATE)) AS INT)" -hive,"DATEFROMPARTS(@year,@month,@day)","CAST(CONCAT(CAST(@year AS STRING),'-',CAST(@month AS STRING),'-',CAST(@day AS STRING)) AS TIMESTAMP)" -hive,"eomonth(@date)","CAST(last_day(@date) AS TIMESTAMP)" -hive,GETDATE(),unix_timestamp() -hive,year(unix_timestamp()),year(from_unixtime(unix_timestamp())) -hive,"DATEPART(YEAR, @date)",year(from_unixtime(unix_timestamp())) -hive,"DATEPART(MONTH, @date)",month(from_unixtime(unix_timestamp())) -hive,"DATEPART(DAY, @date)",day(from_unixtime(unix_timestamp())) -hive,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -hive,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -hive,"HINT PARTITION(@p) @before CREATE TABLE IF NOT EXISTS @table(@pid,@fields) @after;",partitioned table \n@before CREATE TABLE IF NOT EXISTS @table (@fields) @after \n PARTITIONED BY(@p); -hive,"HINT BUCKET(@bucket,@size) @before CREATE TABLE IF NOT EXISTS @table(@fields) @after;",table with bucket \n@before CREATE TABLE IF NOT EXISTS @table (@fields) @after \n CLUSTERED by (@bucket) into @size BUCKETS; -hive,"HINT PARTITION(@p) @before CREATE TABLE @table(@pid,@fields) @after;",partitioned table \n@before CREATE TABLE @table (@fields) @after \n PARTITIONED BY(@p); -hive,"HINT BUCKET(@bucket,@size) @before CREATE TABLE @table(@fields) @after;",table with bucket \n@before CREATE TABLE @table (@fields) @after \n CLUSTERED by (@bucket) into @size BUCKETS; -hive,(SELECT @a UNION SELECT @b) ORDER BY,SELECT * FROM\n(SELECT @a\nUNION\nSELECT @b)\nAS t1 ORDER BY -hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f) SELECT @i INTO #@j FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nCREATE TEMPORARY TABLE @j AS SELECT @i FROM" -hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f), @g AS (@h) SELECT @i INTO #@j FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e; DROP TABLE IF EXISTS @g;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nCREATE TEMPORARY TABLE @g AS @h;\nCREATE TEMPORARY TABLE @j AS SELECT @i FROM" -hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f), @g AS (@h), @i AS (@j) SELECT @k INTO #@l FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e; DROP TABLE IF EXISTS @g; DROP TABLE IF EXISTS @i;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nCREATE TEMPORARY TABLE @g AS @h;\nCREATE TEMPORARY TABLE @i AS @j;\nCREATE TEMPORARY TABLE @l AS SELECT @k FROM" -hive,WITH @a AS @b SELECT @c INTO #@d FROM,DROP TABLE IF EXISTS @a;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @d AS SELECT @c FROM -hive,SELECT @a INTO #@b FROM @c;,CREATE TEMPORARY TABLE IF NOT EXISTS @b AS\nSELECT\n@a\nFROM\n@c; -hive,SELECT @a INTO #@b;,CREATE TEMPORARY TABLE IF NOT EXISTS @b AS\nSELECT\n@a; -hive,CREATE TABLE #@table (@definition),CREATE TEMPORARY TABLE IF NOT EXISTS @table (@definition) -hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f) SELECT @i INTO @j FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nSELECT @i INTO @j FROM" -hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f), @g AS (@h) SELECT @i INTO @j FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e; DROP TABLE IF EXISTS @g;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nCREATE TEMPORARY TABLE @g AS @h;\nSELECT @i INTO @j FROM" -hive,"WITH @a AS (@b), @c AS (@d), @e AS (@f), @g AS (@h), @i AS (@j) SELECT @k INTO @l FROM","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; DROP TABLE IF EXISTS @e; DROP TABLE IF EXISTS @g; DROP TABLE IF EXISTS @i;\n\nCREATE TEMPORARY TABLE @a AS @b;\nCREATE TEMPORARY TABLE @c AS @d;\nCREATE TEMPORARY TABLE @e AS @f;\nCREATE TEMPORARY TABLE @g AS @h;\nCREATE TEMPORARY TABLE @i AS @j;\nSELECT @k INTO @l FROM" -hive,WITH @a AS @b SELECT @c INTO @d FROM,DROP TABLE IF EXISTS @a;\n\nCREATE TEMPORARY TABLE @a AS @b;\nSELECT @c INTO @d FROM -hive,"CREATE TEMPORARY TABLE @a AS (@b), @c (@d) as (@e)\n;","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; CREATE TEMPORARY TABLE @a AS (@b)\n;\nCREATE TEMPORARY TABLE @c AS (@e)\n;" -hive,"CREATE TEMPORARY TABLE @a AS (@b), @c as (@d)\n;","DROP TABLE IF EXISTS @a; DROP TABLE IF EXISTS @c; CREATE TEMPORARY TABLE @a AS (@b)\n;\nCREATE TEMPORARY TABLE @c AS (@d)\n;" -hive,DROP TABLE IF EXISTS @a (@b),DROP TABLE IF EXISTS @a -hive,SELECT @a INTO @b FROM @c;,CREATE TABLE IF NOT EXISTS @b AS\nSELECT\n@a\nFROM\n@c; -hive,SELECT @a INTO @b;,CREATE TABLE IF NOT EXISTS @b AS\nSELECT\n@a; -hive,SELECT DISTINCT @a FROM @b INTERSECT SELECT DISTINCT @a FROM @c;,SELECT t1.@a FROM (SELECT DISTINCT @a FROM @b UNION ALL SELECT DISTINCT @a FROM @c) AS t1 GROUP BY @a HAVING COUNT(*) >= 2; -hive,(SELECT DISTINCT @a FROM @b INTERSECT SELECT DISTINCT @a FROM @c),(SELECT t1.@a FROM (SELECT DISTINCT @a FROM @b UNION ALL SELECT DISTINCT @a FROM @c) AS t1 GROUP BY @a HAVING COUNT(*) >= 2) -hive,.dbo.,. -hive,##, -hive,#, -hive,#.@table (,@table ( -hive,#.@table;,@table; -hive,#.@table),@table) -hive,,## -hive,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; -hive,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) -hive,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; -hive,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) -hive,DATE,TIMESTAMP -hive,DATETIME,TIMESTAMP -hive,DATETIME2,TIMESTAMP -hive,BIGINT NOT NULL,BIGINT -hive,BOOLEAN NOT NULL,BOOLEAN -hive,CHAR NOT NULL,CHAR -hive,DECIMAL NOT NULL,DECIMAL -hive,DOUBLE NOT NULL,DOUBLE -hive,FLOAT NOT NULL,FLOAT -hive,INT NOT NULL,INT -hive,REAL NOT NULL,FLOAT -hive,SMALLINT NOT NULL,SMALLINT -hive,STRING NOT NULL,VARCHAR -hive,TIMESTAMP NOT NULL,TIMESTAMP -hive,TINYINT NOT NULL,TINYINT -hive,VARCHAR(@a) NOT NULL,VARCHAR(@a) -hive,BIGINT NULL,BIGINT -hive,BOOLEAN NULL,BOOLEAN -hive,CHAR NULL,CHAR -hive,DECIMAL NULL,DECIMAL -hive,DOUBLE NULL,DOUBLE -hive,FLOAT NULL,FLOAT -hive,INT NULL,INT -hive,REAL NULL,FLOAT -hive,SMALLINT NULL,SMALLINT -hive,STRING NULL,VARCHAR -hive,TIMESTAMP NULL,TIMESTAMP -hive,TINYINT NULL,TINYINT -hive,VARCHAR(@a) NULL,VARCHAR(@a) -hive,"CHAR,","CHAR(1)," -hive,"CHAR\n+","CHAR(1)\n" -hive,"CHAR)","CHAR(1))" -hive,"CONSTRAINT @a DEFAULT unix_timestamp()","" -hive,"DEFAULT unix_timestamp()","" -hive,stats,_stats -hive,UPDATE STATISTICS @a;,-- hive does not support COMPUTE STATS -hive,CAST(@a AS VARCHAR),CAST(@a AS VARCHAR(1000)) -hive,"ISNULL(@a,@b)","COALESCE(@a,@b)" -hive,(@a) AS select,(@a) AS select -hive,"TABLE @cte_name (@a) AS (","TABLE @cte_name AS (" -hive,"TABLE @cte_name (@a) AS select","TABLE @cte_name AS select" -hive,"WHEN .@digits * ","WHEN 0.@digits * " -hive,">= .@digits * ",">= 0.@digits * " -hive, _stats, stats -hive,"ISNUMERIC(@a)","case when cast(@a as double) is not null then 1 else 0 end" -hive,as \"@a\",as @a -hive,"HASHBYTES('MD5',@a)","hash(@a)" -hive,"CONVERT(VARBINARY, @a, 1)","@a" -spark,...@([0-9]+|y)a,xxx@a -spark,TRY_CAST(@a),CAST(@a) -spark,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -spark,tempdb..#@table +,%temp_prefix%%session_id% || -spark,#@([^\s]+)table.@([^\s]+)field,%session_id%@table.@field -spark,"--HINT BUCKET(@a, @b)","" -spark,"--HINT PARTITION(@a @b)","" -spark,"HINT DISTRIBUTE_ON_KEY(@key) CREATE TABLE IF NOT EXISTS @table\nUSING DELTA\nAS\n@definition;","HINT DISTRIBUTE_ON_KEY(@key)\nCREATE TABLE IF NOT EXISTS @table\nUSING DELTA\nAS\n@definition;\nOPTIMIZE @table ZORDER BY @key;" -spark,"HINT DISTRIBUTE_ON_KEY(@key) IF OBJECT_ID('@d', 'U') IS NULL WITH @a AS @b SELECT @c INTO @d FROM @e;",HINT DISTRIBUTE_ON_KEY(@key)\nCREATE TABLE IF NOT EXISTS @d\nUSING DELTA\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nOPTIMIZE @d ZORDER BY @key; -spark,"HINT DISTRIBUTE_ON_KEY(@key) IF OBJECT_ID('@b', 'U') IS NULL SELECT @a INTO @b FROM @c;",HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE IF NOT EXISTS @b\nUSING DELTA\nAS\nSELECT\n@a\nFROM\n@c;\nOPTIMIZE @b ZORDER BY @key; -spark,"HINT DISTRIBUTE_ON_KEY(@key) IF OBJECT_ID('@b', 'U') IS NULL SELECT @a INTO @b WHERE @c;",HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE IF NOT EXISTS @b\nUSING DELTA\nAS\nSELECT\n@a WHERE @c;\nOPTIMIZE @b ZORDER BY @key; -spark,"HINT DISTRIBUTE_ON_KEY(@key) IF OBJECT_ID('@b', 'U') IS NULL SELECT @a INTO @b GROUP BY @c;",HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE IF NOT EXISTS @b\nUSING DELTA\nAS\nSELECT\n@a\nGROUP BY\n@c;\nOPTIMIZE @b ZORDER BY @key; -spark,HINT DISTRIBUTE_ON_KEY(@key) WITH @a AS @b SELECT @c INTO @d FROM @e;,HINT DISTRIBUTE_ON_KEY(@key)\nCREATE TABLE @d\nUSING DELTA\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nOPTIMIZE @d ZORDER BY @key; -spark,HINT DISTRIBUTE_ON_KEY(@key) SELECT @a INTO @b FROM @c;,HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE @b\nUSING DELTA\nAS\nSELECT\n@a\nFROM\n@c;\nOPTIMIZE @b ZORDER BY @key; -spark,HINT DISTRIBUTE_ON_KEY(@key) SELECT @a INTO @b WHERE @c;,HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE @b\nUSING DELTA\nAS\nSELECT\n@a WHERE @c;\nOPTIMIZE @b ZORDER BY @key; -spark,HINT DISTRIBUTE_ON_KEY(@key) SELECT @a INTO @b GROUP BY @c;,HINT DISTRIBUTE_ON_KEY(@key) \nCREATE TABLE @b\nUSING DELTA\nAS\nSELECT\n@a\nGROUP BY\n@c;\nOPTIMIZE @b ZORDER BY @key; -spark,WITH @with SELECT @c INTO @d FROM @e;,@with\n CREATE TABLE @d\nUSING DELTA\nAS\n(SELECT\n@c\nFROM\n@e); -spark,"@a (@columns) AS (@b),",@a AS (@b) -spark,"@a AS (@b),",@a AS (@b) -spark,@a (@columns) AS (@b),@a AS (@b) -spark,"@a AS (@b)",DROP VIEW IF EXISTS @a; CREATE TEMPORARY VIEW @a AS (@b);\n -spark,SELECT @a INTO @b FROM @c;,CREATE TABLE @b\nUSING DELTA\nAS\nSELECT\n@a\nFROM\n@c; -spark,SELECT @a INTO @b WHERE @c;,CREATE TABLE @b\nUSING DELTA\n AS\nSELECT\n@a WHERE @c; -spark,SELECT @a INTO @b GROUP BY @c;,CREATE TABLE @b\nUSING DELTA\n AS\nSELECT\n@a GROUP BY @c; -spark,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table\nUSING DELTA\nAS\n@definition;",CREATE TABLE IF NOT EXISTS @table\nUSING DELTA\nAS\n@definition; -spark,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -spark,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -spark,CREATE TABLE #@([^\s]+)table,DROP TABLE IF EXISTS %temp_prefix%%session_id%@table;\nCREATE TABLE %temp_prefix%%session_id%@table -spark,#,%temp_prefix%%session_id% -spark,\"@a\",`@a` -spark,+ '@a',|| '@a' -spark,'@a' +,'@a' || -spark,CREATE INDEX @index_name ON @table (@variable);,-- spark does not support indexes -spark,"ROUND(@a,@b)","ROUND(CAST(@a AS DOUBLE),@b)" -spark,"HASHBYTES('MD5',@a)","MD5(@a)" -spark,"CONVERT(VARBINARY, CONCAT('0x', @a), 1)","CAST(CONCAT('x', @a) AS BIT(32))" -spark,"CONVERT(DATE, @a)","TO_DATE(@a, 'yyyy-MM-dd')" -spark,"DATEPART(@part, @date)","DATE_PART('@part', @date)" -spark,"DATEADD(d,@days,@date)","DATEADD(day,@days,@date)" -spark,"DATEADD(dd,@days,@date)","DATEADD(day,@days,@date)" -spark,"DATEADD(m,@months,@date)","DATEADD(month,@months,@date)" -spark,"DATEADD(mm,@months,@date)","DATEADD(month,@months,@date)" -spark,"DATEADD(yy,@years,@date)","DATEADD(year,@years,@date)" -spark,"DATEADD(yyyy,@years,@date)","DATEADD(year,@years,@date)" -spark,"DATEADD(@part,@(-?[0-9]+)a.0,@date)","DATEADD(@part,@a,@date)" -spark,INTERVAL @(-?[0-9]+)a.0,INTERVAL @a -spark,"DATEDIFF(d,@start, @end)","datediff(day,@start,@end)" -spark,"DATEDIFF(dd,@start, @end)","datediff(day,@start,@end)" -spark,"CONVERT(VARCHAR,@date,112)","@date" -spark,GETDATE(),CURRENT_DATE -spark,CAST(@a AS varchar(@b)) +,"SUBSTRING(CAST(@a AS string), 0, @b) ||" -spark,+ CAST(@a AS varchar(@b)),"|| SUBSTRING(CAST(@a AS string), 0, @b)" -spark,CAST(@a AS varchar) +,CAST(@a AS string) || -spark,+ CAST(@a AS varchar),|| CAST(@a AS string) -spark,"DATEFROMPARTS(@year,@month,@day)","to_date(cast(@year as string) || '-' || cast(@month as string) || '-' || cast(@day as string))" -spark,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)",to_timestamp(cast(@year as string) || '-' || cast(@month as string) || '-' || cast(@day as string) || ' ' || cast(@hour as string) || ':' || cast(@minute as string) || ':' || cast(@second as string) || '.' || cast(@ms as string)) -spark,EOMONTH(@date),last_day(@date) -spark,STDEV(@a),STDDEV(@a) -spark,VAR(@a),VARIANCE(@a) -spark,LEN(@a),LENGTH(@a) -spark,"CHARINDEX(@a,@b)","INSTR(@b,@a)" -spark,"LOG(@expression,@base)","(@base,@expression)" -spark,LOG(@expression),LN(@expression) -spark,,LOG -spark,LOG10(@expression),"LOG(10,@expression)" -spark,"ISNULL(@a,@b)","COALESCE(@a,@b)" -spark,"ISNUMERIC(@a)","CASE WHEN CAST(@a AS DOUBLE) IS NOT NULL THEN 1 ELSE 0 END" -spark,COUNT_BIG(@a),COUNT(@a) -spark,SQUARE(@a),((@a)*(@a)) -spark,NEWID(),UUID() -spark,.dbo.,. -spark,CREATE CLUSTERED INDEX @index_name ON @table (@variable);, -spark,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);, -spark,PRIMARY KEY NONCLUSTERED, -spark,DATETIME,TIMESTAMP -spark,DATETIME2,TIMESTAMP -spark,VARCHAR(MAX),STRING -spark,VARCHAR(@a),STRING -spark,VARCHAR,STRING -spark,DOUBLE PRECISION,DOUBLE -spark,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; -spark,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) -spark,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; -spark,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) -spark,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as STRING) @s2)" -spark,UPDATE STATISTICS @a;, -spark,"SELECT @columns FROM (@a) @x,(@b) @y;","SELECT @columns FROM (@a) @x cross join (@b) @y;" -spark,"ALTER TABLE @table ADD COLUMN @([\w_-]+)column @type DEFAULT @default;","ALTER TABLE @table ADD COLUMN @column @type; \nALTER TABLE @table SET TBLPROPERTIES('delta.feature.allowColumnDefaults' = 'supported'); \nALTER TABLE @table ALTER COLUMN @column SET DEFAULT @default;" -spark,"CAST(@a AS DATE)","IF(try_cast(@a AS DATE) IS NULL, to_date(cast(@a AS STRING), 'yyyyMMdd'), try_cast(@a AS DATE))" -spark,"DATEADD(@part,@amount,@([0-9a-zA-Z_]+_date)date)","CAST(DATEA##(@part,@amount,@date) AS DATE)" -spark,DATEA##,DATEADD -spark,FLOAT,DOUBLE -sqlite extended,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -sqlite extended,"ROUND(@a,@b)","ROUND(CAST(@a AS REAL),@b)" -sqlite extended,+ '@a',|| '@a' -sqlite extended,'@a' +,'@a' || -sqlite extended,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || -sqlite extended,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) -sqlite extended,CAST(@a AS varchar) +,CAST(@a AS varchar) || -sqlite extended,+ CAST(@a AS varchar),|| CAST(@a AS varchar) -sqlite extended,VAR(@a),VARIANCE(@a) -sqlite extended,RAND(),RANDOM() -sqlite extended,LEN(@a),LENGTH(@a) -sqlite extended,"LOG(@expression,@base)","(LOG(@expression)/LOG(@base))" -sqlite extended,"ISNULL(@a,@b)","COALESCE(@a,@b)" -sqlite extended,"ISNUMERIC(@a)","CASE WHEN @a GLOB '[0-9]*' OR @a GLOB '[0-9]*.[0-9]*' OR @a GLOB '.[0-9]*' THEN 1 ELSE 0 END" -sqlite extended,COUNT_BIG(@a),COUNT(@a) -sqlite extended,NEWID(),RANDOM() -sqlite extended,"RIGHT(@a,@b)","SUBSTR(@a,-@b)" -sqlite extended,"LEFT(@str,@chars)","SUBSTR(@str,1,@chars)" -sqlite extended,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -sqlite extended,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -sqlite extended,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -sqlite extended,.dbo.,. -sqlite extended,CREATE TABLE #@table (@definition),CREATE TEMP TABLE @table (@definition) -sqlite extended,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE INDEX @index_name ON @table (@variable); -sqlite extended,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable); -sqlite extended,CREATE INDEX @index_name ON @schema.@table (@variable);,CREATE INDEX @index_name ON @table (@variable); -sqlite extended,CREATE UNIQUE INDEX @index_name ON @schema.@table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable); -sqlite extended,DROP INDEX @schema.@index_name;,DROP INDEX @index_name; -sqlite extended,PRIMARY KEY NONCLUSTERED,PRIMARY KEY -sqlite extended,VARCHAR(@a),TEXT -sqlite extended,VARCHAR,TEXT -sqlite extended,FLOAT,REAL -sqlite extended,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nANALYZE @d; -sqlite extended,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -sqlite extended,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a\nFROM\n@c;\nANALYZE @b; -sqlite extended,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; -sqlite extended,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; -sqlite extended,#,temp. -sqlite extended,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; -sqlite extended,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) -sqlite,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; -sqlite,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) -sqlite extended,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as TEXT) @s2)" -sqlite extended,UPDATE STATISTICS @a;,ANALYZE @a; -sqlite extended,TRUNCATE TABLE @a;,DELETE FROM @a; -sqlite extended,"CONCAT(@a, @b, @c)","CONCAT(@a, CONCAT(@b, @c))" -sqlite extended,"CONCAT(@a, @b)","@a || @b" -duckdb,...@([0-9]+|y)a,xxx@a -duckdb,TRY_CAST(@a),CAST(@a) -duckdb,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -duckdb,"ROUND(@a,@b)","ROUND(CAST(@a AS NUMERIC),@b)" -duckdb,"HASHBYTES('MD5',@a)","MD5(@a)" -duckdb,"DATEADD(second,@seconds,@datetime)",(@datetime + TO_SECONDS(CAST(@seconds AS INTEGER))) -duckdb,"DATEADD(minute,@minutes,@datetime)",(@datetime + TO_MINUTES(CAST(@minutes AS INTEGER))) -duckdb,"DATEADD(hour,@hours,@datetime)",(@datetime + TO_HOURS(CAST(@hours AS INTEGER))) -duckdb,"DATEADD(d,@days,@date)",(@date + TO_DAYS(CAST(@days AS INTEGER))) -duckdb,"DATEADD(dd,@days,@date)",(@date + TO_DAYS(CAST(@days AS INTEGER))) -duckdb,"DATEADD(day,@days,@date)",(@date + TO_DAYS(CAST(@days AS INTEGER))) -duckdb,"DATEADD(m,@months,@date)",(@date + TO_MONTHS(CAST(@months AS INTEGER))) -duckdb,"DATEADD(mm,@months,@date)",(@date + TO_MONTHS(CAST(@months AS INTEGER))) -duckdb,"DATEADD(month,@months,@date)",(@date + TO_MONTHS(CAST(@months AS INTEGER))) -duckdb,"DATEADD(yy,@years,@date)",(@date + TO_YEARS(CAST(@years AS INTEGER))) -duckdb,"DATEADD(yyyy,@years,@date)",(@date + TO_YEARS(CAST(@years AS INTEGER))) -duckdb,"DATEADD(year,@years,@date)",(@date + TO_YEARS(CAST(@years AS INTEGER))) -duckdb,INTERVAL'@(-?[0-9]+)a.0 @b',INTERVAL'@a @b' -duckdb,"DATEDIFF(d,@start, @end)","(CONVERT(DATE, @end) - CAST(@start AS DATE))" -duckdb,"DATEDIFF(dd,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) -duckdb,"DATEDIFF(day,@start, @end)",(CAST(@end AS DATE) - CAST(@start AS DATE)) -duckdb,"DATEDIFF(year,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) -duckdb,"DATEDIFF(yyyy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) -duckdb,"DATEDIFF(yy,@start, @end)",(EXTRACT(YEAR FROM CAST(@end AS DATE)) - EXTRACT(YEAR FROM CAST(@start AS DATE))) -duckdb,"DATEDIFF(month,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" -duckdb,"DATEDIFF(mm,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" -duckdb,"DATEDIFF(m,@start, @end)","(extract(year from age(CAST(@end AS DATE), CAST(@start AS DATE)))*12 + extract(month from age(CAST(@end AS DATE), CAST(@start AS DATE))))" -duckdb,"CONVERT(VARCHAR,@date,112)","STRFTIME(@date, '%Y%m%d')" -duckdb,GETDATE(),CURRENT_DATE -duckdb,"CONVERT(DATE, @a)","CAST(@a AS DATE)" -duckdb,"CAST('@a' AS DATE)","CAST(strptime('@a', '%Y%m%d') AS DATE)" -duckdb,"CAST('@a' + @b AS DATE)","CAST(strptime('@a' + @b, '%Y%m%d') AS DATE)" -duckdb,"CAST(@a + '@b' AS DATE)","CAST(strptime(@a + '@b', '%Y%m%d') AS DATE)" -duckdb,"CAST(CONCAT(@a) AS DATE)","CAST(strptime(CONCAT(@a), '%Y%m%d') AS DATE)" -duckdb,+ '@a',|| '@a' -duckdb,'@a' +,'@a' || -duckdb,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || -duckdb,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) -duckdb,CAST(@a AS varchar) +,CAST(@a AS varchar) || -duckdb,+ CAST(@a AS varchar),|| CAST(@a AS varchar) -duckdb,"DATEFROMPARTS(@year,@month,@day)","(CAST(@year AS VARCHAR) || '-' || CAST(@month AS VARCHAR) || '-' || CAST(@day AS VARCHAR)) :: DATE" -duckdb,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","(CAST(@year AS VARCHAR) || '-' || CAST(@month AS VARCHAR) || '-' || CAST(@day AS VARCHAR) || '-' || CAST(@hour AS VARCHAR) || '-' || CAST(@minute AS VARCHAR) || '-' || CAST(@second AS VARCHAR)) :: DATE" -duckdb,YEAR(@date),YEAR(CAST(@date AS DATE)) -duckdb,MONTH(@date),MONTH(CAST(@date AS DATE)) -duckdb,DAY(@date),DAY(CAST(@date AS DATE)) -duckdb,"DATEPART(YEAR, @date)",YEAR(CAST(@date AS DATE)) -duckdb,"DATEPART(MONTH, @date)",MONTH(CAST(@date AS DATE)) -duckdb,"DATEPART(DAY, @date)",DAY(CAST(@date AS DATE)) -duckdb,EOMONTH(@date),"(DATE_TRUNC('MONTH', @date) + INTERVAL '1 MONTH' - INTERVAL '1 day')::DATE" -duckdb,STDEV(@a),STDDEV(@a) -duckdb,VAR(@a),VARIANCE(@a) -duckdb,RAND(),RANDOM() -duckdb,LEN(@a),LENGTH(@a) -duckdb,"CHARINDEX(@a,@b)","STRPOS(@b,@a)" -duckdb,"LOG(@expression,@base)",(LN(CAST((@expression) AS REAL))/LN(CAST((@base) AS REAL))) -duckdb,LOG(@expression),LN(CAST((@expression) AS REAL)) -duckdb,,LOG -duckdb,LOG10(@expression),"LOG(@expression)" -duckdb,"ISNULL(@a,@b)","COALESCE(@a,@b)" -duckdb,"ISNUMERIC(@a)","CASE WHEN (CAST(@a AS VARCHAR) ~ '^([0-9]+\.?[0-9]*|\.[0-9]+)$') THEN 1 ELSE 0 END" -duckdb,COUNT_BIG(@a),COUNT(@a) -duckdb,SQUARE(@a),((@a)*(@a)) -duckdb,NEWID(),uuid() -duckdb,USE @schema;,SET search_path TO @schema; -duckdb,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -duckdb,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -duckdb,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -duckdb,.dbo.,. -duckdb,CREATE TABLE #@table (@definition),CREATE TEMP TABLE @table (@definition) -duckdb,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE INDEX @index_name ON @table (@variable); -duckdb,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE UNIQUE INDEX @index_name ON @table (@variable); -duckdb,PRIMARY KEY NONCLUSTERED,PRIMARY KEY -duckdb,DATETIME,TIMESTAMP -duckdb,DATETIME2,TIMESTAMP -duckdb,VARCHAR(MAX),TEXT -duckdb,FLOAT,NUMERIC -duckdb,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e;\nANALYZE @d; -duckdb,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -duckdb,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE @b\nAS\nSELECT\n@a\nFROM\n@c;\nANALYZE @b; -duckdb,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; -duckdb,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; -duckdb,#, -duckdb,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; -duckdb,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) -duckdb,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; -duckdb,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) -duckdb,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as TEXT) @s2)" -duckdb,UPDATE STATISTICS @a;,ANALYZE @a; -duckdb,TRUNCATE TABLE @a;,DELETE FROM @a; -duckdb,ALTER TABLE @a DROP CONSTRAINT @b;,CREATE TABLE @a_new AS SELECT * FROM @a;DROP TABLE @a;ALTER TABLE @a_new RENAME TO @a; -duckdb,"ALTER TABLE @table ADD @a, @b;","ALTER TABLE @table ADD @a; ALTER TABLE @table ADD @b;" -duckdb,"ALTER TABLE @table ALTER COLUMN @([0-9a-z_]+)a @b;","ALTER TABLE @table ALTER @a TYPE @b;" -snowflake,.@([0-9a-z_]+)a...@([0-9]+|y)b,x@a...@b -snowflake,.@([0-9a-z_]+)a...@([0-9]+|y)b,x@axxx@b -snowflake,...@([0-9]+|y)a,xxx@a -snowflake,"AS drvd(@a)","AS values_table(@a)" -snowflake,TRY_CAST(@a),CAST(@a) -snowflake,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -snowflake,"HASHBYTES('MD5',@a)","MD5(@a)" -snowflake,"CONVERT(VARBINARY, @a, 1)","CAST(CONCAT('x', @a) AS BIT(32))" -snowflake,"CONVERT(DATE, @a)","TO_DATE(@a, 'yyyymmdd')" -snowflake,"CONVERT(VARCHAR,@date,112)","TO_CHAR(@date, 'YYYYMMDD')" -snowflake,"CAST('@a' AS DATE)","TO_DATE('@a', 'YYYYMMDD')" -snowflake,"CAST('@a' + @b AS DATE)","TO_DATE('@a' + @b, 'YYYYMMDD')" -snowflake,"CAST(@a + '@b' AS DATE)","TO_DATE(@a + '@b', 'YYYYMMDD')" -snowflake,"CAST(CONCAT(@a) AS DATE)","TO_DATE(CONCAT(@a), 'YYYYMMDD')" -snowflake,GETDATE(),CURRENT_DATE -snowflake,+ '@a',|| '@a' -snowflake,'@a' +,'@a' || -snowflake,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || -snowflake,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) -snowflake,CAST(@a AS varchar) +,CAST(@a AS varchar) || -snowflake,+ CAST(@a AS varchar),|| CAST(@a AS varchar) -snowflake,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'FM0000')||'-'||TO_CHAR(@month,'FM00')||'-'||TO_CHAR(@day,'FM00'), 'YYYY-MM-DD')" -snowflake,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","TO_DATE(TO_CHAR(@year,'FM0000')||'-'||TO_CHAR(@month,'FM00')||'-'||TO_CHAR(@day,'FM00')||' '||TO_CHAR(@hour,'FM00')||':'||TO_CHAR(@minute,'FM00')||':'||TO_CHAR(@second,'FM00'), 'YYYY-MM-DD HH24:MI:SS')" -snowflake,YEAR(@date),EXTRACT(YEAR FROM @date) -snowflake,MONTH(@date),EXTRACT(MONTH FROM @date) -snowflake,DAY(@date),EXTRACT(DAY FROM @date) -snowflake,"DATEPART(YEAR, @date)",EXTRACT(YEAR FROM @date) -snowflake,"DATEPART(MONTH, @date)",EXTRACT(MONTH FROM @date) -snowflake,"DATEPART(DAY, @date)",EXTRACT(DAY FROM @date) -snowflake,EOMONTH(@date),last_day(@date) -snowflake,STDEV(@a),STDDEV(@a) -snowflake,VAR(@a),VARIANCE(@a) -snowflake,RAND(),RANDOM() -snowflake,CEILING(@a),CEIL(@a) -snowflake,"LOG(@expression,@base)","(@base,@expression)" -snowflake,LOG(@expression),LN(CAST((@expression) AS REAL)) -snowflake,,LOG -snowflake,LOG10(@expression),"LOG(10,@expression)" -snowflake,"ISNULL(@a,@b)","COALESCE(@a,@b)" -snowflake,"ISNUMERIC(@a)","IS_REAL(TRY_TO_NUMERIC(@a))" -snowflake,CAST(@a AS INTEGER),TRY_CAST(CAST(@a AS TEXT) AS INTEGER) -snowflake,CAST(@a AS int),TRY_CAST(CAST(@a AS TEXT) AS int) -snowflake,COUNT_BIG(@a),COUNT(@a) -snowflake,NEWID(),UUID_STRING() -snowflake,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -snowflake,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -snowflake,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE #@table;",DROP TABLE IF EXISTS #@table; -snowflake,.dbo.,. -snowflake,CREATE TABLE #@table (@definition),CREATE TEMP TABLE #@table (@definition) -snowflake,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,-- snowflake does not support indexes -snowflake,CREATE INDEX @index_name ON @table (@variable);,-- snowflake does not support indexes -snowflake,CREATE INDEX @index_name ON @table (@variable) WHERE @c;,-- snowflake does not support indexes -snowflake,CREATE UNIQUE CLUSTERED INDEX @index_name ON @table (@variable);,-- snowflake does not support indexes -snowflake,DROP INDEX @index_name;,-- snowflake does not support indexes -snowflake,PRIMARY KEY NONCLUSTERED,PRIMARY KEY -snowflake,DATETIME,TIMESTAMP -snowflake,DATETIME2,TIMESTAMP -snowflake,VARCHAR(MAX),TEXT -snowflake,WITH @a AS @b SELECT @c INTO #@d FROM @e;,CREATE TEMP TABLE #@d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -snowflake,WITH @a AS @b SELECT @c INTO @d FROM @e;,CREATE TABLE @d\nAS\nWITH @a AS @b SELECT\n@c\nFROM\n@e; -snowflake,WITH @a INSERT INTO @b SELECT @c;,INSERT INTO @b WITH @a SELECT @c; -snowflake,SELECT @a INTO #@b FROM @c;,CREATE TEMP TABLE #@b\nAS\nSELECT\n@a\nFROM\n@c; -snowflake,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS\nSELECT\n@a\nFROM\n@c; -snowflake,SELECT @a INTO @b;,CREATE TABLE @b AS\nSELECT\n@a; -snowflake,SELECT @a INTO #@b;,CREATE TEMP TABLE #@b AS\nSELECT\n@a; -snowflake,SELECT TOP @([0-9]+)rows @a;,SELECT @a LIMIT @rows; -snowflake,(SELECT TOP @([0-9]+)rows @a),(SELECT @a LIMIT @rows) -snowflake,SELECT DISTINCT TOP @([0-9]+)rows @a;,SELECT DISTINCT @a LIMIT @rows; -snowflake,(SELECT DISTINCT TOP @([0-9]+)rows @a),(SELECT DISTINCT @a LIMIT @rows) -snowflake,"WITH @cte AS (SELECT @s1 '@literal' @s2)","WITH @cte AS (SELECT @s1 CAST('@literal' as TEXT) @s2)" -snowflake,UPDATE STATISTICS @a;, -snowflake,"--HINT BUCKET(@a, @b)", -snowflake,"--HINT PARTITION(@a @b)", -snowflake,"--HINT DISTRIBUTE_ON_KEY(@key)", -snowflake,#,%temp_prefix%%session_id% -snowflake,(@a & @b),"BITAND(@a, @b)" -synapse,...@([0-9]+|y)a,xxx@a -synapse,VARCHAR(MAX),VARCHAR(8000) -synapse,CREATE INDEX @index_name ON #@table (@variable);,-- synapse does not support non-clustered index on temp tables. -synapse,"CONSTRAINT @a DEFAULT GETDATE()","" -synapse,"DEFAULT GETDATE()","" -synapse,CREATE INDEX @index_name ON @table (@variable) WHERE @b;,CREATE INDEX @index_name ON @table (@variable); -synapse,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -synapse,DROP TABLE IF EXISTS #@table;,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE #@table;" -synapse,DROP TABLE IF EXISTS @table;,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;" -synapse,CREATE TABLE IF NOT EXISTS @table (@definition);,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);" -sql server,.@([^\s]+)p2...@([0-9]+)p3,x@p2...@p3 -sql server,.@([^\s]+)p2...@([0-9]+)p3,x@p2...@p3 -sql server,...@([0-9]+|y)a,xxx@a -sql server,DROP TABLE IF EXISTS #@table;,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE #@table;" -sql server,DROP TABLE IF EXISTS @table;,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;" -sql server,CREATE TABLE IF NOT EXISTS @table (@definition);,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);" -iris,...@([0-9]+)a,xxx@a --??? -iris,"IIF(@condition, @whentrue, @whenfalse)","CASE WHEN @condition THEN @whentrue ELSE @whenfalse END" -iris,TRY_CAST(@a),CAST(@a) -iris,+ '@a',|| '@a' -iris,'@a' +,'@a' || -iris,CAST(@a AS varchar(@b)) +,CAST(@a AS varchar(@b)) || -iris,+ CAST(@a AS varchar(@b)),|| CAST(@a AS varchar(@b)) -iris,CAST(@a AS varchar) +,CAST(@a AS varchar) || -iris,+ CAST(@a AS varchar),|| CAST(@a AS varchar) -iris,COUNT_BIG(@a),COUNT(@a) -iris,.dbo.,. -iris,CREATE TABLE #@table (@definition),DROP TABLE IF EXISTS #@table; CREATE GLOBAL TEMPORARY TABLE #@table (@definition) -iris,"IF OBJECT_ID('@table', 'U') IS NULL CREATE TABLE @table (@definition);",CREATE TABLE IF NOT EXISTS @table (@definition); -iris,"IF OBJECT_ID('@table', 'U') IS NOT NULL DROP TABLE @table;",DROP TABLE IF EXISTS @table; -iris,"IF OBJECT_ID('tempdb..#@table', 'U') IS NOT NULL DROP TABLE #@table;",DROP TABLE IF EXISTS #@table; -iris,PRIMARY KEY NONCLUSTERED,PRIMARY KEY -iris,"AS drvd(@a)","AS drvd(@a)" -iris,"@a, @b)","@a, @b)" -iris,"","NULL AS " -iris,"FROM (VALUES @a) AS drvd(@b)","FROM ((SELECT @b WHERE (0 = 1)) UNION ALL VALUES @a) AS values_table" -iris,"UNION ALL VALUES (@a), (@b)","UNION ALL (SELECT @a) UNION ALL VALUES (@b)" -iris,"UNION ALL VALUES (@a)","UNION ALL (SELECT @a)" -iris,SELECT @a INTO #@b FROM @c;,CREATE GLOBAL TEMPORARY TABLE #@b AS SELECT @a FROM @c; -iris,SELECT @a INTO @b FROM @c;,CREATE TABLE @b AS SELECT @a FROM @c; -iris,SELECT @a INTO @b;,CREATE TABLE @b AS SELECT @a; -iris,SELECT @a INTO #@b;,CREATE GLOBAL TEMPORARY TABLE #@b AS SELECT @a; -iris,"WITH @cte CREATE TABLE #@table AS @select;","CREATE GLOBAL TEMPORARY TABLE #@table AS WITH @cte @select;" -iris,"WITH @cte CREATE GLOBAL TEMPORARY TABLE @table AS @select;","CREATE GLOBAL TEMPORARY TABLE @table AS WITH @cte @select;" -iris,"WITH @cte CREATE TABLE @table AS @select;","CREATE TABLE @table AS WITH @cte @select;" -iris,#,%temp_prefix%%session_id% -iris,UPDATE STATISTICS @a;,TUNE TABLE @a; -iris,"--HINT BUCKET(@a, @b)", "-- haven't looked into this yet, skip it for now" -iris,"--HINT PARTITION(@a @b)", -- "haven't looked into this yet, skip it for now"" -iris,"--HINT DISTRIBUTE_ON_KEY(@key)", -- "haven't looked into this yet, skip it for now"" -iris,"DATEFROMPARTS(@year,@month,@day)","TO_DATE(TO_CHAR(@year,'FM0000')||'-'||TO_CHAR(@month,'FM00')||'-'||TO_CHAR(@day,'FM00'), 'YYYY-MM-DD')" -iris,"DATETIMEFROMPARTS(@year,@month,@day,@hour,@minute,@second,@ms)","TO_TIMESTAMP(TO_CHAR(@year,'FM0000')||'-'||TO_CHAR(@month,'FM00')||'-'||TO_CHAR(@day,'FM00')||' '||TO_CHAR(@hour,'FM00')||':'||TO_CHAR(@minute,'FM00')||':'||TO_CHAR(@second,'FM00')||'.'||TO_CHAR(@ms,'FM000'), 'YYYY-MM-DD HH24:MI:SS.FF')" -iris," DATEADD(d, @a, @b) AS"," TO_DATE(DATEADD(d, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" -iris," DATEADD(dd, @a, @b) AS"," TO_DATE(DATEADD(dd, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" -iris," DATEADD(day, @a, @b) AS"," TO_DATE(DATEADD(day, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" -iris," DATEADD(m, @a, @b) AS"," TO_DATE(DATEADD(m, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" -iris," DATEADD(mm, @a, @b) AS"," TO_DATE(DATEADD(mm, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" -iris," DATEADD(yy, @a, @b) AS"," TO_DATE(DATEADD(yy, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" -iris," DATEADD(yyyy, @a, @b) AS"," TO_DATE(DATEADD(yyyy, @a, @b),'YYYY-MM-DD HH:MI:SS') AS" -iris," COALESCE(p.birth_datetime", COALESCE(CAST(p.birth_datetime AS DATE) -iris,"COALESCE(@a, DATEADD(day,@b,@c))","COALESCE(@a, CAST (DATEADD(day,@b,@c) AS DATE))" -iris,"COALESCE(@a, DATEADD(day,@b,@c), DATEADD(day,@d,@e))","COALESCE(@a, CAST (DATEADD(day,@b,@c) AS DATE), CAST (DATEADD(day,@d,@e) AS DATE))" -iris,"case when DATEADD(day,@a,@b) > op_end_date then op_end_date else DATEADD(day,@a,@b) end as end_date","case when CAST (DATEADD(day,@a,@b) AS DATE) > op_end_date then op_end_date else CAST (DATEADD(day,@a,@b) AS DATE) end as end_date" -iris,"select @a as cohort_definition_id, person_id, start_date, end_date","select @a as cohort_definition_id, person_id, CAST (start_date AS DATE), CAST (end_date AS DATE)" -iris,"select @a as design_hash, person_id, start_date, end_date","select @a as design_hash, person_id, CAST (start_date AS DATE), CAST (end_date AS DATE)" -iris,"select cohort_definition_id, subject_id, cohort_start_date, cohort_end_date, @a as adjusted_start_date, @b as adjusted_end_date","select cohort_definition_id, subject_id, cohort_start_date, cohort_end_date, CAST(@a AS DATE) as adjusted_start_date, CAST(@b AS DATE) as adjusted_end_date" -iris,"CONCAT(p.year_of_birth, @b, @c)",p.year_of_birth||'-'||@b||'-'|| @c -iris,"CONCAT(@a, @b,","@a || CONCAT(@b," -iris,"CONCAT(@a,@b)",@a || @b -iris,CREATE CLUSTERED INDEX @index_name ON @table (@variable);,CREATE INDEX @index_name ON @table (@variable); -iris,CREATE INDEX @index_name ON @table (@variable) WHERE @b;,CREATE INDEX @index_name ON @table (@variable); -iris,AS MIN,AS "MIN" -iris,AS MAX,AS "MAX" -iris,AS COUNT,AS "COUNT" -iris,STDEV(@a),STDDEV(@a) -iris,STDEV_POP(@a),STDDEV_POP(@a) -iris,STDEV_SAMP(@a),STDDEV_SAMP(@a) -iris,EOMONTH(@date),LAST_DAY(@date) -iris,.DOMAIN ,."DOMAIN" -iris,NEWID(),$TSQL_NEWID() -iris,RAND(),$TSQL_NEWID() -iris,CREATE TABLE @a AS (@b) ORDER BY @c;,CREATE TABLE @a AS @b ORDER BY @c; -iris,CREATE TABLE @a AS (@b ORDER BY @c);,CREATE TABLE @a AS @b ORDER BY @c; diff --git a/circe/sqlrender/splitter.py b/circe/sqlrender/splitter.py deleted file mode 100644 index 61290a6f..00000000 --- a/circe/sqlrender/splitter.py +++ /dev/null @@ -1,44 +0,0 @@ -from .tokenizer import tokenize_sql - - -def split_sql(sql: str) -> list[str]: - tokens = tokenize_sql(sql.lower()) - nest_stack: list[str] = [] - last_pop = "" - start = 0 - cursor = 0 - quote = False - bracket = False - quote_text = "" - parts: list[str] = [] - - for cursor in range(len(tokens)): - token = tokens[cursor] - - if quote: - if token.text == quote_text: - quote = False - elif bracket: - if token.text == "]": - bracket = False - elif token.text in ("'", '"'): - quote = True - quote_text = token.text - elif token.text == "[": - bracket = True - elif token.text in ("begin", "case"): - nest_stack.append(token.text) - elif token.text == "end" and (cursor == len(tokens) - 1 or tokens[cursor + 1].text != "if"): - if nest_stack: - last_pop = nest_stack.pop() - elif len(nest_stack) == 0 and token.text == ";": - if cursor == 0 or (tokens[cursor - 1].text == "end" and last_pop == "begin"): - parts.append(sql[tokens[start].start : token.end]) - else: - parts.append(sql[tokens[start].start : token.end - 1]) - start = cursor + 1 - - if start < cursor + 1: - parts.append(sql[tokens[start].start : tokens[cursor].end]) - - return parts diff --git a/circe/sqlrender/tokenizer.py b/circe/sqlrender/tokenizer.py deleted file mode 100644 index e216b299..00000000 --- a/circe/sqlrender/tokenizer.py +++ /dev/null @@ -1,94 +0,0 @@ -from dataclasses import dataclass - -HINT_KEY_WORD = "hint" - - -@dataclass -class Token: - start: int = 0 - end: int = 0 - text: str = "" - in_quotes: bool = False - - -def tokenize_sql(sql: str) -> list[Token]: - tokens: list[Token] = [] - start = 0 - cursor = 0 - comment_type1 = False - comment_type2 = False - in_single_quotes = False - in_double_quotes = False - - while cursor < len(sql): - ch = sql[cursor] - - if comment_type1: - if ch == "\n": - comment_type1 = False - start = cursor + 1 - cursor += 1 - continue - - if comment_type2: - if ch == "/" and cursor > 0 and sql[cursor - 1] == "*": - comment_type2 = False - start = cursor + 1 - cursor += 1 - continue - - if not (ch.isalnum() or ch == "_" or ch == "@"): - if cursor > start: - token = Token( - start=start, - end=cursor, - text=sql[start:cursor], - in_quotes=in_single_quotes or in_double_quotes, - ) - tokens.append(token) - - if ( - ch == "-" - and cursor + 1 < len(sql) - and sql[cursor + 1] == "-" - and not in_single_quotes - and not in_double_quotes - and (len(sql) - cursor < 6 or sql[cursor + 2 : cursor + 6].lower() != HINT_KEY_WORD) - ): - comment_type1 = True - elif ( - ch == "/" - and cursor + 1 < len(sql) - and sql[cursor + 1] == "*" - and not in_single_quotes - and not in_double_quotes - ): - comment_type2 = True - elif not ch.isspace(): - token = Token( - start=cursor, - end=cursor + 1, - text=sql[cursor], - in_quotes=in_single_quotes or in_double_quotes, - ) - tokens.append(token) - if ch == "'" and not in_double_quotes: - in_single_quotes = not in_single_quotes - if ch == '"' and not in_single_quotes: - in_double_quotes = not in_double_quotes - - start = cursor + 1 - cursor += 1 - else: - cursor += 1 - - if cursor > start and not comment_type1 and not comment_type2: - token = Token( - start=start, - end=cursor, - text=sql[start:cursor], - in_quotes=in_single_quotes or in_double_quotes, - ) - tokens.append(token) - - return tokens diff --git a/circe/sqlrender/translator.py b/circe/sqlrender/translator.py deleted file mode 100644 index 1f057020..00000000 --- a/circe/sqlrender/translator.py +++ /dev/null @@ -1,371 +0,0 @@ -import re -from dataclasses import dataclass, field - -from .patterns import ( - MAX_TABLE_NAME_LENGTH, - get_global_session_id, - load_patterns, -) -from .tokenizer import tokenize_sql - - -class SqlTranslateError(RuntimeError): - pass - - -@dataclass -class Block: - start: int = 0 - end: int = 0 - text: str = "" - in_quotes: bool = False - is_variable: bool = False - reg_ex: str | None = None - - -@dataclass -class MatchedPattern: - start: int = -1 - end: int = -1 - start_token: int = -1 - variable_to_value: dict[str, str] = field(default_factory=dict) - - -def parse_search_pattern(pattern: str) -> list[Block]: - tokens = tokenize_sql(pattern.lower()) - blocks: list[Block] = [] - i = 0 - while i < len(tokens): - block = Block( - start=tokens[i].start, - end=tokens[i].end, - text=tokens[i].text, - in_quotes=tokens[i].in_quotes, - ) - - if len(block.text) > 2 and block.text[0] == "@": - block.is_variable = True - - if block.text == "@@" and i < len(tokens) - 2 and tokens[i + 1].text == "(": - escape = False - nesting = 0 - for j in range(i + 2, len(tokens)): - if escape: - escape = False - elif tokens[j].text == "\\": - escape = True - elif not escape and tokens[j].text == "(": - nesting += 1 - elif not escape and tokens[j].text == ")": - if nesting == 0: - block.text = "@@" + tokens[j + 1].text - block.reg_ex = pattern[tokens[i + 1].end : tokens[j].start] - block.end = tokens[j + 1].end - block.is_variable = True - i = j + 1 - break - nesting -= 1 - blocks.append(block) - i += 1 - continue - - blocks.append(block) - i += 1 - - if blocks and blocks[0].is_variable and blocks[0].reg_ex is None: - raise SqlTranslateError( - "Error in search pattern: pattern cannot start or end with a non-regex variable: " + pattern - ) - if blocks and blocks[-1].is_variable and blocks[-1].reg_ex is None: - raise SqlTranslateError( - "Error in search pattern: pattern cannot start or end with a non-regex variable: " + pattern - ) - - return blocks - - -def _matches(regex: str, string: str) -> bool: - return bool(re.match(regex, string, re.DOTALL | re.MULTILINE | re.IGNORECASE)) - - -def _matches_end(regex: str, string: str) -> int: - stripped = re.sub(r"\s+$", "", string) - pattern = re.compile(regex, re.DOTALL | re.MULTILINE | re.IGNORECASE) - start = -1 - for m in pattern.finditer(stripped): - if m.end() == len(stripped): - start = m.start() - return start - - -def search(sql: str, parsed_pattern: list[Block], start_token: int = 0) -> MatchedPattern: - lowercase_sql = sql.lower() - tokens = tokenize_sql(lowercase_sql) - match_count = 0 - var_start = 0 - nest_stack: list[str] = [] - in_pattern_quote = False - matched = MatchedPattern() - - cursor = start_token - while cursor < len(tokens): - token = tokens[cursor] - - if parsed_pattern[match_count].is_variable: - block = parsed_pattern[match_count] - - if block.reg_ex is not None and ( - match_count == len(parsed_pattern) - 1 or parsed_pattern[match_count + 1].is_variable - ): - pat = re.compile(block.reg_ex, re.DOTALL | re.MULTILINE | re.IGNORECASE) - m = pat.match(sql[token.start :]) - if m and m.start() == 0: - if match_count == 0: - matched.start = token.start - matched.start_token = cursor - matched.variable_to_value[block.text] = sql[token.start : token.start + m.end()] - match_count += 1 - if match_count == len(parsed_pattern): - matched.end = token.start + m.end() - return matched - elif parsed_pattern[match_count].is_variable: - var_start = token.start + m.end() - while cursor < len(tokens) and tokens[cursor].start < token.start + m.end(): - cursor += 1 - cursor -= 1 - else: - match_count = 0 - cursor += 1 - continue - - if ( - len(nest_stack) == 0 - and match_count < len(parsed_pattern) - 1 - and token.text == parsed_pattern[match_count + 1].text - ): - if block.reg_ex is not None and match_count == 0: - s = _matches_end(block.reg_ex, sql[var_start : token.start]) - if s != -1: - matched.variable_to_value[block.text] = sql[var_start + s : token.start] - matched.start = var_start + s - matched.start_token = cursor - match_count += 2 - if match_count == len(parsed_pattern): - matched.end = token.end - return matched - elif parsed_pattern[match_count].is_variable: - var_start = tokens[cursor + 1].start if cursor < len(tokens) - 1 else -1 - if token.text in ("'", '"'): - in_pattern_quote = not in_pattern_quote - else: - match_count = 0 - cursor = matched.start_token - elif block.reg_ex is not None and not _matches(block.reg_ex, sql[var_start : token.start]): - match_count = 0 - cursor = matched.start_token - else: - matched.variable_to_value[block.text] = sql[var_start : token.start] - match_count += 2 - if match_count == len(parsed_pattern): - matched.end = token.end - return matched - elif parsed_pattern[match_count].is_variable: - var_start = tokens[cursor + 1].start if cursor < len(tokens) - 1 else -1 - if token.text in ("'", '"'): - in_pattern_quote = not in_pattern_quote - cursor += 1 - continue - - if ( - match_count != 0 - and len(nest_stack) == 0 - and not in_pattern_quote - and token.text in (";", ")") - ): - match_count = 0 - cursor = matched.start_token - cursor += 1 - continue - - if nest_stack: - top = nest_stack[-1] - if top in ('"', "'"): - if token.text == top: - nest_stack.pop() - else: - if token.text in ('"', "'") or not in_pattern_quote and token.text == "(": - nest_stack.append(token.text) - elif not in_pattern_quote and nest_stack and token.text == ")" and nest_stack[-1] == "(": - nest_stack.pop() - else: - if token.text in ('"', "'") or not in_pattern_quote and token.text == "(": - nest_stack.append(token.text) - elif not in_pattern_quote and nest_stack and token.text == ")" and nest_stack[-1] == "(": - nest_stack.pop() - cursor += 1 - continue - - if token.text == parsed_pattern[match_count].text and (match_count != 0 or not token.in_quotes): - if match_count == 0: - matched.start = token.start - matched.start_token = cursor - match_count += 1 - if match_count == len(parsed_pattern): - matched.end = token.end - return matched - elif parsed_pattern[match_count].is_variable: - var_start = tokens[cursor + 1].start if cursor < len(tokens) - 1 else -1 - if token.text in ("'", '"'): - in_pattern_quote = not in_pattern_quote - elif match_count != 0: - match_count = 0 - cursor = matched.start_token - - cursor += 1 - - if match_count != 0 and cursor >= len(tokens): - match_count = 0 - cursor = matched.start_token + 1 - - matched.start = -1 - return matched - - -def search_and_replace(sql: str, parsed_pattern: list[Block], replace_pattern: str) -> str: - matched = search(sql, parsed_pattern, 0) - while matched.start != -1: - replacement = replace_pattern - for var_name, var_value in matched.variable_to_value.items(): - replacement = replacement.replace(var_name, var_value) - sql = sql[: matched.start] + replacement + sql[matched.end :] - - delta = 1 - repl_tokens = tokenize_sql(replacement) - if len(repl_tokens) == 0: - delta = 0 - if ( - delta > 0 - and replace_pattern.startswith("@@") - and replacement.lower().strip().startswith(parsed_pattern[0].text) - ): - delta = 0 - matched = search(sql, parsed_pattern, matched.start_token + delta) - return sql - - -def _strip_blank_lines(sql: str) -> str: - return re.sub(r"(?m)^[ \t]*\r?\n", "", sql) - - -def translate( - sql: str, - target_dialect: str, - session_id: str | None = None, - temp_emulation_schema: str | None = None, -) -> str: - patterns = load_patterns() - - if session_id is None: - session_id = get_global_session_id() - else: - if len(session_id) != 8: - raise SqlTranslateError(f"Session ID has length {len(session_id)}, should be 8") - if not session_id[0].isalpha(): - raise SqlTranslateError("Session ID does not start with a letter") - for ch in session_id[1:]: - if not ch.isalnum(): - raise SqlTranslateError(f"Illegal character in session ID: {ch}") - - oracle_temp_prefix = "" - if temp_emulation_schema is not None: - oracle_temp_prefix = temp_emulation_schema + "." - - replacement_patterns = patterns.get(target_dialect) - if replacement_patterns is None: - supported = ", ".join(sorted(patterns.keys())) - raise SqlTranslateError( - f"Don't know how to translate to {target_dialect}. Valid target dialects are {supported}" - ) - - for pattern, replacement in replacement_patterns: - replacement = replacement.replace("%session_id%", session_id) - replacement = replacement.replace("%temp_prefix%", oracle_temp_prefix) - parsed = parse_search_pattern(pattern) - sql = _strip_blank_lines(search_and_replace(sql, parsed, replacement)) - - sql = _strip_blank_lines(sql) - - lower = target_dialect.lower() - if lower in ("impala", "bigquery", "spark"): - sql = _replace_with_concat(sql) - - return sql - - -def _replace_with_concat(val: str) -> str: - pattern = re.compile(r"(? str: - inner = s[1:-1] - parts = inner.split("''") - concat_parts = [] - for part in parts: - if part == "": - concat_parts.append("'\\047'") - else: - escaped = part.replace("\\", "\\\\").replace('"', "\\042").replace("/", "\\/") - concat_parts.append("'" + escaped + "'") - return "CONCAT(" + ",".join(concat_parts) + ")" - - -def check(sql: str, target_dialect: str) -> list[str]: - warnings: list[str] = [] - pattern = re.compile(r"#[0-9a-zA-Z_]+") - long_temp_names: set[str] = set() - for m in pattern.finditer(sql): - name = m.group() - if len(name) > MAX_TABLE_NAME_LENGTH - 8 - 1: - long_temp_names.add(name) - for name in sorted(long_temp_names): - warnings.append( - f"Temp table name '{name}' is too long. Temp table names should be shorter than " - f"{MAX_TABLE_NAME_LENGTH - 8} characters to prevent some DMBSs from throwing an error." - ) - - pattern2 = re.compile(r"(create|drop|truncate)\s+table\s+[0-9a-zA-Z_]+", re.IGNORECASE) - long_names: set[str] = set() - for m in pattern2.finditer(sql): - name = m.group().split()[-1] - if len(name) > MAX_TABLE_NAME_LENGTH and "#" + name not in long_temp_names: - long_names.add(name) - for name in sorted(long_names): - warnings.append( - f"Table name '{name}' is too long. Table names should be shorter than " - f"{MAX_TABLE_NAME_LENGTH} characters to prevent some DMBSs from throwing an error." - ) - return warnings - - -def generate_session_id() -> str: - from .patterns import generate_session_id as _gen - - return _gen() - - -def set_replacement_patterns_path(path: str | None) -> None: - from .patterns import set_replacement_patterns_path as _set - - _set(path) diff --git a/tests/test_sqlrender_csv_format.py b/tests/test_sqlrender_csv_format.py deleted file mode 100644 index d9d62403..00000000 --- a/tests/test_sqlrender_csv_format.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Test replacementPatterns.csv format - ported from OHDSI SqlRender test-replacement-patterns-file-format.R""" - -import pytest - -from circe.sqlrender import translate -from circe.sqlrender.patterns import _safe_split - - -class TestCsvFormat: - - def test_csv_has_valid_format(self): - from importlib.resources import files - - f = files("circe.sqlrender").joinpath("replacementPatterns.csv").open("r", encoding="utf-8") - content = f.read() - - lines = content.splitlines() - assert len(lines) > 1, "CSV should have header row plus at least one pattern" - - for i, line in enumerate(lines): - columns = _safe_split(line, ",") - if i == 0: - assert columns[0] == "To" - assert columns[1] == "Pattern" - assert columns[2] == "Replacement" - continue - assert len(columns) >= 3, ( - f"Row {i} has {len(columns)} columns (expected at least 3): {columns}" - ) - - def test_all_patterns_can_be_parsed(self): - from circe.sqlrender.translator import parse_search_pattern - from circe.sqlrender.patterns import load_patterns - - patterns = load_patterns() - for dialect, pairs in patterns.items(): - for pattern, replacement in pairs: - try: - parse_search_pattern(pattern) - except Exception as e: - pytest.fail( - f"Failed to parse pattern for dialect '{dialect}': " - f"pattern={pattern!r}, error={e}" - ) - - def test_duckdb_and_postgresql_can_translate_simple_sql(self): - sql = "SELECT * FROM table;" - for dialect in ("duckdb", "postgresql"): - result = translate(sql, dialect) - assert "SELECT" in result - assert "table" in result diff --git a/tests/test_sqlrender_render.py b/tests/test_sqlrender_render.py deleted file mode 100644 index d9b41c4f..00000000 --- a/tests/test_sqlrender_render.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Test parameter rendering - ported from OHDSI SqlRender test-renderSql.R""" - -import warnings - -import pytest - -from circe.sqlrender import render - - -class TestRender: - S = "{DEFAULT @a = '123'} SELECT * FROM table WHERE x = @a AND {@b == 'blaat'}?{y = 1234}:{x = 1};" - - def test_parameter_substitution(self): - sql = render(self.S, a="abc") - assert sql == " SELECT * FROM table WHERE x = abc AND x = 1;" - - def test_empty_parameter(self): - sql = render(self.S, a="abc", b="") - assert sql == " SELECT * FROM table WHERE x = abc AND x = 1;" - - def test_default(self): - sql = render(self.S, b="1") - assert sql == " SELECT * FROM table WHERE x = 123 AND x = 1;" - - def test_if_then_else_then(self): - sql = render(self.S, b="blaat") - assert sql == " SELECT * FROM table WHERE x = 123 AND y = 1234;" - - def test_if_then_else_else(self): - sql = render(self.S, b="bla") - assert sql == " SELECT * FROM table WHERE x = 123 AND x = 1;" - - def test_boolean_param_true(self): - sql = render("SELECT * FROM table {@a}?{WHERE x = 1}", a=True) - assert sql == "SELECT * FROM table WHERE x = 1" - - def test_boolean_param_false(self): - sql = render("SELECT * FROM table {@a}?{WHERE x = 1}", a=False) - assert sql == "SELECT * FROM table " - - def test_in_pattern_true(self): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - sql = render("{1 IN (@a)}?{SELECT * FROM table}", a=[1, 2, 3, 4]) - assert sql == "SELECT * FROM table" - - def test_in_pattern_false(self): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - sql = render("{1 IN (@a)}?{SELECT * FROM table}", a=[2, 3, 4]) - assert sql == "" - - def test_in_pattern_space_start_true(self): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - sql = render("{ 1 IN (@a)}?{SELECT * FROM table}", a=[1, 2, 3, 4]) - assert sql == "SELECT * FROM table" - - def test_in_pattern_space_start_false(self): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - sql = render("{ 1 IN (@a)}?{SELECT * FROM table}", a=[2, 3, 4]) - assert sql == "" - - def test_and_operator_true(self): - sql = render("{true & true}?{true}:{false}") - assert sql == "true" - - def test_and_operator_false(self): - sql = render("{true & false}?{true}:{false}") - assert sql == "false" - - def test_or_operator_true_1(self): - sql = render("{true | false}?{true}:{false}") - assert sql == "true" - - def test_or_operator_true_2(self): - sql = render("{true | true}?{true}:{false}") - assert sql == "true" - - def test_or_operator_false(self): - sql = render("{false | false}?{true}:{false}") - assert sql == "false" - - def test_nested_in_boolean(self): - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - sql = render("{true & (true & (true & 4 IN (@a)))}?{true}:{false}", a=[1, 2, 3]) - assert sql == "false" - - def test_nested_if_then_else_true(self): - sql = render("{true}?{{true}?{double true}:{true false}}:{false}") - assert sql == "double true" - - def test_nested_if_then_else_false(self): - sql = render("{false}?{{true}?{double true}:{true false}}:{false}") - assert sql == "false" - - def test_simple_negation(self): - sql = render("{!false}?{true}:{false}") - assert sql == "true" - - def test_negation_of_param(self): - sql = render("{!@a}?{true}:{false}", a="true") - assert sql == "false" - - def test_not_equals_operator_1(self): - sql = render("{123 != 123}?{true}:{false}") - assert sql == "false" - - def test_not_equals_operator_2(self): - sql = render("{123 != 234}?{true}:{false}") - assert sql == "true" - - def test_not_equals_operator_3(self): - sql = render("{123 <> 123}?{true}:{false}") - assert sql == "false" - - def test_not_equals_operator_4(self): - sql = render("{123 <> 234}?{true}:{false}") - assert sql == "true" - - def test_nested_in_evaluates_true(self): - sql = render("{TRUE & (FALSE | 1 IN (1,2,3))}?{true}:{false}") - assert sql == "true" - - def test_nested_in_evaluates_false(self): - sql = render("{TRUE & (FALSE | 4 IN (1,2,3))}?{true}:{false}") - assert sql == "false" - - def test_backslash_in_parameter(self): - sql = render("SELECT * FROM table WHERE name = '@name';", name="NA\\joe") - assert sql == "SELECT * FROM table WHERE name = 'NA\\joe';" - - def test_dollar_in_parameter(self): - sql = render("SELECT * FROM table WHERE name = '@name';", name="NA$joe") - assert sql == "SELECT * FROM table WHERE name = 'NA$joe';" - - def test_error_on_bad_boolean_syntax(self): - from circe.sqlrender.renderer import SqlRenderError - - with pytest.raises(SqlRenderError): - render("{true = true}?{true}:{false}") - - def test_warning_on_parameter_name_mismatch(self): - with pytest.warns(UserWarning): - render("SELECT * FROM @my_table", a_table="x") - - def test_no_problem_missing_parameters(self): - assert render("SELECT * FROM @my_table") == "SELECT * FROM @my_table" - - def test_warning_on_old_function(self): - with pytest.warns(UserWarning): - render("SELECT * FROM @my_table", x="y") - - def test_inline_simple(self): - sql = render("{1 == 1}?{yes}:{no}") - assert sql == "yes" - - def test_inline_false(self): - sql = render("{1 == 2}?{yes}:{no}") - assert sql == "no" - - def test_inline_no_else(self): - sql = render("{false}?{hide}") - assert sql == "" diff --git a/tests/test_sqlrender_split.py b/tests/test_sqlrender_split.py deleted file mode 100644 index c5c233a1..00000000 --- a/tests/test_sqlrender_split.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Test SQL splitting - ported from OHDSI SqlRender test-splitSql.R""" - -import pytest - -from circe.sqlrender import split_sql - - -class TestSplitSql: - - def test_split_simple_statements(self): - parts = split_sql("SELECT * INTO a FROM b; USE x; DROP TABLE c;") - assert parts == ["SELECT * INTO a FROM b", "USE x", "DROP TABLE c"] - - def test_split_with_begin_end(self): - parts = split_sql("BEGIN\nSELECT * INTO a FROM b;\nEND;\nUSE x;") - assert parts == ["BEGIN\nSELECT * INTO a FROM b;\nEND;", "USE x"] - - def test_split_with_case_end(self): - parts = split_sql( - "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END AS x INTO a FROM b;\nUSE x;" - ) - assert parts == [ - "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END AS x INTO a FROM b", - "USE x", - ] - - def test_split_with_end_in_quoted_text(self): - parts = split_sql( - "insert into a (x) values ('end');\n insert into a (x) values ('begin');" - ) - assert parts == [ - "insert into a (x) values ('end')", - "insert into a (x) values ('begin')", - ] - - def test_split_with_case_end_at_end(self): - sql = "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END FROM a GROUP BY CASE WHEN x=1 THEN 0 ELSE 1 END;" - parts = split_sql(sql) - assert parts == [ - "SELECT CASE WHEN x=1 THEN 0 ELSE 1 END FROM a GROUP BY CASE WHEN x=1 THEN 0 ELSE 1 END" - ] - - def test_split_with_reserved_word_end_as_field(self): - sql = "INSERT INTO t (data_source, start, [end]) VALUES ('hes', '1990-01-01', '2014-12-31');" - parts = split_sql(sql) - assert parts == [ - "INSERT INTO t (data_source, start, [end]) VALUES ('hes', '1990-01-01', '2014-12-31')" - ] - - def test_split_with_comment_last_line_no_eol(self): - parts = split_sql("SELECT * FROM table;\n-- end") - assert parts == ["SELECT * FROM table"] - - def test_split_with_hint_at_start(self): - parts = split_sql( - "--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist" - ) - assert parts == [ - "--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist" - ] - - def test_split_with_hint_in_second_statement(self): - parts = split_sql( - "DROP TABLE blah;\n--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist;" - ) - assert parts == [ - "DROP TABLE blah", - "--HINT DISTRIBUTE_ON_KEY(analysis_id)\nCREATE TABLE results.achilles_results_dist", - ] diff --git a/tests/test_sqlrender_translate.py b/tests/test_sqlrender_translate.py deleted file mode 100644 index a56bca37..00000000 --- a/tests/test_sqlrender_translate.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Test general translation behavior - ported from OHDSI SqlRender test-translateSql.R""" - -import pytest - -from circe.sqlrender import translate -from circe.sqlrender.translator import SqlTranslateError, check - - -def setup_function(): - global _target_to_patterns - _target_to_patterns = None - - -class TestGeneralTranslate: - def test_invalid_target_dialect(self): - with pytest.raises(SqlTranslateError, match="Don't know how to translate to pwd"): - translate("SELECT * FROM a;", target_dialect="pwd") - - def test_table_name_too_long_warning(self): - warnings = check( - "DROP TABLE abcdefghijklmnopqrstuvwxyz1234567890123456789012345678901234567890", - "pdw", - ) - assert len(warnings) > 0 - assert "too long" in warnings[0].lower() - - def test_no_warning_for_short_table_name(self): - warnings = check("DROP TABLE short_name;", "pdw") - assert len(warnings) == 0 - - def test_list_supported_dialects(self): - from circe.sqlrender.patterns import load_patterns - - patterns = load_patterns() - # Should have at least ome common dialects - for d in ("duckdb", "postgresql", "oracle", "bigquery"): - assert d in patterns, f"Missing dialect: {d}" diff --git a/tests/test_sqlrender_translate_duckdb.py b/tests/test_sqlrender_translate_duckdb.py deleted file mode 100644 index e88e0d1f..00000000 --- a/tests/test_sqlrender_translate_duckdb.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Test DuckDB translation - ported from OHDSI SqlRender test-translate-duckdb.R""" - -import re - -from circe.sqlrender import translate - -# Force fresh pattern load for each test module - - -def setup_function(): - global _target_to_patterns - _target_to_patterns = None - - -def normalize_sql(s: str) -> str: - s = re.sub(r"([;()'+\-/|*\n])", r" \1 ", s) - s = re.sub(r" +", " ", s) - return s.strip() - - -def assert_sql_equal(actual: str, expected: str): - assert normalize_sql(actual) == normalize_sql(expected), f"\nExpected: {expected}\nGot: {actual}" - - -class TestDuckDBTranslation: - def test_string_concat_1(self): - sql = translate("'x' + b ( 'x' + b)", "duckdb") - assert_sql_equal(sql, "'x' || b ( 'x' || b)") - - def test_string_concat_2(self): - sql = translate("a + ';b'", "duckdb") - assert_sql_equal(sql, "a || ';b'") - - def test_string_concat_3(self): - sql = translate("a + ';('", "duckdb") - assert_sql_equal(sql, "a || ';('") - - def test_add_months(self): - sql = translate("DATEADD(mm,2,date)", "duckdb") - assert_sql_equal(sql, "(date + TO_MONTHS(CAST(2 AS INTEGER)))") - - def test_add_years(self): - sql = translate("DATEADD(yy,2,date)", "duckdb") - assert_sql_equal(sql, "(date + TO_YEARS(CAST(2 AS INTEGER)))") - - def test_cte_select_into(self): - sql = translate( - "WITH cte1 AS (SELECT a FROM b) SELECT c INTO d FROM cte1;", - "duckdb", - ) - expected = "CREATE TABLE d \nAS\nWITH cte1 AS (SELECT a FROM b) SELECT\nc \nFROM\ncte1;" - assert_sql_equal(sql, expected) - - def test_select_into(self): - sql = translate("SELECT c INTO d;", "duckdb") - expected = "CREATE TABLE d AS\nSELECT\nc ;" - assert_sql_equal(sql, expected) - - def test_cte_insert_into_select(self): - sql = translate( - "WITH cte1 AS (SELECT a FROM b) INSERT INTO c (d int) SELECT e FROM cte1;", - "duckdb", - ) - expected = "WITH cte1 AS (SELECT a FROM b) INSERT INTO c (d int) SELECT e FROM cte1;" - assert_sql_equal(sql, expected) - - def test_create_table_if_not_exists(self): - sql = translate( - "IF OBJECT_ID('cohort', 'U') IS NULL\n CREATE TABLE cohort\n(cohort_definition_id INT);", - "duckdb", - ) - expected = "CREATE TABLE IF NOT EXISTS cohort\n (cohort_definition_id INT);" - assert_sql_equal(sql, expected) - - def test_select_random_row(self): - sql = translate( - "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY RAND()) AS rn FROM table) tmp WHERE rn <= 1", - "duckdb", - ) - expected = "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY RANDOM()) AS rn FROM table) tmp WHERE rn <= 1" - assert_sql_equal(sql, expected) - - def test_temp_table(self): - sql = translate("SELECT * FROM #my_temp;", "duckdb") - assert_sql_equal(sql, "SELECT * FROM my_temp;") - - def test_top(self): - sql = translate("SELECT TOP 10 * FROM my_table WHERE a = b;", "duckdb") - assert_sql_equal(sql, "SELECT * FROM my_table WHERE a = b LIMIT 10;") - - def test_top_subquery(self): - sql = translate( - "SELECT name FROM (SELECT TOP 1 name FROM my_table WHERE a = b);", - "duckdb", - ) - expected = "SELECT name FROM (SELECT name FROM my_table WHERE a = b LIMIT 1);" - assert_sql_equal(sql, expected) - - def test_convert_varchar_date_112(self): - sql = translate("CONVERT(VARCHAR,start_date,112) FROM table;", "duckdb") - assert_sql_equal(sql, "STRFTIME(start_date, '%Y%m%d') FROM table;") - - def test_convert_date(self): - sql = translate("CONVERT(DATE, '20000101');", "duckdb") - assert_sql_equal(sql, "CAST(strptime('20000101', '%Y%m%d') AS DATE);") - - def test_cast_date(self): - sql = translate("CAST('20000101' AS DATE);", "duckdb") - assert_sql_equal(sql, "CAST(strptime('20000101', '%Y%m%d') AS DATE);") - - def test_log_any_base(self): - sql = translate("SELECT LOG(number, base) FROM table", "duckdb") - expected = "SELECT (LN(CAST((number) AS REAL))/LN(CAST((base) AS REAL))) FROM table" - assert_sql_equal(sql, expected) - - def test_isnumeric(self): - sql = translate("SELECT CASE WHEN ISNUMERIC(a) = 1 THEN a ELSE b FROM c;", "duckdb") - expected = ( - "SELECT CASE WHEN CASE WHEN (CAST(a AS VARCHAR) ~ '^([0-9]+\\.?[0-9]*|\\.[0-9]+)$')" - " THEN 1 ELSE 0 END = 1 THEN a ELSE b FROM c;" - ) - assert_sql_equal(sql, expected) - - def test_isnumeric_where(self): - sql = translate("SELECT a FROM table WHERE ISNUMERIC(a) = 1", "duckdb") - expected = ( - "SELECT a FROM table WHERE CASE WHEN (CAST(a AS VARCHAR) ~ '^([0-9]+\\.?[0-9]*|\\.[0-9]+)$')" - " THEN 1 ELSE 0 END = 1" - ) - assert_sql_equal(sql, expected) - - def test_update_statistics(self): - sql = translate("UPDATE STATISTICS results_schema.heracles_results;", "duckdb") - assert_sql_equal(sql, "ANALYZE results_schema.heracles_results;") - - def test_datetime_types(self): - sql = translate("CREATE TABLE x (a DATETIME2, b DATETIME);", "duckdb") - assert_sql_equal(sql, "CREATE TABLE x (a TIMESTAMP, b TIMESTAMP);") - - def test_getdate(self): - sql = translate("GETDATE()", "duckdb") - assert_sql_equal(sql, "CURRENT_DATE") - - def test_create_index(self): - sql = translate("CREATE INDEX idx_1 ON main.person (person_id);", "duckdb") - assert_sql_equal(sql, "CREATE INDEX idx_1 ON main.person (person_id);") - - def test_datediff_with_literals(self): - sql = translate("SELECT DATEDIFF(DAY, '20000131', '20000101');", "duckdb") - expected = ( - "SELECT (CAST(strptime('20000101', '%Y%m%d') AS DATE)" - " - CAST(strptime('20000131', '%Y%m%d') AS DATE));" - ) - assert_sql_equal(sql, expected) - - def test_datediff_date_fields(self): - sql = translate("SELECT DATEDIFF(DAY, date1, date2);", "duckdb") - expected = "SELECT (CAST(date2 AS DATE) - CAST(date1 AS DATE));" - assert_sql_equal(sql, expected) - - def test_datediff_year_literals(self): - sql = translate("SELECT DATEDIFF(YEAR, '20010131', '20000101');", "duckdb") - expected = ( - "SELECT (EXTRACT(YEAR FROM CAST(strptime('20000101', '%Y%m%d') AS DATE))" - " - EXTRACT(YEAR FROM CAST(strptime('20010131', '%Y%m%d') AS DATE)));" - ) - assert_sql_equal(sql, expected) - - def test_datediff_year_fields(self): - sql = translate("SELECT DATEDIFF(YEAR, date1, date2);", "duckdb") - expected = "SELECT (EXTRACT(YEAR FROM CAST(date2 AS DATE)) - EXTRACT(YEAR FROM CAST(date1 AS DATE)));" - assert_sql_equal(sql, expected) - - def test_datediff_month_literals(self): - sql = translate("SELECT DATEDIFF(MONTH, '20000115', '20010116');", "duckdb") - expected = ( - "SELECT (extract(year from age(CAST(strptime('20010116', '%Y%m%d') AS DATE)," - " CAST(strptime('20000115', '%Y%m%d') AS DATE)))*12" - " + extract(month from age(CAST(strptime('20010116', '%Y%m%d') AS DATE)," - " CAST(strptime('20000115', '%Y%m%d') AS DATE))));" - ) - assert_sql_equal(sql, expected) - - def test_datediff_month_fields(self): - sql = translate("SELECT DATEDIFF(MONTH, date1, date2);", "duckdb") - expected = ( - "SELECT (extract(year from age(CAST(date2 AS DATE), CAST(date1 AS DATE)))*12" - " + extract(month from age(CAST(date2 AS DATE), CAST(date1 AS DATE))));" - ) - assert_sql_equal(sql, expected) - - def test_ceiling(self): - sql = translate("SELECT CEILING(0.1);", "duckdb") - assert_sql_equal(sql, "SELECT CEILING(0.1);") - - def test_drop_table_if_exists(self): - sql = translate("DROP TABLE IF EXISTS test;", "duckdb") - assert_sql_equal(sql, "DROP TABLE IF EXISTS test;") - - def test_iif(self): - sql = translate("SELECT IIF(a>b, 1, b) AS max_val FROM table;", "duckdb") - expected = "SELECT CASE WHEN a>b THEN 1 ELSE b END AS max_val FROM table ;" - assert_sql_equal(sql, expected) - - def test_add_days_with_period(self): - sql = translate("DATEADD(DAY, -2.0, date)", "duckdb") - assert_sql_equal(sql, "(date + TO_DAYS(CAST(-2.0 AS INTEGER)))") - - def test_newid(self): - sql = translate("SELECT NEWID()", "duckdb") - assert_sql_equal(sql, "SELECT uuid()") - - def test_cast_concat_date(self): - sql = translate("CAST(CONCAT('2000', '0101') AS DATE);", "duckdb") - assert_sql_equal(sql, "CAST(strptime(CONCAT('2000', '0101'), '%Y%m%d') AS DATE);") - - def test_alter_table_add_single(self): - sql = translate("ALTER TABLE my_table ADD a INT;", "duckdb") - assert_sql_equal(sql, "ALTER TABLE my_table ADD a INT;") - - def test_alter_table_add_multiple(self): - sql = translate("ALTER TABLE my_table ADD a INT, b INT, c VARCHAR(255);", "duckdb") - expected = "ALTER TABLE my_table ADD a INT; ALTER TABLE my_table ADD b INT; ALTER TABLE my_table ADD c VARCHAR(255);" - assert_sql_equal(sql, expected) - - def test_alter_table_alter_column(self): - sql = translate("ALTER TABLE my_table ALTER COLUMN a BIGINT;", "duckdb") - assert_sql_equal(sql, "ALTER TABLE my_table ALTER a TYPE BIGINT;") diff --git a/tests/test_sqlrender_translate_postgresql.py b/tests/test_sqlrender_translate_postgresql.py deleted file mode 100644 index 30da8b53..00000000 --- a/tests/test_sqlrender_translate_postgresql.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Test PostgreSQL translation - ported from OHDSI SqlRender test-translate-postgresql.R""" - -import re - -from circe.sqlrender import translate - - -def setup_function(): - global _target_to_patterns - _target_to_patterns = None - - -def normalize_sql(s: str) -> str: - s = re.sub(r"([;()'+\-/|*\n])", r" \1 ", s) - s = re.sub(r" +", " ", s) - return s.strip() - - -def assert_sql_equal(actual: str, expected: str): - assert normalize_sql(actual) == normalize_sql(expected), f"\nExpected: {expected}\nGot: {actual}" - - -class TestPostgreSQLTranslation: - def test_use(self): - sql = translate("USE vocabulary;", "postgresql") - assert_sql_equal(sql, "SET search_path TO vocabulary;") - - def test_string_concat_1(self): - sql = translate("'x' + b ( 'x' + b)", "postgresql") - assert_sql_equal(sql, "'x' || b ( 'x' || b)") - - def test_string_concat_2(self): - sql = translate("a + ';b'", "postgresql") - assert_sql_equal(sql, "a || ';b'") - - def test_string_concat_3(self): - sql = translate("a + ';('", "postgresql") - assert_sql_equal(sql, "a || ';('") - - def test_dateadd_month(self): - sql = translate("DATEADD(mm,1,date)", "postgresql") - assert_sql_equal(sql, "(date + 1*INTERVAL'1 month')") - - def test_datediff_month(self): - sql = translate( - "SELECT DATEDIFF(month,drug_era_start_date,drug_era_end_date) FROM drug_era;", - "postgresql", - ) - expected = ( - "SELECT (extract(year from age(CAST(drug_era_end_date AS DATE)," - " CAST(drug_era_start_date AS DATE)))*12" - " + extract(month from age(CAST(drug_era_end_date AS DATE)," - " CAST(drug_era_start_date AS DATE)))) FROM drug_era;" - ) - assert_sql_equal(sql, expected) - - def test_datediff_hour(self): - sql = translate( - "SELECT DATEDIFF(hour,drug_exposure_start_datetime,drug_exposure_end_datetime) FROM drug_exposure;", - "postgresql", - ) - expected = ( - "SELECT (EXTRACT(EPOCH FROM (drug_exposure_end_datetime" - " - drug_exposure_start_datetime)) / 3600) FROM drug_exposure;" - ) - assert_sql_equal(sql, expected) - - def test_datediff_minute(self): - sql = translate( - "SELECT DATEDIFF(minute,drug_exposure_start_datetime,drug_exposure_end_datetime) FROM drug_exposure;", - "postgresql", - ) - expected = ( - "SELECT (EXTRACT(EPOCH FROM (drug_exposure_end_datetime" - " - drug_exposure_start_datetime)) / 60) FROM drug_exposure;" - ) - assert_sql_equal(sql, expected) - - def test_datediff_second(self): - sql = translate( - "SELECT DATEDIFF(second,drug_exposure_start_datetime,drug_exposure_end_datetime) FROM drug_exposure;", - "postgresql", - ) - expected = ( - "SELECT EXTRACT(EPOCH FROM (drug_exposure_end_datetime" - " - drug_exposure_start_datetime)) FROM drug_exposure;" - ) - assert_sql_equal(sql, expected) - - def test_with_select(self): - sql = translate("WITH cte1 AS (SELECT a FROM b) SELECT c FROM cte1;", "postgresql") - assert_sql_equal(sql, "WITH cte1 AS (SELECT a FROM b) SELECT c FROM cte1;") - - def test_with_select_into(self): - sql = translate("WITH cte1 AS (SELECT a FROM b) SELECT c INTO d FROM cte1;", "postgresql") - expected = "CREATE TABLE d \nAS\nWITH cte1 AS (SELECT a FROM b) SELECT\nc \nFROM\ncte1;" - assert_sql_equal(sql, expected) - - def test_select_into_without_from(self): - sql = translate("SELECT c INTO d;", "postgresql") - expected = "CREATE TABLE d AS\nSELECT\nc ;" - assert_sql_equal(sql, expected) - - def test_with_insert_into_select(self): - sql = translate( - "WITH cte1 AS (SELECT a FROM b) INSERT INTO c (d int) SELECT e FROM cte1;", - "postgresql", - ) - expected = "WITH cte1 AS (SELECT a FROM b) INSERT INTO c (d int) SELECT e FROM cte1;" - assert_sql_equal(sql, expected) - - def test_create_table_if_not_exists(self): - sql = translate( - "IF OBJECT_ID('cohort', 'U') IS NULL\n CREATE TABLE cohort\n(cohort_definition_id INT);", - "postgresql", - ) - expected = "CREATE TABLE IF NOT EXISTS cohort\n (cohort_definition_id INT);" - assert_sql_equal(sql, expected) - - def test_select_random_row(self): - sql = translate( - "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY RAND()) AS rn FROM table) tmp WHERE rn <= 1", - "postgresql", - ) - expected = "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY RANDOM()) AS rn FROM table) tmp WHERE rn <= 1" - assert_sql_equal(sql, expected) - - def test_hashbytes_md5(self): - sql = translate( - "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY HASHBYTES('MD5',CAST(person_id AS varchar))) tmp WHERE rn <= 1", - "postgresql", - ) - expected = "SELECT column FROM (SELECT column, ROW_NUMBER() OVER (ORDER BY MD5(CAST(person_id AS varchar))) tmp WHERE rn <= 1" - assert_sql_equal(sql, expected) - - def test_convert_varbinary(self): - sql = translate( - "SELECT ROW_NUMBER() OVER CONVERT(VARBINARY, val, 1) rn WHERE rn <= 1", - "postgresql", - ) - expected = "SELECT ROW_NUMBER() OVER CAST(CONCAT('x', val) AS BIT(32)) rn WHERE rn <= 1" - assert_sql_equal(sql, expected) - - def test_top(self): - sql = translate("SELECT TOP 10 * FROM my_table WHERE a = b;", "postgresql") - assert_sql_equal(sql, "SELECT * FROM my_table WHERE a = b LIMIT 10;") - - def test_top_subquery(self): - sql = translate( - "SELECT name FROM (SELECT TOP 1 name FROM my_table WHERE a = b);", - "postgresql", - ) - expected = "SELECT name FROM (SELECT name FROM my_table WHERE a = b LIMIT 1);" - assert_sql_equal(sql, expected) - - def test_convert_varchar_date(self): - sql = translate("CONVERT(VARCHAR,start_date,112) FROM table;", "postgresql") - assert_sql_equal(sql, "TO_CHAR(start_date, 'YYYYMMDD') FROM table;") - - def test_log(self): - sql = translate("SELECT LOG(number) FROM table", "postgresql") - assert_sql_equal(sql, "SELECT LN(CAST((number) AS REAL)) FROM table") - - def test_log10(self): - sql = translate("SELECT LOG10(number) FROM table;", "postgresql") - assert_sql_equal(sql, "SELECT LOG(10,CAST((number) AS NUMERIC)) FROM table;") - - def test_log_any_base(self): - sql = translate("SELECT LOG(number, base) FROM table", "postgresql") - expected = "SELECT LOG(CAST((base) AS NUMERIC),CAST((number) AS NUMERIC)) FROM table" - assert_sql_equal(sql, expected) - - def test_isnumeric(self): - sql = translate("SELECT CASE WHEN ISNUMERIC(a) = 1 THEN a ELSE b FROM c;", "postgresql") - expected = ( - "SELECT CASE WHEN CASE WHEN (CAST(a AS VARCHAR) ~ '^([0-9]+\\.?[0-9]*|\\.[0-9]+)$')" - " THEN 1 ELSE 0 END = 1 THEN a ELSE b FROM c;" - ) - assert_sql_equal(sql, expected) - - sql = translate("SELECT a FROM table WHERE ISNUMERIC(a) = 1", "postgresql") - expected = ( - "SELECT a FROM table WHERE CASE WHEN (CAST(a AS VARCHAR) ~ '^([0-9]+\\.?[0-9]*|\\.[0-9]+)$')" - " THEN 1 ELSE 0 END = 1" - ) - assert_sql_equal(sql, expected) - - sql = translate("SELECT a FROM table WHERE ISNUMERIC(a) = 0", "postgresql") - expected = ( - "SELECT a FROM table WHERE CASE WHEN (CAST(a AS VARCHAR) ~ '^([0-9]+\\.?[0-9]*|\\.[0-9]+)$')" - " THEN 1 ELSE 0 END = 0" - ) - assert_sql_equal(sql, expected) - - def test_cte_string_literal_cast(self): - sql = translate( - "WITH expression AS(SELECT 'my literal', col1, CAST('other literal' as VARCHAR(MAX)), col2 FROM table WHERE a = b) SELECT * FROM expression ORDER BY 1, 2, 3, 4;", - "postgresql", - ) - expected = ( - "WITH expression AS (SELECT CAST('my literal' as TEXT), col1, CAST('other literal' as TEXT)," - " col2 FROM table WHERE a = b) SELECT * FROM expression ORDER BY 1, 2, 3, 4;" - ) - assert_sql_equal(sql, expected) - - def test_update_statistics(self): - sql = translate("UPDATE STATISTICS results_schema.heracles_results;", "postgresql") - assert_sql_equal(sql, "ANALYZE results_schema.heracles_results;") - - def test_datetime_types(self): - sql = translate("CREATE TABLE x (a DATETIME2, b DATETIME);", "postgresql") - assert_sql_equal(sql, "CREATE TABLE x (a TIMESTAMP, b TIMESTAMP);") - - def test_drop_table_if_exists(self): - sql = translate("DROP TABLE IF EXISTS test;", "postgresql") - assert_sql_equal(sql, "DROP TABLE IF EXISTS test;") - - def test_comments_in_quotes_1(self): - sql = ( - "WITH cte_all\nAS (\nSELECT * FROM my_table\n\nUNION ALL\n\n" - "SELECT '(--12 hours fasting)' AS check_description\n)\n" - "INSERT INTO cdm.main\nSELECT *\nFROM cte_all;" - ) - result = translate(sql, "postgresql") - expected = ( - "WITH cte_all\n AS (SELECT * FROM my_table\nUNION ALL\n" - "SELECT CAST('(--12 hours fasting)' as TEXT) AS check_description\n)\n" - "INSERT INTO cdm.main\nSELECT *\nFROM cte_all;" - ) - assert_sql_equal(result, expected) - - def test_comments_in_quotes_2(self): - sql = ( - "WITH cte_all\nAS (\nSELECT * FROM my_table\n\nUNION ALL\n\n" - "SELECT '(/*12 hours fasting)' AS check_description\n)\n" - "INSERT INTO cdm.main\nSELECT *\nFROM cte_all;" - ) - result = translate(sql, "postgresql") - expected = ( - "WITH cte_all\n AS (SELECT * FROM my_table\nUNION ALL\n" - "SELECT CAST('(/*12 hours fasting)' as TEXT) AS check_description\n)\n" - "INSERT INTO cdm.main\nSELECT *\nFROM cte_all;" - ) - assert_sql_equal(result, expected) - - def test_iif(self): - sql = translate("SELECT IIF(a>b, 1, b) AS max_val FROM table;", "postgresql") - expected = "SELECT CASE WHEN a>b THEN 1 ELSE b END AS max_val FROM table ;" - assert_sql_equal(sql, expected) - - def test_alter_table_add_single(self): - sql = translate("ALTER TABLE my_table ADD a INT;", "postgresql") - assert_sql_equal(sql, "ALTER TABLE my_table ADD COLUMN a INT;") - - def test_alter_table_add_multiple(self): - sql = translate("ALTER TABLE my_table ADD a INT, b INT, c VARCHAR(255);", "postgresql") - expected = "ALTER TABLE my_table ADD COLUMN a INT, ADD COLUMN b INT, ADD COLUMN c VARCHAR(255);" - assert_sql_equal(sql, expected) - - def test_alter_table_add_column(self): - sql = translate("ALTER TABLE my_table ADD COLUMN a INT;", "postgresql") - assert_sql_equal(sql, "ALTER TABLE my_table ADD COLUMN a INT;") - - def test_alter_table_add_constraint(self): - sql = translate( - "ALTER TABLE cdm.MEASUREMENT ADD CONSTRAINT xpk_MEASUREMENT PRIMARY KEY NONCLUSTERED (measurement_id);", - "postgresql", - ) - expected = "ALTER TABLE cdm.MEASUREMENT ADD CONSTRAINT xpk_MEASUREMENT PRIMARY KEY (measurement_id);" - assert_sql_equal(sql, expected) - - def test_alter_table_alter_column(self): - sql = translate("ALTER TABLE my_table ALTER COLUMN a BIGINT;", "postgresql") - assert_sql_equal(sql, "ALTER TABLE my_table ALTER COLUMN a TYPE BIGINT;") From 9cd893cf7c1f7d7d27924eb99126de846e9a5c6a Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Fri, 10 Jul 2026 16:04:40 -0700 Subject: [PATCH 61/62] Updated version number and changelog for new version --- CHANGELOG.md | 74 +++++++++++++++----------------------------------- pyproject.toml | 2 +- 2 files changed, 23 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ea0e58..566955b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.3.0] - 2026-07-10 + +### Added +- Experimental Ibis execution engine for building and writing cohorts as relational expressions (`build_cohort()`, `write_cohort()`) +- Support for snake_case YAML cohort definitions via `cohort_expression_from_yaml()` +- Persistent caching of concept set resolution in the IBIS execution layer +- `load_expression()` helper for loading cohort expressions from JSON, YAML, dict, or file paths + +### Fixed +- ERA collapse ordering made deterministic across repeated executions +- Collapse tie handling aligned with Java CIRCE-BE semantics +- Era filter semantics restored with correct observation filtering +- Nested correlated criteria now correctly applied within criteria groups +- Package now importable without ibis installed +- Pydantic deprecation warnings resolved + +### Changed +- Dropped Python 3.8 support (minimum version is now 3.9) +- Added PyYAML as a core dependency +- Added `ibis`, `ibis-duckdb`, `ibis-postgres`, and `ibis-databricks` optional dependency groups + ## [0.2.0] - 2026-02-25 ### Added @@ -17,58 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.0] - 2026-01-23 ### Added - - Initial Alpha Release of the CIRCE Python implementation. - Full parity with OHDSI CIRCE-BE Java library for cohort definition and SQL generation. - Expanded test suite with 3,400+ tests including parity checks. -- Comprehensive documentation and GitHub Actions release workflows. - -## [Unreleased] - -### Planned -- Performance optimizations for large cohort definitions -- Additional output formats (JSON schema, XML) -- Integration examples with common OMOP tools ---- - -### Features - -- Support for Python 3.8, 3.9, 3.10, 3.11, and 3.12 -- Full OMOP CDM v5.x compatibility -- Type hints throughout the codebase -- Concept set expression handling with include/exclude logic -- Window criteria for temporal relationships -- Correlated criteria for complex cohort logic -- Date adjustment strategies (DateOffsetStrategy) -- Custom era strategies for drug exposures -- Observation period and demographic criteria -- Inclusion rules and censoring criteria -- Result limits and ordinal expressions -- Comprehensive error messages and validation warnings -- Builder pattern for SQL generation -- Pydantic models for data validation and serialization - -### Documentation - -- Complete README with installation instructions -- Comprehensive CLI usage documentation -- Python API examples and quick start guide -- Contributing guidelines with development setup -- Java class mapping reference for interoperability -- Package structure documentation -- Troubleshooting and FAQ sections - -### Technical Details - -- Built with Pydantic v2.0+ for robust validation -- Uses typing-extensions for backward compatibility -- Modular architecture matching Java CIRCE-BE structure -- Extensive test coverage across all modules -- Black, isort, flake8, and mypy for code quality -- pytest with coverage reporting - -### Known Limitations - -- Negative control cohort classes yet implemented -- Documentation website under development -- Performance not yet optimized for extremely large cohorts (1000+ criteria) \ No newline at end of file +- Comprehensive documentation and GitHub Actions release workflows. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4f0b2a09..a69657b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ohdsi-circe-python-alpha" -version = "0.2.0" +version = "0.3.0" description = "Python implementation of OHDSI CIRCE-BE for cohort definition and SQL generation" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "Apache-2.0"} From 19fac519530b3dd39991298fc6fed630532ef865 Mon Sep 17 00:00:00 2001 From: Jamie Gilbert Date: Fri, 10 Jul 2026 16:56:33 -0700 Subject: [PATCH 62/62] Features/ibis custom eras (#37) * implementation of custom end era logic in ibis layer with tests to confirm parity with circe-be implementation --- circe/__init__.py | 2 +- circe/execution/engine/custom_era.py | 171 ++++++ circe/execution/engine/end_strategy.py | 4 +- circe/execution/normalize/cohort.py | 6 +- circe/execution/normalize/end_strategy.py | 1 + docs/conf.py | 4 +- tests/execution/test_api_ibis.py | 19 +- tests/execution/test_custom_era.py | 715 ++++++++++++++++++++++ tests/execution/test_error_messages.py | 12 +- 9 files changed, 905 insertions(+), 29 deletions(-) create mode 100644 circe/execution/engine/custom_era.py create mode 100644 tests/execution/test_custom_era.py diff --git a/circe/__init__.py b/circe/__init__.py index 9a09bc7a..ae7cb25d 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -19,7 +19,7 @@ License: Apache License 2.0 """ -__version__ = "0.2.0" +__version__ = "0.3.0" __author__ = "CIRCE Python Implementation Team" __email__ = "circe-python@ohdsi.org" __license__ = "Apache License 2.0" diff --git a/circe/execution/engine/custom_era.py b/circe/execution/engine/custom_era.py new file mode 100644 index 00000000..a9c5363a --- /dev/null +++ b/circe/execution/engine/custom_era.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import ibis + +from ..plan.schema import PERSON_ID, START_DATE +from .end_strategy import _replace_end_date, attach_observation_bounds + + +def _compute_exposure_end_date(table, *, days_supply_override: int | None): + start = table["drug_exposure_start_date"].cast("date") + + if days_supply_override is not None: + return start + ibis.interval(days=days_supply_override) + + raw_end = ( + table["drug_exposure_end_date"].cast("date") + if "drug_exposure_end_date" in table.columns + else ibis.null().cast("date") + ) + days_supply = ( + table["days_supply"].cast("int64") if "days_supply" in table.columns else ibis.null().cast("int64") + ) + supply_end = start + days_supply.as_interval("D") + + return ibis.coalesce(raw_end, supply_end, start + ibis.interval(days=1)) + + +def _compute_eras(exposures, *, gap_days: int, offset: int): + padded = exposures.mutate( + _padded_end=(exposures._exposure_end + ibis.interval(days=int(gap_days + offset))) + ) + + ordering = [ + padded.start_date, + padded._padded_end.desc(), + padded._exposure_end.desc(), + ] + + cumulative_window = ibis.cumulative_window(group_by=padded.person_id, order_by=ordering) + ordered_window = ibis.window(group_by=padded.person_id, order_by=ordering) + + with_cummax = padded.mutate(_cummax_padded_end=padded._padded_end.max().over(cumulative_window)) + + with_prev = with_cummax.mutate(_prev_max=with_cummax._cummax_padded_end.lag().over(ordered_window)) + + marked = with_prev.mutate( + _is_new=ibis.ifelse( + with_prev._prev_max.isnull() | (with_prev._prev_max < with_prev.start_date), + ibis.literal(1, type="int64"), + ibis.literal(0, type="int64"), + ) + ) + + group_window = ibis.cumulative_window( + group_by=marked.person_id, + order_by=[ + marked.start_date, + marked._padded_end.desc(), + marked._exposure_end.desc(), + marked._is_new.desc(), + ], + ) + era_indexed = marked.mutate(_era_id=marked._is_new.sum().over(group_window)) + + collapsed = era_indexed.group_by(era_indexed.person_id, era_indexed._era_id).aggregate( + era_start_date=era_indexed.start_date.min(), + _max_exposure_end=era_indexed._exposure_end.max(), + ) + + return collapsed.select( + collapsed.person_id.cast("int64").name(PERSON_ID), + collapsed.era_start_date.cast("date").name("era_start_date"), + (collapsed._max_exposure_end + ibis.interval(days=int(offset))).cast("date").name("era_end_date"), + ) + + +def compute_drug_eras( + ctx, + *, + drug_codeset_id: int, + gap_days: int, + offset: int, + days_supply_override: int | None, + cohort_person_ids=None, +): + concept_ids = ctx.concept_ids_for_codeset(drug_codeset_id) + + if not concept_ids: + de = ctx.table("drug_exposure") + return de.filter(ibis.literal(False)).select( + de.person_id.cast("int64").name(PERSON_ID), + ibis.null().cast("date").name("era_start_date"), + ibis.null().cast("date").name("era_end_date"), + ) + + de = ctx.table("drug_exposure") + if cohort_person_ids is not None: + de = de.semi_join( + cohort_person_ids, + predicates=[de.person_id == cohort_person_ids.person_id], + ) + + if "drug_source_concept_id" in de.columns: + filtered = de.filter( + de.drug_concept_id.isin(concept_ids) | de.drug_source_concept_id.isin(concept_ids) + ) + else: + filtered = de.filter(de.drug_concept_id.isin(concept_ids)) + + prepared = filtered.select( + filtered.person_id.cast("int64").name("person_id"), + filtered.drug_exposure_start_date.cast("date").name("start_date"), + _compute_exposure_end_date(filtered, days_supply_override=days_supply_override).name("_exposure_end"), + ) + + return _compute_eras(prepared, gap_days=gap_days, offset=offset) + + +def apply_custom_era_strategy(events, strategy, ctx): + payload = strategy.payload + drug_codeset_id = payload["drug_codeset_id"] + gap_days = payload["gap_days"] + offset = payload["offset"] + days_supply_override = payload.get("days_supply_override") + + if drug_codeset_id is None: + with_bounds = attach_observation_bounds(events, ctx) + return _replace_end_date(events, with_bounds, with_bounds.op_end_date) + + cohort_person_ids = events.select(events.person_id).distinct() + + eras = compute_drug_eras( + ctx, + drug_codeset_id=drug_codeset_id, + gap_days=gap_days, + offset=offset, + days_supply_override=days_supply_override, + cohort_person_ids=cohort_person_ids, + ) + + eras_for_join = eras.select( + eras.person_id.name("_era_person_id"), + eras.era_start_date, + eras.era_end_date, + ) + + with_bounds = attach_observation_bounds(events, ctx) + + joined = with_bounds.left_join( + eras_for_join, + predicates=[ + with_bounds.person_id == eras_for_join._era_person_id, + with_bounds[START_DATE] >= eras_for_join.era_start_date, + with_bounds[START_DATE] <= eras_for_join.era_end_date, + ], + ) + + event_window = ibis.window( + group_by=[joined.person_id, joined.event_id], + order_by=[joined.era_end_date.asc()], + ) + ranked = joined.mutate(_rn=ibis.row_number().over(event_window)) + one_per_event = ranked.filter(ranked._rn == 0) + + effective_end = ibis.coalesce( + one_per_event.era_end_date, + one_per_event.op_end_date, + ) + final_end = ibis.least(effective_end, one_per_event.op_end_date) + + return _replace_end_date(events, one_per_event, final_end) diff --git a/circe/execution/engine/end_strategy.py b/circe/execution/engine/end_strategy.py index a099985b..4b8e5b9e 100644 --- a/circe/execution/engine/end_strategy.py +++ b/circe/execution/engine/end_strategy.py @@ -64,7 +64,9 @@ def apply_end_strategy(events, strategy, ctx): return _replace_end_date(events, with_bounds, end_date_expr) if strategy.kind == "custom_era": - raise UnsupportedFeatureError("Ibis executor end-strategy error: custom_era is not supported.") + from .custom_era import apply_custom_era_strategy + + return apply_custom_era_strategy(events, strategy, ctx) # Fallback: preserve default semantics of op_end_date clipping. return _replace_end_date(events, with_bounds, with_bounds.op_end_date) diff --git a/circe/execution/normalize/cohort.py b/circe/execution/normalize/cohort.py index b2f657ff..61765b47 100644 --- a/circe/execution/normalize/cohort.py +++ b/circe/execution/normalize/cohort.py @@ -3,7 +3,7 @@ from ...cohortdefinition import CohortExpression from ...vocabulary.concept import ConceptSet from .._dataclass import frozen_slots_dataclass -from ..errors import ExecutionNormalizationError, UnsupportedFeatureError +from ..errors import ExecutionNormalizationError from .collapse import NormalizedCollapseSettings, normalize_collapse_settings from .criteria import NormalizedCriterion, normalize_criterion from .end_strategy import NormalizedEndStrategy, normalize_end_strategy @@ -149,10 +149,6 @@ def normalize_cohort( ) normalized_end_strategy = normalize_end_strategy(expression.end_strategy) - if normalized_end_strategy is not None and normalized_end_strategy.kind == "custom_era": - raise UnsupportedFeatureError( - "Ibis executor normalization error: custom_era end strategy is not supported." - ) return NormalizedCohort( title=expression.title, diff --git a/circe/execution/normalize/end_strategy.py b/circe/execution/normalize/end_strategy.py index 62ff666b..8e034091 100644 --- a/circe/execution/normalize/end_strategy.py +++ b/circe/execution/normalize/end_strategy.py @@ -32,6 +32,7 @@ def normalize_end_strategy( "drug_codeset_id": value.drug_codeset_id, "offset": int(value.offset), "gap_days": int(value.gap_days), + "days_supply_override": value.days_supply_override, }, ) return NormalizedEndStrategy(kind="end_strategy", payload={}) diff --git a/docs/conf.py b/docs/conf.py index c19e43a7..e8b62b0f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,8 +13,8 @@ project = "OHDSI CIRCE Python" copyright = "2024, OHDSI Community" author = "CIRCE Python Implementation Team" -release = "0.2.0" -version = "0.2.0" +release = "0.3.0" +version = "0.3.0" # -- General configuration --------------------------------------------------- extensions = [ diff --git a/tests/execution/test_api_ibis.py b/tests/execution/test_api_ibis.py index ef0a73e4..db55e52e 100644 --- a/tests/execution/test_api_ibis.py +++ b/tests/execution/test_api_ibis.py @@ -26,8 +26,7 @@ VisitDetail, VisitOccurrence, ) -from circe.cohortdefinition.core import CustomEraStrategy, NumericRange -from circe.execution.errors import UnsupportedFeatureError +from circe.cohortdefinition.core import NumericRange from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -1213,10 +1212,12 @@ def test_build_cohort_location_region_keeps_repeated_location_history_rows(): assert sorted(result.start_date.astype(str).tolist()) == ["2020-01-01", "2020-02-01"] -def test_build_cohort_rejects_unsupported_features(): - expression = CohortExpression( - primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence()]), - end_strategy=CustomEraStrategy(drug_codeset_id=1, gap_days=30, offset=0), - ) - with pytest.raises(UnsupportedFeatureError, match="custom_era"): - _ = build_cohort(expression, backend=object(), cdm_schema="main") +def test_build_cohort_rejects_unsupported_criteria(): + """Unsupported base criteria type is rejected at normalization time.""" + from circe.cohortdefinition.criteria import Criteria as RawCriteria + from circe.execution.errors import UnsupportedCriterionError + + with pytest.raises(UnsupportedCriterionError): + from circe.execution.normalize.criteria import normalize_criterion + + normalize_criterion(RawCriteria()) diff --git a/tests/execution/test_custom_era.py b/tests/execution/test_custom_era.py new file mode 100644 index 00000000..f57e6a51 --- /dev/null +++ b/tests/execution/test_custom_era.py @@ -0,0 +1,715 @@ +from __future__ import annotations + +from datetime import date + +import pytest + +from circe.api import build_cohort +from circe.cohortdefinition import ( + CohortExpression, + ConditionOccurrence, + DrugExposure, + PrimaryCriteria, +) +from circe.cohortdefinition.core import CustomEraStrategy, ResultLimit +from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem + + +def _make_concept_set(set_id: int, concept_id: int) -> ConceptSet: + return ConceptSet( + id=set_id, + expression=ConceptSetExpression(items=[ConceptSetItem(concept=Concept(conceptId=concept_id))]), + ) + + +def _seed_common_tables(conn, ibis): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1], + "year_of_birth": [1980], + "gender_concept_id": [8507], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1], + "observation_period_id": [10], + "observation_period_start_date": [date(2019, 1, 1)], + "observation_period_end_date": [date(2021, 12, 31)], + } + ), + overwrite=True, + ) + + +def test_custom_era_merges_drugs_within_gap(): + """Drug exposures within gap_days merge into one era; cohort end_date reflects it.""" + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [1, 1], + "drug_exposure_id": [1, 2], + "drug_concept_id": [222, 222], + "drug_exposure_start_date": [date(2020, 1, 1), date(2020, 2, 1)], + "drug_exposure_end_date": [date(2020, 1, 31), date(2020, 3, 3)], + "days_supply": [0, 0], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": [date(2020, 1, 1)], + "condition_end_date": [date(2020, 1, 1)], + "visit_occurrence_id": [10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=CustomEraStrategy(drug_codeset_id=2, gap_days=30, offset=0), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-01-01" + # exp 1: end=2020-01-31, exp 2: end=2020-03-03 + # gap = 1 <= 30 -> merged era: start=2020-01-01, end=2020-03-03 + assert str(result.iloc[0]["end_date"])[:10] == "2020-03-03" + + +def test_custom_era_no_merge_across_large_gap(): + """Drug exposures beyond gap_days form separate eras; cohort uses nearest era.""" + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [1, 1], + "drug_exposure_id": [1, 2], + "drug_concept_id": [222, 222], + "drug_exposure_start_date": [date(2020, 1, 1), date(2020, 2, 1)], + "drug_exposure_end_date": [date(2020, 1, 6), date(2020, 3, 3)], + "days_supply": [0, 0], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": [date(2020, 1, 1)], + "condition_end_date": [date(2020, 1, 1)], + "visit_occurrence_id": [10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=CustomEraStrategy(drug_codeset_id=2, gap_days=5, offset=0), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-01-01" + # exp 1: end=2020-01-06, exp 2: end=2020-03-03 + # gap = 26 > 5 -> separate eras + # cohort start 2020-01-01 matches era 1: end 2020-01-06 + assert str(result.iloc[0]["end_date"])[:10] == "2020-01-06" + + +def test_custom_era_offset_applied(): + """Offset days are added to the drug era end_date.""" + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [1], + "drug_exposure_id": [1], + "drug_concept_id": [222], + "drug_exposure_start_date": [date(2020, 1, 1)], + "drug_exposure_end_date": [date(2020, 1, 10)], + "days_supply": [0], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": [date(2020, 1, 1)], + "condition_end_date": [date(2020, 1, 1)], + "visit_occurrence_id": [10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=CustomEraStrategy(drug_codeset_id=2, gap_days=30, offset=7), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-01-01" + # drug effective end: 2020-01-10 (end_date override) + # era: start=2020-01-01, end=2020-01-10+7=2020-01-17 + assert str(result.iloc[0]["end_date"])[:10] == "2020-01-17" + + +def test_custom_era_no_matching_drugs(): + """No matching drug exposures -> fall back to observation_period_end_date.""" + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": [date(2020, 1, 15)], + "condition_end_date": [date(2020, 1, 15)], + "visit_occurrence_id": [10], + } + ), + overwrite=True, + ) + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [], + "drug_exposure_id": [], + "drug_concept_id": [], + "drug_exposure_start_date": [], + "drug_exposure_end_date": [], + "days_supply": [], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 999), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=CustomEraStrategy(drug_codeset_id=2, gap_days=30, offset=0), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-01-15" + # No matching drugs -> end_date = observation_period_end_date = 2021-12-31 + assert str(result.iloc[0]["end_date"])[:10] == "2021-12-31" + + +def test_custom_era_with_drug_exposure_as_primary(): + """Custom era works with DrugExposure as the primary criterion.""" + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [1, 1], + "drug_exposure_id": [1, 2], + "drug_concept_id": [222, 222], + "drug_exposure_start_date": [date(2020, 1, 1), date(2020, 2, 1)], + "drug_exposure_end_date": [date(2020, 1, 31), date(2020, 3, 3)], + "days_supply": [0, 0], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 222)], + primary_criteria=PrimaryCriteria(criteria_list=[DrugExposure(codeset_id=1)]), + end_strategy=CustomEraStrategy(drug_codeset_id=1, gap_days=30, offset=0), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + # With primary_limit_type="all", both drug exposures produce cohort entries. + # Both entries get end_date from the merged drug era (2020-03-03). + assert len(result) == 2 + start_dates = sorted(result["start_date"].astype(str).tolist()) + assert start_dates == ["2020-01-01", "2020-02-01"] + assert all(str(d)[:10] == "2020-03-03" for d in result["end_date"]) + + +def test_compute_drug_eras_matches_java_sql_logic(): + """compute_drug_eras ibis output matches equivalent raw SQL (Java template translated to DuckDB).""" + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + from types import SimpleNamespace + + from circe.execution.engine.custom_era import compute_drug_eras + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + + # 5 exposures for person 1, with gap_days=7, offset=3. + # Exposure end_dates are set explicitly so COALESCE is predictable. + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [1, 1, 1, 1, 1], + "drug_exposure_id": [1, 2, 3, 4, 5], + "drug_concept_id": [222, 222, 222, 222, 222], + "drug_exposure_start_date": [ + date(2020, 1, 1), + date(2020, 1, 10), + date(2020, 3, 1), + date(2020, 3, 20), + date(2020, 5, 1), + ], + "drug_exposure_end_date": [ + date(2020, 1, 6), + date(2020, 2, 9), + date(2020, 3, 21), + date(2020, 3, 30), + date(2020, 5, 15), + ], + "days_supply": [0, 0, 0, 0, 0], + } + ), + overwrite=True, + ) + + ctx = SimpleNamespace( + table=lambda name: conn.table(name), + concept_ids_for_codeset=lambda cid: (222,) if cid == 2 else (), + ) + + # --- ibis path --- + ibis_result = compute_drug_eras( + ctx, drug_codeset_id=2, gap_days=7, offset=3, days_supply_override=None + ).execute() + ibis_result = ibis_result.sort_values(["person_id", "era_start_date"]).reset_index(drop=True) + + # --- raw SQL path (Java template core logic, DuckDB dialect) --- + # Java template uses: COALESCE(end, start+days_supply, start+1) + # then pads by (gap_days + offset), groups by cumulative-max-over-preceding, + # and finally subtracts gap_days from max(end) to leave only offset. + gap = 7 + off = 3 + + sql = f""" + WITH exposures AS ( + SELECT + person_id::INTEGER AS person_id, + drug_exposure_start_date::DATE AS start_date, + COALESCE( + drug_exposure_end_date::DATE, + drug_exposure_start_date::DATE + days_supply::INTEGER, + drug_exposure_start_date::DATE + 1 + ) + {gap + off} AS padded_end + FROM drug_exposure + WHERE drug_concept_id IN (222) + ), + with_prev_max AS ( + SELECT *, + MAX(padded_end) OVER ( + PARTITION BY person_id ORDER BY start_date, padded_end DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ) AS prev_max + FROM exposures + ), + with_markers AS ( + SELECT *, + CASE WHEN prev_max IS NULL OR prev_max < start_date THEN 1 ELSE 0 END AS is_new + FROM with_prev_max + ), + with_era AS ( + SELECT *, + SUM(is_new) OVER ( + PARTITION BY person_id + ORDER BY start_date, is_new DESC, padded_end DESC + ) AS era_id + FROM with_markers + ) + SELECT + person_id, + MIN(start_date)::DATE AS era_start_date, + (MAX(padded_end) - {gap})::DATE AS era_end_date + FROM with_era + GROUP BY person_id, era_id + ORDER BY person_id, MIN(start_date) + """ + + raw_conn = conn.con + sql_result = raw_conn.sql(sql).fetchdf() + + # --- compare --- + pd = pytest.importorskip("pandas") + pd.testing.assert_frame_equal( + ibis_result, + sql_result, + check_dtype=False, + check_column_type=False, + ) + + +def test_custom_era_offset_affects_era_grouping(): + """Offset in padded_end determines whether exposures merge into eras. + + Two exposures are separated by more than gap_days (0) but less than or + equal to gap_days + offset (10). If offset is *not* included in the + padded_end before grouping the exposures would remain in separate eras, + producing a wrong cohort end_date. + """ + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [1, 1], + "drug_exposure_id": [1, 2], + "drug_concept_id": [222, 222], + "drug_exposure_start_date": [date(2020, 1, 1), date(2020, 1, 15)], + "drug_exposure_end_date": [date(2020, 1, 10), date(2020, 1, 20)], + "days_supply": [0, 0], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": [date(2020, 1, 1)], + "condition_end_date": [date(2020, 1, 1)], + "visit_occurrence_id": [10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=CustomEraStrategy(drug_codeset_id=2, gap_days=0, offset=10), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert len(result) == 1 + assert str(result.iloc[0]["start_date"])[:10] == "2020-01-01" + # exp1 end=2020-01-10, exp2 start=2020-01-15 (gap=5 days) + # Without offset in padded_end: padded_end1=2020-01-10 < start2 → SPLIT + # → wrong cohort end = 2020-01-10+10 = 2020-01-20 + # With offset in padded_end: padded_end1=2020-01-20 >= start2 → MERGED + # → correct cohort end = max(end)+offset = 2020-01-20+10 = 2020-01-30 + assert str(result.iloc[0]["end_date"])[:10] == "2020-01-30" + + +def test_full_cohort_custom_era_matches_sql_end_dates(): + """Full cohort pipeline with CustomEraStrategy produces same end_dates as raw SQL.""" + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables(conn, ibis) + + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [1, 1], + "drug_exposure_id": [1, 2], + "drug_concept_id": [222, 222], + "drug_exposure_start_date": [date(2020, 1, 1), date(2020, 2, 1)], + "drug_exposure_end_date": [date(2020, 1, 31), date(2020, 3, 3)], + "days_supply": [0, 0], + } + ), + overwrite=True, + ) + conn.create_table( + "condition_occurrence", + obj=ibis.memtable( + { + "person_id": [1], + "condition_occurrence_id": [100], + "condition_concept_id": [111], + "condition_start_date": [date(2020, 1, 1)], + "condition_end_date": [date(2020, 1, 1)], + "visit_occurrence_id": [10], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[ + _make_concept_set(1, 111), + _make_concept_set(2, 222), + ], + primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence(codeset_id=1)]), + end_strategy=CustomEraStrategy(drug_codeset_id=2, gap_days=30, offset=0), + ) + + # --- ibis pipeline --- + cohort_result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + # --- raw SQL pipeline (Circe BE generateCohort.sql logic, DuckDB dialect) --- + # Mirrors Circe BE's @cohort_end_unions approach: + # The default observation-period end and every strategy end are UNIONed, + # then the earliest valid end_date per (person_id, event_id) is selected: + # ROW_NUMBER() OVER (PARTITION BY person_id, event_id ORDER BY CE.end_date) + # WHERE CE.end_date >= I.start_date + sql = """ + WITH drug_eras AS ( + SELECT + person_id, + MIN(start_date) AS era_start_date, + MAX(exposure_end) AS era_end_date + FROM ( + SELECT + person_id, start_date, exposure_end, padded_end, + SUM(is_new) OVER ( + PARTITION BY person_id + ORDER BY start_date, is_new DESC, padded_end DESC + ) AS era_id + FROM ( + SELECT + person_id, start_date, exposure_end, padded_end, + CASE WHEN prev_max IS NULL OR prev_max < start_date THEN 1 ELSE 0 END AS is_new + FROM ( + SELECT + person_id, start_date, exposure_end, padded_end, + MAX(padded_end) OVER ( + PARTITION BY person_id ORDER BY start_date, padded_end DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ) AS prev_max + FROM ( + SELECT + de.person_id, + de.drug_exposure_start_date::DATE AS start_date, + COALESCE( + de.drug_exposure_end_date::DATE, + de.drug_exposure_start_date::DATE + de.days_supply::INTEGER, + de.drug_exposure_start_date::DATE + 1 + ) AS exposure_end, + COALESCE( + de.drug_exposure_end_date::DATE, + de.drug_exposure_start_date::DATE + de.days_supply::INTEGER, + de.drug_exposure_start_date::DATE + 1 + ) + 30 AS padded_end + FROM drug_exposure de + WHERE de.drug_concept_id = 222 + ) raw_ends + ) maxes + ) marked + ) indexed + GROUP BY person_id, era_id + ), + events AS ( + SELECT + e.condition_occurrence_id AS event_id, + e.person_id, + e.condition_start_date::DATE AS start_date, + op.observation_period_end_date::DATE AS op_end_date + FROM condition_occurrence e + JOIN observation_period op ON e.person_id = op.person_id + ), + cohort_ends AS ( + SELECT event_id, person_id, start_date, op_end_date AS end_date FROM events + UNION ALL + SELECT e.event_id, e.person_id, e.start_date, er.era_end_date AS end_date + FROM events e + JOIN drug_eras er + ON e.person_id = er.person_id + AND e.start_date BETWEEN er.era_start_date AND er.era_end_date + ), + ranked AS ( + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY person_id, event_id + ORDER BY end_date ASC + ) AS rn + FROM cohort_ends + WHERE end_date >= start_date + ) + SELECT + person_id, + start_date, + end_date::DATE AS end_date + FROM ranked + WHERE rn = 1 + ORDER BY person_id, start_date + """ + + sql_result = conn.con.sql(sql).fetchdf() + + # Compare end_dates and start_dates after sorting + ibis_ends = sorted(cohort_result["end_date"].astype(str).tolist()) + sql_ends = sorted(sql_result["end_date"].astype(str).tolist()) + assert ibis_ends == sql_ends + + ibis_starts = sorted(cohort_result["start_date"].astype(str).tolist()) + sql_starts = sorted(sql_result["start_date"].astype(str).tolist()) + assert ibis_starts == sql_starts + + +# --------------------------------------------------------------------------- +# Regression: CustomEra must preserve all events when event_id is shared +# +# After ``first=True`` + ``QualifiedLimit=First`` + ``ExpressionLimit=First`` +# every person contributes at most one event, and ``_assign_primary_event_ids`` +# assigns ``event_id=1`` to all of them. The CustomEra window that selects +# one matching era per event must therefore partition on *(person_id, event_id)* +# — otherwise all rows collapse into a single partition and only one survives. +# --------------------------------------------------------------------------- + + +def _seed_common_tables_multi_person(conn, ibis): + conn.create_table( + "person", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "year_of_birth": [1980, 1985, 1990], + "gender_concept_id": [8507, 8507, 8507], + } + ), + overwrite=True, + ) + conn.create_table( + "observation_period", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "observation_period_id": [10, 11, 12], + "observation_period_start_date": [date(2019, 1, 1), date(2019, 1, 1), date(2019, 1, 1)], + "observation_period_end_date": [date(2021, 12, 31), date(2021, 12, 31), date(2021, 12, 31)], + } + ), + overwrite=True, + ) + + +def test_custom_era_preserves_all_persons_with_first_true(): + """All persons survive when DrugExposure(first=True) + CustomEra + limits. + + The window ``group_by=joined.event_id`` previously collapsed every row + into a single partition because all events had ``event_id=1`` (assigned + by ``_assign_primary_event_ids`` — each person has exactly 1 event after + ``first=True`` and the per-person limits). + """ + ibis = pytest.importorskip("ibis") + _ = pytest.importorskip("duckdb") + + conn = ibis.duckdb.connect() + _seed_common_tables_multi_person(conn, ibis) + + conn.create_table( + "drug_exposure", + obj=ibis.memtable( + { + "person_id": [1, 2, 3], + "drug_exposure_id": [100, 200, 300], + "drug_concept_id": [222, 222, 222], + "drug_exposure_start_date": [date(2020, 1, 1), date(2020, 2, 1), date(2020, 3, 1)], + "drug_exposure_end_date": [date(2020, 1, 31), date(2020, 2, 28), date(2020, 3, 31)], + "days_supply": [0, 0, 0], + } + ), + overwrite=True, + ) + + expression = CohortExpression( + concept_sets=[_make_concept_set(1, 222)], + primary_criteria=PrimaryCriteria(criteria_list=[DrugExposure(codeset_id=1, first=True)]), + qualified_limit=ResultLimit(Type="First"), + expression_limit=ResultLimit(Type="First"), + end_strategy=CustomEraStrategy(drug_codeset_id=1, gap_days=30, offset=0), + ) + + result = build_cohort(expression, backend=conn, cdm_schema="main").execute() + + assert len(result) == 3, f"expected 3 rows, got {len(result)}" + assert set(result["person_id"]) == {1, 2, 3} diff --git a/tests/execution/test_error_messages.py b/tests/execution/test_error_messages.py index 80133b45..8e71481b 100644 --- a/tests/execution/test_error_messages.py +++ b/tests/execution/test_error_messages.py @@ -14,7 +14,7 @@ Occurrence, PrimaryCriteria, ) -from circe.cohortdefinition.core import CustomEraStrategy, NumericRange +from circe.cohortdefinition.core import NumericRange from circe.execution.errors import CompilationError, UnsupportedCriterionError, UnsupportedFeatureError from circe.execution.normalize.criteria import normalize_criterion from circe.vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -55,16 +55,6 @@ def _concept_set(set_id: int, concept_id: int) -> ConceptSet: ) -def test_error_message_for_custom_era_end_strategy(): - expression = CohortExpression( - primary_criteria=PrimaryCriteria(criteria_list=[ConditionOccurrence()]), - end_strategy=CustomEraStrategy(drug_codeset_id=1, gap_days=30, offset=0), - ) - - with pytest.raises(UnsupportedFeatureError, match="custom_era end strategy"): - _ = build_cohort(expression, backend=object(), cdm_schema="main") - - def test_error_message_for_unsupported_criterion_type(): with pytest.raises( UnsupportedCriterionError,