fairagro-middleware-shared 11.0.1.dev28__tar.gz → 11.0.1.dev30__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 (38) hide show
  1. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/PKG-INFO +1 -1
  2. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/report/__init__.py +4 -8
  3. fairagro_middleware_shared-11.0.1.dev30/src/middleware/shared/report/formats/jsonld.py +90 -0
  4. fairagro_middleware_shared-11.0.1.dev30/src/middleware/shared/report/model.py +235 -0
  5. fairagro_middleware_shared-11.0.1.dev30/tests/unit/test_harvest_report.py +421 -0
  6. fairagro_middleware_shared-11.0.1.dev28/src/middleware/shared/report/emit.py +0 -26
  7. fairagro_middleware_shared-11.0.1.dev28/src/middleware/shared/report/formats/jsonld.py +0 -94
  8. fairagro_middleware_shared-11.0.1.dev28/src/middleware/shared/report/model.py +0 -46
  9. fairagro_middleware_shared-11.0.1.dev28/tests/unit/test_harvest_report.py +0 -234
  10. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/.gitignore +0 -0
  11. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/README.md +0 -0
  12. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/pyproject.toml +0 -0
  13. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/__init__.py +0 -0
  14. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/__init__.py +0 -0
  15. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/common/__init__.py +0 -0
  16. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/common/models.py +0 -0
  17. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/common/rocrate.py +0 -0
  18. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/py.typed +0 -0
  19. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v1/__init__.py +0 -0
  20. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v1/models.py +0 -0
  21. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v2/__init__.py +0 -0
  22. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v2/models.py +0 -0
  23. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v3/__init__.py +0 -0
  24. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v3/models.py +0 -0
  25. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/config/__init__.py +0 -0
  26. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/config/config_base.py +0 -0
  27. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/config/config_wrapper.py +0 -0
  28. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/config/logging.py +0 -0
  29. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/py.typed +0 -0
  30. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/report/formats/__init__.py +0 -0
  31. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/report/formats/base.py +0 -0
  32. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/tracing.py +0 -0
  33. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_api_models.py +0 -0
  34. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_config_base.py +0 -0
  35. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_config_wrapper.py +0 -0
  36. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_logging.py +0 -0
  37. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_rocrate.py +0 -0
  38. {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_tracing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairagro-middleware-shared
3
- Version: 11.0.1.dev28
3
+ Version: 11.0.1.dev30
4
4
  Summary: The FAIRagro advanced middleware shared components
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: opentelemetry-api>=1.26.0
@@ -1,12 +1,8 @@
1
- """Harvest run report model, serializers, and stdout emission."""
1
+ """Harvest report models and serializers (format-neutral counting API)."""
2
2
 
3
- from middleware.shared.report.emit import print_report
4
3
  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
4
+ from middleware.shared.report.formats.jsonld import FAIRAGRO_HARVEST_REPORT_NS, JsonLdReportSerializer
5
+ from middleware.shared.report.model import FailedRecord, HarvestReport, RepositoryReport, RepositoryScope
10
6
 
11
7
  __all__ = [
12
8
  "FAIRAGRO_HARVEST_REPORT_NS",
@@ -15,5 +11,5 @@ __all__ = [
15
11
  "JsonLdReportSerializer",
16
12
  "ReportSerializer",
17
13
  "RepositoryReport",
18
- "print_report",
14
+ "RepositoryScope",
19
15
  ]
@@ -0,0 +1,90 @@
1
+ """JSON-LD serializer for HarvestReport (harvester-compatible wire shape)."""
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: dict[str, Any] = {
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
+ if value.tzinfo is None:
31
+ raise ValueError("HarvestReport timestamps must be timezone-aware UTC")
32
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
33
+
34
+
35
+ def _failed_record_node(record: FailedRecord) -> dict[str, Any]:
36
+ node: dict[str, Any] = {"fairagro:message": record.message}
37
+ if record.record_id is not None:
38
+ node["fairagro:recordId"] = record.record_id
39
+ if record.url is not None:
40
+ node["fairagro:url"] = record.url
41
+ return node
42
+
43
+
44
+ def _entry_point(repo: RepositoryReport) -> dict[str, Any]:
45
+ entry: dict[str, Any] = {
46
+ "@type": "schema:EntryPoint",
47
+ "@id": repo.rdi,
48
+ "name": repo.rdi,
49
+ "identifier": repo.rdi,
50
+ "schema:duration": _format_iso_duration(repo.duration_seconds),
51
+ "fairagro:harvestId": repo.harvest_id,
52
+ "fairagro:harvestedDatasets": repo.harvested_datasets,
53
+ "fairagro:failedDatasets": repo.failed_datasets,
54
+ "fairagro:skippedDatasets": repo.skipped_datasets,
55
+ }
56
+ if repo.expected_datasets is not None:
57
+ entry["fairagro:expectedDatasets"] = repo.expected_datasets
58
+ if repo.total_studies is not None:
59
+ entry["fairagro:totalStudies"] = repo.total_studies
60
+ if repo.total_assays is not None:
61
+ entry["fairagro:totalAssays"] = repo.total_assays
62
+ if repo.failed_records:
63
+ entry["fairagro:failedRecords"] = [_failed_record_node(r) for r in repo.failed_records]
64
+ return entry
65
+
66
+
67
+ class JsonLdReportSerializer:
68
+ """Serialize a finished :class:`HarvestReport` to a JSON-LD document string."""
69
+
70
+ context: dict[str, Any] = _JSON_LD_CONTEXT
71
+
72
+ def render(self, report: HarvestReport) -> str:
73
+ """Return a JSON-LD document encoding the finished harvest run.
74
+
75
+ Raises:
76
+ ValueError: If :meth:`HarvestReport.finish` has not been called, or
77
+ timestamps are naive.
78
+ """
79
+ start = _format_iso_timestamp(report.start_time)
80
+ end = _format_iso_timestamp(report.end_time)
81
+ document: dict[str, Any] = {
82
+ "@context": self.context,
83
+ "@type": "schema:Action",
84
+ "name": report.name,
85
+ "schema:startTime": start,
86
+ "schema:endTime": end,
87
+ "fairagro:harvestDurationSeconds": report.duration_seconds,
88
+ "schema:result": [_entry_point(repo) for repo in report.repository_reports],
89
+ }
90
+ return json.dumps(document, indent=2, ensure_ascii=False) + "\n"
@@ -0,0 +1,235 @@
1
+ """Mutable harvest-run report with counting methods and format-neutral snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime
8
+
9
+
10
+ def _require_aware_utc(value: datetime, *, what: str) -> datetime:
11
+ """Reject naive datetimes and normalize timezone-aware values to UTC."""
12
+ if value.tzinfo is None:
13
+ raise ValueError(f"{what} must be timezone-aware UTC")
14
+ return value.astimezone(UTC)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class FailedRecord:
19
+ """A single dataset that failed during harvesting."""
20
+
21
+ message: str
22
+ record_id: str | None = None
23
+ url: str | None = None
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RepositoryReport: # pylint: disable=too-many-instance-attributes
28
+ """Immutable snapshot of statistics for one repository scope."""
29
+
30
+ rdi: str
31
+ harvest_id: str | None
32
+ duration_seconds: float
33
+ expected_datasets: int | None = None
34
+ harvested_datasets: int = 0
35
+ failed_datasets: int = 0
36
+ skipped_datasets: int = 0
37
+ failed_records: tuple[FailedRecord, ...] = ()
38
+ total_studies: int | None = None
39
+ total_assays: int | None = None
40
+
41
+
42
+ @dataclass
43
+ class _DatasetCounts:
44
+ """Mutable dataset-level counters for one repository scope."""
45
+
46
+ expected_datasets: int | None = None
47
+ harvest_id: str | None = None
48
+ harvested_datasets: int = 0
49
+ failed_datasets: int = 0
50
+ skipped_datasets: int = 0
51
+ failed_records: list[FailedRecord] = field(default_factory=list)
52
+
53
+
54
+ @dataclass
55
+ class _CompositionCounts:
56
+ """Mutable study/assay totals for one repository scope."""
57
+
58
+ total_studies: int = 0
59
+ total_assays: int = 0
60
+
61
+
62
+ class RepositoryScope:
63
+ """Mutable counting handle for one RDI within a harvest run."""
64
+
65
+ def __init__(self, rdi: str, *, opened_at: datetime | None = None) -> None:
66
+ """Open a repository scope for ``rdi``; timing starts immediately."""
67
+ self._rdi = rdi
68
+ if opened_at is None:
69
+ self._opened_at = datetime.now(UTC)
70
+ else:
71
+ self._opened_at = _require_aware_utc(opened_at, what="RepositoryScope opened_at")
72
+ self._closed_at: datetime | None = None
73
+ self._lock = threading.Lock()
74
+ self._datasets = _DatasetCounts()
75
+ self._composition = _CompositionCounts()
76
+
77
+ @property
78
+ def rdi(self) -> str:
79
+ """RDI identifier for this scope."""
80
+ return self._rdi
81
+
82
+ def close(self, *, closed_at: datetime | None = None) -> None:
83
+ """Close the scope and record end time for duration."""
84
+ if closed_at is not None:
85
+ closed_at = _require_aware_utc(closed_at, what="RepositoryScope closed_at")
86
+ with self._lock:
87
+ if self._closed_at is None:
88
+ self._closed_at = closed_at or datetime.now(UTC)
89
+
90
+ def set_expected_datasets(self, count: int) -> None:
91
+ """Set the optional expected dataset count for this repository."""
92
+ if count < 0:
93
+ raise ValueError("expected dataset count must be non-negative")
94
+ with self._lock:
95
+ self._datasets.expected_datasets = count
96
+
97
+ def set_harvest_id(self, harvest_id: str | None) -> None:
98
+ """Set or clear the harvest identifier for this repository."""
99
+ with self._lock:
100
+ self._datasets.harvest_id = harvest_id
101
+
102
+ def record_harvested(self) -> None:
103
+ """Record one successfully harvested dataset."""
104
+ with self._lock:
105
+ self._datasets.harvested_datasets += 1
106
+
107
+ def record_failed(
108
+ self,
109
+ message: str,
110
+ *,
111
+ record_id: str | None = None,
112
+ url: str | None = None,
113
+ ) -> None:
114
+ """Record one failed dataset and append a failed-record detail."""
115
+ with self._lock:
116
+ self._datasets.failed_datasets += 1
117
+ self._datasets.failed_records.append(FailedRecord(message=message, record_id=record_id, url=url))
118
+
119
+ def record_skipped(self) -> None:
120
+ """Record one intentionally skipped dataset."""
121
+ with self._lock:
122
+ self._datasets.skipped_datasets += 1
123
+
124
+ def add_studies(self, count: int = 1) -> None:
125
+ """Add ``count`` studies to this repository's totals."""
126
+ if count < 0:
127
+ raise ValueError("study count must be non-negative")
128
+ with self._lock:
129
+ self._composition.total_studies += count
130
+
131
+ def add_assays(self, count: int = 1) -> None:
132
+ """Add ``count`` assays to this repository's totals."""
133
+ if count < 0:
134
+ raise ValueError("assay count must be non-negative")
135
+ with self._lock:
136
+ self._composition.total_assays += count
137
+
138
+ def snapshot(self) -> RepositoryReport:
139
+ """Return a format-neutral immutable view of current statistics."""
140
+ with self._lock:
141
+ datasets = self._datasets
142
+ composition = self._composition
143
+ closed_at = self._closed_at or datetime.now(UTC)
144
+ duration = (closed_at - self._opened_at).total_seconds()
145
+ if composition.total_studies == 0 and composition.total_assays == 0:
146
+ total_studies: int | None = None
147
+ total_assays: int | None = None
148
+ else:
149
+ total_studies = composition.total_studies
150
+ total_assays = composition.total_assays
151
+ return RepositoryReport(
152
+ rdi=self._rdi,
153
+ harvest_id=datasets.harvest_id,
154
+ duration_seconds=duration,
155
+ expected_datasets=datasets.expected_datasets,
156
+ harvested_datasets=datasets.harvested_datasets,
157
+ failed_datasets=datasets.failed_datasets,
158
+ skipped_datasets=datasets.skipped_datasets,
159
+ failed_records=tuple(datasets.failed_records),
160
+ total_studies=total_studies,
161
+ total_assays=total_assays,
162
+ )
163
+
164
+
165
+ class HarvestReport:
166
+ """Mutable harvest-run accumulator; serializers read finished snapshots."""
167
+
168
+ def __init__(
169
+ self,
170
+ *,
171
+ name: str = "FAIRagro Harvest Run",
172
+ start_time: datetime | None = None,
173
+ ) -> None:
174
+ """Create a run report and record the authoritative start time."""
175
+ self.name = name
176
+ if start_time is None:
177
+ self._start_time = datetime.now(UTC)
178
+ else:
179
+ self._start_time = _require_aware_utc(start_time, what="HarvestReport start_time")
180
+ self._end_time: datetime | None = None
181
+ self._scopes: list[RepositoryScope] = []
182
+ self._scopes_lock = threading.Lock()
183
+
184
+ @property
185
+ def start_time(self) -> datetime:
186
+ """Run start timestamp."""
187
+ return self._start_time
188
+
189
+ @property
190
+ def end_time(self) -> datetime:
191
+ """Run end timestamp.
192
+
193
+ Raises:
194
+ ValueError: If :meth:`finish` has not been called yet.
195
+ """
196
+ if self._end_time is None:
197
+ raise ValueError("HarvestReport.finish() must be called before reading end_time")
198
+ return self._end_time
199
+
200
+ @property
201
+ def duration_seconds(self) -> float:
202
+ """Total harvest run duration in seconds."""
203
+ return (self.end_time - self.start_time).total_seconds()
204
+
205
+ @property
206
+ def repository_reports(self) -> tuple[RepositoryReport, ...]:
207
+ """Immutable snapshots of all repository scopes in open order."""
208
+ with self._scopes_lock:
209
+ scopes = list(self._scopes)
210
+ return tuple(scope.snapshot() for scope in scopes)
211
+
212
+ def open_repository(self, rdi: str, *, opened_at: datetime | None = None) -> RepositoryScope:
213
+ """Open a repository scope handle for ``rdi`` (multiple may be open)."""
214
+ scope = RepositoryScope(rdi, opened_at=opened_at)
215
+ with self._scopes_lock:
216
+ self._scopes.append(scope)
217
+ return scope
218
+
219
+ def finish(self, *, end_time: datetime | None = None) -> None:
220
+ """Finish the harvest run, close open scopes, and record the end timestamp.
221
+
222
+ Raises:
223
+ ValueError: If :meth:`finish` has already been called.
224
+ """
225
+ if self._end_time is not None:
226
+ raise ValueError("HarvestReport.finish() has already been called")
227
+ if end_time is None:
228
+ finished_at = datetime.now(UTC)
229
+ else:
230
+ finished_at = _require_aware_utc(end_time, what="HarvestReport end_time")
231
+ with self._scopes_lock:
232
+ scopes = list(self._scopes)
233
+ for scope in scopes:
234
+ scope.close(closed_at=finished_at)
235
+ self._end_time = finished_at
@@ -0,0 +1,421 @@
1
+ """Unit tests for the shared harvest report counting API and JSON-LD serializer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import threading
8
+ from collections.abc import Callable
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from datetime import UTC, datetime, timedelta
11
+ from zoneinfo import ZoneInfo
12
+
13
+ import pytest
14
+
15
+ from middleware.shared.report import (
16
+ FAIRAGRO_HARVEST_REPORT_NS,
17
+ FailedRecord,
18
+ HarvestReport,
19
+ JsonLdReportSerializer,
20
+ RepositoryScope,
21
+ )
22
+
23
+ _START = datetime(2026, 5, 6, 14, 0, 0, tzinfo=UTC)
24
+ _END = datetime(2026, 5, 6, 14, 3, 45, tzinfo=UTC)
25
+ _SCOPE_CLOSE = datetime(2026, 5, 6, 14, 0, 17, tzinfo=UTC)
26
+
27
+ _SCOPE_DURATION_SECONDS = 12.5
28
+ _SAMPLE_EXPECTED = 100
29
+ _SAMPLE_HARVESTED = 95
30
+ _SAMPLE_FAILED = 5
31
+ _SAMPLE_SKIPPED = 2
32
+ _EXPECTED_COUNT = 10
33
+ _HARVESTED_TWICE = 2
34
+ _STUDIES = 3
35
+ _ASSAYS = 7
36
+ _REPO_COUNT = 2
37
+ _ASYNC_HARVESTED = 100
38
+ _ASYNC_FAILED = 40
39
+ _PARALLEL_HARVESTED = 80
40
+ _THREAD_HARVESTED = 200
41
+ _THREAD_FAILED = 50
42
+
43
+
44
+ def _finished_report(
45
+ *,
46
+ populate: Callable[[HarvestReport], None] | None = None,
47
+ start_time: datetime = _START,
48
+ end_time: datetime = _END,
49
+ ) -> HarvestReport:
50
+ """Build a finished report; optional ``populate(report)`` opens scopes."""
51
+ report = HarvestReport(start_time=start_time)
52
+ if populate is not None:
53
+ populate(report)
54
+ report.finish(end_time=end_time)
55
+ return report
56
+
57
+
58
+ def _open_sample_scope(report: HarvestReport) -> None:
59
+ """Open one sample repository scope with typical counts."""
60
+ scope = report.open_repository("bonares")
61
+ scope.set_expected_datasets(_SAMPLE_EXPECTED)
62
+ scope.set_harvest_id("harvest-1")
63
+ for _ in range(_SAMPLE_HARVESTED):
64
+ scope.record_harvested()
65
+ for i in range(_SAMPLE_FAILED):
66
+ scope.record_failed(
67
+ "map failed" if i == 0 else f"fail-{i}",
68
+ record_id="frl:123" if i == 0 else None,
69
+ url="https://example.test/frl:123" if i == 0 else None,
70
+ )
71
+ for _ in range(_SAMPLE_SKIPPED):
72
+ scope.record_skipped()
73
+ scope.close(closed_at=_SCOPE_CLOSE)
74
+
75
+
76
+ def test_failed_record_with_optional_identifiers() -> None:
77
+ """Failed records expose message, record id, and URL when provided."""
78
+ record = FailedRecord(message="boom", record_id="id-1", url="https://x.test")
79
+ assert record.message == "boom"
80
+ assert record.record_id == "id-1"
81
+ assert record.url == "https://x.test"
82
+
83
+
84
+ def test_failed_record_message_only() -> None:
85
+ """Optional identifiers are unset when omitted."""
86
+ record = FailedRecord(message="boom")
87
+ assert record.record_id is None
88
+ assert record.url is None
89
+
90
+
91
+ def test_mutable_run_records_start_time() -> None:
92
+ """Creating a report records an authoritative start timestamp."""
93
+ report = HarvestReport(start_time=_START)
94
+ assert report.start_time == _START
95
+
96
+
97
+ def test_finish_records_end_time_and_duration() -> None:
98
+ """Finishing the run records end time used for duration."""
99
+ report = HarvestReport(start_time=_START)
100
+ report.finish(end_time=_END)
101
+ assert report.end_time == _END
102
+ assert report.duration_seconds == (_END - _START).total_seconds()
103
+
104
+
105
+ def test_end_time_requires_finish() -> None:
106
+ """Reading end time before finish raises."""
107
+ report = HarvestReport(start_time=_START)
108
+ with pytest.raises(ValueError, match="finish"):
109
+ _ = report.end_time
110
+
111
+
112
+ def test_open_and_close_repository_scope() -> None:
113
+ """Open/close records RDI and per-scope duration."""
114
+ report = HarvestReport(start_time=_START)
115
+ opened = datetime(2026, 5, 6, 14, 1, 0, tzinfo=UTC)
116
+ closed = opened + timedelta(seconds=_SCOPE_DURATION_SECONDS)
117
+ scope = report.open_repository("bonares", opened_at=opened)
118
+ scope.close(closed_at=closed)
119
+ report.finish(end_time=_END)
120
+ repos = report.repository_reports
121
+ assert len(repos) == 1
122
+ assert repos[0].rdi == "bonares"
123
+ assert repos[0].duration_seconds == _SCOPE_DURATION_SECONDS
124
+
125
+
126
+ def test_concurrent_scopes_are_isolated() -> None:
127
+ """Counting on one handle does not change another handle's totals."""
128
+ report = HarvestReport(start_time=_START)
129
+ a = report.open_repository("rdi-a")
130
+ b = report.open_repository("rdi-b")
131
+ a.record_harvested()
132
+ a.record_harvested()
133
+ b.record_failed("oops")
134
+ snap_a = a.snapshot()
135
+ snap_b = b.snapshot()
136
+ assert snap_a.harvested_datasets == _HARVESTED_TWICE
137
+ assert snap_a.failed_datasets == 0
138
+ assert snap_b.harvested_datasets == 0
139
+ assert snap_b.failed_datasets == 1
140
+
141
+
142
+ def test_counting_increments() -> None:
143
+ """Counting methods increment harvested, failed, and skipped."""
144
+ report = HarvestReport(start_time=_START)
145
+ scope = report.open_repository("bonares")
146
+ scope.set_expected_datasets(_EXPECTED_COUNT)
147
+ scope.set_harvest_id("h-1")
148
+ scope.record_harvested()
149
+ scope.record_harvested()
150
+ scope.record_failed("bad", record_id="r1", url="https://x.test/r1")
151
+ scope.record_skipped()
152
+ scope.add_studies(_STUDIES)
153
+ scope.add_assays(_ASSAYS)
154
+ snap = scope.snapshot()
155
+ assert snap.expected_datasets == _EXPECTED_COUNT
156
+ assert snap.harvest_id == "h-1"
157
+ assert snap.harvested_datasets == _HARVESTED_TWICE
158
+ assert snap.failed_datasets == 1
159
+ assert snap.skipped_datasets == 1
160
+ assert snap.total_studies == _STUDIES
161
+ assert snap.total_assays == _ASSAYS
162
+ assert snap.failed_records == (FailedRecord(message="bad", record_id="r1", url="https://x.test/r1"),)
163
+
164
+
165
+ def test_study_assay_omit_when_both_zero() -> None:
166
+ """Unset study/assay totals are None when never incremented."""
167
+ report = HarvestReport(start_time=_START)
168
+ scope = report.open_repository("bonares")
169
+ snap = scope.snapshot()
170
+ assert snap.total_studies is None
171
+ assert snap.total_assays is None
172
+
173
+
174
+ def test_render_requires_finish() -> None:
175
+ """JSON-LD render requires finish()."""
176
+ report = HarvestReport(start_time=_START)
177
+ with pytest.raises(ValueError, match="finish"):
178
+ JsonLdReportSerializer().render(report)
179
+
180
+
181
+ def test_naive_start_time_rejected() -> None:
182
+ """Naive start times must not be silently treated as local time."""
183
+ with pytest.raises(ValueError, match="timezone-aware"):
184
+ HarvestReport(start_time=datetime(2026, 5, 6, 14, 0, 0))
185
+
186
+
187
+ def test_naive_opened_at_rejected() -> None:
188
+ """Naive opened_at on a repository scope is rejected."""
189
+ with pytest.raises(ValueError, match="timezone-aware"):
190
+ RepositoryScope("bonares", opened_at=datetime(2026, 5, 6, 14, 0, 0))
191
+
192
+
193
+ def test_naive_closed_at_rejected() -> None:
194
+ """Naive closed_at on a repository scope is rejected."""
195
+ scope = RepositoryScope("bonares", opened_at=_START)
196
+ with pytest.raises(ValueError, match="timezone-aware"):
197
+ scope.close(closed_at=datetime(2026, 5, 6, 14, 1, 0))
198
+
199
+
200
+ def test_finish_closes_open_scopes_deterministically() -> None:
201
+ """finish() closes still-open scopes at the run end time."""
202
+ opened = datetime(2026, 5, 6, 14, 0, 0, tzinfo=UTC)
203
+ report = HarvestReport(start_time=opened)
204
+ scope = report.open_repository("bonares", opened_at=opened)
205
+ report.finish(end_time=_END)
206
+ assert scope.snapshot().duration_seconds == (_END - opened).total_seconds()
207
+ assert report.end_time == _END
208
+
209
+
210
+ def test_finish_is_single_shot() -> None:
211
+ """Calling finish() twice raises so run and scope durations stay aligned."""
212
+ report = HarvestReport(start_time=_START)
213
+ report.open_repository("bonares", opened_at=_START)
214
+ report.finish(end_time=_END)
215
+ with pytest.raises(ValueError, match="already been called"):
216
+ report.finish(end_time=_END + timedelta(seconds=30))
217
+ assert report.end_time == _END
218
+
219
+
220
+ def test_require_aware_utc_normalizes_non_utc() -> None:
221
+ """Timezone-aware non-UTC timestamps are stored as UTC equivalents."""
222
+ berlin = ZoneInfo("Europe/Berlin")
223
+ local_start = datetime(2026, 5, 6, 16, 0, 0, tzinfo=berlin) # UTC+2 in May
224
+ report = HarvestReport(start_time=local_start)
225
+ assert report.start_time == datetime(2026, 5, 6, 14, 0, 0, tzinfo=UTC)
226
+ assert report.start_time.tzinfo == UTC
227
+
228
+
229
+ def test_jsonld_context_and_types() -> None:
230
+ """JSON-LD uses schema.org Action with EntryPoint results."""
231
+ report = _finished_report(populate=_open_sample_scope)
232
+ document = json.loads(JsonLdReportSerializer().render(report))
233
+ assert document["@context"]["@vocab"] == "https://schema.org/"
234
+ assert document["@context"]["schema"] == "https://schema.org/"
235
+ assert document["@context"]["fairagro"] == FAIRAGRO_HARVEST_REPORT_NS
236
+ assert document["@context"]["fairagro"].endswith("/ns/harvest-report/v1/#")
237
+ assert document["@type"] == "schema:Action"
238
+ assert document["schema:result"][0]["@type"] == "schema:EntryPoint"
239
+
240
+
241
+ def test_jsonld_timestamps_and_durations() -> None:
242
+ """Timestamps end with Z; run duration is seconds; scope duration is ISO."""
243
+ report = _finished_report(populate=_open_sample_scope)
244
+ document = json.loads(JsonLdReportSerializer().render(report))
245
+ assert document["schema:startTime"].endswith("Z")
246
+ assert document["schema:endTime"].endswith("Z")
247
+ assert document["fairagro:harvestDurationSeconds"] == report.duration_seconds
248
+ assert document["schema:result"][0]["schema:duration"].startswith("PT")
249
+
250
+
251
+ def test_jsonld_metrics_and_failed_records() -> None:
252
+ """Fairagro metrics and nested failed records are emitted."""
253
+ report = _finished_report(populate=_open_sample_scope)
254
+ entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
255
+ assert entry["@id"] == "bonares"
256
+ assert entry["fairagro:harvestId"] == "harvest-1"
257
+ assert entry["fairagro:expectedDatasets"] == _SAMPLE_EXPECTED
258
+ assert entry["fairagro:harvestedDatasets"] == _SAMPLE_HARVESTED
259
+ assert entry["fairagro:failedDatasets"] == _SAMPLE_FAILED
260
+ assert entry["fairagro:skippedDatasets"] == _SAMPLE_SKIPPED
261
+ assert entry["fairagro:failedRecords"][0] == {
262
+ "fairagro:message": "map failed",
263
+ "fairagro:recordId": "frl:123",
264
+ "fairagro:url": "https://example.test/frl:123",
265
+ }
266
+
267
+
268
+ def test_jsonld_optional_study_and_assay_totals() -> None:
269
+ """Optional study and assay totals appear as fairagro properties."""
270
+
271
+ def populate(report: HarvestReport) -> None:
272
+ scope = report.open_repository("sql")
273
+ scope.add_studies(_STUDIES)
274
+ scope.add_assays(_ASSAYS)
275
+ scope.close(closed_at=_SCOPE_CLOSE)
276
+
277
+ report = _finished_report(populate=populate)
278
+ entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
279
+ assert entry["fairagro:totalStudies"] == _STUDIES
280
+ assert entry["fairagro:totalAssays"] == _ASSAYS
281
+
282
+
283
+ def test_jsonld_omits_unset_expected_and_study_assay() -> None:
284
+ """Unset expected and zero study/assay totals are omitted."""
285
+
286
+ def populate(report: HarvestReport) -> None:
287
+ scope = report.open_repository("bonares")
288
+ scope.set_harvest_id("harvest-1")
289
+ scope.record_harvested()
290
+ scope.close(closed_at=_SCOPE_CLOSE)
291
+
292
+ report = _finished_report(populate=populate)
293
+ entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
294
+ assert "fairagro:expectedDatasets" not in entry
295
+ assert "fairagro:totalStudies" not in entry
296
+ assert "fairagro:totalAssays" not in entry
297
+ assert entry["fairagro:harvestedDatasets"] == 1
298
+ assert entry["fairagro:failedDatasets"] == 0
299
+ assert entry["fairagro:skippedDatasets"] == 0
300
+ assert entry["fairagro:harvestId"] == "harvest-1"
301
+
302
+
303
+ def test_jsonld_harvest_id_null_when_unset() -> None:
304
+ """Unset harvest id is emitted as JSON null."""
305
+
306
+ def populate(report: HarvestReport) -> None:
307
+ scope = report.open_repository("bonares")
308
+ scope.close(closed_at=_SCOPE_CLOSE)
309
+
310
+ report = _finished_report(populate=populate)
311
+ entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
312
+ assert entry["fairagro:harvestId"] is None
313
+
314
+
315
+ def test_jsonld_omits_empty_failed_records() -> None:
316
+ """Empty failed-record lists are omitted from JSON-LD."""
317
+
318
+ def populate(report: HarvestReport) -> None:
319
+ scope = report.open_repository("bonares")
320
+ scope.close(closed_at=_SCOPE_CLOSE)
321
+
322
+ report = _finished_report(populate=populate)
323
+ entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
324
+ assert "fairagro:failedRecords" not in entry
325
+
326
+
327
+ def test_jsonld_empty_result_array() -> None:
328
+ """A run with no repositories emits an empty result array."""
329
+ document = json.loads(JsonLdReportSerializer().render(_finished_report()))
330
+ assert document["schema:result"] == []
331
+
332
+
333
+ def test_jsonld_multi_repository_action() -> None:
334
+ """Two scopes produce two EntryPoints in schema:result."""
335
+
336
+ def populate(report: HarvestReport) -> None:
337
+ a = report.open_repository("rdi-a")
338
+ b = report.open_repository("rdi-b")
339
+ a.close(closed_at=_SCOPE_CLOSE)
340
+ b.close(closed_at=_SCOPE_CLOSE)
341
+
342
+ report = _finished_report(populate=populate)
343
+ document = json.loads(JsonLdReportSerializer().render(report))
344
+ assert len(document["schema:result"]) == _REPO_COUNT
345
+ assert all(e["@type"] == "schema:EntryPoint" for e in document["schema:result"])
346
+
347
+
348
+ def test_serializer_returns_string() -> None:
349
+ """The JSON-LD serializer returns a document string, not a dict."""
350
+ rendered = JsonLdReportSerializer().render(_finished_report())
351
+ assert isinstance(rendered, str)
352
+ assert json.loads(rendered)["@type"] == "schema:Action"
353
+
354
+
355
+ def test_thread_safe_counting_on_one_handle() -> None:
356
+ """Concurrent thread updates on one handle do not lose counts."""
357
+ report = HarvestReport(start_time=_START)
358
+ scope = report.open_repository("bonares")
359
+
360
+ def harvest(_i: int) -> None:
361
+ scope.record_harvested()
362
+
363
+ def fail(_i: int) -> None:
364
+ scope.record_failed("x")
365
+
366
+ with ThreadPoolExecutor(max_workers=16) as pool:
367
+ list(pool.map(harvest, range(_THREAD_HARVESTED)))
368
+ list(pool.map(fail, range(_THREAD_FAILED)))
369
+
370
+ snap = scope.snapshot()
371
+ assert snap.harvested_datasets == _THREAD_HARVESTED
372
+ assert snap.failed_datasets == _THREAD_FAILED
373
+
374
+
375
+ def test_asyncio_interleaved_counting() -> None:
376
+ """Interleaved asyncio tasks on one handle preserve totals."""
377
+ report = HarvestReport(start_time=_START)
378
+ scope = report.open_repository("bonares")
379
+
380
+ async def run() -> None:
381
+ async def harvested() -> None:
382
+ for _ in range(_ASYNC_HARVESTED):
383
+ scope.record_harvested()
384
+ await asyncio.sleep(0)
385
+
386
+ async def failed() -> None:
387
+ for _ in range(_ASYNC_FAILED):
388
+ scope.record_failed("e")
389
+ await asyncio.sleep(0)
390
+
391
+ await asyncio.gather(harvested(), failed())
392
+
393
+ asyncio.run(run())
394
+ snap = scope.snapshot()
395
+ assert snap.harvested_datasets == _ASYNC_HARVESTED
396
+ assert snap.failed_datasets == _ASYNC_FAILED
397
+
398
+
399
+ def test_parallel_rdis_do_not_cross_count() -> None:
400
+ """Concurrent tasks on different handles stay isolated."""
401
+ report = HarvestReport(start_time=_START)
402
+ first = report.open_repository("rdi-a")
403
+ second = report.open_repository("rdi-b")
404
+ barrier = threading.Barrier(_REPO_COUNT)
405
+
406
+ def only_first() -> None:
407
+ barrier.wait()
408
+ for _ in range(_PARALLEL_HARVESTED):
409
+ first.record_harvested()
410
+
411
+ def idle_second() -> None:
412
+ barrier.wait()
413
+
414
+ t1 = threading.Thread(target=only_first)
415
+ t2 = threading.Thread(target=idle_second)
416
+ t1.start()
417
+ t2.start()
418
+ t1.join()
419
+ t2.join()
420
+ assert first.snapshot().harvested_datasets == _PARALLEL_HARVESTED
421
+ assert second.snapshot().harvested_datasets == 0
@@ -1,26 +0,0 @@
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)
@@ -1,94 +0,0 @@
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 timezone-aware datetime as an ISO 8601 UTC timestamp ending in Z.
30
-
31
- Raises:
32
- ValueError: If ``value`` is naive (no ``tzinfo``). Callers must supply
33
- timezone-aware UTC times; treating naive values as local time would
34
- silently shift the wall clock on non-UTC hosts.
35
- """
36
- if value.tzinfo is None:
37
- raise ValueError("HarvestReport timestamps must be timezone-aware UTC")
38
- return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
39
-
40
-
41
- def _failed_record_to_jsonld(record: FailedRecord) -> dict[str, Any]:
42
- """Convert a failed record to its JSON-LD object, omitting unset fields."""
43
- result: dict[str, Any] = {"fairagro:message": record.message}
44
- if record.record_id:
45
- result["fairagro:recordId"] = record.record_id
46
- if record.url:
47
- result["fairagro:url"] = record.url
48
- return result
49
-
50
-
51
- def _repository_to_jsonld(report: RepositoryReport) -> dict[str, Any]:
52
- """Convert a repository report to a schema.org EntryPoint JSON-LD object."""
53
- result: dict[str, Any] = {
54
- "@type": "schema:EntryPoint",
55
- "name": report.rdi,
56
- "identifier": report.rdi,
57
- "schema:duration": _format_iso_duration(report.duration_seconds),
58
- "fairagro:harvestId": report.harvest_id,
59
- "fairagro:skippedDatasets": report.skipped_datasets,
60
- }
61
- if report.harvested_datasets is not None:
62
- result["fairagro:harvestedDatasets"] = report.harvested_datasets
63
- if report.expected_datasets is not None:
64
- result["fairagro:expectedDatasets"] = report.expected_datasets
65
- if report.failed_datasets is not None:
66
- result["fairagro:failedDatasets"] = report.failed_datasets
67
- if report.total_studies is not None:
68
- result["fairagro:totalStudies"] = report.total_studies
69
- if report.total_assays is not None:
70
- result["fairagro:totalAssays"] = report.total_assays
71
- if report.failed_records:
72
- result["fairagro:failedRecords"] = [_failed_record_to_jsonld(record) for record in report.failed_records]
73
- return result
74
-
75
-
76
- class JsonLdReportSerializer:
77
- """Render a harvest report as an operator-readable JSON-LD document."""
78
-
79
- def __init__(self, *, indent: int = 2) -> None:
80
- """Configure JSON indentation for operator-readable output."""
81
- self._indent = indent
82
-
83
- def render(self, report: HarvestReport) -> str:
84
- """Return the report as indented JSON-LD text."""
85
- document: dict[str, Any] = {
86
- "@context": _JSON_LD_CONTEXT,
87
- "@type": "schema:Action",
88
- "name": report.name,
89
- "schema:startTime": _format_iso_timestamp(report.start_time),
90
- "schema:endTime": _format_iso_timestamp(report.end_time),
91
- "fairagro:harvestDurationSeconds": report.duration_seconds,
92
- "schema:result": [_repository_to_jsonld(entry) for entry in report.repository_reports],
93
- }
94
- return json.dumps(document, ensure_ascii=False, indent=self._indent)
@@ -1,46 +0,0 @@
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()
@@ -1,234 +0,0 @@
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_rejects_naive_timestamps() -> None:
135
- """Naive start/end times must not be silently treated as local time."""
136
- report = HarvestReport(
137
- start_time=datetime(2026, 5, 6, 14, 0, 0),
138
- end_time=datetime(2026, 5, 6, 14, 3, 45),
139
- )
140
- with pytest.raises(ValueError, match="timezone-aware"):
141
- JsonLdReportSerializer().render(report)
142
-
143
-
144
- def test_jsonld_metrics_and_failed_records() -> None:
145
- """Fairagro metrics and nested failed records are emitted."""
146
- repo = _sample_repo()
147
- entry = json.loads(JsonLdReportSerializer().render(_sample_report(repositories=(repo,))))["schema:result"][0]
148
- assert entry["fairagro:harvestId"] == repo.harvest_id
149
- assert entry["fairagro:expectedDatasets"] == repo.expected_datasets
150
- assert entry["fairagro:harvestedDatasets"] == repo.harvested_datasets
151
- assert entry["fairagro:failedDatasets"] == repo.failed_datasets
152
- assert entry["fairagro:skippedDatasets"] == repo.skipped_datasets
153
- failed = repo.failed_records[0]
154
- assert entry["fairagro:failedRecords"] == [
155
- {
156
- "fairagro:message": failed.message,
157
- "fairagro:recordId": failed.record_id,
158
- "fairagro:url": failed.url,
159
- }
160
- ]
161
-
162
-
163
- def test_jsonld_optional_study_and_assay_totals() -> None:
164
- """Optional study and assay totals appear as fairagro properties."""
165
- total_studies = 3
166
- total_assays = 7
167
- report = _sample_report(repositories=(_sample_repo(total_studies=total_studies, total_assays=total_assays),))
168
- entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
169
- assert entry["fairagro:totalStudies"] == total_studies
170
- assert entry["fairagro:totalAssays"] == total_assays
171
-
172
-
173
- def test_jsonld_omits_unset_expected_datasets() -> None:
174
- """Unset optional counts are omitted rather than null."""
175
- report = _sample_report(
176
- repositories=(
177
- _sample_repo(
178
- expected_datasets=None,
179
- harvested_datasets=None,
180
- failed_datasets=None,
181
- total_studies=None,
182
- total_assays=None,
183
- ),
184
- )
185
- )
186
- entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
187
- assert "fairagro:expectedDatasets" not in entry
188
- assert "fairagro:harvestedDatasets" not in entry
189
- assert "fairagro:failedDatasets" not in entry
190
- assert "fairagro:totalStudies" not in entry
191
- assert "fairagro:totalAssays" not in entry
192
- assert "fairagro:harvestId" in entry
193
-
194
-
195
- def test_jsonld_omits_empty_failed_records() -> None:
196
- """Empty failed-record lists are omitted from JSON-LD."""
197
- report = _sample_report(repositories=(_sample_repo(failed_records=()),))
198
- entry = json.loads(JsonLdReportSerializer().render(report))["schema:result"][0]
199
- assert "fairagro:failedRecords" not in entry
200
-
201
-
202
- def test_jsonld_empty_result_array() -> None:
203
- """A run with no repositories emits an empty result array."""
204
- document = json.loads(JsonLdReportSerializer().render(_sample_report(repositories=())))
205
- assert document["schema:result"] == []
206
-
207
-
208
- def test_print_report_writes_jsonld_to_stdout(capsys: pytest.CaptureFixture[str]) -> None:
209
- """print_report writes a JSON-LD document to stdout."""
210
- print_report(_sample_report())
211
- captured = capsys.readouterr()
212
- document = json.loads(captured.out)
213
- assert document["@type"] == "schema:Action"
214
- assert captured.err == ""
215
-
216
-
217
- def test_print_report_does_not_raise_on_serialization_failure(
218
- caplog: pytest.LogCaptureFixture,
219
- ) -> None:
220
- """Serialization failures are logged and do not propagate."""
221
-
222
- class _BoomSerializer:
223
- def __init__(self) -> None:
224
- self._fail = True
225
-
226
- def render(self, report: HarvestReport) -> str:
227
- if self._fail:
228
- raise RuntimeError(f"cannot serialise {type(report).__name__}")
229
- return "{}"
230
-
231
- with caplog.at_level(logging.WARNING):
232
- print_report(_sample_report(), serializer=_BoomSerializer())
233
-
234
- assert any("Failed to serialise harvest report" in message for message in caplog.messages)