diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..e4c513d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +## Development setup + +```bash +uv sync --all-extras --dev +uv run pytest -q +uv run ruff check . +``` + +## Opening a pull request + +1. Apply **exactly one** label before merging: + + | Label | When to use | + |---|---| + | `breaking` | Public API change, backward-incompatible | + | `feature` | New functionality, backward-compatible | + | `fix` | Bug fix | + | `dependencies` | Dependency version update | + | `chore` | CI changes, refactoring, test additions, docs — anything that does not affect the public-facing package. Bypasses the label gate; excluded from the changelog and does not bump the version. | + +2. When merging (squash), write a clear extended description in the merge dialog. That text — not the PR's opening description — becomes the changelog entry for this change. Leave it blank for `chore` PRs. + +## Versioning and releases + +Versions are derived from git tags; there is no version string in any source file. Releases are triggered by a maintainer publishing the standing draft release on the repository's Releases page. There is no automated commit-back to `main`. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..46ef5eb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,66 @@ +name: "🐞 Bug Report" +description: Report a reproducible bug. +type: "Bug" +body: + - type: checkboxes + attributes: + label: Pre-flight + options: + - label: I searched existing issues and this is not a duplicate. + required: true + + - type: textarea + id: summary + attributes: + label: Summary + description: One or two sentences describing the bug. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Steps and/or a minimal code example that reproduces the issue. Wrap code in triple backticks. + placeholder: | + 1. Import X and call Y with Z + 2. Observe error + + ```python + # minimal example + ``` + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behaviour + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behaviour + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Error output + description: Full traceback if applicable. Formatted automatically. + render: python-traceback + + - type: textarea + id: system + attributes: + label: System info + description: | + Run this in your environment and paste the output: + + ```shell + python <(curl -s https://raw.githubusercontent.com/AustralianCancerDataNetwork/cava-devops/main/scripts/cava_system_info.py) + ``` + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a6cc5b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Discussions + url: https://github.com/orgs/AustralianCancerDataNetwork/discussions + about: Questions and general discussion about the CAVA stack. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7b1d3ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,32 @@ +name: "🚀 Feature Request" +description: Propose a new feature or enhancement. +type: "Feature" +body: + - type: checkboxes + attributes: + label: Pre-flight + options: + - label: I searched existing issues and this has not been requested before. + required: true + + - type: textarea + id: problem + attributes: + label: Problem or motivation + description: What are you trying to do that you currently cannot? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: How would you like this to work? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches you considered and why you ruled them out. diff --git a/.github/ISSUE_TEMPLATE/not_working.yml b/.github/ISSUE_TEMPLATE/not_working.yml new file mode 100644 index 0000000..176a64e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/not_working.yml @@ -0,0 +1,65 @@ +name: "❗ Something is not working" +description: Something behaves unexpectedly but you are not sure if it is a bug. +body: + - type: checkboxes + attributes: + label: Pre-flight + options: + - label: I searched existing issues for this problem. + required: true + + - type: textarea + id: summary + attributes: + label: Problem summary + description: 1-2 sentences describing what is not working. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Steps and/or a minimal code example. Wrap code in triple backticks. + placeholder: | + 1. Import X and call Y with Z + 2. Observe unexpected behaviour + + ```python + # minimal example + ``` + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual outcome + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected outcome + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Error messages + description: Full traceback if applicable. Formatted automatically. + render: python-traceback + + - type: textarea + id: system + attributes: + label: System info + description: | + Run this in your environment and paste the output: + + ```shell + python <(curl -s https://raw.githubusercontent.com/AustralianCancerDataNetwork/cava-devops/main/scripts/cava_system_info.py) + ``` + render: shell diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..159dda2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +## Summary + + + +## Checklist + +- [ ] Applied exactly one label (`breaking`, `feature`, `fix`, `dependencies`, or `chore`) +- [ ] Tests pass locally (`uv run pytest -q`) +- [ ] Lint passes (`uv run ruff check .`) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..259b28c --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,31 @@ +name-template: 'v$RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' +commitish: main + +categories: + - title: Breaking Changes + labels: ['breaking'] + - title: Features + labels: ['feature'] + - title: Fixes + labels: ['fix'] + - title: Dependencies + labels: ['dependencies'] + +template: | + $CHANGES + +change-template: '- **$TITLE** (#$NUMBER) @$AUTHOR' + +version-resolver: + major: + labels: ['breaking'] + minor: + labels: ['feature'] + patch: + labels: ['fix', 'dependencies'] + default: patch + +exclude-labels: ['chore'] + +autolabeler: [] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5261635 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, labeled, unlabeled] +jobs: + label-gate: + uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/label-gate.yml@main + build-test-sqlite: + uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test.yml@main + with: + ty-src: omop_alchemy + build-test-postgres: + uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test-postgres.yml@main + with: + ty-src: omop_alchemy + postgres-db: test_db + setup-commands: | + uv run omop-config configure omop_alchemy \ + --test-dialect postgresql+psycopg \ + --test-database pg_test \ + --test-host localhost \ + --test-port 5432 \ + --test-cdm-schema public \ + --test-user test \ + --test-password test \ + --test-database-name test_db diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0dfbfb1..09bfde3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,28 +1,10 @@ name: Deploy Docs - on: push: - branches: - - main - + tags: ['v*'] + workflow_dispatch: permissions: contents: write - jobs: deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install MkDocs - run: | - pip install mkdocs mkdocs-material mkdocstrings-python mkdocs-mermaid2-plugin - pip install -e . - - - name: Deploy to GitHub Pages - run: | - mkdocs gh-deploy --force \ No newline at end of file + uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/deploy-docs.yml@main diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml new file mode 100644 index 0000000..c43dab6 --- /dev/null +++ b/.github/workflows/merge.yml @@ -0,0 +1,13 @@ +name: Release Update +on: + pull_request: + types: [closed] + branches: [main] +permissions: + contents: write + pull-requests: read +jobs: + draft: + if: github.event.pull_request.merged == true + uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/release-drafter.yml@main + secrets: inherit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d68d946 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,21 @@ +name: Publish +on: + push: + tags: ['v*'] +jobs: + build: + uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/publish.yml@main + publish: + # Inline: PyPI OIDC checks job_workflow_ref, which must point to this file. + # Moving pypa/gh-action-pypi-publish into cava-devops would break the trusted publisher. + needs: build + runs-on: ubuntu-latest + permissions: + id-token: write + environment: + name: pypi + url: https://pypi.org/p/omop-alchemy + steps: + - uses: actions/download-artifact@v4 + with: { name: dist, path: dist/ } + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index dc4c693..0000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Publish to PyPI - -on: - release: - types: [published] - - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - id-token: write # REQUIRED for trusted publishing - contents: read - environment: - name: pypi - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install build tools - run: python -m pip install --upgrade build - - - name: Build package - run: python -m build - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index e06b472..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Tests - -on: - push: - branches: [main] - pull_request: - -jobs: - sqlite-tests: - name: SQLite tests (Python ${{ matrix.python-version }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.12", "3.13"] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: pip install -e ".[dev]" - - - name: Run tests - run: pytest -q - - postgres-tests: - name: PostgreSQL tests (Python ${{ matrix.python-version }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.12", "3.13"] - - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: test - POSTGRES_PASSWORD: test - POSTGRES_DB: test_db - ports: - - 55432:5432 - options: >- - --health-cmd "pg_isready -U test -d test_db" - --health-interval 2s - --health-timeout 5s - --health-retries 10 - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies (including postgres extra) - run: pip install -e ".[dev,postgres]" - - - name: Provision test_cdm_db - run: | - omop-config configure omop_alchemy \ - --test-dialect postgresql+psycopg --test-database test_cdm \ - --test-host localhost --test-port 55432 --test-cdm-schema public \ - --test-user test --test-password test --test-database-name test_db - - - name: Run tests - run: pytest -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd51f8..e40171c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +> [!NOTE] +> This file is no longer maintained. Release history from this point forward is in [GitHub Releases](https://github.com/AustralianCancerDataNetwork/OMOP_Alchemy/releases). + ## 0.2.0 - Initial public release - SQLAlchemy 2.0 typed OMOP CDM models diff --git a/omop_alchemy/cdm/base/domain_validation.py b/omop_alchemy/cdm/base/domain_validation.py index d88cf12..d49606b 100644 --- a/omop_alchemy/cdm/base/domain_validation.py +++ b/omop_alchemy/cdm/base/domain_validation.py @@ -121,7 +121,7 @@ def collect_domain_rules(cls) -> list[DomainRule]: for field, spec in cls.__expected_domains__.items(): rules.append( DomainRule( - table=cls.__tablename__, # type: ignore[attr-defined] + table=cls.__tablename__, # ty: ignore[invalid-argument-type] field=field, allowed_domains=spec.domains, ) @@ -153,7 +153,7 @@ def _check_domain(self, field: str) -> bool: ConceptCls = get_model_by_tablename("Concept") if ConceptCls is None: return False - concept = session.get(ConceptCls, concept_id) # type: ignore + concept = session.get(ConceptCls, concept_id) return concept.domain_id in expected.domains if concept else False # type: ignore[union-attr] diff --git a/omop_alchemy/cdm/base/indexing.py b/omop_alchemy/cdm/base/indexing.py index 7cc5220..0414926 100644 --- a/omop_alchemy/cdm/base/indexing.py +++ b/omop_alchemy/cdm/base/indexing.py @@ -225,7 +225,7 @@ def consume(part: TableArg) -> None: if isinstance(part, tuple): for item in part: - consume(item) + consume(item) # ty: ignore[invalid-argument-type] return items.append(part) diff --git a/omop_alchemy/cdm/base/modifier_interface.py b/omop_alchemy/cdm/base/modifier_interface.py index f18f6f4..0fcf2ef 100644 --- a/omop_alchemy/cdm/base/modifier_interface.py +++ b/omop_alchemy/cdm/base/modifier_interface.py @@ -22,10 +22,10 @@ def modifier_field_concept_id(cls) -> int: @classmethod def modifier_target_table(cls) -> str: - return cls.__tablename__ # type: ignore[attr-defined] + return cls.__tablename__ # ty: ignore[unresolved-attribute] @hybrid_property - def event_id(self) -> int: # type: ignore + def event_id(self) -> int: return getattr(self, self.__event_id_col__) @event_id.expression diff --git a/omop_alchemy/cdm/handlers/timeline/event_timeline.py b/omop_alchemy/cdm/handlers/timeline/event_timeline.py index ed6f3b1..a558d48 100644 --- a/omop_alchemy/cdm/handlers/timeline/event_timeline.py +++ b/omop_alchemy/cdm/handlers/timeline/event_timeline.py @@ -142,7 +142,7 @@ def event_metadata(self) -> Mapping[str, Any]: return {} - def __repr__(self: ClinicalEventProtocol) -> str: + def __repr__(self: ClinicalEventProtocol) -> str: # ty: ignore[invalid-method-override] et = self.event_time ev = self.event_value() @@ -269,5 +269,5 @@ def timeline(self) -> list[ClinicalEvent]: key=lambda e: e.event_time.start, ) - def to_json(self) -> list[str]: # type: ignore[override] - return [e.to_json() for e in self.timeline] # type: ignore[return-value] \ No newline at end of file + def to_json(self) -> list[str]: # ty: ignore[invalid-method-override] + return [e.to_json() for e in self.timeline] # ty: ignore[invalid-argument-type] \ No newline at end of file diff --git a/omop_alchemy/cdm/model/clinical/person.py b/omop_alchemy/cdm/model/clinical/person.py index df87335..a22b05b 100644 --- a/omop_alchemy/cdm/model/clinical/person.py +++ b/omop_alchemy/cdm/model/clinical/person.py @@ -113,7 +113,7 @@ class PersonView(Person, PersonContext, DomainValidationMixin): } @hybrid_method - def age_at(self, on_date: date) -> Optional[int]: # type: ignore + def age_at(self, on_date: date) -> Optional[int]: if not self.year_of_birth: return None return on_date.year - self.year_of_birth diff --git a/omop_alchemy/cdm/model/structural/episode_event.py b/omop_alchemy/cdm/model/structural/episode_event.py index 2627e42..ae3efb5 100644 --- a/omop_alchemy/cdm/model/structural/episode_event.py +++ b/omop_alchemy/cdm/model/structural/episode_event.py @@ -2,7 +2,7 @@ import sqlalchemy.orm as so from typing import TYPE_CHECKING, Any, Type, cast from functools import cached_property -from orm_loader.helpers import Base, get_model_by_tablename # type: ignore +from orm_loader.helpers import Base, get_model_by_tablename from omop_alchemy.cdm.base import ( cdm_table, CDMTableBase, @@ -71,7 +71,7 @@ def resolved_event(self) -> Any | None: cls = cast(Type[Any] | None, get_model_by_tablename(table_name)) if cls is not None: - return session.get(cls, self.event_id) # type: ignore + return session.get(cls, self.event_id) return None def __repr__(self): diff --git a/omop_alchemy/maintenance/_cli_utils.py b/omop_alchemy/maintenance/_cli_utils.py index 09ca063..2abe24b 100644 --- a/omop_alchemy/maintenance/_cli_utils.py +++ b/omop_alchemy/maintenance/_cli_utils.py @@ -70,8 +70,8 @@ def wrapper(**kwargs: Any) -> Any: ) try: if dry_run: - return func(conn, engine, dry_run=_dry_run, **kwargs) # type: ignore[arg-type] - return func(conn, engine, **kwargs) # type: ignore[arg-type] + return func(conn, engine, dry_run=_dry_run, **kwargs) + return func(conn, engine, **kwargs) finally: engine.dispose() except Exception as exc: @@ -99,10 +99,10 @@ def wrapper(**kwargs: Any) -> Any: annotation=bool, ) ) - wrapper.__signature__ = inspect.signature(func).replace(parameters=new_params) # type: ignore[attr-defined] + wrapper.__signature__ = inspect.signature(func).replace(parameters=new_params) # ty: ignore[unresolved-attribute] - return wrapper # type: ignore[return-value] - return decorator # type: ignore[return-value] + return wrapper # ty: ignore[invalid-return-type] + return decorator # ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/omop_alchemy/maintenance/cli_vocab.py b/omop_alchemy/maintenance/cli_vocab.py index ca5a711..72327dd 100644 --- a/omop_alchemy/maintenance/cli_vocab.py +++ b/omop_alchemy/maintenance/cli_vocab.py @@ -173,14 +173,14 @@ def _load_vocab_model_csv( load_kwargs["chunksize"] = chunksize try: - return int(model.load_csv(session, csv_path, **load_kwargs)) # type: ignore[arg-type] + return int(model.load_csv(session, csv_path, **load_kwargs)) # ty: ignore[invalid-argument-type] except Exception as exc: if not _is_missing_staging_table_error(exc, model=model): raise session.rollback() model.create_staging_table(session) - return int(model.load_csv(session, csv_path, **load_kwargs)) # type: ignore[arg-type] + return int(model.load_csv(session, csv_path, **load_kwargs)) # ty: ignore[invalid-argument-type] def _find_vocab_csv_path(source_path: Path, table_name: str) -> Path | None: diff --git a/omop_alchemy/maintenance/help.py b/omop_alchemy/maintenance/help.py index 1f81d8c..13c9209 100644 --- a/omop_alchemy/maintenance/help.py +++ b/omop_alchemy/maintenance/help.py @@ -164,4 +164,4 @@ def _print_commands_panel_with_backend_grouping( def install_help_customizations() -> None: - typer_rich_utils._print_commands_panel = _print_commands_panel_with_backend_grouping + typer_rich_utils._print_commands_panel = _print_commands_panel_with_backend_grouping # ty: ignore[invalid-assignment] diff --git a/omop_alchemy/maintenance/tables.py b/omop_alchemy/maintenance/tables.py index c1fe156..53b34a3 100644 --- a/omop_alchemy/maintenance/tables.py +++ b/omop_alchemy/maintenance/tables.py @@ -103,9 +103,9 @@ def collect_maintenance_tables() -> list[MaintenanceTable]: for mapped_class in sorted( _mapped_cdm_table_classes(), - key=lambda cls: cls.__table__.name, + key=lambda cls: cls.__table__.name, # ty: ignore[unresolved-attribute] ): - table = mapped_class.__table__ + table = mapped_class.__table__ # ty: ignore[unresolved-attribute] tables.append( MaintenanceTable( table_name=table.name, @@ -270,7 +270,7 @@ def schema_adjusted_metadata( for maintenance_table in tables: adjusted_tables[maintenance_table.table_name] = maintenance_table.table.to_metadata( metadata, - schema=db_schema, # type: ignore[arg-type] + schema=db_schema, # ty: ignore[invalid-argument-type] referred_schema_fn=( lambda _table, to_schema, _constraint, _referred_schema: to_schema ), diff --git a/pyproject.toml b/pyproject.toml index 4c05b25..25f2ed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omop-alchemy" -version = "0.8.0" +dynamic = ["version"] description = "SQLAlchemy-based models, validation, and utilities for the OHDSI OMOP Common Data Model" readme = "README.md" requires-python = ">=3.12" @@ -50,7 +50,7 @@ dev = [ "requests>=2.33.0", "pytest>=9.0.3", "pytest-cov>=4.0", - "mypy>=1.8", + "ty>=0.0.59", "ruff>=0.4", "mkdocs-material>=9.7.1", "mkdocstrings-python>=2.0.1", @@ -58,10 +58,6 @@ dev = [ "mkdocs-mermaid2-plugin" ] -docs = [ - "sphinx", - "myst-parser", -] [project.urls] Homepage = "https://australiancancerdatanetwork.github.io/OMOP_Alchemy/" @@ -76,10 +72,17 @@ omop_alchemy = "omop_alchemy.config:OmopAlchemyConfig" [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +[tool.hatch.version] +source = "vcs" +raw-options = { tag_regex = '^v?(?P[0-9]+\.[0-9]+\.[0-9]+)$' } + [tool.hatch.build.targets.wheel] packages = ["omop_alchemy"] +[tool.uv] +cache-keys = [{ file = "pyproject.toml" }, { git = { commit = true, tags = true } }] + [tool.pytest.ini_options]