fairagro-middleware-shared 10.0.3.dev26__tar.gz → 11.0.1.dev27__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.
Files changed (35) hide show
  1. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/.gitignore +0 -3
  2. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/PKG-INFO +1 -1
  3. fairagro_middleware_shared-11.0.1.dev27/src/middleware/shared/report/__init__.py +19 -0
  4. fairagro_middleware_shared-11.0.1.dev27/src/middleware/shared/report/emit.py +26 -0
  5. fairagro_middleware_shared-11.0.1.dev27/src/middleware/shared/report/formats/__init__.py +9 -0
  6. fairagro_middleware_shared-11.0.1.dev27/src/middleware/shared/report/formats/base.py +15 -0
  7. fairagro_middleware_shared-11.0.1.dev27/src/middleware/shared/report/formats/jsonld.py +86 -0
  8. fairagro_middleware_shared-11.0.1.dev27/src/middleware/shared/report/model.py +46 -0
  9. fairagro_middleware_shared-11.0.1.dev27/tests/unit/test_harvest_report.py +224 -0
  10. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/README.md +0 -0
  11. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/pyproject.toml +0 -0
  12. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/__init__.py +0 -0
  13. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/__init__.py +0 -0
  14. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/common/__init__.py +0 -0
  15. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/common/models.py +0 -0
  16. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/common/rocrate.py +0 -0
  17. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/py.typed +0 -0
  18. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/v1/__init__.py +0 -0
  19. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/v1/models.py +0 -0
  20. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/v2/__init__.py +0 -0
  21. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/v2/models.py +0 -0
  22. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/v3/__init__.py +0 -0
  23. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/api_models/v3/models.py +0 -0
  24. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/config/__init__.py +0 -0
  25. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/config/config_base.py +0 -0
  26. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/config/config_wrapper.py +0 -0
  27. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/config/logging.py +0 -0
  28. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/py.typed +0 -0
  29. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/src/middleware/shared/tracing.py +0 -0
  30. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/tests/unit/test_api_models.py +0 -0
  31. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/tests/unit/test_config_base.py +0 -0
  32. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/tests/unit/test_config_wrapper.py +0 -0
  33. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/tests/unit/test_logging.py +0 -0
  34. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/tests/unit/test_rocrate.py +0 -0
  35. {fairagro_middleware_shared-10.0.3.dev26 → fairagro_middleware_shared-11.0.1.dev27}/tests/unit/test_tracing.py +0 -0
@@ -228,6 +228,3 @@ helmchart/**/client_ext.conf
228
228
  .cache_ggshield
229
229
 
230
230
  docker/Dockerfile.api.bak
