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.
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/PKG-INFO +1 -1
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/report/__init__.py +4 -8
- fairagro_middleware_shared-11.0.1.dev30/src/middleware/shared/report/formats/jsonld.py +90 -0
- fairagro_middleware_shared-11.0.1.dev30/src/middleware/shared/report/model.py +235 -0
- fairagro_middleware_shared-11.0.1.dev30/tests/unit/test_harvest_report.py +421 -0
- fairagro_middleware_shared-11.0.1.dev28/src/middleware/shared/report/emit.py +0 -26
- fairagro_middleware_shared-11.0.1.dev28/src/middleware/shared/report/formats/jsonld.py +0 -94
- fairagro_middleware_shared-11.0.1.dev28/src/middleware/shared/report/model.py +0 -46
- fairagro_middleware_shared-11.0.1.dev28/tests/unit/test_harvest_report.py +0 -234
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/.gitignore +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/README.md +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/pyproject.toml +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/__init__.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/__init__.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/common/__init__.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/common/models.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/common/rocrate.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/py.typed +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v1/__init__.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v1/models.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v2/__init__.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v2/models.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v3/__init__.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/api_models/v3/models.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/config/__init__.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/config/config_base.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/config/config_wrapper.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/config/logging.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/py.typed +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/report/formats/__init__.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/report/formats/base.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/src/middleware/shared/tracing.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_api_models.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_config_base.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_config_wrapper.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_logging.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_rocrate.py +0 -0
- {fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/tests/unit/test_tracing.py +0 -0
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
"""Harvest
|
|
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
|
-
|
|
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
|
-
"
|
|
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)
|
{fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/.gitignore
RENAMED
|
File without changes
|
{fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/README.md
RENAMED
|
File without changes
|
{fairagro_middleware_shared-11.0.1.dev28 → fairagro_middleware_shared-11.0.1.dev30}/pyproject.toml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|