diff --git a/.github/workflows/basic_tests.yml b/.github/workflows/basic_tests.yml index 717a31ee..33fed6a8 100644 --- a/.github/workflows/basic_tests.yml +++ b/.github/workflows/basic_tests.yml @@ -31,8 +31,34 @@ jobs: run: tox - name: Upload coverage to Codecov - if: secrets.CODECOV_TOKEN != '' continue-on-error: true 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/.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/.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/.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 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/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/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/CONTRIBUTING.md b/CONTRIBUTING.md index a463493b..8b7b3ddb 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) @@ -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 @@ -95,6 +98,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`: @@ -217,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 @@ -238,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 4553d5a2..55c3699f 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -5,13 +5,15 @@ ## 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) +- **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,21 +218,27 @@ 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 ### Minimum Requirements -- Python 3.8+ +- Python 3.9+ - 100 MB free disk space - 512 MB RAM 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 4e87bfd6..b1d076e6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # CIRCE Python Implementation -[![Python](https://img.shields.io/badge/python-3.8%2B-blue)](https://www.python.org/downloads/) -[![Tests](https://img.shields.io/badge/tests-3400%2B%20passed-brightgreen)](tests/) +[![Python](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org/downloads/) +[![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,37 +27,41 @@ 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) -- **Python**: 3.8+ +- **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 source 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 > ``` @@ -136,11 +140,23 @@ 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 build_cohort + +# Requires optional extras, e.g. `pip install ohdsi-circe-python-alpha[ibis-duckdb]` +events = build_cohort(cohort, backend=conn, cdm_schema="main") # lazy ibis relation +``` + ## What's Included 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 @@ -148,12 +164,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 @@ -181,6 +208,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 @@ -196,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 @@ -325,33 +353,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/ -``` +The full test suite should pass. -### 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 @@ -370,7 +396,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 @@ -426,11 +452,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/circe/__init__.py b/circe/__init__.py index 743fdb3c..ae7cb25d 100644 --- a/circe/__init__.py +++ b/circe/__init__.py @@ -19,7 +19,7 @@ License: Apache License 2.0 """ -__version__ = "0.1.0" +__version__ = "0.3.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, @@ -84,13 +78,15 @@ ) from .api import ( + build_cohort, build_cohort_query, cohort_expression_from_json, cohort_print_friendly, + write_cohort, ) # Main exports -from .cohortdefinition import CohortExpression +from .io import load_expression from .vocabulary import Concept, ConceptSet, ConceptSetExpression, ConceptSetItem @@ -100,30 +96,19 @@ def safe_model_rebuild(package): In embedded environments like R's reticulate, this avoids 'ValueError: call stack is not deep enough' during instantiation. """ - try: - for loader, module_name, is_pkg in pkgutil.walk_packages( - package.__path__, package.__name__ + "." - ): - try: + with suppress(Exception): + for _loader, module_name, _is_pkg in pkgutil.walk_packages(package.__path__, package.__name__ + "."): + with suppress(ImportError): mod = importlib.import_module(module_name) - 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: + 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: @@ -132,7 +117,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, @@ -181,7 +166,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) @@ -220,6 +205,10 @@ 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 helpers + "load_expression", ] diff --git a/circe/api.py b/circe/api.py index b4deac57..4c8f3a56 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 List, Optional +from typing import TYPE_CHECKING, Any, Literal, Optional from .cohortdefinition import ( BuildExpressionQueryOptions, @@ -15,8 +17,15 @@ CohortExpressionQueryBuilder, MarkdownRender, ) +from .cohortdefinition.yaml_utils import snake_case_dict_to_cohort_expression 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. @@ -72,8 +81,40 @@ 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 + expression: CohortExpression, + options: Optional[BuildExpressionQueryOptions] = None, ) -> str: """Generate SQL query from a cohort expression. @@ -101,9 +142,125 @@ 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, + ) + + 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: @@ -128,7 +285,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 25261c11..fd58d8d1 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: @@ -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 @@ -124,7 +122,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): @@ -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: @@ -222,7 +216,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 @@ -259,6 +253,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/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..ecc65243 100644 --- a/circe/check/checker.py +++ b/circe/check/checker.py @@ -9,8 +9,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List - from .check import Check from .warning import Warning @@ -33,7 +31,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 +65,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 +94,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 +103,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/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..2d7681de 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: @@ -68,7 +66,8 @@ 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" + self, + criteria: "DemographicCriteria", ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. @@ -86,11 +85,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 29ec5413..b6b957bc 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: @@ -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..10b3eb05 100644 --- a/circe/check/checkers/base_checker_factory.py +++ b/circe/check/checkers/base_checker_factory.py @@ -64,7 +64,8 @@ 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" + self, + criteria: "DemographicCriteria", ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for a demographic criteria (to be implemented by subclasses). diff --git a/circe/check/checkers/base_corelated_criteria_check.py b/circe/check/checkers/base_corelated_criteria_check.py index 84ccacd8..bdf1561d 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,24 +41,17 @@ 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): - 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: - 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: @@ -69,44 +60,35 @@ def _check_criteria_group( 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 - ) + 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: 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 - ) + 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) def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, ) -> None: """Check a single corelated criteria (to be implemented by subclasses). @@ -117,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/base_criteria_check.py b/circe/check/checkers/base_criteria_check.py index 6703602b..3b918d2e 100644 --- a/circe/check/checkers/base_criteria_check.py +++ b/circe/check/checkers/base_criteria_check.py @@ -8,21 +8,19 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -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 CorelatedCriteria, Criteria + from ...cohortdefinition.criteria import Criteria except ImportError: from typing import TYPE_CHECKING if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression - from ...cohortdefinition.criteria import CorelatedCriteria, Criteria + from ...cohortdefinition.criteria import Criteria class BaseCriteriaCheck(BaseIterableCheck): @@ -34,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: @@ -49,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: @@ -82,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 5ca633a5..79950c17 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: @@ -30,10 +27,7 @@ if TYPE_CHECKING: from ...cohortdefinition.cohort import CohortExpression from ...cohortdefinition.criteria import ( - CorelatedCriteria, - Criteria, CriteriaGroup, - DemographicCriteria, PrimaryCriteria, ) @@ -66,7 +60,9 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N self._check_censoring_criteria(expression, reporter) def _check_primary_criteria( - self, primary_criteria: Optional["PrimaryCriteria"], reporter: WarningReporter + self, + primary_criteria: Optional["PrimaryCriteria"], + reporter: WarningReporter, ) -> None: """Check primary criteria. @@ -79,7 +75,9 @@ def _check_primary_criteria( self._check_criteria(criteria, reporter, self.PRIMARY_CRITERIA) def _check_additional_criteria( - self, criteria_group: Optional["CriteriaGroup"], reporter: WarningReporter + self, + criteria_group: Optional["CriteriaGroup"], + reporter: WarningReporter, ) -> None: """Check additional criteria. @@ -88,10 +86,7 @@ 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 ( @@ -104,9 +99,7 @@ def _check_additional_criteria( 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: @@ -117,9 +110,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: @@ -130,10 +121,7 @@ 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 ( @@ -161,10 +149,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: @@ -183,10 +168,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 895f31d4..29306aa2 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: @@ -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,10 +144,8 @@ def is_before(window: "Window") -> bool: 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 False # type: ignore[unreachable] + 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: @@ -191,18 +189,19 @@ 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,31 +257,24 @@ 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, + ConditionOccurrence, + Death, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ProcedureOccurrence, + Specimen, + VisitOccurrence, + VisitDetail, + ), + ): return c1.codeset_id == c2.codeset_id 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 0c069369..118058d5 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 @@ -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( @@ -418,7 +400,8 @@ def default_check(c: "Criteria") -> None: return default_check def _get_check_demographic( - self, criteria: "DemographicCriteria" + self, + criteria: "DemographicCriteria", ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. @@ -440,15 +423,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 +439,8 @@ 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 fb97a00c..22cc3680 100644 --- a/circe/check/checkers/concept_set_criteria_check.py +++ b/circe/check/checkers/concept_set_criteria_check.py @@ -17,42 +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 ( - ConditionEra, - ConditionOccurrence, - Criteria, - Death, - DeviceExposure, - DoseEra, - DrugEra, - DrugExposure, - Measurement, - Observation, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, - ) + from ...cohortdefinition.criteria import Criteria class ConceptSetCriteriaCheck(BaseCriteriaCheck): @@ -61,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. @@ -73,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: @@ -104,89 +70,86 @@ def _check_criteria( 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 + ( + 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) + ) ) - .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_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 67efa591..3967a9ff 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,12 +99,11 @@ 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" + self, + criteria: "DemographicCriteria", ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. @@ -135,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 237c473b..47f19fe0 100644 --- a/circe/check/checkers/criteria_checker_factory.py +++ b/circe/check/checkers/criteria_checker_factory.py @@ -8,10 +8,7 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Callable, List, Optional - -from .base_checker_factory import BaseCheckerFactory -from .warning_reporter import WarningReporter +from typing import Callable, Optional # Import at runtime to avoid circular dependencies try: @@ -88,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: @@ -100,7 +95,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, @@ -122,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 @@ -143,28 +131,20 @@ 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 + 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 + 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 @@ -222,8 +202,9 @@ def default_check(c: "Criteria") -> bool: return default_check def _get_concept_set_selection_suppliers( - self, criteria: "VisitDetail" - ) -> List[Callable[[], Optional["ConceptSetSelection"]]]: + self, + criteria: "VisitDetail", + ) -> list[Callable[[], Optional["ConceptSetSelection"]]]: """Get suppliers for ConceptSetSelection fields in VisitDetail. Args: @@ -232,7 +213,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..f9982088 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. @@ -77,7 +77,10 @@ def _define_severity(self) -> WarningSeverity: return WarningSeverity.WARNING def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, ) -> None: """Collect criteria information. @@ -89,9 +92,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: @@ -105,14 +106,10 @@ def _after_check( 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 - ): + ) 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: @@ -131,7 +128,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 +147,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/death_time_window_check.py b/circe/check/checkers/death_time_window_check.py index 1b4ccf99..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 @@ -33,7 +35,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. @@ -43,9 +47,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 +66,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 +90,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,12 +107,13 @@ 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 + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, ) -> None: """Check a corelated criteria for death time window issues. @@ -127,10 +124,12 @@ 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: 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 cfa5d879..6acf8925 100644 --- a/circe/check/checkers/domain_type_check.py +++ b/circe/check/checkers/domain_type_check.py @@ -8,50 +8,22 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -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: 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 ( - ConditionOccurrence, - Criteria, - Death, - DeviceExposure, - DrugExposure, - Measurement, - Observation, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, - ) + from ...cohortdefinition.criteria import Criteria class DomainTypeCheck(BaseCriteriaCheck): @@ -65,7 +37,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. @@ -75,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: @@ -104,67 +74,81 @@ 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: + 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 5ad241c3..c251a726 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 @@ -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"] = [] + 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,16 @@ 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" + 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 208c20f5..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 @@ -40,7 +42,10 @@ def _define_severity(self) -> WarningSeverity: return WarningSeverity.INFO def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, ) -> None: """Check drug era criteria for missing days supply information. @@ -50,24 +55,26 @@ 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: 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_concept_set_check.py b/circe/check/checkers/duplicates_concept_set_check.py index b30f7e37..58d5a138 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,14 +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): @@ -56,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 d2532890..bc8c0f0b 100644 --- a/circe/check/checkers/duplicates_criteria_check.py +++ b/circe/check/checkers/duplicates_criteria_check.py @@ -8,8 +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 from .base_criteria_check import BaseCriteriaCheck @@ -38,11 +36,9 @@ 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" - ) -> None: + def _after_check(self, reporter: WarningReporter, expression: "CohortExpression") -> None: """Check for duplicates after all criteria have been collected. Args: @@ -79,7 +75,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 @@ -105,22 +101,20 @@ def _compare_criteria(self, c1: "Criteria", c2: "Criteria") -> bool: 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 + 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, + DeviceExposure, + DoseEra, + DrugEra, + DrugExposure, + Measurement, + Observation, + ), + ): return c1.codeset_id == c2.codeset_id elif isinstance(c1, ObservationPeriod): # For ObservationPeriod, compare all fields @@ -129,13 +123,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, Specimen, VisitOccurrence, VisitDetail)): return c1.codeset_id == c2.codeset_id elif isinstance(c1, PayerPlanPeriod): return ( @@ -176,9 +164,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: @@ -186,9 +172,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/events_progression_check.py b/circe/check/checkers/events_progression_check.py index 72d92e7f..5a46211d 100644 --- a/circe/check/checkers/events_progression_check.py +++ b/circe/check/checkers/events_progression_check.py @@ -114,18 +114,16 @@ 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") - 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 ed2d1a08..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,10 +41,12 @@ 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: 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..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 @@ -31,9 +33,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. @@ -50,10 +50,12 @@ 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: 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/incomplete_rule_check.py b/circe/check/checkers/incomplete_rule_check.py index b4821817..89f23273 100644 --- a/circe/check/checkers/incomplete_rule_check.py +++ b/circe/check/checkers/incomplete_rule_check.py @@ -8,8 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List - from ..warning import Warning from ..warning_severity import WarningSeverity from ..warnings.incomplete_rule_warning import IncompleteRuleWarning @@ -34,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: @@ -63,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: @@ -74,10 +68,7 @@ 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 - ) + (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 diff --git a/circe/check/checkers/initial_event_check.py b/circe/check/checkers/initial_event_check.py index a4595b4a..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,10 +39,12 @@ 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 - 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..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 @@ -29,9 +31,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. @@ -48,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 b50e36fc..1ae8b1c4 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, Any + 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, Occurrence +if TYPE_CHECKING: + from ...cohortdefinition.criteria import CorelatedCriteria class OcurrenceCheck(BaseCorelatedCriteriaCheck): @@ -29,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: @@ -41,7 +39,10 @@ def _define_severity(self) -> WarningSeverity: return WarningSeverity.WARNING def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, ) -> None: """Check occurrence for invalid values. @@ -51,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 6f0731bd..8bd6b166 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 @@ -18,15 +17,13 @@ # 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 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): @@ -45,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( @@ -60,9 +55,7 @@ def _check(self, expression: "CohortExpression", reporter: WarningReporter) -> N 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: @@ -76,20 +69,16 @@ def _check_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" + 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( + criteria, "startWindow", None ) - end_window = criteria.get("endWindow") or criteria.get( - "end_window" + end_window = getattr(criteria, "end_window", None) or getattr( + criteria, "endWindow", None ) - 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) self._check_window(start_window, reporter, rule.name) self._check_window(end_window, reporter, rule.name) @@ -109,31 +98,19 @@ def _check_window(self, window, reporter: WarningReporter, name: str) -> None: if start: start_days = ( - start.get("days") - if isinstance(start, dict) - else getattr(start, "days", None) + 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") @@ -152,13 +129,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. @@ -176,12 +149,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 7969fdd8..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 @@ -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, @@ -44,8 +43,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, @@ -76,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" @@ -93,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: @@ -194,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, @@ -377,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): @@ -395,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): @@ -468,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): @@ -581,7 +567,8 @@ def default_check(c) -> None: return default_check def _get_check_demographic( - self, criteria: "DemographicCriteria" + self, + criteria: "DemographicCriteria", ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. @@ -603,9 +590,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 @@ -628,50 +613,53 @@ 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: 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) + ) 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: 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) - .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( - 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: @@ -685,14 +673,12 @@ def check_range( 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) + 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) @@ -717,8 +703,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, Criteria)): super().check(expression_or_criteria) diff --git a/circe/check/checkers/text_checker_factory.py b/circe/check/checkers/text_checker_factory.py index 9af4f21d..d002f2da 100644 --- a/circe/check/checkers/text_checker_factory.py +++ b/circe/check/checkers/text_checker_factory.py @@ -150,7 +150,8 @@ def check(c: "Specimen") -> None: return lambda c: None # No text checks for other criteria types def _get_check_demographic( - self, criteria: "DemographicCriteria" + self, + criteria: "DemographicCriteria", ) -> Callable[["DemographicCriteria"], None]: """Get a checker function for demographic criteria. @@ -162,9 +163,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 +175,8 @@ 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 64512fb6..7a636248 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. @@ -84,7 +84,10 @@ def _define_severity(self) -> WarningSeverity: return WarningSeverity.INFO def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, ) -> None: """Collect time window information. @@ -94,13 +97,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 +110,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) diff --git a/circe/check/checkers/time_window_check.py b/circe/check/checkers/time_window_check.py index d50f2dd7..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 @@ -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. @@ -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: @@ -65,7 +63,10 @@ def _before_check( self._observation_filter = expression.primary_criteria.observation_window def _check_criteria( - self, criteria: "CorelatedCriteria", group_name: str, reporter: WarningReporter + self, + criteria: "CorelatedCriteria", + group_name: str, + reporter: WarningReporter, ) -> None: """Check criteria for time window issues. @@ -76,10 +77,12 @@ 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 - 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/check/checkers/unused_concepts_check.py b/circe/check/checkers/unused_concepts_check.py index 76e9f456..3cf065a5 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 @@ -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: @@ -94,23 +90,19 @@ 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) - ) + 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 - ) + self._to_criteria_list_from_groups(expression.additional_criteria.groups) ) return 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. @@ -124,11 +116,12 @@ 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): @@ -140,10 +133,7 @@ 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 - ): + if hasattr(rule.expression, "criteria_list") and rule.expression.criteria_list: rule_criteria_list.extend( [ c.criteria @@ -157,16 +147,17 @@ 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). @@ -187,10 +178,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): @@ -200,7 +188,9 @@ 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. @@ -212,30 +202,22 @@ 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 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,11 +226,8 @@ def _correlated_criteria_to_list(self, correlated_criteria) -> List["Criteria"]: Returns: A list of Criteria """ - criteria_list: List["Criteria"] = [] - if ( - hasattr(correlated_criteria, "criteria_list") - and correlated_criteria.criteria_list - ): + criteria_list: list[Criteria] = [] + if hasattr(correlated_criteria, "criteria_list") and correlated_criteria.criteria_list: criteria_list.extend( [ c.criteria @@ -260,17 +239,11 @@ def _correlated_criteria_to_list(self, correlated_criteria) -> List["Criteria"]: 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 - ] + [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: @@ -281,13 +254,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: @@ -296,7 +265,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..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 Any, 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/operations/operations.py b/circe/check/operations/operations.py index f2d5bc3e..3e4f0ca6 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") @@ -71,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]: @@ -89,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/check/utils/criteria_name_helper.py b/circe/check/utils/criteria_name_helper.py index 8ba64c2e..dc083ef2 100644 --- a/circe/check/utils/criteria_name_helper.py +++ b/circe/check/utils/criteria_name_helper.py @@ -9,51 +9,14 @@ 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: - from ...cohortdefinition.criteria import ( - ConditionEra, - ConditionOccurrence, - Death, - DeviceExposure, - DoseEra, - DrugEra, - DrugExposure, - LocationRegion, - Measurement, - Observation, - ObservationPeriod, - PayerPlanPeriod, - ProcedureOccurrence, - Specimen, - VisitDetail, - VisitOccurrence, - ) -except ImportError: - 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, - ) +with suppress(ImportError): + pass class CriteriaNameHelper: @@ -84,7 +47,6 @@ def get_criteria_name(criteria) -> str: DoseEra, DrugEra, DrugExposure, - LocationRegion, Measurement, Observation, ObservationPeriod, @@ -128,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/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/cli.py b/circe/cli.py index e642c5f8..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(): @@ -23,23 +24,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.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 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 = subparsers.add_parser("generate-sql", help="Generate SQL from cohort definition") + 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("--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 +44,28 @@ 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.add_argument("input", help="Input JSON file") - md_parser.add_argument( - "--output", "-o", help="Output Markdown file (default: stdout)" - ) + md_parser = subparsers.add_parser("render-markdown", help="Render cohort definition as Markdown") + 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" - ) + 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.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 = subparsers.add_parser("process", help="Validate, generate SQL and Markdown") + 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" - ) + 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", @@ -123,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() @@ -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}") @@ -155,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: @@ -190,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: @@ -219,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() @@ -261,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/__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/builders/__init__.py b/circe/cohortdefinition/builders/__init__.py index f907b0de..80a0d235 100644 --- a/circe/cohortdefinition/builders/__init__.py +++ b/circe/cohortdefinition/builders/__init__.py @@ -9,6 +9,8 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ +from circe.extensions import get_registry + from .base import CriteriaSqlBuilder from .condition_era import ConditionEraSqlBuilder from .condition_occurrence import ConditionOccurrenceSqlBuilder @@ -28,6 +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", @@ -52,4 +62,5 @@ "PayerPlanPeriodSqlBuilder", "VisitDetailSqlBuilder", "LocationRegionSqlBuilder", + "get_builder_for_criteria", ] diff --git a/circe/cohortdefinition/builders/base.py b/circe/cohortdefinition/builders/base.py index 172a0bef..3cf9c9f4 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 @@ -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) @@ -59,9 +55,7 @@ def get_criteria_sql_with_options( if options is not None: filtered_columns = [ - column - for column in options.additional_columns - if column not in self.get_default_columns() + column for column in options.additional_columns if column not in self.get_default_columns() ] if filtered_columns: query = query.replace( @@ -70,8 +64,6 @@ def get_criteria_sql_with_options( ) else: query = query.replace("@additionalColumns", "") - else: - query = query.replace("@additionalColumns", "") return query @@ -92,7 +84,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() @@ -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() @@ -147,7 +131,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 +140,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 +148,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,14 +158,11 @@ 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() """ return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] + [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 663d3552..180480cb 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 Optional from ..criteria import ConditionEra from .base import CriteriaSqlBuilder @@ -48,13 +48,11 @@ 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 - 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: @@ -92,8 +88,10 @@ def embed_ordinal_expression( return query def resolve_select_clauses( - self, criteria: ConditionEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: ConditionEra, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for condition era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -123,7 +121,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 = [] @@ -134,31 +132,27 @@ 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]: + 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) @@ -198,9 +192,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 9706a8ac..d9173fd7 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 Optional from ..criteria import ConditionOccurrence from .base import CriteriaSqlBuilder @@ -53,13 +53,11 @@ 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 - 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", @@ -86,7 +84,10 @@ 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 @@ -101,8 +102,10 @@ def embed_ordinal_expression( return query def resolve_select_clauses( - self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: ConditionOccurrence, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for condition occurrence criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -118,8 +121,7 @@ def resolve_select_clauses( # providerSpecialty if ( - criteria.provider_specialty is not None - and len(criteria.provider_specialty) > 0 + 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") @@ -154,8 +156,10 @@ def resolve_select_clauses( return select_cols def resolve_join_clauses( - self, criteria: ConditionOccurrence, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: ConditionOccurrence, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for condition occurrence criteria.""" join_clauses = [] @@ -165,9 +169,7 @@ 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") # join to VISIT_OCCURRENCE if ( @@ -179,8 +181,7 @@ def resolve_join_clauses( # join to PROVIDER if ( - criteria.provider_specialty is not None - and len(criteria.provider_specialty) > 0 + 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" @@ -190,31 +191,25 @@ 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 = [] # 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( @@ -233,9 +228,7 @@ 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) @@ -251,9 +244,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 +257,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 +274,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 +290,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 72d3aa85..2ae267cc 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 Optional from ..criteria import Death from .base import CriteriaSqlBuilder @@ -44,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, @@ -52,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)", @@ -76,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. @@ -87,9 +81,7 @@ 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"] @@ -110,15 +102,11 @@ 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 = [] @@ -128,35 +116,25 @@ def resolve_join_clauses( 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" - ) + 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: @@ -169,24 +147,18 @@ 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 - ) + 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" - ) + 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 5dd3c192..c2b35181 100644 --- a/circe/cohortdefinition/builders/device_exposure.py +++ b/circe/cohortdefinition/builders/device_exposure.py @@ -8,10 +8,6 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -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 @@ -38,7 +34,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, @@ -46,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", @@ -60,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", @@ -107,14 +99,13 @@ 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 = [] @@ -124,9 +115,7 @@ def resolve_join_clauses( 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" - ) + 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 ( @@ -140,9 +129,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 @@ -158,36 +145,26 @@ 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: @@ -207,43 +184,31 @@ def resolve_where_clauses( # 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 - ) + 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" - ) + 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: @@ -255,12 +220,8 @@ def resolve_where_clauses( # 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: @@ -272,17 +233,17 @@ def resolve_where_clauses( 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]: + 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 1eb795cd..f0b9c591 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 Optional from ..criteria import DoseEra from .base import CriteriaSqlBuilder @@ -53,13 +53,11 @@ 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 - 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: @@ -99,8 +95,10 @@ def embed_ordinal_expression( return query def resolve_select_clauses( - self, criteria: DoseEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: DoseEra, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for dose era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -122,15 +120,11 @@ def resolve_select_clauses( ) ) 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 +135,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 +159,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: @@ -223,9 +207,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 76457424..a44c9b4c 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 Optional from ..criteria import DrugEra from .base import CriteriaSqlBuilder @@ -57,13 +57,11 @@ 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 - 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: @@ -103,8 +99,10 @@ def embed_ordinal_expression( return query def resolve_select_clauses( - self, criteria: DrugEra, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: DrugEra, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for drug era criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -128,15 +126,11 @@ def resolve_select_clauses( ) ) 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,31 +141,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: 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) @@ -193,9 +179,7 @@ def resolve_where_clauses( # 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) @@ -219,9 +203,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 44492f60..6ae1994c 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() @@ -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() @@ -122,8 +120,10 @@ def embed_ordinal_expression( return query def resolve_select_clauses( - self, criteria: DrugExposure, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: DrugExposure, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveSelectClauses() @@ -140,9 +140,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,9 +148,7 @@ 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 @@ -162,9 +158,7 @@ def resolve_select_clauses( 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 @@ -196,8 +190,10 @@ def resolve_select_clauses( return select_cols def resolve_join_clauses( - self, criteria: DrugExposure, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: DrugExposure, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveJoinClauses() @@ -205,19 +201,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 - ): - 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" ) @@ -233,8 +221,10 @@ def resolve_join_clauses( return join_clauses def resolve_where_clauses( - self, criteria: DrugExposure, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: DrugExposure, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for drug exposure criteria. Java equivalent: DrugExposureSqlBuilder.resolveWhereClauses() @@ -245,16 +235,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,9 +248,7 @@ 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: @@ -276,20 +260,12 @@ def resolve_where_clauses( # 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: @@ -302,9 +278,7 @@ def resolve_where_clauses( # 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: @@ -316,63 +290,43 @@ def resolve_where_clauses( # 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 - ) + 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 - ) + 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" - ) + 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: @@ -384,12 +338,8 @@ 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: diff --git a/circe/cohortdefinition/builders/location_region.py b/circe/cohortdefinition/builders/location_region.py index 48742a28..e305408c 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 Optional from ..criteria import LocationRegion from .base import CriteriaSqlBuilder -from .utils import BuilderOptions, BuilderUtils, CriteriaColumn +from .utils import BuilderOptions, CriteriaColumn class LocationRegionSqlBuilder(CriteriaSqlBuilder[LocationRegion]): @@ -51,13 +51,11 @@ 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 - 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,15 @@ 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]: + 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"] @@ -101,13 +99,17 @@ def resolve_select_clauses( return select_cols def resolve_join_clauses( - self, criteria: LocationRegion, options: Optional[BuilderOptions] = None - ) -> List[str]: + 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]: + 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 221aa870..f00f9b2c 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 Optional from ..criteria import Measurement from .base import CriteriaSqlBuilder @@ -39,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, @@ -48,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", @@ -61,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() @@ -93,8 +87,10 @@ def embed_codeset_clause(self, query: str, criteria: Measurement) -> str: ) def resolve_select_clauses( - self, criteria: Measurement, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: Measurement, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveSelectClauses() @@ -111,9 +107,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 @@ -121,9 +115,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 @@ -161,8 +153,10 @@ def resolve_select_clauses( return select_cols def resolve_join_clauses( - self, criteria: Measurement, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: Measurement, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveJoinClauses() @@ -175,9 +169,7 @@ def resolve_join_clauses( 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_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 ( @@ -199,7 +191,9 @@ def resolve_join_clauses( return join_clauses def resolve_ordinal_expression( - self, criteria: Measurement, options: Optional[BuilderOptions] = None + self, + criteria: Measurement, + options: Optional[BuilderOptions] = None, ) -> str: """Resolve ordinal expression for measurement criteria.""" if criteria.first: @@ -207,8 +201,10 @@ def resolve_ordinal_expression( return "" def resolve_where_clauses( - self, criteria: Measurement, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: Measurement, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for measurement criteria. Java equivalent: MeasurementSqlBuilder.resolveWhereClauses() @@ -219,17 +215,13 @@ 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))})" @@ -247,9 +239,7 @@ 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: @@ -263,19 +253,13 @@ def resolve_where_clauses( 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" - ) + 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: @@ -288,32 +272,24 @@ def resolve_where_clauses( # 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" - ) + 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" - ) + 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" - ) + BuilderUtils.build_numeric_range_clause("C.range_high", criteria.range_high, ".4f") ) # rangeLowRatio @@ -345,17 +321,13 @@ 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 - ) + 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( @@ -368,12 +340,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: @@ -387,12 +355,8 @@ 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: @@ -404,14 +368,11 @@ 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() """ return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] + [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 e0bf114e..8efdcb76 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 Optional from ..criteria import Observation from .base import CriteriaSqlBuilder @@ -39,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, @@ -48,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", @@ -74,8 +70,10 @@ def embed_codeset_clause(self, query: str, criteria: Observation) -> str: ) def resolve_select_clauses( - self, criteria: Observation, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: Observation, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for observation criteria. Java equivalent: ObservationSqlBuilder.resolveSelectClauses() @@ -93,15 +91,11 @@ 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 @@ -118,7 +112,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() @@ -131,9 +125,7 @@ def resolve_join_clauses( 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_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 @@ -155,31 +147,27 @@ def resolve_join_clauses( return join_clauses def resolve_where_clauses( - self, criteria: Observation, options: Optional[BuilderOptions] = None - ) -> List[str]: + 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))})" @@ -197,17 +185,13 @@ 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" - ) + 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" - ) + BuilderUtils.build_text_filter_clause(criteria.value_as_string, "C.value_as_string") ) # valueAsConcept @@ -216,12 +200,8 @@ def resolve_where_clauses( 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))})" - ) + 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: @@ -234,28 +214,18 @@ def resolve_where_clauses( # 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" - ) + 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: @@ -268,17 +238,13 @@ 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 - ) + 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( @@ -291,12 +257,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: @@ -310,12 +272,8 @@ 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: @@ -327,21 +285,16 @@ 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() """ return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] + [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: @@ -354,9 +307,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 f08b8a83..2ece7b56 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 Optional from ..criteria import ObservationPeriod from .base import CriteriaSqlBuilder @@ -54,13 +54,11 @@ 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 - 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", @@ -73,7 +71,9 @@ 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] + self, + criteria: ObservationPeriod, + options: Optional[BuilderOptions], ) -> str: """Get SQL query for criteria with builder options.""" query = super().get_criteria_sql_with_options(criteria, options) @@ -89,8 +89,7 @@ def get_criteria_sql_with_options( 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) @@ -102,14 +101,19 @@ 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]: + 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. @@ -142,22 +146,24 @@ def resolve_select_clauses( return select_cols def resolve_join_clauses( - self, criteria: ObservationPeriod, options: Optional[BuilderOptions] = None - ) -> List[str]: + 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]: + self, + criteria: ObservationPeriod, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for observation period criteria.""" where_clauses = [] @@ -169,34 +175,26 @@ 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 - ) + 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 - ) + 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) @@ -206,13 +204,9 @@ def resolve_where_clauses( and hasattr(criteria.period_type, "__len__") and len(criteria.period_type) > 0 ): - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.period_type - ) + 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: @@ -250,14 +244,11 @@ 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() """ return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] + [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 e1b40fd6..27466f75 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 Optional from ..criteria import PayerPlanPeriod from .base import CriteriaSqlBuilder @@ -59,13 +59,11 @@ 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 - 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", @@ -77,7 +75,9 @@ 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] + self, + criteria: PayerPlanPeriod, + options: Optional[BuilderOptions], ) -> str: """Get SQL query for criteria with builder options.""" query = super().get_criteria_sql_with_options(criteria, options) @@ -92,8 +92,7 @@ def get_criteria_sql_with_options( 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) @@ -105,14 +104,19 @@ 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]: + self, + criteria: PayerPlanPeriod, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for payer plan period criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -178,8 +182,10 @@ def resolve_select_clauses( return select_cols def resolve_join_clauses( - self, criteria: PayerPlanPeriod, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: PayerPlanPeriod, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for payer plan period criteria.""" join_clauses = [] @@ -189,15 +195,15 @@ 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]: + self, + criteria: PayerPlanPeriod, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for payer plan period criteria.""" where_clauses = [] @@ -210,34 +216,26 @@ 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 - ) + 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 - ) + 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) @@ -266,16 +264,10 @@ def resolve_where_clauses( 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: @@ -337,14 +329,11 @@ 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() """ return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] + [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 ff3b6c95..de1d7bc4 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() @@ -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() @@ -129,8 +127,10 @@ def embed_codeset_clause(self, query: str, criteria: Criteria) -> str: ) def resolve_select_clauses( - self, criteria: Criteria, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: Criteria, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for criteria. Java equivalent: ProcedureOccurrenceSqlBuilder.resolveSelectClauses() @@ -142,18 +142,13 @@ def resolve_select_clauses( 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 - ): + ) 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 @@ -161,10 +156,7 @@ def resolve_select_clauses( 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 - ): + ) 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 @@ -191,9 +183,7 @@ def resolve_select_clauses( 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 +193,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( @@ -231,10 +211,7 @@ def resolve_join_clauses( 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 - ): + ) 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" ) @@ -242,8 +219,10 @@ def resolve_join_clauses( return join_clauses def resolve_where_clauses( - self, criteria: Criteria, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: Criteria, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for criteria. Java equivalent: ProcedureOccurrenceSqlBuilder.resolveWhereClauses() @@ -251,14 +230,9 @@ 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 - ): + 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 - ) + BuilderUtils.build_date_range_clause("C.start_date", criteria.occurrence_start_date) ) # procedureType @@ -267,13 +241,10 @@ def resolve_where_clauses( and criteria.procedure_type and len(criteria.procedure_type) > 0 ): - concept_ids = BuilderUtils.get_concept_ids_from_concepts( - criteria.procedure_type - ) + concept_ids = BuilderUtils.get_concept_ids_from_concepts(criteria.procedure_type) exclude = ( "not " - if hasattr(criteria, "procedure_type_exclude") - and criteria.procedure_type_exclude + if hasattr(criteria, "procedure_type_exclude") and criteria.procedure_type_exclude else "" ) where_clauses.append( @@ -281,10 +252,7 @@ def resolve_where_clauses( ) # procedureTypeCS - if ( - hasattr(criteria, "procedure_type_cs") - and criteria.procedure_type_cs is not None - ): + 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" @@ -292,15 +260,9 @@ def resolve_where_clauses( ) # 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: @@ -312,31 +274,23 @@ def resolve_where_clauses( # 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 - ) + 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" - ) + BuilderUtils.get_codeset_in_expression(criteria.gender_cs.codeset_id, "P.gender_concept_id") ) # providerSpecialty @@ -345,18 +299,11 @@ def resolve_where_clauses( 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))})" - ) + 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 - ): + 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" @@ -364,17 +311,9 @@ def resolve_where_clauses( ) # 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: diff --git a/circe/cohortdefinition/builders/specimen.py b/circe/cohortdefinition/builders/specimen.py index 702b1c21..7aaea3d5 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 Optional from ..criteria import Specimen from .base import CriteriaSqlBuilder @@ -39,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, @@ -47,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", @@ -74,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") @@ -88,9 +82,7 @@ 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 = [] @@ -100,15 +92,15 @@ def resolve_join_clauses( 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" - ) + 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]: + self, + criteria: Specimen, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for specimen criteria.""" where_clauses = [] @@ -122,13 +114,9 @@ def resolve_where_clauses( # 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: @@ -141,34 +129,24 @@ def resolve_where_clauses( # quantity if criteria.quantity: where_clauses.append( - BuilderUtils.build_numeric_range_clause( - "C.quantity", criteria.quantity, ".4f" - ) + 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" - ) + 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: @@ -180,12 +158,8 @@ def resolve_where_clauses( # 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: @@ -198,9 +172,7 @@ def resolve_where_clauses( # sourceId if criteria.source_id: where_clauses.append( - BuilderUtils.build_text_filter_clause( - criteria.source_id, "C.specimen_source_id" - ) + BuilderUtils.build_text_filter_clause(criteria.source_id, "C.specimen_source_id") ) # age @@ -214,16 +186,12 @@ def resolve_where_clauses( # 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" - ) + 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 95471af6..5ef033fd 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, Optional from ...vocabulary.concept import Concept -from ..core import ConceptSetSelection, DateAdjustment, DateRange, NumericRange +from ..core import DateAdjustment, DateRange, NumericRange from ..criteria import CriteriaColumn @@ -25,7 +23,7 @@ class BuilderOptions: """ def __init__(self): - self.additional_columns: List[CriteriaColumn] = [] + self.additional_columns: list[CriteriaColumn] = [] class BuilderUtils: @@ -35,25 +33,21 @@ 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 + date_adjustment: DateAdjustment, + start_column: str, + end_column: str, ) -> str: """Get date adjustment expression for SQL. @@ -104,27 +98,21 @@ 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]: + 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: @@ -145,9 +133,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) @@ -207,9 +193,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() @@ -232,26 +216,21 @@ 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( - 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() @@ -277,7 +256,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 358fac15..8eb93569 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 Optional from ..criteria import VisitDetail from .base import CriteriaSqlBuilder @@ -61,13 +61,11 @@ 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 - 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: @@ -104,8 +100,10 @@ def embed_ordinal_expression( return query def resolve_select_clauses( - self, criteria: VisitDetail, options: Optional[BuilderOptions] = None - ) -> List[str]: + self, + criteria: VisitDetail, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve select clauses for visit detail criteria.""" select_cols = list(self.DEFAULT_SELECT_COLUMNS) @@ -151,27 +149,20 @@ def resolve_select_clauses( return select_cols def resolve_join_clauses( - self, criteria: VisitDetail, options: Optional[BuilderOptions] = None - ) -> List[str]: + 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 + 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" - ) + 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( @@ -179,15 +170,15 @@ def resolve_join_clauses( ) 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]: + self, + criteria: VisitDetail, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve where clauses for visit detail criteria.""" where_clauses = [] @@ -201,9 +192,7 @@ def resolve_where_clauses( # 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) @@ -236,20 +225,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: @@ -261,60 +244,43 @@ 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() """ return ", ".join( - [ - f"{self.get_table_column_for_criteria_column(col)} as {col.value}" - for col in columns - ] + [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( self, - where_clauses: List[str], + 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 ) 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 - ) + 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 1447c8ca..b68ec3ff 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 Optional from ..criteria import VisitOccurrence from .base import CriteriaSqlBuilder @@ -40,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, @@ -48,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" @@ -63,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.""" @@ -78,8 +72,10 @@ 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]: + 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"] @@ -119,20 +115,18 @@ def resolve_select_clauses( else "vo.visit_end_date" ) select_cols.append( - BuilderUtils.get_date_adjustment_expression( - criteria.date_adjustment, start_col, end_col - ) + 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]: + self, + criteria: VisitOccurrence, + options: Optional[BuilderOptions] = None, + ) -> list[str]: """Resolve join clauses for visit occurrence criteria.""" join_clauses = [] @@ -142,21 +136,15 @@ def resolve_join_clauses( 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_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 ( @@ -167,43 +155,35 @@ def resolve_join_clauses( ) 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]: + 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 - ) + 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 - ) + 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: @@ -226,17 +206,13 @@ 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 - ) + 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: @@ -250,12 +226,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: @@ -269,12 +241,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,10 +256,11 @@ def resolve_where_clauses( return 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: @@ -301,25 +270,15 @@ 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( - 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 - ) + 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 fc1bc4e1..44e64d53 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 def to_python_code(obj: Any) -> str: @@ -14,20 +7,16 @@ 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 ( - 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__}") 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): @@ -41,8 +30,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() @@ -65,10 +52,8 @@ 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(): - fields[name] = val + if val is not None and val != field_info.get_default(): + fields[name] = val if not fields: return f"{cls_name}()" @@ -85,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})" @@ -124,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.py b/circe/cohortdefinition/cohort.py index a8c010e8..6071dc81 100644 --- a/circe/cohortdefinition/cohort.py +++ b/circe/cohortdefinition/cohort.py @@ -8,12 +8,12 @@ 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, - BaseModel, ConfigDict, Field, field_validator, @@ -26,7 +26,6 @@ CustomEraStrategy, DateOffsetStrategy, EndStrategy, - ObservationFilter, Period, ResultLimit, ) @@ -38,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 @@ -60,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", @@ -75,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", @@ -103,7 +98,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", @@ -113,11 +108,9 @@ 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" - ), + validation_alias=AliasChoices("CensoringCriteria", "censoring_criteria", "censoringCriteria"), serialization_alias="CensoringCriteria", ) @@ -225,7 +218,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] @@ -254,9 +247,7 @@ def deserialize_censoring_criteria(cls, v: Any) -> Any: ): 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) @@ -270,11 +261,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 @@ -306,9 +295,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: """ @@ -324,9 +311,7 @@ def remove_censoring_criteria_by_type(self, criteria_type: str) -> None: """ if self.censoring_criteria: self.censoring_criteria = [ - c - for c in self.censoring_criteria - if c.__class__.__name__ != criteria_type + c for c in self.censoring_criteria if c.__class__.__name__ != criteria_type ] def validate_expression(self) -> bool: @@ -342,13 +327,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 @@ -402,35 +387,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): - # Check if these look like ConceptSetItems (have 'concept') - 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"]: - # 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: @@ -500,11 +483,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. @@ -514,7 +493,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: @@ -562,7 +541,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: @@ -571,10 +550,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 52ff132d..b275ce6c 100644 --- a/circe/cohortdefinition/cohort_expression_query_builder.py +++ b/circe/cohortdefinition/cohort_expression_query_builder.py @@ -9,8 +9,11 @@ """ import json -from typing import Any, Dict, List, Optional, Union +from typing import Any, Optional, Union +from circe.extensions import get_registry + +from ..vocabulary.concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder from .builders import ( ConditionEraSqlBuilder, ConditionOccurrenceSqlBuilder, @@ -28,10 +31,10 @@ SpecimenSqlBuilder, VisitDetailSqlBuilder, VisitOccurrenceSqlBuilder, + get_builder_for_criteria, ) 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, @@ -49,7 +52,6 @@ Measurement, Observation, ObservationPeriod, - Occurrence, PayerPlanPeriod, PrimaryCriteria, ProcedureOccurrence, @@ -93,12 +95,10 @@ 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( - IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher -): +class CohortExpressionQueryBuilder(IGetCriteriaSqlDispatcher, IGetEndStrategySqlDispatcher): """Main SQL query builder for cohort expressions. Java equivalent: org.ohdsi.circe.cohortdefinition.CohortExpressionQueryBuilder @@ -130,16 +130,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; @@ -201,17 +201,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""" @@ -327,9 +332,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 ( @@ -340,19 +350,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 ; """ @@ -365,10 +383,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 @@ -418,12 +438,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 @@ -478,11 +503,9 @@ 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: + def get_additional_columns(self, columns: list[CriteriaColumn], prefix: str) -> str: """Get additional columns string. Java equivalent: getAdditionalColumns() @@ -502,7 +525,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 @@ -531,7 +556,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() @@ -542,20 +567,16 @@ 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) - 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() @@ -563,15 +584,15 @@ 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 + self, + primary_criteria: PrimaryCriteria, + subquery: Optional[str] = None, ) -> str: """Get primary events query. @@ -592,18 +613,18 @@ 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" + ( + 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 = ( @@ -643,14 +664,12 @@ 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" ) - 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" @@ -672,19 +691,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) - 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 - ) + 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. @@ -707,9 +719,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) @@ -722,9 +732,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 @@ -734,9 +747,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 = """ @@ -768,22 +779,18 @@ 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 @@ -791,9 +798,7 @@ 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( @@ -814,9 +819,7 @@ def build_expression_query( 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", "") @@ -839,9 +842,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", "") @@ -853,9 +854,7 @@ 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}") @@ -871,15 +870,10 @@ def build_expression_query( ) inclusion_rule_inserts.extend( - [ - f"TRUNCATE TABLE {table};\nDROP TABLE {table};\n" - for table in inclusion_rule_temp_tables - ] + [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", @@ -904,9 +898,7 @@ def build_expression_query( ) 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 ( @@ -917,14 +909,16 @@ def build_expression_query( 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: 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( @@ -936,7 +930,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( @@ -945,9 +939,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"), @@ -957,9 +949,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", "") @@ -976,70 +966,49 @@ def build_expression_query( 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) 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" - # Always generate inclusion analysis if stats are requested, even if no rules - inclusion_analysis_query += self._build_inclusion_analysis_section( - expression + 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" ) - result_sql = result_sql.replace( - "@inclusionAnalysisQuery", inclusion_analysis_query - ) + # 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) # 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 ) 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 @@ -1075,9 +1044,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": @@ -1108,20 +1075,16 @@ 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 + 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 - 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() @@ -1134,17 +1097,13 @@ 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 - ) + 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: @@ -1159,9 +1118,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: @@ -1176,9 +1133,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: @@ -1193,25 +1148,20 @@ 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 - ) + 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 - ) + BuilderUtils.build_date_range_clause("E.end_date", criteria.occurrence_end_date) ) - 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 @@ -1233,26 +1183,10 @@ 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 - for key in inner_criteria.keys(): + for key in inner_criteria: criteria_type = key criteria_data = inner_criteria[key] break @@ -1284,41 +1218,29 @@ 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 ): 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 else: raise ValueError(f"Unknown criteria type in dict: {criteria_type}") else: @@ -1339,9 +1261,7 @@ 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 @@ -1349,19 +1269,13 @@ def _get_windowed_criteria_query_internal( # 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 - ) + 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 - ) + if (start_window.use_event_end is not None and start_window.use_event_end) else "A.START_DATE" ) @@ -1370,10 +1284,10 @@ def _get_windowed_criteria_query_internal( 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 - and start_window.start - and start_window.start.coeff == -1 - else "P.OP_END_DATE" if check_observation_period else None + else None ) if start_expression: @@ -1384,10 +1298,10 @@ 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 + else "P.OP_END_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 + else None ) if end_expression: @@ -1415,19 +1329,25 @@ 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: 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" 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: @@ -1437,14 +1357,15 @@ 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 + self, + criteria: Any, + event_table: str, + options: Optional[BuilderOptions] = None, ) -> str: """Get windowed criteria query. @@ -1454,9 +1375,7 @@ def get_windowed_criteria_query( 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() @@ -1466,16 +1385,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 ) @@ -1487,12 +1403,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 @@ -1501,9 +1413,7 @@ def get_corelated_criteria_query( # 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 + "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) @@ -1530,7 +1440,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 @@ -1543,23 +1455,25 @@ 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) 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): + 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 @@ -1578,7 +1492,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 @@ -1604,37 +1518,40 @@ def get_criteria_sql( "DrugEra": DrE, "DoseEra": DoE, } + 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 - if criteria_type in criteria_class_map: + 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 + + 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 - ): + 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 ): 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 @@ -1642,82 +1559,62 @@ 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: 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( 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 ) 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] + 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) @@ -1739,7 +1636,9 @@ def get_date_field_for_offset_strategy(self, date_field: str) -> str: return "start_date" def get_strategy_sql( - self, strategy: Union[DateOffsetStrategy, CustomEraStrategy], event_table: str + self, + strategy: Union[DateOffsetStrategy, CustomEraStrategy], + event_table: str, ) -> str: """Get strategy SQL for date offset or custom era strategy.""" if isinstance(strategy, DateOffsetStrategy): @@ -1749,36 +1648,24 @@ 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) ) 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( @@ -1787,9 +1674,7 @@ def _get_custom_era_strategy_sql( 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 deleted file mode 100644 index 944008e4..00000000 --- a/circe/cohortdefinition/concept_set_expression_query_builder.py +++ /dev/null @@ -1,201 +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 typing import List, Optional - -from ..vocabulary.concept import Concept, ConceptSetExpression, ConceptSetItem -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/cohortdefinition/core.py b/circe/cohortdefinition/core.py index 59371a61..714da872 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 @@ -65,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) @@ -86,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) @@ -217,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"), @@ -292,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"), @@ -340,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 5e1591df..d1542b10 100644 --- a/circe/cohortdefinition/criteria.py +++ b/circe/cohortdefinition/criteria.py @@ -9,11 +9,12 @@ """ from enum import Enum -from typing import TYPE_CHECKING, Any, ClassVar, List, Optional, Union +from typing import Annotated, Any, Optional, Union from pydantic import ( AliasChoices, BaseModel, + BeforeValidator, ConfigDict, Field, field_validator, @@ -23,11 +24,9 @@ from ..vocabulary.concept import Concept from .core import ( CirceBaseModel, - CollapseSettings, ConceptSetSelection, DateAdjustment, DateRange, - EndStrategy, NumericRange, ObservationFilter, Period, @@ -103,12 +102,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"), @@ -157,9 +152,7 @@ class WindowedCriteria(CirceBaseModel): ) ignore_observation_period: bool = Field( default=False, - validation_alias=AliasChoices( - "IgnoreObservationPeriod", "ignoreObservationPeriod" - ), + validation_alias=AliasChoices("IgnoreObservationPeriod", "ignoreObservationPeriod"), serialization_alias="IgnoreObservationPeriod", ) @@ -187,7 +180,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", @@ -202,7 +195,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", @@ -222,7 +215,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", @@ -257,13 +250,26 @@ class Criteria(CirceBaseModel): @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": - return {self.__class__.__name__: data} - return data + 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 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 + # 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} def accept(self, dispatcher: Any, options: Optional[Any] = None) -> str: """Accept method for visitor pattern.""" @@ -324,7 +330,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", @@ -346,9 +352,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( @@ -356,13 +360,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", @@ -372,7 +376,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", @@ -382,7 +386,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", @@ -407,7 +411,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"), @@ -428,7 +432,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", @@ -453,12 +457,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", @@ -478,7 +482,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", @@ -489,7 +493,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", @@ -534,27 +538,17 @@ class ProcedureOccurrence(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" - ) + 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") 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: Optional[list[Concept]] = Field(default=None, alias="ProcedureType") + 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" - ) - visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") - modifier: Optional[List[Concept]] = Field(default=None, alias="Modifier") + 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") codeset_id: Optional[int] = Field(default=None, alias="CodesetId") first: Optional[bool] = Field( @@ -562,14 +556,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) @@ -582,39 +572,21 @@ 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" - ) + 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_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="VisitTypeCS" - ) + visit_type: Optional[list[Concept]] = Field(default=None, alias="VisitType") + 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) @@ -625,7 +597,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"), @@ -633,9 +605,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( @@ -643,7 +613,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", @@ -655,9 +625,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( @@ -670,7 +638,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", @@ -680,7 +648,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", @@ -690,7 +658,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", @@ -700,7 +668,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", @@ -725,7 +693,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", @@ -746,48 +714,34 @@ class 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" - ) + 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") 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 + 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") - 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") + 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", ) - value_as_concept: Optional[List[Concept]] = Field( + value_as_concept: Optional[list[Concept]] = Field( default=None, validation_alias=AliasChoices("ValueAsConcept", "valueAsConcept"), serialization_alias="ValueAsConcept", @@ -812,28 +766,20 @@ 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" - ) - visits: Optional[List[Concept]] = None # Placeholder if needed, but not in list - visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") + 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", ) - 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) @@ -844,41 +790,27 @@ class DeviceExposure(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" - ) + 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") 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: Optional[list[Concept]] = Field(default=None, alias="DeviceType") + 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" - ) - visit_type: Optional[List[Concept]] = Field(default=None, alias="VisitType") + 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", ) - 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) @@ -889,30 +821,20 @@ class 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" - ) + 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") 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: 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_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" - ) + 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_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( @@ -921,9 +843,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) @@ -934,35 +854,23 @@ class 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" - ) + 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") 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: Optional[list[Concept]] = Field(default=None, alias="DeathType") + 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) @@ -975,49 +883,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: 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" - ) - discharge_to: Optional[List[Concept]] = Field(default=None, alias="DischargeTo") - discharge_to_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="DischargeToCS" - ) + 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") model_config = ConfigDict(populate_by_name=True) @@ -1029,17 +911,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" - ) - period_type: Optional[List[Concept]] = Field(default=None, alias="PeriodType") - period_type_cs: Optional[ConceptSetSelection] = Field( - default=None, alias="PeriodTypeCS" - ) + 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_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") @@ -1054,32 +930,22 @@ 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") - 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") 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) @@ -1114,17 +980,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: 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) @@ -1143,18 +1005,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: 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) @@ -1169,13 +1027,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) @@ -1206,7 +1064,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", @@ -1216,16 +1074,14 @@ 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" - ), + validation_alias=AliasChoices("DemographicCriteriaList", "demographicCriteriaList"), serialization_alias="DemographicCriteriaList", ) type: Optional[str] = Field( @@ -1240,9 +1096,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") @@ -1284,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"] @@ -1302,11 +1156,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: @@ -1321,10 +1171,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 @@ -1385,18 +1232,14 @@ def normalize_window(window_dict: dict) -> dict: 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: + 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 @@ -1404,7 +1247,7 @@ def normalize_window(window_dict: dict) -> dict: try: deserialized.append(CorelatedCriteria.model_validate(item_copy)) - except: + except Exception: deserialized.append(item) elif any( @@ -1420,7 +1263,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", @@ -1459,9 +1302,7 @@ def normalize_window(window_dict: dict) -> dict: 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, @@ -1483,10 +1324,8 @@ 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) - ) - except: + deserialized.append(CorelatedCriteria.model_validate(corelated_dict)) + except Exception: deserialized.append(item) else: deserialized.append(item) @@ -1519,14 +1358,10 @@ def normalize_window(window_dict: dict) -> dict: 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) - ) - except: + deserialized.append(CorelatedCriteria.model_validate(corelated_dict)) + except Exception: deserialized.append(item) else: deserialized.append(item) @@ -1534,8 +1369,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 +1389,28 @@ 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, @@ -1581,7 +1438,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", @@ -1638,27 +1495,18 @@ 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 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/interfaces.py b/circe/cohortdefinition/interfaces.py index 00d4a0ba..0492ab66 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. @@ -41,126 +61,21 @@ class IGetCriteriaSqlDispatcher(ABC): """ @abstractmethod - def get_criteria_sql( - self, location_region: LocationRegion, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for location region criteria.""" - pass + def get_criteria_sql(self, criteria: Criteria, options: Optional[BuilderOptions] = None) -> str: + """Generate SQL for various criteria types. - @abstractmethod - def get_criteria_sql( - self, condition_era: ConditionEra, options: Optional[BuilderOptions] = None - ) -> str: - """Generate SQL for condition era criteria.""" - pass + Args: + criteria: Any supported criteria type (LocationRegion, ConditionEra, etc.) + options: Optional builder options - @abstractmethod - def get_criteria_sql( - self, - condition_occurrence: ConditionOccurrence, - options: Optional[BuilderOptions] = None, - ) -> str: - """Generate SQL for condition occurrence criteria.""" + Returns: + SQL string for the criteria + """ pass - @abstractmethod - 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: - """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 +85,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/cohortdefinition/printfriendly/markdown_render.py b/circe/cohortdefinition/printfriendly/markdown_render.py index 380defb3..e2a1c761 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,22 +34,40 @@ class MarkdownRender: def __init__( self, - concept_sets: Optional[List[ConceptSet]] = None, + 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 @@ -59,6 +77,12 @@ def __init__( 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 @@ -96,9 +120,7 @@ def render_cohort_expression( # Determine whether to include concept sets should_include = ( - include_concept_sets - if include_concept_sets is not None - else self._include_concept_sets + include_concept_sets if include_concept_sets is not None else self._include_concept_sets ) # Load and render the main template @@ -111,9 +133,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[]) @@ -127,10 +147,11 @@ 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" @@ -165,9 +186,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() @@ -219,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/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/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/execution/__init__.py b/circe/execution/__init__.py new file mode 100644 index 00000000..180d7927 --- /dev/null +++ b/circe/execution/__init__.py @@ -0,0 +1,28 @@ +"""New Ibis execution subsystem. + +This package is intentionally parallel to the existing SQL builder path and does +not modify cohortdefinition model semantics. +""" + +from .api import build_cohort, write_cohort +from .databricks_compat import apply_databricks_post_connect_workaround +from .errors import ( + CompilationError, + ExecutionError, + ExecutionNormalizationError, + UnsupportedCriterionError, + UnsupportedFeatureError, +) +from .ibis.codesets import clear_codeset_cache + +__all__ = [ + "build_cohort", + "write_cohort", + "clear_codeset_cache", + "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..f7129f39 --- /dev/null +++ b/circe/execution/_dataclass.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Any, Callable, TypeVar, cast, overload + +from typing_extensions import dataclass_transform + +T = TypeVar("T") + + +@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 + compatibility with older Python runtimes that do not support dataclass slots. + """ + + def wrap(cls: type[T]) -> type[T]: + 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 + return wrap(_cls) diff --git a/circe/execution/api.py b/circe/execution/api.py new file mode 100644 index 00000000..a9574b87 --- /dev/null +++ b/circe/execution/api.py @@ -0,0 +1,167 @@ +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, + create_table, + 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, + use_persistent_cache: bool = False, +) -> 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, + use_persistent_cache=use_persistent_cache, + ) + + 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: + 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( + "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", + use_persistent_cache: bool = False, +) -> 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, + use_persistent_cache=use_persistent_cache, + ) + 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", + ) diff --git a/circe/execution/databricks_compat.py b/circe/execution/databricks_compat.py new file mode 100644 index 00000000..f9b79fd5 --- /dev/null +++ b/circe/execution/databricks_compat.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +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() -> type[Any] | None: + 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: Callable[..., Any]) -> 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: object) -> 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: type[Any] | None = 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. + """ + # 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 + + 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 + + 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: + 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: object) -> 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..b6d0cc39 --- /dev/null +++ b/circe/execution/engine/collapse.py @@ -0,0 +1,86 @@ +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, + 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)) + 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"), + ) + ) + + grouping_window = ibis.cumulative_window( + group_by=marked.person_id, + 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) + + 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/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 new file mode 100644 index 00000000..4b8e5b9e --- /dev/null +++ b/circe/execution/engine/end_strategy.py @@ -0,0 +1,72 @@ +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": + 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/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..3ed7c8dd --- /dev/null +++ b/circe/execution/engine/group_operators.py @@ -0,0 +1,193 @@ +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) + 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( + 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/__init__.py b/circe/execution/ibis/__init__.py new file mode 100644 index 00000000..5f0cdbac --- /dev/null +++ b/circe/execution/ibis/__init__.py @@ -0,0 +1,11 @@ +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", + "compile_event_plan", + "STANDARD_EVENT_COLUMNS", + "standardize_event_table", +] diff --git a/circe/execution/ibis/codesets.py b/circe/execution/ibis/codesets.py new file mode 100644 index 00000000..f0df82e1 --- /dev/null +++ b/circe/execution/ibis/codesets.py @@ -0,0 +1,229 @@ +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 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: + """Resolve concept sets to concrete concept IDs using vocabulary tables.""" + + def __init__( + self, + *, + 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) + 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 () + + # 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: + 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 + + # 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]: + 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) -> Table: + 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: Table) -> 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 + + # ------------------------------------------------------------------ + # 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/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..b7b05ce2 --- /dev/null +++ b/circe/execution/ibis/context.py @@ -0,0 +1,77 @@ +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, + use_persistent_cache: bool = False, +) -> 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, + 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, + 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..5df9e473 --- /dev/null +++ b/circe/execution/ibis/materialize.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from ..typing import Table + + +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 + + 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..c8ba111e --- /dev/null +++ b/circe/execution/ibis/operations.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import sqlglot as sg +import sqlglot.expressions as sge + +from ..errors import ExecutionError +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, + *, + 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.""" + 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( + 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: + _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( + "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 new file mode 100644 index 00000000..2e4b379e --- /dev/null +++ b/circe/execution/ibis_compat.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from typing import Any + +import ibis +import ibis.expr.operations as ops +from ibis.common.collections import FrozenOrderedDict + +from .typing import IbisBackendLike, Table + + +def _is_nullish(value: Any) -> bool: + if value is None: + return True + try: + return bool(value != value) + except Exception: + return False + + +def _typed_literal(value: Any, *, dtype: str) -> Any: + 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, + 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(dtype).op()}) + ).to_expr() + return dummy.select(dummy[column_name]).filter(ibis.literal(False)) + + 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(), + column_name, + None, + 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..6823f908 --- /dev/null +++ b/circe/execution/lower/__init__.py @@ -0,0 +1,40 @@ +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__ = [ + "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/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..5786fa13 --- /dev/null +++ b/circe/execution/lower/condition_era.py @@ -0,0 +1,39 @@ +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, PlanStep +from ..plan.schema import OCCURRENCE_COUNT +from .common import ( + append_duration_filter, + append_numeric_filter, + build_standard_domain_plan, + lower_common_steps, +) + + +@lowerer(ConditionEra) +def lower_condition_era( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + steps = lower_common_steps(criterion) + post_standardize_steps: list[PlanStep] = [] + 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/condition_occurrence.py b/circe/execution/lower/condition_occurrence.py new file mode 100644 index 00000000..294677cf --- /dev/null +++ b/circe/execution/lower/condition_occurrence.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from circe.extensions import lowerer + +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, +) + + +@lowerer(ConditionOccurrence) +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..4a17e90c --- /dev/null +++ b/circe/execution/lower/criteria.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Protocol + +from circe.extensions import get_registry + +from ..errors import UnsupportedCriterionError +from ..normalize.criteria import NormalizedCriterion +from ..plan.events import EventPlan + + +class LowerFn(Protocol): + def __call__( + self, + criterion: NormalizedCriterion, + *, + criterion_index: int, + ) -> EventPlan: ... + + +def lower_criterion( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + 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 new file mode 100644 index 00000000..2b5f44f7 --- /dev/null +++ b/circe/execution/lower/death.py @@ -0,0 +1,35 @@ +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, + *, + 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..23f44654 --- /dev/null +++ b/circe/execution/lower/device_exposure.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from circe.extensions import lowerer + +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, +) + + +@lowerer(DeviceExposure) +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..53daaa38 --- /dev/null +++ b/circe/execution/lower/dose_era.py @@ -0,0 +1,26 @@ +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, PlanStep +from .common import append_duration_filter, build_standard_domain_plan, lower_common_steps + + +@lowerer(DoseEra) +def lower_dose_era( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + steps = lower_common_steps(criterion) + post_standardize_steps: list[PlanStep] = [] + 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 new file mode 100644 index 00000000..9b388f0c --- /dev/null +++ b/circe/execution/lower/drug_era.py @@ -0,0 +1,44 @@ +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, PlanStep +from ..plan.schema import GAP_DAYS, OCCURRENCE_COUNT +from .common import ( + append_duration_filter, + append_numeric_filter, + build_standard_domain_plan, + lower_common_steps, +) + + +@lowerer(DrugEra) +def lower_drug_era( + criterion: NormalizedCriterion, + *, + criterion_index: int, +) -> EventPlan: + steps = lower_common_steps(criterion) + post_standardize_steps: list[PlanStep] = [] + 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/circe/execution/lower/drug_exposure.py b/circe/execution/lower/drug_exposure.py new file mode 100644 index 00000000..d7100bf5 --- /dev/null +++ b/circe/execution/lower/drug_exposure.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from circe.extensions import lowerer + +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, +) + + +@lowerer(DrugExposure) +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..c25722a3 --- /dev/null +++ b/circe/execution/lower/location_region.py @@ -0,0 +1,51 @@ +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, + EventSource, + FilterByCodeset, + FilterByText, + JoinLocationRegion, + StandardizeEventShape, +) + + +@lowerer(LocationRegion) +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..4fadb112 --- /dev/null +++ b/circe/execution/lower/measurement.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from circe.extensions import lowerer + +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, +) + + +@lowerer(Measurement) +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..fa991894 --- /dev/null +++ b/circe/execution/lower/observation.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from circe.extensions import lowerer + +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, +) + + +@lowerer(Observation) +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..d1bccbfd --- /dev/null +++ b/circe/execution/lower/observation_period.py @@ -0,0 +1,17 @@ +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, + *, + 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..f5a1221d --- /dev/null +++ b/circe/execution/lower/payer_plan_period.py @@ -0,0 +1,17 @@ +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, + *, + 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..13846f3a --- /dev/null +++ b/circe/execution/lower/procedure_occurrence.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from circe.extensions import lowerer + +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, +) + + +@lowerer(ProcedureOccurrence) +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..10f1cdbb --- /dev/null +++ b/circe/execution/lower/specimen.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from circe.extensions import lowerer + +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, +) + + +@lowerer(Specimen) +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..f85c3354 --- /dev/null +++ b/circe/execution/lower/visit_detail.py @@ -0,0 +1,68 @@ +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 +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, +) + + +@lowerer(VisitDetail) +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..871287bb --- /dev/null +++ b/circe/execution/lower/visit_occurrence.py @@ -0,0 +1,62 @@ +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 +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, +) + + +@lowerer(VisitOccurrence) +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..61765b47 --- /dev/null +++ b/circe/execution/normalize/cohort.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from ...cohortdefinition import CohortExpression +from ...vocabulary.concept import ConceptSet +from .._dataclass import frozen_slots_dataclass +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 +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 # type: ignore[unreachable] + 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 # type: ignore[unreachable] + 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) + + 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..c3fb6eb3 --- /dev/null +++ b/circe/execution/normalize/criteria.py @@ -0,0 +1,484 @@ +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING + +from circe.extensions import get_registry, normalizer + +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), + ) + + +@normalizer(ConditionOccurrence) +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), + ) + + +@normalizer(DrugExposure) +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), + ) + + +@normalizer(VisitOccurrence) +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), + ) + + +@normalizer(Measurement) +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), + ) + + +@normalizer(ProcedureOccurrence) +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), + ) + + +@normalizer(Observation) +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), + ) + + +@normalizer(VisitDetail) +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), + ) + + +@normalizer(DeviceExposure) +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), + ) + + +@normalizer(Specimen) +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), + ) + + +@normalizer(Death) +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, + ) + + +@normalizer(ObservationPeriod) +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), + ) + + +@normalizer(PayerPlanPeriod) +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), + ) + + +@normalizer(ConditionEra) +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), + ) + + +@normalizer(DrugEra) +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), + ) + + +@normalizer(DoseEra) +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), + ) + + +@normalizer(LocationRegion) +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: + 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 + + 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..8e034091 --- /dev/null +++ b/circe/execution/normalize/end_strategy.py @@ -0,0 +1,38 @@ +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), + "days_supply_override": value.days_supply_override, + }, + ) + 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/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..edf6ba28 --- /dev/null +++ b/circe/execution/typing.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any, Protocol + +from typing_extensions import TypeAlias + +# 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): + """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/circe/extensions/__init__.py b/circe/extensions/__init__.py new file mode 100644 index 00000000..d67a624c --- /dev/null +++ b/circe/extensions/__init__.py @@ -0,0 +1,317 @@ +""" +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 pathlib import Path + +# Forward references to avoid circular imports +# Actual imports happen inside methods or with TYPE_CHECKING +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 + +NormalizerFn = Callable[["Criteria"], "NormalizedCriterion"] + + +class ExtensionRegistry: + """Central registry for OMOP CDM extensions.""" + + def __init__(self) -> None: + # 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 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] = {} + + # 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_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. + + 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_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. + + 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) + return cls + + return decorator + + +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) + return builder_cls + + return decorator + + +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) + 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) + 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. + + 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) + 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..37576469 --- /dev/null +++ b/circe/extensions/waveform/__init__.py @@ -0,0 +1,31 @@ +from pathlib import Path + +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 + +# Importing builders triggers @sql_builder and @markdown_template decorators +from .builders.waveform_occurrence import WaveformOccurrenceSqlBuilder +from .builders.waveform_registry import WaveformRegistrySqlBuilder + +# 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") + +__all__ = [ + "lower", + "normalizer", + "WaveformChannelMetadata", + "WaveformFeature", + "WaveformOccurrence", + "WaveformRegistry", + "WaveformChannelMetadataSqlBuilder", + "WaveformFeatureSqlBuilder", + "WaveformOccurrenceSqlBuilder", + "WaveformRegistrySqlBuilder", +] diff --git a/circe/extensions/waveform/builders/__init__.py b/circe/extensions/waveform/builders/__init__.py new file mode 100644 index 00000000..622b5a40 --- /dev/null +++ b/circe/extensions/waveform/builders/__init__.py @@ -0,0 +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 new file mode 100644 index 00000000..eace3fd5 --- /dev/null +++ b/circe/extensions/waveform/builders/waveform_channel_metadata.py @@ -0,0 +1,123 @@ +from circe.cohortdefinition.builders.base import CriteriaSqlBuilder +from circe.cohortdefinition.builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn +from circe.extensions import markdown_template, sql_builder + +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: list[str] = [] + 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..006e7e89 --- /dev/null +++ b/circe/extensions/waveform/builders/waveform_feature.py @@ -0,0 +1,147 @@ +from circe.cohortdefinition.builders.base import CriteriaSqlBuilder +from circe.cohortdefinition.builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn +from circe.extensions import markdown_template, sql_builder + +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: list[str] = [] + 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..9f6cd124 --- /dev/null +++ b/circe/extensions/waveform/builders/waveform_occurrence.py @@ -0,0 +1,118 @@ +from circe.cohortdefinition.builders.base import CriteriaSqlBuilder +from circe.cohortdefinition.builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn +from circe.extensions import markdown_template, sql_builder + +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: list[str] = [] + 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..f91b8b6d --- /dev/null +++ b/circe/extensions/waveform/builders/waveform_registry.py @@ -0,0 +1,103 @@ +from circe.cohortdefinition.builders.base import CriteriaSqlBuilder +from circe.cohortdefinition.builders.utils import BuilderOptions, BuilderUtils, CriteriaColumn +from circe.extensions import markdown_template, sql_builder + +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: list[str] = [] + 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..d79f9e59 --- /dev/null +++ b/circe/extensions/waveform/criteria.py @@ -0,0 +1,307 @@ +from typing import Optional + +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 + + +@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 +# 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) 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/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/circe/helper/cohort_modifiers.py b/circe/helper/cohort_modifiers.py index 629a6a78..639df561 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. @@ -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 @@ -362,7 +360,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 +398,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 @@ -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 @@ -438,9 +434,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: @@ -498,8 +494,7 @@ def set_end_date_strategy( else: raise ValueError( - f"Unknown strategy '{strategy}'. " - "Expected 'fixed_duration', 'end_of_observation', or 'custom_era'." + f"Unknown strategy '{strategy}'. Expected 'fixed_duration', 'end_of_observation', or 'custom_era'." ) return cohort_expression @@ -619,8 +614,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." + "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 @@ -629,7 +623,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, @@ -661,10 +655,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, @@ -706,8 +697,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 +742,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. @@ -823,9 +814,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 +832,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 @@ -906,11 +893,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 new file mode 100644 index 00000000..af2f1515 --- /dev/null +++ b/circe/io.py @@ -0,0 +1,95 @@ +""" +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 collections.abc import Mapping +from pathlib import Path +from typing import Any, Union + +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] + + +def load_expression(value: ExpressionInput) -> CohortExpression: + """Normalize different expression inputs into a CohortExpression. + + Accepted inputs: + - CohortExpression + - mapping/dict compatible with CohortExpression + - JSON string + - YAML string + - path to a JSON or YAML file + """ + if isinstance(value, CohortExpression): + return value + + if isinstance(value, Mapping): + return CohortExpression.model_validate(dict(value)) + + if isinstance(value, Path): + 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() + + # 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(): + 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, 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/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/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 25ffb9a6..6b752b65 100644 --- a/circe/vocabulary/concept.py +++ b/circe/vocabulary/concept.py @@ -8,24 +8,27 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import Any, List, 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( 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 +43,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( @@ -69,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 @@ -100,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/circe/vocabulary/concept_set_expression_query_builder.py b/circe/vocabulary/concept_set_expression_query_builder.py index ab8bb644..4babbf64 100644 --- a/circe/vocabulary/concept_set_expression_query_builder.py +++ b/circe/vocabulary/concept_set_expression_query_builder.py @@ -8,10 +8,8 @@ Reference: JAVA_CLASS_MAPPINGS.md for Java equivalents. """ -from typing import List, Optional - from ..cohortdefinition.builders.utils import BuilderUtils -from .concept import Concept, ConceptSetExpression, ConceptSetItem +from .concept import Concept, ConceptSetExpression class ConceptSetExpressionQueryBuilder: @@ -51,18 +49,14 @@ 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() """ - 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() @@ -71,12 +65,8 @@ 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: @@ -84,51 +74,41 @@ def build_concept_set_sub_query( 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 - ) + 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] + 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, - 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. 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 9d9d46b3..3ec216cc 100644 --- a/cohort_definition.py +++ b/cohort_definition.py @@ -1,15 +1,15 @@ -from circe.cohort_builder import CohortBuilder from circe.api import cohort_print_friendly +from circe.cohort_builder import CohortBuilder # Define inferred concept sets 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 45d1ccb7..2e4dfa77 100644 --- a/debug_app/app.py +++ b/debug_app/app.py @@ -1,11 +1,12 @@ +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__), '..'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from debug_app import utils @@ -13,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: @@ -37,67 +39,74 @@ 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 Exception: + 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_result["sql"] + ref_result["markdown"] + # Check overrides is_user_ok = False if USER_OVERRIDES_FILE.exists(): @@ -105,40 +114,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 Exception: + 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 1c8314b1..a83e4ab5 100644 --- a/debug_app/sandbox.py +++ b/debug_app/sandbox.py @@ -6,46 +6,49 @@ """ import re -from typing import Dict, Any +from typing import Any 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]: +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,98 +62,92 @@ 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 - import json - + # 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 - ) - + json_output = json.dumps(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." + "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." + "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." + "error": f"Attribute Error: {str(e)}\n\nCheck 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 @@ -162,7 +159,6 @@ def execute_cohort_code(code: str) -> Dict[str, Any]: .with_condition(1) .build() )""", - "with_criteria": """from circe.cohort_builder import CohortBuilder cohort = ( @@ -174,7 +170,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 = ( @@ -186,7 +181,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 edccafaa..73203624 100644 --- a/debug_app/utils.py +++ b/debug_app/utils.py @@ -1,128 +1,159 @@ -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__), '..'))) +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. 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', 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) + 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 { @@ -131,115 +162,122 @@ 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, '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 { "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: """ 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""" @@ -266,16 +304,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, 'r') as f: + 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}") @@ -284,32 +322,32 @@ def get_ai_explanation(ref_content: str, gen_content: str, type_label: str = "SQ from google import genai except ImportError: 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 - ) - + + 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: + 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/CONTRIBUTING.md b/docs/CONTRIBUTING.md index b8361f0c..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. @@ -10,28 +14,36 @@ 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) ### 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 +52,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 +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 @@ -236,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/docs/README.md b/docs/README.md index aff17dd9..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. @@ -7,20 +11,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 +31,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 +51,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 +66,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..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. @@ -6,11 +10,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 +41,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 +61,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 +84,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 +119,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 +129,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 +170,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 +180,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 +251,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 +290,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/_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/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/conf.py b/docs/conf.py index e764c146..e8b62b0f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,69 +7,68 @@ 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.3.0" +version = "0.3.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, + "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 +77,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 +108,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/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/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/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/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 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 8a6e6afa..6f41f157 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 @@ -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 @@ -110,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 ================== @@ -120,4 +121,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/docs/installation.rst b/docs/installation.rst index dda573bc..8750fe18 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -3,43 +3,57 @@ Installation Requirements ------------ +* Python 3.9 or higher +* uv (recommended for the reproducible, lockfile-backed workflow) +* pip (supported as a fallback installer) -* Python 3.8 or higher -* pip (Python package installer) +Source Installation +------------------- -Basic 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 --------------------- @@ -51,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: @@ -66,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: @@ -91,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 ~~~~~~~~~~~~~~~~~ @@ -114,37 +131,38 @@ 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 ~~~~~~~~~~~~~~~~~~~~~ -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 - 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 ------------ @@ -153,7 +171,7 @@ To uninstall CIRCE Python: .. code-block:: bash - pip uninstall ohdsi-circe + pip uninstall ohdsi-circe-python-alpha Next Steps ---------- @@ -161,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/docs/waveform_extension.md b/docs/waveform_extension.md new file mode 100644 index 00000000..822ba65c --- /dev/null +++ b/docs/waveform_extension.md @@ -0,0 +1,107 @@ +--- +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. + +## 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/basic_cohort.py b/examples/basic_cohort.py index 3b7339b2..34b34407 100644 --- a/examples/basic_cohort.py +++ b/examples/basic_cohort.py @@ -6,16 +6,18 @@ """ 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(): """Create a simple Type 2 Diabetes cohort definition.""" - + # Define the Type 2 Diabetes concept set diabetes_concept_set = ConceptSet( id=1, @@ -30,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. @@ -75,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 @@ -85,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(f"\nGenerated SQL (first 500 chars):") + 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/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/examples/complex_cohort.py b/examples/complex_cohort.py index 2065ba89..e958ee7e 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(): @@ -33,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, @@ -41,16 +51,13 @@ def create_complex_cohort(): expression=ConceptSetExpression( items=[ ConceptSetItem( - concept=Concept( - concept_id=201826, - concept_name="Type 2 diabetes mellitus" - ), - include_descendants=True + concept=Concept(concept_id=201826, concept_name="Type 2 diabetes mellitus"), + include_descendants=True, ) ] - ) + ), ) - + # Concept Set 2: Metformin metformin_concepts = ConceptSet( id=2, @@ -58,16 +65,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, @@ -75,16 +79,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, @@ -94,14 +95,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, @@ -109,16 +110,13 @@ def create_complex_cohort(): expression=ConceptSetExpression( items=[ ConceptSetItem( - concept=Concept( - concept_id=46271022, - concept_name="End stage renal disease" - ), - include_descendants=True + concept=Concept(concept_id=46271022, concept_name="End stage renal disease"), + include_descendants=True, ) ] - ) + ), ) - + # Concept Set 6: Hospice Care (censoring event) hospice_concepts = ConceptSet( id=6, @@ -126,16 +124,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=[ @@ -144,84 +139,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", @@ -229,29 +206,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", @@ -259,40 +236,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 @@ -300,25 +275,25 @@ 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"Inclusion Rules: {len(cohort.inclusion_rules)} rules") 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] print(f" - {criteria_type}") - + # Generate SQL print("\nGenerating SQL...") # Note: For SqlRender compatibility, leave schema parameters unset @@ -327,21 +302,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") @@ -350,4 +325,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 73cc29e2..e05176ba 100644 --- a/examples/generate_sql.py +++ b/examples/generate_sql.py @@ -6,18 +6,19 @@ 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 cohort = cohort_expression_from_json(json_data) return cohort @@ -26,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" @@ -50,77 +51,70 @@ 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" - + inclusion_rules_sql = ( + builder.get_inclusion_rule_table_sql(cohort) + if cohort.inclusion_rules + else "-- 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 @@ -131,41 +125,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 ded2fa1e..f2efecc2 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,24 +162,36 @@ }, { "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", "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", @@ -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,65 +248,70 @@ }, { "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", " 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 9eb0dab9..80e51e5a 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\")" + ] }, { - "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", @@ -104,37 +119,23 @@ " 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(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 + "print(f\" Items: {len(t2dm_concept_set.expression.items)}\")" + ] }, { - "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,54 +166,54 @@ ] }, { + "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", " 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(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 + "print(f\" First Occurrence Only: {primary_criteria.criteria_list[0].first}\")" + ] }, { - "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__}\")" + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Step 7: Validate Cohort Definition\n", "\n", @@ -262,25 +265,14 @@ ] }, { + "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", - "source": [ - "# Validate the cohort\n", - "checker = Checker()\n", - "warnings = checker.check(cohort)\n", - "\n", - "if not warnings:\n", - " print(\"✓ Cohort definition is valid with no warnings!\")\n", - "else:\n", - " print(f\"⚠️ Validation found {len(warnings)} issues:\")\n", - " for warning in warnings:\n", - " print(f\" {warning.to_message()}\")\n" - ], "outputs": [ { "name": "stdout", @@ -291,11 +283,22 @@ ] } ], - "execution_count": 11 + "source": [ + "# Validate the cohort\n", + "checker = Checker()\n", + "warnings = checker.check(cohort)\n", + "\n", + "if not warnings:\n", + " print(\"✓ Cohort definition is valid with no warnings!\")\n", + "else:\n", + " print(f\"⚠️ Validation found {len(warnings)} issues:\")\n", + " for warning in warnings:\n", + " print(f\" {warning.to_message()}\")" + ] }, { - "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)" + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Step 9: Save Outputs\n", "\n", @@ -384,39 +387,14 @@ ] }, { + "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", - "source": [ - "# Save cohort definition as JSON\n", - "# 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", - " 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", - " f.write(sql)\n", - "print(\"✓ SQL query saved to: type2_diabetes_cohort.sql\")\n", - "\n", - "# Display summary\n", - "print(f\"\\n{'='*80}\")\n", - "print(\"SUMMARY\")\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(f\"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", @@ -438,17 +416,42 @@ ] } ], - "execution_count": 13 + "source": [ + "# Save cohort definition as JSON\n", + "# 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", + " 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", + " f.write(sql)\n", + "print(\"✓ SQL query saved to: type2_diabetes_cohort.sql\")\n", + "\n", + "# Display summary\n", + "print(f\"\\n{'=' * 80}\")\n", + "print(\"SUMMARY\")\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}\")" + ] }, { + "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", @@ -496,36 +515,22 @@ " 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(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 + "print(f\" Name: {metformin_concept_set.name}\")" + ] }, { - "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", @@ -561,7 +580,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", @@ -572,18 +591,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", @@ -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.\")" + ] }, { - "metadata": {}, "cell_type": "markdown", + "metadata": {}, "source": [ "## Summary\n", "\n", diff --git a/examples/validate_cohort.py b/examples/validate_cohort.py index 8bbaf24b..b51bdaad 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 @@ -14,133 +15,124 @@ 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, 'r') as f: + 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 @@ -154,36 +146,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,7 +187,7 @@ def main(): else: print("No example cohort JSON files found.") print("Run basic_cohort.py or complex_cohort.py first to generate example files.") - + print("\n" + "=" * 50) print("Validation examples completed!") diff --git a/examples/waveform_extension.py b/examples/waveform_extension.py new file mode 100644 index 00000000..8d75f1c0 --- /dev/null +++ b/examples/waveform_extension.py @@ -0,0 +1,211 @@ +""" +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 +""" + +from circe.cohortdefinition import CohortExpression, PrimaryCriteria +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.vocabulary.concept import Concept + + +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 31f1fb8b..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"} @@ -32,27 +32,47 @@ 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", - "jinja2>=3.1.0" + "jinja2>=3.1.0", + "PyYAML>=6.0" ] [project.optional-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", + "ibis-framework[duckdb]>=11.0.0", + "polars>=0.20.0", + "deepdiff>=8.6.0", + "javalang>=0.13.0", ] 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'", +] +ibis-duckdb = [ + "ibis-framework[duckdb]>=11.0.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'", +] +waveform = [ + "pydantic>=2.0.0", ] [project.urls] @@ -74,30 +94,7 @@ exclude = ["circe.tests*"] [tool.setuptools.package-data] circe = ["py.typed"] - -[tool.black] -line-length = 88 -target-version = ['py38'] -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"] +"circe.extensions.waveform" = ["templates/*.j2"] [tool.mypy] python_version = "3.9" @@ -114,6 +111,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 = [ @@ -154,4 +174,49 @@ 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 +] + +[tool.ruff] +# Allow longer lines for code and docstrings. +line-length = 110 + +# 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 = [ + "E501", # Line too long (handled by formatter) +] + +[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/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/scripts/generate_skill_backup.py b/scripts/generate_skill_backup.py index 880d7f48..f7eafceb 100644 --- a/scripts/generate_skill_backup.py +++ b/scripts/generate_skill_backup.py @@ -12,87 +12,89 @@ """ 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 # 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, ) @dataclass class MethodInfo: """Information about a method.""" + name: str 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) 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.""" 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("'", "") - + return_type = ( + "Unknown" + if return_annotation == inspect.Signature.empty + else 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,69 +102,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_'): + 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']: + 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']: + 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']: + 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", + ]: 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: 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.""" md = [] - + # Header md.append("---") md.append("description: Build OHDSI cohort definitions using the fluent Python API") @@ -174,7 +224,7 @@ def generate_markdown(self) -> str: md.append("") 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("") @@ -182,32 +232,39 @@ def generate_markdown(self) -> str: 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("") @@ -226,16 +283,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,64 +305,69 @@ 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, 'r') as f: + with open(prompt_path) as f: prompt_content = f.read() 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() @@ -311,47 +375,45 @@ 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) - + 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: + + for prompt_path, _model_type in prompts: 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/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/conftest.py b/tests/conftest.py index 21ee432b..3c8d795d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,13 @@ - -import pytest - 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/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..db55e52e --- /dev/null +++ b/tests/execution/test_api_ibis.py @@ -0,0 +1,1223 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort +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 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): + 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_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(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_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(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + + +def test_build_cohort_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(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + + +def test_build_cohort_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(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} + assert set(result.concept_id) == {100, 200} + + +def test_build_cohort_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( + expression, + backend=conn, + cdm_schema="main", + vocabulary_schema="vocab", + ).execute() + assert set(result.concept_id) == {100, 101} + + +def test_build_cohort_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(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_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(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_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(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "measurement") + + +def test_build_cohort_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(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {2} + assert all(result.domain == "measurement") + + +def test_build_cohort_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(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_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(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_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(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "observation") + + +def test_build_cohort_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(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {2} + assert all(result.domain == "observation") + + +def test_build_cohort_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(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_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(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_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(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_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(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "specimen") + + +def test_build_cohort_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(expression, backend=conn, cdm_schema="main") + result = table.execute() + + assert set(result.person_id) == {1} + assert all(result.domain == "death") + + +def test_build_cohort_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(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_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(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_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(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_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") + + 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(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_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") + + 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(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_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") + + 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(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_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(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_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_api_public.py b/tests/execution/test_api_public.py new file mode 100644 index 00000000..a8f074d2 --- /dev/null +++ b/tests/execution/test_api_public.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +import pytest + +import circe.api as api +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 +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_execution_functions_are_exported(): + assert hasattr(api, "build_cohort") + assert hasattr(api, "write_cohort") + assert hasattr(api, "build_cohort_query") + + +def test_build_cohort_returns_relation(): + 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") + + assert hasattr(relation, "execute") + assert len(relation.execute()) == 2 + + +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(): + 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 + + +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_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 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_compile_steps_helpers.py b/tests/execution/test_compile_steps_helpers.py new file mode 100644 index 00000000..74a3d878 --- /dev/null +++ b/tests/execution/test_compile_steps_helpers.py @@ -0,0 +1,319 @@ +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, + ) + 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), + 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_context_wiring.py b/tests/execution/test_context_wiring.py new file mode 100644 index 00000000..1231f121 --- /dev/null +++ b/tests/execution/test_context_wiring.py @@ -0,0 +1,100 @@ +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: + 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)] + + +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_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_databricks_compat.py b/tests/execution/test_databricks_compat.py new file mode 100644 index 00000000..448bd3df --- /dev/null +++ b/tests/execution/test_databricks_compat.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import pytest + +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(): + 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() + + +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_domain_filter_parity.py b/tests/execution/test_domain_filter_parity.py new file mode 100644 index 00000000..def5b8a7 --- /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 +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(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(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(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(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(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(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( + 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( + 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..12b3b0c2 --- /dev/null +++ b/tests/execution/test_end_strategy_censoring.py @@ -0,0 +1,360 @@ +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 + + +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(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(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(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(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" + + +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_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_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") + + 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" diff --git a/tests/execution/test_error_messages.py b/tests/execution/test_error_messages.py new file mode 100644 index 00000000..8e71481b --- /dev/null +++ b/tests/execution/test_error_messages.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort +from circe.cohortdefinition import ( + CohortExpression, + ConditionOccurrence, + CorelatedCriteria, + Criteria, + CriteriaGroup, + DemographicCriteria, + Measurement, + Occurrence, + PrimaryCriteria, +) +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 + + +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_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(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(expression, backend=conn, cdm_schema="main").execute() 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_groups.py b/tests/execution/test_groups.py new file mode 100644 index 00000000..016cec53 --- /dev/null +++ b/tests/execution/test_groups.py @@ -0,0 +1,926 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort +from circe.cohortdefinition import ( + CohortExpression, + ConditionOccurrence, + CorelatedCriteria, + CriteriaColumn, + CriteriaGroup, + DemographicCriteria, + DrugEra, + 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(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(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(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(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} + + +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} + + +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} 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..46ce20ee --- /dev/null +++ b/tests/execution/test_inclusion.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import pytest + +from circe.api import build_cohort +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(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(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1, 2} 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..ce42448a --- /dev/null +++ b/tests/execution/test_operations.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +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, +) + + +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") + + +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 + + +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() + + 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") + + +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), + ] + + +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_parity_regressions.py b/tests/execution/test_parity_regressions.py new file mode 100644 index 00000000..4dec4d5f --- /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 +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(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(expression, backend=conn, cdm_schema="main").execute() + assert set(result.person_id) == {1} 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} 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" diff --git a/tests/execution/test_result_limits.py b/tests/execution/test_result_limits.py new file mode 100644 index 00000000..15d835b9 --- /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 +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(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(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(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(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..c9cdecfd --- /dev/null +++ b/tests/execution/test_scaffolding.py @@ -0,0 +1,26 @@ +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") + + +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..7191c0b5 --- /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 +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(expression, backend=conn, cdm_schema="main").execute() + assert_standard_event_columns(result.columns) 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_builder_utils_coverage.py b/tests/test_builder_utils_coverage.py index 6b09457e..6f726120 100644 --- a/tests/test_builder_utils_coverage.py +++ b/tests/test_builder_utils_coverage.py @@ -2,39 +2,42 @@ 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 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") 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) @@ -59,7 +62,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) @@ -68,25 +71,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 "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.""" clause = BuilderUtils.build_date_range_clause("start_date", None) @@ -95,35 +104,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 @@ -131,55 +136,55 @@ 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) 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 @@ -188,18 +193,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 "(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.""" 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") @@ -208,24 +213,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 aa7e1ca5..bd5ffb9d 100644 --- a/tests/test_builders.py +++ b/tests/test_builders.py @@ -5,29 +5,37 @@ 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 + 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): """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") @@ -40,12 +48,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") @@ -54,131 +62,130 @@ 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" - ) - + + 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)" 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)" 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 @@ -186,50 +193,50 @@ 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] 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") @@ -239,57 +246,58 @@ 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: return f"test.{column.value}" - + 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() 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() @@ -301,45 +309,45 @@ 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) 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) 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) 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) 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) 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) 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) @@ -348,24 +356,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) @@ -373,44 +381,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) @@ -420,53 +428,53 @@ 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) 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) 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) 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) 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) @@ -474,7 +482,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) @@ -483,53 +491,53 @@ 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) 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) 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) 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) 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) @@ -537,7 +545,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) @@ -546,75 +554,99 @@ 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 ( - BuilderUtils, BuilderOptions, CriteriaColumn, - CriteriaSqlBuilder, ConditionOccurrenceSqlBuilder, - DrugExposureSqlBuilder, ProcedureOccurrenceSqlBuilder + ConditionOccurrenceSqlBuilder, + CriteriaSqlBuilder, + 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 ConditionOccurrence, DrugExposure, ProcedureOccurrence - + from circe.cohortdefinition.criteria import ( + ConditionOccurrence, + 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() - + + 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 ConditionOccurrence, DrugExposure, ProcedureOccurrence - + from circe.cohortdefinition.criteria import ( + ConditionOccurrence, + 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) @@ -625,5 +657,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 3aebc304..8cdde7d0 100644 --- a/tests/test_builders_sql.py +++ b/tests/test_builders_sql.py @@ -1,53 +1,48 @@ -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() + 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 - + 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 - + 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 89e5f6fc..9b0cb8a4 100644 --- a/tests/test_checkers.py +++ b/tests/test_checkers.py @@ -6,141 +6,143 @@ """ 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: """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, 'r') as f: + 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) @@ -148,14 +150,14 @@ 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") 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() @@ -171,42 +173,34 @@ 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 - } - } - ] - } + primary_criteria={"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( @@ -218,18 +212,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( @@ -244,71 +238,59 @@ 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 - } - ] - ) + expression = CohortExpression(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) - ] - + 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) - ] - + 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 @@ -318,18 +300,16 @@ 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) - ] - + + 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 @@ -340,61 +320,47 @@ 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) - ] - + + 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( 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 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") check = DuplicatesConceptSetCheck() warnings = check.check(expression) - + assert len(warnings) == 1 assert isinstance(warnings[0], DefaultWarning) except FileNotFoundError: @@ -411,14 +377,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, @@ -430,30 +396,30 @@ 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") check = DuplicatesConceptSetCheck() warnings = check.check(expression) - + assert len(warnings) == 0 except FileNotFoundError: pytest.skip("Test resource not available") @@ -461,32 +427,24 @@ 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") 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 - } - } - ] - } + primary_criteria={"criteriaList": [{"conditionOccurrence": {"codesetId": 0}}]} ) check = ConceptSetCriteriaCheck() print(f"DEBUG: criteria list: {expression.primary_criteria.criteria_list}") @@ -496,13 +454,13 @@ 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: @@ -517,35 +475,27 @@ 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: @@ -560,81 +510,66 @@ 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() - + 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) - + 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" - } + "criteriaList": [{"conditionOccurrence": {"codesetId": 0}}], + "primaryLimit": {"type": "All"}, }, - expression_limit={ - "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.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( inclusion_rules=[ @@ -649,71 +584,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") @@ -721,14 +647,14 @@ 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") check = OcurrenceCheck() warnings = check.check(expression) - + assert len(warnings) >= 1 assert warnings[0].severity == WarningSeverity.WARNING except FileNotFoundError: @@ -739,144 +665,109 @@ 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 - ) - + 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 = CohortExpression( - inclusion_rules=[inclusion_rule] + expression=CriteriaGroup(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 - } - } - ] - } + primary_criteria={"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 - } - } - ] - } + primary_criteria={"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 - 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 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") 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") check = EventsProgressionCheck() warnings = check.check(expression) - + assert len(warnings) == 0 except FileNotFoundError: pytest.skip("Test resource not available") @@ -884,65 +775,57 @@ 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") 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") 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") check = CriteriaContradictionsCheck() warnings = check.check(expression) - + assert len(warnings) == 0 except FileNotFoundError: pytest.skip("Test resource not available") @@ -950,26 +833,26 @@ 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") 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") @@ -977,24 +860,22 @@ 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") 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.""" - from circe.cohortdefinition.criteria import ConditionOccurrence, Death, DeviceExposure - from circe.vocabulary import Concept - + expression = CohortExpression( primary_criteria={ "criteriaList": [ @@ -1012,9 +893,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", } - ] + ], } } ] @@ -1022,32 +903,32 @@ 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") 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") check = DeathTimeWindowCheck() warnings = check.check(expression) - + assert len(warnings) == 0 except FileNotFoundError: pytest.skip("Test resource not available") @@ -1055,184 +936,164 @@ 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 - - from datetime import date, timedelta + 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" - ) - + + warning = DefaultWarning(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 - ) - - concept_set = ConceptSet( - id=0, - name="Test Set", - expression=concept_set_expression + items=[], is_excluded=False, include_mapped=False, include_descendants=False ) - + + concept_set = ConceptSet(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" - ) - + warning = IncompleteRuleWarning(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() @@ -1240,4 +1101,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 4364962f..dd7d42df 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,50 +5,52 @@ 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 # 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", ] -@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 + 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 @@ -56,109 +58,125 @@ 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 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() -@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 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}") - + # 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" -@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() @@ -170,25 +188,22 @@ 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 49f5ee8b..5fcd17bc 100644 --- a/tests/test_code_generator.py +++ b/tests/test_code_generator.py @@ -1,64 +1,70 @@ - -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) 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', 'r') 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', 'r') 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 + + 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 cc703b67..aa79c45f 100644 --- a/tests/test_cohort_expression.py +++ b/tests/test_cohort_expression.py @@ -5,35 +5,37 @@ 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 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) @@ -46,144 +48,126 @@ 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 @@ -191,49 +175,46 @@ 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] - ) + + 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 @@ -242,13 +223,11 @@ 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] - ) + + 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=[]) @@ -258,7 +237,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( @@ -273,9 +252,9 @@ def test_cohort_expression_full_configuration(self): 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) @@ -288,90 +267,80 @@ 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() - ) - + cohort = CohortExpression(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 @@ -391,17 +360,15 @@ 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, []) # 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, []) @@ -410,69 +377,61 @@ 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) ) 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") - + 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 b0553cc4..be745225 100644 --- a/tests/test_cohort_expression_query_builder_coverage.py +++ b/tests/test_cohort_expression_query_builder_coverage.py @@ -1,13 +1,20 @@ - 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.""" @@ -28,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) @@ -70,36 +80,36 @@ 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): @@ -109,29 +119,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.""" @@ -139,54 +151,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 8b6afd5c..2d621793 100644 --- a/tests/test_cohort_expression_query_builder_extended.py +++ b/tests/test_cohort_expression_query_builder_extended.py @@ -1,18 +1,33 @@ - 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): +class TestCohortExpressionQueryBuilderExtended(unittest.TestCase): def setUp(self): self.builder = CohortExpressionQueryBuilder() # Mock sub-builders to isolate testing of the main builder logic @@ -35,25 +50,85 @@ 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: @@ -66,10 +141,12 @@ def test_get_criteria_sql_dispatch(self): 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) call_args = self.builder.condition_occurrence_sql_builder.get_criteria_sql_with_options.call_args self.assertIsInstance(call_args[0][0], ConditionOccurrence) @@ -80,26 +157,26 @@ 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"): 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) @@ -107,10 +184,10 @@ 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) @@ -119,14 +196,14 @@ 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) @@ -137,45 +214,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 6344084b..5af1b763 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 # --------------------------------------------------------------------------- @@ -75,6 +75,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 +99,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 +119,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 +138,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 +147,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 +173,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 +213,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) @@ -240,6 +248,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) @@ -264,8 +273,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 +300,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,16 +330,14 @@ 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.""" 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): @@ -334,8 +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 @@ -356,15 +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) @@ -379,21 +387,20 @@ 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 - if getattr(r, "name", None) == "__clean_window__" ) + 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 @@ -412,21 +419,20 @@ 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 - if getattr(r, "name", None) == "__clean_window__" ) + 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 @@ -434,22 +440,21 @@ 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 - if getattr(r, "name", None) == "__clean_window__" ) + 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 @@ -470,8 +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 @@ -480,21 +484,20 @@ 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" 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 +521,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,31 +538,26 @@ 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 - if getattr(r, "name", None) == "__clean_window__" ) + 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) - ) + 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 @@ -566,6 +565,7 @@ 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") @@ -597,6 +597,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 +622,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) @@ -676,16 +678,12 @@ 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 +696,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 +754,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 +788,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 b8709418..651368bc 100644 --- a/tests/test_comparisons_coverage.py +++ b/tests/test_comparisons_coverage.py @@ -1,18 +1,34 @@ - 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 --- - + def test_start_is_greater_than_end_none(self): self.assertFalse(Comparisons.start_is_greater_than_end(None)) @@ -27,8 +43,12 @@ def test_start_is_greater_than_end_date_incomplete(self): 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"))) @@ -36,26 +56,38 @@ def test_start_is_greater_than_end_period_incomplete(self): 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))) 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)) @@ -67,7 +99,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)) @@ -80,7 +112,7 @@ 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) @@ -88,26 +120,23 @@ def test_compare_to_none(self): 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) - ) - + 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) - + 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)) @@ -116,71 +145,93 @@ 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) - ) + 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 --- - + def test_compare_concept_set(self): - from circe.vocabulary.concept import ConceptSet, Concept, 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") - + 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", + ) + 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)]) 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): @@ -189,21 +240,34 @@ 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.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 be1a6bdf..6c70d3c5 100644 --- a/tests/test_concept_checker_factory_coverage.py +++ b/tests/test_concept_checker_factory_coverage.py @@ -1,15 +1,27 @@ - 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() @@ -24,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): @@ -33,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) @@ -64,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): @@ -109,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) @@ -131,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) @@ -154,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): @@ -187,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) @@ -206,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) @@ -224,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 @@ -274,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 5920cf56..75e6832a 100644 --- a/tests/test_concept_set_expression_query_builder.py +++ b/tests/test_concept_set_expression_query_builder.py @@ -1,18 +1,19 @@ 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 -class TestConceptSetExpressionQueryBuilder(unittest.TestCase): +from circe.vocabulary.concept import Concept, ConceptExpressionItem, ConceptSetExpression +from circe.vocabulary.concept_set_expression_query_builder import ( + ConceptSetExpressionQueryBuilder, +) + +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]) @@ -50,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") @@ -60,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 = ConceptExpressionItem( + 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) @@ -75,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 = ConceptExpressionItem( + concept=c1, + is_excluded=False, + include_descendants=False, + include_mapped=False, + ) + item2 = ConceptExpressionItem( + 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) @@ -90,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 = ConceptExpressionItem( + 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 @@ -107,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 = ConceptExpressionItem( + 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_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_condition_occurrence_sql_builder.py b/tests/test_condition_occurrence_sql_builder.py index cbbfae3b..2a2b0662 100644 --- a/tests/test_condition_occurrence_sql_builder.py +++ b/tests/test_condition_occurrence_sql_builder.py @@ -5,45 +5,48 @@ 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): """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() @@ -55,34 +58,32 @@ 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) 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) 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) 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) 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) 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) @@ -91,7 +92,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) @@ -100,47 +101,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) @@ -149,239 +153,274 @@ 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) - + 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) - + 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)) result = self.builder.resolve_join_clauses(criteria) 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)) 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.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)) - + 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)) - + 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)) - + 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)) - + 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)) 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)) 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)) - + 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)) - + 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"), 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("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) @@ -389,18 +428,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) @@ -408,62 +447,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 @@ -473,21 +514,21 @@ 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)) 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)) 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( @@ -507,12 +548,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) @@ -524,7 +565,7 @@ 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 @@ -532,5 +573,5 @@ def test_comprehensive_integration_test(self): 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 04b5975f..d795301b 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 @@ -32,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) @@ -61,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") @@ -72,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) @@ -96,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) @@ -125,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) @@ -135,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) @@ -159,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) @@ -187,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) @@ -201,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) @@ -219,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) @@ -232,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) @@ -259,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") @@ -273,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) @@ -307,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) @@ -322,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) @@ -350,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") @@ -365,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) @@ -394,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) @@ -409,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) @@ -430,9 +438,9 @@ def test_death_with_fields(self): 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) @@ -444,27 +452,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) @@ -479,9 +481,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) @@ -492,9 +494,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) @@ -523,5 +523,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 b5a4b989..4ec151fc 100644 --- a/tests/test_date_adjustment_parity.py +++ b/tests/test_date_adjustment_parity.py @@ -1,15 +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 def setup_class(cls): # ... setup db ... @@ -26,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(""" @@ -46,21 +53,26 @@ 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 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) @@ -68,13 +80,15 @@ 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(""" @@ -96,20 +110,25 @@ 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 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) @@ -119,17 +138,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(""" @@ -158,20 +181,25 @@ 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 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) @@ -179,11 +207,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(""" @@ -199,27 +227,32 @@ 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 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(); @@ -229,32 +262,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(""" @@ -268,13 +301,15 @@ 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)") - + # Construct full query # We replace @indexId with 0 or similar query = f""" @@ -287,37 +322,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 4e57954d..02da8365 100644 --- a/tests/test_device_exposure_sql.py +++ b/tests/test_device_exposure_sql.py @@ -1,57 +1,66 @@ 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): - 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() - + # 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") + 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") - ) + 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) 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 9df6b083..d2e55d71 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -7,11 +7,13 @@ import re from pathlib import Path + try: import tomllib except ModuleNotFoundError: # Python <3.11 fallback import tomli as tomllib + class TestDocumentation: """Test suite for documentation validation.""" @@ -44,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.""" @@ -75,15 +77,13 @@ 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.""" @@ -93,53 +93,20 @@ 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() 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() @@ -155,9 +122,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.""" @@ -170,10 +135,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.""" @@ -185,8 +149,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): @@ -195,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): @@ -233,10 +196,8 @@ 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}" + 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 a019695e..aa5cd543 100644 --- a/tests/test_drug_era_sql_builder.py +++ b/tests/test_drug_era_sql_builder.py @@ -6,59 +6,66 @@ """ 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 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) assert result == "C.drug_concept_id" - + # Test 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) assert result == "C.gap_days" - + # Test 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) assert result == "C.start_date" - + # Test 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) assert result == "NULL" - - def test_get_query_template(self): """Test get_query_template method.""" template = self.builder.get_query_template() @@ -69,247 +76,253 @@ 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)" + 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 - + 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] - + 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] - + 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] - + 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)) - + 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] - + 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] - + 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)) - + 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( @@ -321,11 +334,11 @@ 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) @@ -336,13 +349,13 @@ def test_resolve_where_clauses_with_multiple_conditions(self): 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) - + 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 @@ -353,132 +366,134 @@ 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 "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, @@ -491,14 +506,16 @@ 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 diff --git a/tests/test_drug_exposure_builder.py b/tests/test_drug_exposure_builder.py index fde116d2..6d7ef059 100644 --- a/tests/test_drug_exposure_builder.py +++ b/tests/test_drug_exposure_builder.py @@ -1,35 +1,56 @@ 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): 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( @@ -38,48 +59,53 @@ 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)) - + 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)) - + 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, @@ -93,10 +119,10 @@ 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.refills > 1" in clause for clause in where_clauses)) self.assertTrue(any("C.quantity < 10" in clause for clause in where_clauses)) @@ -116,12 +142,24 @@ 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_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_extension_system.py b/tests/test_extension_system.py new file mode 100644 index 00000000..e76c16b0 --- /dev/null +++ b/tests/test_extension_system.py @@ -0,0 +1,185 @@ +import json +from typing import Optional + +from pydantic import AliasChoices, Field + +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 ( + BuildExpressionQueryOptions, + CohortExpressionQueryBuilder, +) +from circe.cohortdefinition.criteria import Criteria +from circe.cohortdefinition.printfriendly.markdown_render import MarkdownRender +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( + 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(_types_namespace={"CriteriaGroup": CriteriaGroup}) + + +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 or 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_hashing.py b/tests/test_hashing.py index 110839e6..445d629a 100644 --- a/tests/test_hashing.py +++ b/tests/test_hashing.py @@ -1,9 +1,14 @@ - 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.""" @@ -21,7 +26,7 @@ def test_concept_name_agnosticism(self): cs1 = ConceptSet(id=1, name="Set 1") item1 = ConceptSetItem( concept=Concept(concept_id=123, concept_name="Name A", standard_concept="S"), - isExcluded=False + isExcluded=False, ) cs1.expression = ConceptSetExpression(items=[item1]) c1.concept_sets = [cs1] @@ -31,13 +36,17 @@ def test_concept_name_agnosticism(self): cs2 = ConceptSet(id=1, name="Set 1") item2 = ConceptSetItem( concept=Concept(concept_id=123, concept_name="Name B", standard_concept="S"), - isExcluded=False + 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.""" @@ -46,7 +55,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] @@ -56,7 +65,7 @@ def test_metadata_agnosticism(self): cs2 = ConceptSet(id=1, name="Set 1") item2 = ConceptSetItem( concept=Concept(concept_id=123, standard_concept="C", vocabulary_id="RxNorm"), - isExcluded=False + isExcluded=False, ) cs2.expression = ConceptSetExpression(items=[item2]) c2.concept_sets = [cs2] @@ -98,7 +107,11 @@ 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.""" @@ -113,33 +126,42 @@ def test_sensitivity_to_id(self): 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") - # 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 50cb0c43..866a23f2 100644 --- a/tests/test_java_interoperability.py +++ b/tests/test_java_interoperability.py @@ -6,37 +6,29 @@ """ 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): """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) @@ -47,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 @@ -108,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 @@ -125,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 = { @@ -137,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) @@ -163,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 @@ -179,93 +162,81 @@ 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 - ) - + 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) - + # 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.""" - from circe.cohortdefinition.core import ObservationFilter, ResultLimit - from circe.cohortdefinition.criteria import PrimaryCriteria - + 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") + 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 @@ -279,42 +250,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, 'r') as f: + + 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) @@ -329,5 +300,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 15b8be73..482fe923 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.""" @@ -40,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, @@ -58,11 +84,11 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: 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) @@ -81,7 +107,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: gender=[Concept(concept_id=8507, concept_name="Male")], 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 @@ -103,10 +129,9 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: 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")] + visit_type=[Concept(concept_id=9202, concept_name="Outpatient Visit")], ) - # Procedure Occurrence procedure = ProcedureOccurrence( codeset_id=1, @@ -117,12 +142,12 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: 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"), @@ -134,7 +159,7 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: 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 + place_of_service_location=12345, ) # Measurement @@ -154,7 +179,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 @@ -170,7 +195,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 @@ -183,20 +208,20 @@ 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( @@ -205,104 +230,99 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: race=[Concept(concept_id=8527, concept_name="White")], 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 - 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: # 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") - ) + 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 # 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, @@ -313,9 +333,9 @@ 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, @@ -325,9 +345,9 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: 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, @@ -345,9 +365,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, @@ -358,9 +378,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, @@ -372,9 +392,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, @@ -386,16 +406,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, @@ -407,10 +427,9 @@ def create_kitchen_sink_cohort(self) -> CohortExpression: measurement, observation, device, - specimen + specimen, ] - # 6. Cohort Expression cohort = CohortExpression( title="Kitchen Sink Cohort", @@ -419,41 +438,30 @@ 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 - ), - censor_window=Period( - start_date="2010-01-01", - end_date="2025-01-01" - ), + 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 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) @@ -461,5 +469,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 8a03b6ca..d0932140 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 @@ -50,7 +50,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 +65,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 +81,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 +99,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_package_structure.py b/tests/test_package_structure.py index a5ad3067..6c4a944a 100644 --- a/tests/test_package_structure.py +++ b/tests/test_package_structure.py @@ -5,103 +5,108 @@ """ 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 - + 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, + ) + # 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__": diff --git a/tests/test_print_friendly_parity.py b/tests/test_print_friendly_parity.py index 7fd7db77..c8974a4a 100644 --- a/tests/test_print_friendly_parity.py +++ b/tests/test_print_friendly_parity.py @@ -1,23 +1,26 @@ -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: + 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() @@ -26,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) @@ -35,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) @@ -48,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) @@ -63,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) @@ -79,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) @@ -99,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:", @@ -116,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) @@ -125,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;", @@ -136,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) @@ -145,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) @@ -172,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) @@ -200,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) @@ -222,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) @@ -239,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) @@ -258,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) @@ -278,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) @@ -295,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;", @@ -303,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) @@ -312,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) @@ -331,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.", @@ -339,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) @@ -348,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) @@ -367,42 +373,54 @@ 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) 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.", @@ -427,7 +445,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) @@ -436,14 +454,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) @@ -452,7 +470,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.", @@ -465,7 +483,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) @@ -474,8 +492,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 ab06c42e..623b6836 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, - 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 + BuildExpressionQueryOptions, + CohortExpression, + CohortExpressionQueryBuilder, + CollapseSettings, + CollapseType, + ConceptSetSelection, + ConditionOccurrence, + CorelatedCriteria, + CriteriaColumn, + CriteriaGroup, + CustomEraStrategy, + DateOffsetStrategy, + Death, + DemographicCriteria, + NumericRange, + ObservationFilter, + Occurrence, + Period, + PrimaryCriteria, + ResultLimit, ) +from circe.vocabulary import Concept, ConceptSetExpression, ConceptSetItem +from circe.vocabulary.concept_set_expression_query_builder import ConceptSetExpressionQueryBuilder class TestConceptSetExpressionQueryBuilder(unittest.TestCase): @@ -39,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) @@ -51,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) @@ -68,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) @@ -85,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) @@ -103,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) @@ -119,9 +130,9 @@ 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) - + 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) @@ -129,8 +140,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.""" @@ -138,9 +152,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) @@ -153,22 +169,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) @@ -181,22 +197,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) @@ -211,16 +227,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) @@ -232,28 +248,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) @@ -266,14 +282,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) @@ -292,18 +305,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") @@ -333,40 +348,44 @@ 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) @@ -375,19 +394,13 @@ 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") + 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) @@ -396,7 +409,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) @@ -404,9 +417,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) @@ -418,36 +431,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) @@ -456,27 +469,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) @@ -486,7 +499,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) @@ -495,11 +508,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) @@ -509,20 +522,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) @@ -532,42 +545,33 @@ 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 - ), - occurrence=Occurrence(type=1, count=1, is_distinct=False) + criteria=ConditionOccurrence(first=True, condition_type_exclude=False, codeset_id=12345), + 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) 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") - + self.assertIn("INTO #strategy_ends", query) self.assertIn("12345", query) self.assertIn("30", query) @@ -575,22 +579,17 @@ 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") 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) @@ -600,30 +599,23 @@ 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") + 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_settings=CollapseSettings(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) @@ -633,39 +625,28 @@ 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") + primary_limit=ResultLimit(type="ALL"), ), additional_criteria=CriteriaGroup( type="ALL", criteria_list=[ CorelatedCriteria( - criteria=Death( - first=True, - death_type_exclude=False, - codeset_id=67890 - ), - occurrence=Occurrence(type=1, count=1, is_distinct=False) + 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() 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) @@ -675,27 +656,20 @@ 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") + 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() 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) @@ -704,31 +678,24 @@ 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") + 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() 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 2a0aaeab..8f6426b4 100644 --- a/tests/test_range_checker_factory_coverage.py +++ b/tests/test_range_checker_factory_coverage.py @@ -1,17 +1,30 @@ - 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): @@ -21,192 +34,460 @@ 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.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.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) @@ -214,34 +495,51 @@ 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.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 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 # or at least ensure it's available for the new subclass to be built. @@ -251,14 +549,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() @@ -270,7 +570,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): @@ -278,7 +581,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): @@ -286,15 +592,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): @@ -302,7 +614,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): @@ -310,7 +625,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 --- @@ -320,7 +638,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): @@ -328,23 +649,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() @@ -359,30 +689,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 c73b1964..cb3fae6b 100644 --- a/tests/test_real_example_cohorts.py +++ b/tests/test_real_example_cohorts.py @@ -1,50 +1,56 @@ """ 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 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 random import re -from difflib import unified_diff import textwrap -import difflib +from difflib import unified_diff +from pathlib import Path +from typing import Optional + +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 -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 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: @@ -57,18 +63,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]]: +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) """ @@ -97,162 +101,171 @@ 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(): + 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(): + + 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 "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(): + 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 + # 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() + ): + issues.append("Missing source concept handling - ConditionSourceConcept may not be implemented") + return issues # ============================================================================= @@ -263,49 +276,47 @@ 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) ) @@ -315,20 +326,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 @@ -376,22 +387,21 @@ def test_sql_matches_reference(cohort_name): pytest.fail(textwrap.dedent(failure_msg)) - - # ============================================================================= # Markdown Generation Tests (Moved from test_markdown_parity.py) # ============================================================================= # 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. """ if cohort_name in _MARKDOWN_CACHE: return _MARKDOWN_CACHE[cohort_name] - + cohort_file = COHORTS_DIR / cohort_name markdown = None error = None @@ -413,135 +423,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: + + 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. @@ -549,19 +567,19 @@ 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. @@ -569,24 +587,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}" ) @@ -594,90 +616,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: @@ -693,17 +720,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 87c18325..821324a5 100644 --- a/tests/test_schema_compatibility.py +++ b/tests/test_schema_compatibility.py @@ -4,8 +4,9 @@ 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 -import pytest + from deepdiff import DeepDiff # pip install deepdiff from circe import get_json_schema @@ -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,25 +59,26 @@ 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, "r") as f: + with open(JAVA_SCHEMA_PATH) as f: java_schema = json.load(f) # 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,11 +87,8 @@ 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'\]" - ] - + 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: diff --git a/tests/test_simple_sql_builders.py b/tests/test_simple_sql_builders.py index 933c7270..53c439b4 100644 --- a/tests/test_simple_sql_builders.py +++ b/tests/test_simple_sql_builders.py @@ -6,87 +6,114 @@ 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, ) 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) - + 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() - + 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() - + 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) - + 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() - + 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 = [ @@ -94,40 +121,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 = { @@ -139,18 +166,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 2dac879c..8b6fc40d 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): @@ -45,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) @@ -56,81 +74,69 @@ 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) # Updated alias check self.assertIn("d.cause_concept_id", clause) @@ -139,17 +145,12 @@ 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) self.assertEqual(clause, "SELECT * FROM table ") - - class TestObservationSqlBuilder(unittest.TestCase): """Test ObservationSqlBuilder class.""" @@ -162,7 +163,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) @@ -173,52 +174,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) @@ -227,15 +224,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) @@ -249,11 +243,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) @@ -266,11 +260,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) @@ -283,11 +277,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) @@ -299,11 +293,11 @@ 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) @@ -318,11 +312,11 @@ 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) @@ -334,14 +328,10 @@ 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) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.OBSERVATION o", sql) self.assertIn("WHERE", sql) @@ -359,11 +349,11 @@ def test_get_criteria_sql_complex_scenario(self): 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 + 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) @@ -373,12 +363,8 @@ def test_get_criteria_sql_complex_scenario(self): 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) self.assertIn("o.observation_concept_id", clause) self.assertIn("12345", clause) @@ -386,11 +372,8 @@ def test_embed_codeset_clause(self): 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) self.assertEqual(clause, "SELECT * FROM table ") @@ -399,9 +382,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) @@ -413,9 +396,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) @@ -426,9 +409,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): @@ -437,12 +420,12 @@ 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)) @@ -452,12 +435,12 @@ 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): @@ -465,9 +448,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): @@ -477,12 +460,12 @@ 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)) # Should have multiple conditions self.assertGreater(len(where_clause), 1) @@ -493,13 +476,15 @@ 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.""" @@ -507,12 +492,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): @@ -521,12 +506,12 @@ 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("12345" in clause for clause in where_clause)) @@ -537,12 +522,12 @@ 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("not" in clause for clause in where_clause)) @@ -550,15 +535,11 @@ def test_resolve_where_clauses_with_provider_specialty_exclusion(self): 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) - + # 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, []) @@ -574,12 +555,12 @@ def test_resolve_where_clauses_complex_scenario(self): 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 + 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)) # Check for age condition (uses C.start_date and P.year_of_birth) @@ -597,9 +578,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) @@ -610,15 +591,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, @@ -628,11 +609,11 @@ 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 @@ -646,11 +627,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 @@ -663,18 +644,18 @@ def test_sql_template_placeholder_replacement(self): 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 + 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 @@ -694,7 +675,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) @@ -705,52 +686,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) @@ -759,15 +736,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) @@ -781,11 +755,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) @@ -798,11 +772,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) @@ -815,11 +789,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) @@ -830,11 +804,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) @@ -846,11 +820,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) @@ -862,11 +836,11 @@ 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) @@ -881,11 +855,11 @@ 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 @@ -896,14 +870,10 @@ 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) - + self.assertIn("select", sql.lower()) self.assertIn("FROM @cdm_database_schema.MEASUREMENT m", sql) # Codeset filtering is via JOIN in inner query @@ -925,11 +895,11 @@ def test_get_criteria_sql_complex_scenario(self): 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 + 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) @@ -940,12 +910,8 @@ 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 self.assertIn("12345", clause) @@ -953,11 +919,8 @@ def test_embed_codeset_clause(self): 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) self.assertEqual(clause, "SELECT * FROM table ") @@ -966,9 +929,9 @@ 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.assertIn("m.person_id", select_clause) @@ -982,9 +945,9 @@ 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)) @@ -995,9 +958,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): @@ -1006,12 +969,12 @@ 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)) @@ -1022,12 +985,12 @@ 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): @@ -1035,9 +998,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): @@ -1047,12 +1010,12 @@ 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)) # Should have multiple clauses for date ranges @@ -1064,12 +1027,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)) @@ -1079,14 +1042,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.""" @@ -1094,12 +1056,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): @@ -1108,12 +1070,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): @@ -1122,12 +1084,12 @@ 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("12345" in clause for clause in where_clause)) @@ -1138,12 +1100,12 @@ 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("not" in clause for clause in where_clause)) @@ -1151,15 +1113,11 @@ def test_resolve_where_clauses_with_provider_specialty_exclusion(self): 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) - + # Codeset filtering is now handled via JOIN in inner query, not WHERE clause self.assertEqual(where_clause, []) @@ -1177,12 +1135,12 @@ def test_resolve_where_clauses_complex_scenario(self): 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 + 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)) # Age conditions use YEAR(C.start_date) @@ -1201,9 +1159,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) @@ -1213,15 +1171,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, @@ -1234,11 +1192,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 @@ -1257,11 +1215,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 @@ -1275,18 +1233,18 @@ def test_sql_template_placeholder_replacement(self): 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 + 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 @@ -1306,7 +1264,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) @@ -1317,51 +1275,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) @@ -1369,12 +1323,8 @@ 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) self.assertIn("de.device_concept_id", clause) self.assertIn("12345", clause) @@ -1392,7 +1342,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) @@ -1403,51 +1353,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) @@ -1457,12 +1403,8 @@ 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) self.assertIn("s.specimen_concept_id", clause) self.assertIn("12345", clause) @@ -1474,10 +1416,14 @@ 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 self.assertTrue(DeathSqlBuilder is not None) self.assertTrue(VisitOccurrenceSqlBuilder is not None) @@ -1490,23 +1436,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.""" @@ -1516,12 +1462,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) @@ -1530,12 +1476,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) @@ -1547,16 +1493,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) @@ -1566,12 +1512,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) @@ -1579,79 +1525,95 @@ 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) @@ -1659,46 +1621,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)) @@ -1706,120 +1668,130 @@ 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)) - + 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) @@ -1827,110 +1799,117 @@ 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)) - + 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)) @@ -1938,12 +1917,12 @@ def test_resolve_where_clauses_with_filters(self): 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) @@ -1951,113 +1930,126 @@ 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_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)) - + 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)) @@ -2065,12 +2057,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) @@ -2078,66 +2070,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 = [ @@ -2145,38 +2144,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) @@ -2193,11 +2192,11 @@ 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) -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 97cc68b6..d7e6b151 100644 --- a/tests/test_sql_rendering_parity.py +++ b/tests/test_sql_rendering_parity.py @@ -1,121 +1,232 @@ 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): - 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( + "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") @@ -123,80 +234,198 @@ def test_includes_full_logic(self): 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.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( + "(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.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.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") @@ -206,82 +435,153 @@ def test_includes_full_logic(self): 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") + 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.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.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") # 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.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( + "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( @@ -292,46 +592,78 @@ 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( + "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.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( + "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( @@ -342,142 +674,279 @@ 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( + "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.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( + "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( + "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.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.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( + "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.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( + "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( @@ -490,54 +959,92 @@ 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_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( + "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.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( @@ -554,12 +1061,21 @@ 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") @@ -567,107 +1083,171 @@ def test_includes_full_logic(self): 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") - + # 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.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( + "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") - + # 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.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( + "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 299bd89b..d92d4a57 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): @@ -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,9 @@ 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 - ) - + + 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) self.assertEqual(window.start.coeff, 1) @@ -130,12 +105,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) @@ -144,11 +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) @@ -169,20 +142,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 +164,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 +194,35 @@ 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 +232,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 +247,25 @@ 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 +273,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 +292,9 @@ 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 - ) - + + 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) self.assertEqual(window.start.days, 30) @@ -377,20 +306,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 +329,11 @@ 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 - ) - + + 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") self.assertEqual(condition.stop_reason.op, "eq") @@ -416,11 +341,14 @@ 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 - + # Test that all classes are importable self.assertTrue(TextFilter is not None) self.assertTrue(WindowBound is not None) @@ -430,5 +358,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 16952df7..5b83e659 100644 --- a/tests/test_utils_db.py +++ b/tests/test_utils_db.py @@ -1,24 +1,25 @@ +from typing import Any import duckdb import pytest import sqlglot -from typing import List, Dict, Any, Optional + 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", @@ -37,42 +38,42 @@ 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)") - + 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") - + 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}") - + raise RuntimeError(f"Translation failed: {e}") from e + def execute_query(self, sql: str): """Execute translated query.""" # Remove OHDSI params for local testing BEFORE translation @@ -83,15 +84,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]: + + 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 b6548f43..a399d501 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): @@ -16,30 +18,49 @@ 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) 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) 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) @@ -49,8 +70,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)) @@ -66,29 +90,54 @@ def test_get_criteria_sql_with_age(self): def test_get_criteria_sql_with_provider_specialty(self): 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)) 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() diff --git a/tests/test_waveform_extension.py b/tests/test_waveform_extension.py new file mode 100644 index 00000000..f832a483 --- /dev/null +++ b/tests/test_waveform_extension.py @@ -0,0 +1,124 @@ +""" +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. +""" + +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 ( + WaveformChannelMetadata, + WaveformFeature, + WaveformOccurrence, + WaveformRegistry, +) + +# --------------------------------------------------------------------------- +# 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}" 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} diff --git a/tox.ini b/tox.ini index ea872be1..2d25e303 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38, 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_* diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..25038575 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3313 @@ +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 = "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'", +] +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" }, +] + +[[package]] +name = "cfgv" +version = "3.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/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/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]] +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 = "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 = "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" +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.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 = [ + { 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 = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +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" }, +] + +[[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/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]] +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 = "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" +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 = "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.*'", + "python_full_version < '3.10'", +] +dependencies = [ + { 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 = [ + { 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.*'", +] +dependencies = [ + { 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 = [ + { 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 = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +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/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]] +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 = "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" +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 = "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 = "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 = "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" }, + { name = "ruff" }, + { 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.*'" }, + { 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'" }, +] +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 = "deepdiff", marker = "extra == 'dev'", specifier = ">=8.6.0" }, + { name = "duckdb", marker = "extra == 'dev'", specifier = ">=0.9.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 = "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 = "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" }, + { 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 = "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" +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 = "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 = "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 = "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]] +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 = "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" +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.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" } +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 = "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" +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" }, +]