231
- # Docker DinD: ignore runtime state in docker-config, keep minimal config.json tracked
232
- .devcontainer/docker-config/*
233
- !.devcontainer/docker-config/config.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairagro-middleware-shared
3
- Version: 10.0.3.dev26
3
+ Version: 11.0.1.dev27
4
4
  Summary: The FAIRagro advanced middleware shared components
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: opentelemetry-api>=1.26.0
@@ -0,0 +1,19 @@
1
+ """Harvest run report model, serializers, and stdout emission."""
2
+
3
+ from middleware.shared.report.emit import print_report
4
+ from middleware.shared.report.formats.base import ReportSerializer
5
+ from middleware.shared.report.formats.jsonld import (
6
+ FAIRAGRO_HARVEST_REPORT_NS,
7
+ JsonLdReportSerializer,
8
+ )
9
+ from middleware.shared.report.model import FailedRecord, HarvestReport, RepositoryReport
10
+
11
+ __all__ = [
12
+ "FAIRAGRO_HARVEST_REPORT_NS",
13
+ "FailedRecord",
14
+ "HarvestReport",
15
+ "JsonLdReportSerializer",
16
+ "ReportSerializer",
17
+ "RepositoryReport",
18
+ "print_report",
19
+ ]
@@ -0,0 +1,26 @@
1
+ """Stdout emission for harvest reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from middleware.shared.report.formats.base import ReportSerializer
8
+ from middleware.shared.report.formats.jsonld import JsonLdReportSerializer
9
+ from middleware.shared.report.model import HarvestReport
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def print_report(
15
+ report: HarvestReport,
16
+ serializer: ReportSerializer | None = None,
17
+ ) -> None:
18
+ """Serialize the report and print it to stdout.
19
+
20
+ Serialization or print failures are logged as warnings and do not raise.
21
+ """
22
+ active_serializer: ReportSerializer = serializer or JsonLdReportSerializer()
23
+ try:
24
+ print(active_serializer.render(report))
25
+ except Exception as exc: # noqa: BLE001 — MUST NOT fail the process (harvest-report)
26
+ logger.warning("Failed to serialise harvest report: %s", exc)
@@ -0,0 +1,9 @@
1
+ """Report format serializers."""
2
+
3
+ from middleware.shared.report.formats.base import ReportSerializer
4
+ from middleware.shared.report.formats.jsonld import JsonLdReportSerializer
5
+
6
+ __all__ = [
7
+ "JsonLdReportSerializer",
8
+ "ReportSerializer",
9
+ ]
@@ -0,0 +1,15 @@
1
+ """Report serializer protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ from middleware.shared.report.model import HarvestReport
8
+
9
+
10
+ class ReportSerializer(Protocol):
11
+ """Serialize a harvest report to a string in a specific format."""
12
+
13
+ def render(self, report: HarvestReport) -> str:
14
+ """Return the serialized report document."""
15
+ raise NotImplementedError
@@ -0,0 +1,86 @@
1
+ """JSON-LD harvest report serializer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from middleware.shared.report.model import FailedRecord, HarvestReport, RepositoryReport
10
+
11
+ FAIRAGRO_HARVEST_REPORT_NS = "https://fairagro.github.io/m4.2_advanced_middleware_api/ns/harvest-report/v1/#"
12
+
13
+ _JSON_LD_CONTEXT = {
14
+ "@vocab": "https://schema.org/",
15
+ "schema": "https://schema.org/",
16
+ "fairagro": FAIRAGRO_HARVEST_REPORT_NS,
17
+ }
18
+
19
+
20
+ def _format_iso_duration(seconds: float) -> str:
21
+ """Serialize a duration value as an ISO 8601 duration string."""
22
+ remainder = f"{seconds:.6f}".rstrip("0").rstrip(".")
23
+ if remainder == "":
24
+ remainder = "0"
25
+ return f"PT{remainder}S"
26
+
27
+
28
+ def _format_iso_timestamp(value: datetime) -> str:
29
+ """Format a UTC datetime value as an ISO 8601 timestamp ending in Z."""
30
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
31
+
32
+
33
+ def _failed_record_to_jsonld(record: FailedRecord) -> dict[str, Any]:
34
+ """Convert a failed record to its JSON-LD object, omitting unset fields."""
35
+ result: dict[str, Any] = {"fairagro:message": record.message}
36
+ if record.record_id:
37
+ result["fairagro:recordId"] = record.record_id
38
+ if record.url:
39
+ result["fairagro:url"] = record.url
40
+ return result
41
+
42
+
43
+ def _repository_to_jsonld(report: RepositoryReport) -> dict[str, Any]:
44
+ """Convert a repository report to a schema.org EntryPoint JSON-LD object."""
45
+ result: dict[str, Any] = {
46
+ "@type": "schema:EntryPoint",
47
+ "name": report.rdi,
48
+ "identifier": report.rdi,
49
+ "schema:duration": _format_iso_duration(report.duration_seconds),
50
+ "fairagro:harvestId": report.harvest_id,
51
+ "fairagro:skippedDatasets": report.skipped_datasets,
52
+ }
53
+ if report.harvested_datasets is not None:
54
+ result["fairagro:harvestedDatasets"] = report.harvested_datasets
55
+ if report.expected_datasets is not None:
56
+ result["fairagro:expectedDatasets"] = report.expected_datasets
57
+ if report.failed_datasets is not None:
58
+ result["fairagro:failedDatasets"] = report.failed_datasets
59
+ if report.total_studies is not None:
60
+ result["fairagro:totalStudies"] = report.total_studies
61
+ if report.total_assays is not None:
62
+ result["fairagro:totalAssays"] = report.total_assays
63
+ if report.failed_records:
64
+ result["fairagro:failedRecords"] = [_failed_record_to_jsonld(record) for record in report.failed_records]
65
+ return result
66
+
67
+
68
+ class JsonLdReportSerializer:
69
+ """Render a harvest report as an operator-readable JSON-LD document."""
70
+
71
+ def __init__(self, *, indent: int = 2) -> None:
72
+ """Configure JSON indentation for operator-readable output."""
73
+ self._indent = indent
74
+
75
+ def render(self, report: HarvestReport) -> str:
76
+ """Return the report as indented JSON-LD text."""
77
+ document: dict[str, Any] = {
78
+ "@context": _JSON_LD_CONTEXT,
79
+ "@type": "schema:Action",
80
+ "name": report.name,
81
+ "schema:startTime": _format_iso_timestamp(report.start_time),
82
+ "schema:endTime": _format_iso_timestamp(report.end_time),
83
+ "fairagro:harvestDurationSeconds": report.duration_seconds,
84
+ "schema:result": [_repository_to_jsonld(entry) for entry in report.repository_reports],
85
+ }
86
+ return json.dumps(document, ensure_ascii=False, indent=self._indent)
@@ -0,0 +1,46 @@
1
+ """Format-neutral harvest run report domain model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class FailedRecord:
11
+ """A single dataset that failed during harvesting."""
12
+
13
+ message: str
14
+ record_id: str | None = None
15
+ url: str | None = None
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RepositoryReport: # pylint: disable=too-many-instance-attributes
20
+ """Execution statistics for a single harvested repository."""
21
+
22
+ rdi: str
23
+ harvest_id: str | None
24
+ duration_seconds: float
25
+ expected_datasets: int | None = None
26
+ harvested_datasets: int | None = None
27
+ failed_datasets: int | None = None
28
+ skipped_datasets: int = 0
29
+ failed_records: tuple[FailedRecord, ...] = ()
30
+ total_studies: int | None = None
31
+ total_assays: int | None = None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class HarvestReport:
36
+ """Summary statistics for an entire harvest run."""
37
+
38
+ start_time: datetime
39
+ end_time: datetime
40
+ repository_reports: tuple[RepositoryReport, ...] = ()
41
+ name: str = "FAIRagro Harvest Run"
42
+
43
+ @property
44
+ def duration_seconds(self) -> float:
45
+ """Return the total harvest run duration in seconds."""
46
+ return (self.end_time - self.start_time).total_seconds()
@@ -0,0 +1,224 @@
1
+ """Unit tests for the shared harvest report library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from dataclasses import fields
8
+ from datetime import UTC, datetime
9
+ from typing import Any
10
+
11
+ import pytest
12
+
13
+ from middleware.shared.report import (
14
+ FAIRAGRO_HARVEST_REPORT_NS,
15
+ FailedRecord,
16
+ HarvestReport,
17
+ JsonLdReportSerializer,
18
+ RepositoryReport,
19
+ print_report,
20
+ )
21
+
22
+ _START = datetime(2026, 5, 6, 14, 0, 0, tzinfo=UTC)
23
+ _END = datetime(2026, 5, 6, 14, 3, 45, tzinfo=UTC)
24
+
25
+
26
+ def _sample_repo(**overrides: Any) -> RepositoryReport:
27
+ """Build a repository report with sensible defaults."""
28
+ values: dict[str, Any] = {
29
+ "rdi": "bonares",
30
+ "harvest_id": "harvest-1",
31
+ "duration_seconds": 12.3,
32
+ "expected_datasets": 100,
33
+ "harvested_datasets": 95,
34
+ "failed_datasets": 5,
35
+ "skipped_datasets": 2,
36
+ "failed_records": (
37
+ FailedRecord(
38
+ message="map failed",
39
+ record_id="frl:123",
40
+ url="https://example.test/frl:123",
41
+ ),
42
+ ),
43
+ }
44
+ values.update(overrides)
45
+ return RepositoryReport(**values)
46
+
47
+
48
+ def _sample_report(
49
+ repositories: tuple[RepositoryReport, ...] | None = None,
50
+ ) -> HarvestReport:
51
+ """Build a harvest run report spanning a fixed UTC window."""
52
+ return HarvestReport(
53
+ start_time=_START,
54
+ end_time=_END,
55
+ repository_reports=((_sample_repo(),) if repositories is None else repositories),
56
+ )
57
+
58
+
59
+ def test_failed_record_with_optional_identifiers() -> None:
60
+ """Failed records expose message, record id, and URL when provided."""
61
+ record = FailedRecord(message="boom", record_id="id-1", url="https://x.test")
62
+ assert record.message == "boom"
63
+ assert record.record_id == "id-1"
64
+ assert record.url == "https://x.test"
65
+
66
+
67
+ def test_failed_record_message_only() -> None:
68
+ """Optional identifiers are unset when omitted."""
69
+ record = FailedRecord(message="boom")
70
+ assert record.record_id is None
71
+ assert record.url is None
72
+
73
+
74
+ def test_harvest_report_with_one_repository() -> None:
75
+ """A run report exposes timing and a single repository entry."""
76
+ report = _sample_report()
77
+ assert report.start_time == _START
78
+ assert len(report.repository_reports) == 1
79
+ assert report.repository_reports[0].rdi == "bonares"
80
+ assert report.duration_seconds == (_END - _START).total_seconds()
81
+
82
+
83
+ def test_harvest_report_with_no_repositories() -> None:
84
+ """An empty repository list is preserved."""
85
+ report = _sample_report(repositories=())
86
+ assert not report.repository_reports
87
+ assert report.duration_seconds == (_END - _START).total_seconds()
88
+
89
+
90
+ def test_optional_study_and_assay_counts_set() -> None:
91
+ """Study and assay totals are available when provided."""
92
+ total_studies = 10
93
+ total_assays = 20
94
+ repo = _sample_repo(total_studies=total_studies, total_assays=total_assays)
95
+ assert repo.total_studies == total_studies
96
+ assert repo.total_assays == total_assays
97
+
98
+
99
+ def test_optional_study_and_assay_counts_unset() -> None:
100
+ """Unset study and assay totals are None, not zero."""
101
+ repo = _sample_repo()
102
+ assert repo.total_studies is None
103
+ assert repo.total_assays is None
104
+
105
+
106
+ def test_model_is_format_neutral() -> None:
107
+ """The domain model does not embed a serialized document."""
108
+ report = _sample_report()
109
+ assert not hasattr(report, "to_jsonld")
110
+ assert "repository_reports" in {field.name for field in fields(report)}
111
+
112
+
113
+ def test_jsonld_context_and_types() -> None:
114
+ """JSON-LD uses schema.org Action with EntryPoint results."""
115
+ document = json.loads(JsonLdReportSerializer().render(_sample_report()))
116
+ assert document["@context"]["@vocab"] == "https://schema.org/"
117
+ assert document["@context"]["schema"] == "https://schema.org/"
118
+ assert document["@context"]["fairagro"] == FAIRAGRO_HARVEST_REPORT_NS
119
+ assert document["@context"]["fairagro"].endswith("/ns/harvest-report/v1/#")
120
+ assert document["@type"] == "schema:Action"
121
+ assert document["schema:result"][0]["@type"] == "schema:EntryPoint"
122
+
123
+
124
+ def test_jsonld_timestamps_and_durations() -> None:
125
+ """Timestamps end with Z and durations use ISO 8601 / seconds."""
126
+ report = _sample_report()
127
+ document = json.loads(JsonLdReportSerializer().render(report))
128
+ assert document["schema:startTime"].endswith("Z")
129
+ assert document["schema:endTime"].endswith("Z")
130
+ assert document["fairagro:harvestDurationSeconds"] == report.duration_seconds
131
+ assert document["schema:result"][0]["schema:duration"].startswith("PT")
132
+
133
+
134
+ def test_jsonld_metrics_and_failed_records() -> None:
135
+ """Fairagro metrics and nested failed records are emitted."""
136
+ repo = _sample_repo()
137
+ entry = json.loads(JsonLdReportSerializer().render(_sample_report(repositories=(repo,))))["schema:result"][0]
138
+ assert entry["fairagro:harvestId"] == repo.harvest_id
139
+ assert entry["fairagro:expectedDatasets"] == repo.expected_datasets
140
+ assert entry["fairagro:harvestedDatasets"] == repo.harvested_datasets
141
+ assert entry["fairagro:failedDatasets"] == repo.failed_datasets
142
+ assert entry["fairagro:skippedDatasets"] == repo.skipped_datasets
143
+ failed = repo.failed_records[0]
144
+ assert entry["fairagro:failedRecords"] == [
145
+ {
146
+ "fairagro:message": failed.message,
147
+ "fairagro:recordId": failed.record_id,
148
+ "fairagro:url": failed.url,
149
+ }
150
+ ]
151
+
152
+
153
+ def test_jsonld_optional_study_and_assay_totals() -> None:
154
+ """Optional study and assay totals appear as fairagro properties."""
155
+ total_studies = 3
156
+ total_assays = 7
157
+ report = _sample_report(repositories=(_sample_repo(total_studies=total_studies, total_assays=total_assays),))
158
+ entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
159
+ assert entry["fairagro:totalStudies"] == total_studies
160
+ assert entry["fairagro:totalAssays"] == total_assays
161
+
162
+
163
+ def test_jsonld_omits_unset_expected_datasets() -> None:
164
+ """Unset optional counts are omitted rather than null."""
165
+ report = _sample_report(
166
+ repositories=(
167
+ _sample_repo(
168
+ expected_datasets=None,
169
+ harvested_datasets=None,
170
+ failed_datasets=None,
171
+ total_studies=None,
172
+ total_assays=None,
173
+ ),
174
+ )
175
+ )
176
+ entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
177
+ assert "fairagro:expectedDatasets" not in entry
178
+ assert "fairagro:harvestedDatasets" not in entry
179
+ assert "fairagro:failedDatasets" not in entry
180
+ assert "fairagro:totalStudies" not in entry
181
+ assert "fairagro:totalAssays" not in entry
182
+ assert "fairagro:harvestId" in entry
183
+
184
+
185
+ def test_jsonld_omits_empty_failed_records() -> None:
186
+ """Empty failed-record lists are omitted from JSON-LD."""
187
+ report = _sample_report(repositories=(_sample_repo(failed_records=()),))
188
+ entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
189
+ assert "fairagro:failedRecords" not in entry
190
+
191
+
192
+ def test_jsonld_empty_result_array() -> None:
193
+ """A run with no repositories emits an empty result array."""
194
+ document = json.loads(JsonLdReportSerializer().render(_sample_report(repositories=())))
195
+ assert document["schema:result"] == []
196
+
197
+
198
+ def test_print_report_writes_jsonld_to_stdout(capsys: pytest.CaptureFixture[str]) -> None:
199
+ """print_report writes a JSON-LD document to stdout."""
200
+ print_report(_sample_report())
201
+ captured = capsys.readouterr()
202
+ document = json.loads(captured.out)
203
+ assert document["@type"] == "schema:Action"
204
+ assert captured.err == ""
205
+
206
+
207
+ def test_print_report_does_not_raise_on_serialization_failure(
208
+ caplog: pytest.LogCaptureFixture,
209
+ ) -> None:
210
+ """Serialization failures are logged and do not propagate."""
211
+
212
+ class _BoomSerializer:
213
+ def __init__(self) -> None:
214
+ self._fail = True
215
+
216
+ def render(self, report: HarvestReport) -> str:
217
+ if self._fail:
218
+ raise RuntimeError(f"cannot serialise {type(report).__name__}")
219
+ return "{}"
220
+
221
+ with caplog.at_level(logging.WARNING):
222
+ print_report(_sample_report(), serializer=_BoomSerializer())
223
+
224
+ assert any("Failed to serialise harvest report" in message for message in caplog.messages)