groundstore 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ ## Summary
2
+
3
+ <!-- What does this PR do? This text becomes the changelog entry. Leave blank for chore PRs. -->
4
+
5
+ ## Checklist
6
+
7
+ - [ ] Applied exactly one label (`breaking`, `feature`, `fix`, `dependencies`, or `chore`)
8
+ - [ ] Tests pass locally (`uv run pytest -q`)
9
+ - [ ] Type checking passes (`uv run ty check src/`)
10
+ - [ ] Lint passes (`uv run ruff check .`)
@@ -0,0 +1,31 @@
1
+ name-template: 'v$RESOLVED_VERSION'
2
+ tag-template: 'v$RESOLVED_VERSION'
3
+ commitish: main
4
+
5
+ categories:
6
+ - title: Breaking Changes
7
+ labels: ['breaking']
8
+ - title: Features
9
+ labels: ['feature']
10
+ - title: Fixes
11
+ labels: ['fix']
12
+ - title: Dependencies
13
+ labels: ['dependencies']
14
+
15
+ template: |
16
+ $CHANGES
17
+
18
+ change-template: '- **$TITLE** (#$NUMBER) @$AUTHOR'
19
+
20
+ version-resolver:
21
+ major:
22
+ labels: ['breaking']
23
+ minor:
24
+ labels: ['feature']
25
+ patch:
26
+ labels: ['fix', 'dependencies']
27
+ default: patch
28
+
29
+ exclude-labels: ['chore']
30
+
31
+ autolabeler: []
@@ -0,0 +1,13 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [main]
6
+ types: [opened, synchronize, reopened, labeled, unlabeled]
7
+
8
+ jobs:
9
+ label-gate:
10
+ uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/label-gate.yml@main
11
+
12
+ build-test:
13
+ uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test.yml@main
@@ -0,0 +1,12 @@
1
+ name: Release Update
2
+
3
+ on:
4
+ pull_request:
5
+ types: [closed]
6
+ branches: [main]
7
+
8
+ jobs:
9
+ draft:
10
+ if: github.event.pull_request.merged == true
11
+ uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/release-drafter.yml@main
12
+ secrets: inherit
@@ -0,0 +1,24 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/publish.yml@main
10
+
11
+ publish:
12
+ needs: build
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ id-token: write
16
+ environment:
17
+ name: pypi
18
+ url: https://pypi.org/p/groundstore
19
+ steps:
20
+ - uses: actions/download-artifact@v4
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,43 @@
1
+ # Python bytecode and local tooling
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ .venv/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .mypy_cache/
9
+ .pyright/
10
+ .tox/
11
+ .nox/
12
+ .hypothesis/
13
+
14
+ # Build and distribution artefacts
15
+ build/
16
+ dist/
17
+ *.egg-info/
18
+ *.egg
19
+ .eggs/
20
+ .coverage
21
+ .coverage.*
22
+ htmlcov/
23
+
24
+ # Local configuration and credentials
25
+ .env
26
+ .env.*
27
+ !.env.example
28
+ *.local.toml
29
+ *.local.yaml
30
+
31
+ # Local databases and generated application data
32
+ *.db
33
+ *.db-*
34
+ data/
35
+ site/
36
+ _design/
37
+
38
+ # Editors and operating-system files
39
+ .DS_Store
40
+ .idea/
41
+ .vscode/
42
+ *.swp
43
+ *.swo
@@ -0,0 +1,18 @@
1
+ # Contributing
2
+
3
+ ## Development setup
4
+
5
+ ```bash
6
+ uv sync --all-extras --dev
7
+ uv run ty check src/
8
+ uv run ruff check .
9
+ uv run pytest -q
10
+ ```
11
+
12
+ Pull requests should carry exactly one of the repository labels:
13
+ `breaking`, `feature`, `fix`, `dependencies`, or `chore`. Public API changes
14
+ are breaking changes; compatible functionality is a feature; CI, refactoring,
15
+ tests, and documentation are chore changes.
16
+
17
+ Package versions are derived from Git tags. Releases are published from a
18
+ maintainer-approved `vX.Y.Z` tag through the repository's PyPI environment.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.5
2
+ Name: groundstore
3
+ Version: 0.1.0
4
+ Summary: Shared mapping-task contracts for Groundworkers workflows.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: oa-configurator<2,>=1.3.0
7
+ Requires-Dist: pydantic<3,>=2
8
+ Requires-Dist: sqlalchemy<3,>=2.0.45
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
11
+ Requires-Dist: ruff<1,>=0.4; extra == 'dev'
12
+ Requires-Dist: ty>=0.0.59; extra == 'dev'
13
+ Provides-Extra: postgres
14
+ Requires-Dist: psycopg[binary]<4,>=3.1; extra == 'postgres'
@@ -0,0 +1,34 @@
1
+ # groundstore
2
+
3
+ Shared mapping-task contracts and persistence for Groundworkers-adjacent
4
+ mapping workflows.
5
+
6
+ ## Current status
7
+
8
+ The package provides source-independent Pydantic contracts, SQLAlchemy models,
9
+ and a resumable `MappingStore` for runs, inputs, candidates, separate evidence
10
+ records, versioned decisions, decision history, provenance, and lifecycle
11
+ state. It can use an explicit SQLAlchemy URL for tests or resolve a named
12
+ database resource through `oa-configurator` for consuming packages.
13
+
14
+ `MappingReadContext` exposes common read-only status, coverage, evidence-packet,
15
+ and review-handoff operations. Packets are JSON-safe and carry the full run,
16
+ input, candidate, evidence, and decision-history lineage, so Groundworkers or a
17
+ standalone reviewer can consume the same handoff without importing SQLAlchemy
18
+ models.
19
+
20
+ Representative contract fixtures under `tests/fixtures/` cover one-to-one,
21
+ one-to-many, ambiguous, redirected/incomplete, unmappable, and retryable
22
+ failure outcomes.
23
+
24
+ ## Development
25
+
26
+ ```bash
27
+ uv sync --all-extras --dev
28
+ uv run ty check src/
29
+ uv run ruff check .
30
+ uv run pytest -q
31
+ ```
32
+
33
+ The package version is derived from a `vX.Y.Z` or `X.Y.Z` Git tag, with a
34
+ `0.1.0` fallback when Git metadata is unavailable.
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "groundstore"
3
+ dynamic = ["version"]
4
+ description = "Shared mapping-task contracts for Groundworkers workflows."
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "oa-configurator>=1.3.0,<2",
8
+ "pydantic>=2,<3",
9
+ "sqlalchemy>=2.0.45,<3",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ dev = [
14
+ "pytest>=8,<9",
15
+ "ruff>=0.4,<1",
16
+ "ty>=0.0.59",
17
+ ]
18
+
19
+ postgres = [
20
+ "psycopg[binary]>=3.1,<4",
21
+ ]
22
+
23
+ [build-system]
24
+ requires = ["hatchling", "hatch-vcs"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.version]
28
+ source = "vcs"
29
+ fallback-version = "0.1.0"
30
+ raw-options = { tag_regex = '^v?(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$' }
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/groundstore"]
34
+
35
+ [tool.uv]
36
+ cache-keys = [
37
+ { file = "pyproject.toml" },
38
+ { git = { commit = true, tags = true } },
39
+ ]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py312"
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ addopts = "-q"
@@ -0,0 +1,30 @@
1
+ """Shared mapping-task contracts and persistence for Groundworkers workflows."""
2
+
3
+ from .context import MappingEvidencePacket, MappingReadContext, MappingReviewHandoff
4
+ from .contracts import (
5
+ DecisionStatus,
6
+ LifecycleStatus,
7
+ MappingCandidateSpec,
8
+ MappingDecisionSpec,
9
+ MappingEvidenceSpec,
10
+ MappingInputSpec,
11
+ MappingRunSpec,
12
+ )
13
+ from .engine import create_groundstore_engine, create_schema
14
+ from .store import MappingStore
15
+
16
+ __all__ = [
17
+ "DecisionStatus",
18
+ "LifecycleStatus",
19
+ "MappingCandidateSpec",
20
+ "MappingDecisionSpec",
21
+ "MappingEvidencePacket",
22
+ "MappingEvidenceSpec",
23
+ "MappingInputSpec",
24
+ "MappingReadContext",
25
+ "MappingReviewHandoff",
26
+ "MappingRunSpec",
27
+ "MappingStore",
28
+ "create_groundstore_engine",
29
+ "create_schema",
30
+ ]
@@ -0,0 +1,245 @@
1
+ """Read-only views over persisted mapping work.
2
+
3
+ The persistence API remains useful to standalone writers. This module adds
4
+ the small, transport-neutral packet that a host or review client can consume
5
+ without depending on SQLAlchemy model instances or lazy relationships.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ from .models import (
16
+ MappingCandidate,
17
+ MappingDecision,
18
+ MappingDecisionEvent,
19
+ MappingEvidence,
20
+ MappingInput,
21
+ MappingRun,
22
+ )
23
+ from .store import MappingStore
24
+
25
+
26
+ class MappingEvidencePacket(BaseModel):
27
+ """Stable JSON contract for reviewing one persisted mapping input."""
28
+
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ schema_version: str = "groundstore.mapping-evidence-packet.v1"
32
+ run: dict[str, Any]
33
+ input: dict[str, Any]
34
+ candidates: list[dict[str, Any]] = Field(default_factory=list)
35
+ evidence: list[dict[str, Any]] = Field(default_factory=list)
36
+ decision: dict[str, Any] | None = None
37
+ warnings: list[str] = Field(default_factory=list)
38
+
39
+
40
+ class MappingReviewHandoff(BaseModel):
41
+ """Review-task reference carrying a self-contained evidence packet."""
42
+
43
+ model_config = ConfigDict(extra="forbid")
44
+
45
+ schema_version: str = "groundstore.mapping-review-handoff.v1"
46
+ task_id: str = Field(min_length=1)
47
+ task_type: str = "mapping_review"
48
+ source_namespace: str = Field(min_length=1)
49
+ input_id: str = Field(min_length=1)
50
+ decision_status: str = "needs_review"
51
+ packet: MappingEvidencePacket
52
+ requested_by: str | None = None
53
+ metadata: dict[str, Any] = Field(default_factory=dict)
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class MappingReadContext:
58
+ """Read-only Groundstore façade for host tools and review consumers."""
59
+
60
+ store: MappingStore
61
+
62
+ def status(
63
+ self,
64
+ source_namespace: str,
65
+ *,
66
+ target_system: str | None = None,
67
+ ) -> dict[str, Any]:
68
+ """Return the latest complete run and its common coverage summary."""
69
+
70
+ run = self.store.latest_successful_run(source_namespace, target_system=target_system)
71
+ if run is None:
72
+ return {
73
+ "source_namespace": source_namespace,
74
+ "target_system": target_system,
75
+ "latest_run": None,
76
+ "coverage": None,
77
+ }
78
+ return {
79
+ "source_namespace": source_namespace,
80
+ "target_system": target_system,
81
+ "latest_run": _run_payload(run),
82
+ "coverage": self.store.coverage(run.id),
83
+ }
84
+
85
+ def evidence_packet(self, input_id: str) -> MappingEvidencePacket:
86
+ """Build a detached packet with candidates, evidence, and decision history."""
87
+
88
+ input_record = self.store.get_input(input_id)
89
+ if input_record is None:
90
+ raise KeyError(f"unknown mapping input: {input_id}")
91
+ run = self.store.get_run(input_record.run_id)
92
+ if run is None: # pragma: no cover - protected by the database FK
93
+ raise KeyError(f"unknown mapping run: {input_record.run_id}")
94
+
95
+ candidates = self.store.get_candidates(input_id)
96
+ evidence = self.store.get_evidence(input_id)
97
+ decision = self.store.latest_decision(input_id)
98
+ selected_ids = (
99
+ self.store.get_decision_candidate_ids(decision.id) if decision is not None else []
100
+ )
101
+ events = self.store.get_decision_history(decision.id) if decision is not None else []
102
+ evidence_by_candidate: dict[str, list[dict[str, Any]]] = {}
103
+ unattached: list[dict[str, Any]] = []
104
+ for item in evidence:
105
+ payload = _evidence_payload(item)
106
+ if item.candidate_id is None:
107
+ unattached.append(payload)
108
+ else:
109
+ evidence_by_candidate.setdefault(item.candidate_id, []).append(payload)
110
+
111
+ candidate_payloads = []
112
+ for candidate in candidates:
113
+ payload = _candidate_payload(candidate)
114
+ payload["evidence"] = evidence_by_candidate.get(candidate.id, [])
115
+ candidate_payloads.append(payload)
116
+
117
+ decision_payload = None
118
+ if decision is not None:
119
+ decision_payload = _decision_payload(decision)
120
+ decision_payload["selected_candidate_ids"] = selected_ids
121
+ decision_payload["history"] = [_event_payload(event) for event in events]
122
+
123
+ return MappingEvidencePacket(
124
+ run=_run_payload(run),
125
+ input=_input_payload(input_record),
126
+ candidates=candidate_payloads,
127
+ evidence=unattached,
128
+ decision=decision_payload,
129
+ )
130
+
131
+ def review_handoff(
132
+ self,
133
+ input_id: str,
134
+ *,
135
+ requested_by: str | None = None,
136
+ metadata: dict[str, Any] | None = None,
137
+ ) -> MappingReviewHandoff:
138
+ """Return a deterministic review reference without mutating the store."""
139
+
140
+ packet = self.evidence_packet(input_id)
141
+ decision_status = (
142
+ packet.decision.get("decision_status", "needs_review")
143
+ if packet.decision
144
+ else "needs_review"
145
+ )
146
+ return MappingReviewHandoff(
147
+ task_id=f"mapping-review:{input_id}",
148
+ source_namespace=packet.input["source_namespace"],
149
+ input_id=input_id,
150
+ decision_status=str(decision_status),
151
+ packet=packet,
152
+ requested_by=requested_by,
153
+ metadata=metadata or {},
154
+ )
155
+
156
+
157
+ def _run_payload(run: MappingRun) -> dict[str, Any]:
158
+ return {
159
+ "id": run.id,
160
+ "source_namespace": run.source_namespace,
161
+ "source_fingerprint": run.source_fingerprint,
162
+ "source_snapshot": run.source_snapshot,
163
+ "target_system": run.target_system,
164
+ "target_release": run.target_release,
165
+ "algorithm_version": run.algorithm_version,
166
+ "policy_version": run.policy_version,
167
+ "lifecycle_status": run.lifecycle_status,
168
+ "last_error": run.last_error,
169
+ "created_at": run.created_at.isoformat(),
170
+ "updated_at": run.updated_at.isoformat(),
171
+ }
172
+
173
+
174
+ def _input_payload(input_record: MappingInput) -> dict[str, Any]:
175
+ return {
176
+ "id": input_record.id,
177
+ "run_id": input_record.run_id,
178
+ "source_namespace": input_record.source_namespace,
179
+ "source_kind": input_record.source_kind,
180
+ "source_key": input_record.source_key,
181
+ "source_fingerprint": input_record.source_fingerprint,
182
+ "normalized_projection": input_record.normalized_projection,
183
+ "lifecycle_status": input_record.lifecycle_status,
184
+ "retry_count": input_record.retry_count,
185
+ "last_error": input_record.last_error,
186
+ "created_at": input_record.created_at.isoformat(),
187
+ "updated_at": input_record.updated_at.isoformat(),
188
+ }
189
+
190
+
191
+ def _candidate_payload(candidate: MappingCandidate) -> dict[str, Any]:
192
+ return {
193
+ "id": candidate.id,
194
+ "target_namespace": candidate.target_namespace,
195
+ "target_vocabulary_id": candidate.target_vocabulary_id,
196
+ "target_concept_id": candidate.target_concept_id,
197
+ "target_code": candidate.target_code,
198
+ "target_grain": candidate.target_grain,
199
+ "target_role": candidate.target_role,
200
+ "method": candidate.method,
201
+ "rank": candidate.rank,
202
+ "score": candidate.score,
203
+ "confidence": candidate.confidence,
204
+ "rationale": candidate.rationale,
205
+ "metadata": candidate.metadata_,
206
+ "created_at": candidate.created_at.isoformat(),
207
+ }
208
+
209
+
210
+ def _evidence_payload(evidence: MappingEvidence) -> dict[str, Any]:
211
+ return {
212
+ "id": evidence.id,
213
+ "candidate_id": evidence.candidate_id,
214
+ "decision_id": evidence.decision_id,
215
+ "evidence_key": evidence.evidence_key,
216
+ "evidence_type": evidence.evidence_type,
217
+ "source_reference": evidence.source_reference,
218
+ "method": evidence.method,
219
+ "payload": evidence.payload,
220
+ "created_at": evidence.created_at.isoformat(),
221
+ }
222
+
223
+
224
+ def _decision_payload(decision: MappingDecision) -> dict[str, Any]:
225
+ return {
226
+ "id": decision.id,
227
+ "input_id": decision.input_id,
228
+ "decision_version": decision.decision_version,
229
+ "decision_status": decision.decision_status,
230
+ "outcome_code": decision.outcome_code,
231
+ "reason_codes": decision.reason_codes,
232
+ "decided_by": decision.decided_by,
233
+ "metadata": decision.metadata_,
234
+ "decided_at": decision.decided_at.isoformat(),
235
+ }
236
+
237
+
238
+ def _event_payload(event: MappingDecisionEvent) -> dict[str, Any]:
239
+ return {
240
+ "id": event.id,
241
+ "decision_id": event.decision_id,
242
+ "event_type": event.event_type,
243
+ "detail": event.detail,
244
+ "created_at": event.created_at.isoformat(),
245
+ }
@@ -0,0 +1,108 @@
1
+ """Source-independent contracts for mapping workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
9
+
10
+
11
+ class LifecycleStatus(StrEnum):
12
+ """Processing state for a run or input."""
13
+
14
+ PENDING = "pending"
15
+ IN_PROGRESS = "in_progress"
16
+ COMPLETE = "complete"
17
+ INCOMPLETE = "incomplete"
18
+ FAILED = "failed"
19
+
20
+
21
+ class DecisionStatus(StrEnum):
22
+ """Mapping outcome independent of the processing lifecycle."""
23
+
24
+ MAPPED = "mapped"
25
+ AMBIGUOUS = "ambiguous"
26
+ UNMAPPABLE = "unmappable"
27
+ NEEDS_REVIEW = "needs_review"
28
+
29
+
30
+ class MappingRunSpec(BaseModel):
31
+ """Stable identity and metadata for one mapping run."""
32
+
33
+ model_config = ConfigDict(extra="forbid")
34
+
35
+ source_namespace: str = Field(min_length=1)
36
+ source_fingerprint: str = Field(min_length=1, max_length=128)
37
+ source_snapshot: dict[str, Any] = Field(default_factory=dict)
38
+ target_system: str = Field(min_length=1)
39
+ target_release: str | None = None
40
+ algorithm_version: str = Field(min_length=1)
41
+ policy_version: str = Field(min_length=1)
42
+ lifecycle_status: LifecycleStatus = LifecycleStatus.PENDING
43
+
44
+
45
+ class MappingInputSpec(BaseModel):
46
+ """Source item presented to a mapping workflow."""
47
+
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+ source_namespace: str = Field(min_length=1)
51
+ source_kind: str = Field(min_length=1)
52
+ source_key: str = Field(min_length=1)
53
+ source_fingerprint: str = Field(min_length=1, max_length=128)
54
+ normalized_projection: dict[str, Any] = Field(default_factory=dict)
55
+ lifecycle_status: LifecycleStatus = LifecycleStatus.PENDING
56
+ retry_count: int = Field(default=0, ge=0)
57
+ last_error: str | None = None
58
+
59
+
60
+ class MappingCandidateSpec(BaseModel):
61
+ """One candidate target for a mapping input."""
62
+
63
+ model_config = ConfigDict(extra="forbid")
64
+
65
+ target_namespace: str = Field(min_length=1)
66
+ target_vocabulary_id: str | None = None
67
+ target_concept_id: str | None = None
68
+ target_code: str | None = None
69
+ target_grain: str | None = None
70
+ target_role: str | None = None
71
+ method: str = Field(min_length=1)
72
+ rank: int = Field(default=1, ge=1)
73
+ score: float | None = None
74
+ confidence: float | None = Field(default=None, ge=0, le=1)
75
+ rationale: str | None = None
76
+ metadata: dict[str, Any] = Field(default_factory=dict)
77
+
78
+ @field_validator("target_vocabulary_id", "target_concept_id", "target_code")
79
+ @classmethod
80
+ def reject_blank_identifiers(cls, value: str | None) -> str | None:
81
+ if value is not None and not value.strip():
82
+ raise ValueError("target identifiers cannot be blank")
83
+ return value
84
+
85
+
86
+ class MappingEvidenceSpec(BaseModel):
87
+ """Evidence attached to a candidate or decision."""
88
+
89
+ model_config = ConfigDict(extra="forbid")
90
+
91
+ evidence_key: str = Field(min_length=1, max_length=128)
92
+ evidence_type: str = Field(min_length=1)
93
+ source_reference: str | None = None
94
+ method: str | None = None
95
+ payload: dict[str, Any] = Field(default_factory=dict)
96
+
97
+
98
+ class MappingDecisionSpec(BaseModel):
99
+ """Versioned decision for one mapping input."""
100
+
101
+ model_config = ConfigDict(extra="forbid")
102
+
103
+ decision_status: DecisionStatus
104
+ selected_candidate_ids: list[str] = Field(default_factory=list)
105
+ outcome_code: str | None = None
106
+ reason_codes: list[str] = Field(default_factory=list)
107
+ decided_by: str | None = None
108
+ metadata: dict[str, Any] = Field(default_factory=dict)