sws-api-client 2.26.0__tar.gz → 3.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/PKG-INFO +24 -1
  2. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/README.md +23 -0
  3. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/pyproject.toml +2 -1
  4. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/__init__.py +1 -1
  5. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/campaign_manager.py +64 -4
  6. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/schemas/validation-report.schema.json +63 -4
  7. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/tags.py +1 -0
  8. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/.gitignore +0 -0
  9. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/LICENSE +0 -0
  10. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/auth.py +0 -0
  11. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/case_agnostic_model.py +0 -0
  12. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/codelist.py +0 -0
  13. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/data_retrieval.py +0 -0
  14. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/datasets.py +0 -0
  15. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/datatable.py +0 -0
  16. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/db.py +0 -0
  17. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/discover.py +0 -0
  18. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/files.py +0 -0
  19. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/flaglist.py +0 -0
  20. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/generic_models.py +0 -0
  21. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/harvester.py +0 -0
  22. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/mail.py +0 -0
  23. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/metadata_instances.py +0 -0
  24. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/plugins.py +0 -0
  25. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/s3.py +0 -0
  26. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/sessions.py +0 -0
  27. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/sws_api_client.py +0 -0
  28. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/tasks.py +0 -0
  29. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/token.py +0 -0
  30. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/users.py +0 -0
  31. {sws_api_client-2.26.0 → sws_api_client-3.1.0}/sws_api_client/validation_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sws_api_client
3
- Version: 2.26.0
3
+ Version: 3.1.0
4
4
  Author-email: Matteo <matteo.terrinoni@fao.org>, Daniele <daniele.mansillo@fao.org>, Luca <luca.simi@fao.org>
5
5
  License-File: LICENSE
6
6
  Requires-Python: <4,>=3.10
@@ -16,6 +16,29 @@ Description-Content-Type: text/markdown
16
16
 
17
17
  This library provides the user with a set of useful tools to easily interact with the FAO SWS (Statistical Working System) REST APIs.
18
18
 
19
+ ## Campaign validation outputs
20
+
21
+ Campaign files use top-level `visibility: shared | internal`, defaulting to shared. Pass
22
+ `visibility="internal"` to `create_file` before uploading internal content. `CampaignFile` reads
23
+ `visibility` and `validation_report_id`; `RunOutput` uses `visibility` instead of `audience`.
24
+ The migration is `audience="both"` → `visibility="shared"`, and
25
+ `audience="internal"` → `visibility="internal"`.
26
+
27
+ `register_validation_output(team_id, report_id, source_file_id, descriptor)` registers an internal
28
+ artifact of the exact report. `validation_outputs` lists it and `download_content_to_file` streams it.
29
+ Every manual harvesting event names one file. Worker methods `claim_manual_harvest`,
30
+ `heartbeat_manual_harvest`, `manual_harvest_child_task` and `complete_manual_harvest` use the file ID,
31
+ request ID and wrapper task ID. Record the child immediately after launch; completion has one outcome.
32
+ Statistical writes use the existing `Datasets.import_file_via_plugin` and
33
+ `Datatables.import_file_via_plugin` methods to start separate Save Data tasks.
34
+
35
+ The packaged report schema supports a DATA ZIP with root `data.csv` and optional observation
36
+ `metadata.csv`, plus explicit separator/quote options. METADATA outputs are independent datatable
37
+ inputs; separate report files are not implicitly paired.
38
+
39
+ Pass `payload.cycleId` from `ValidationTriggered` to `complete_validation(cycle_id=...)`, including
40
+ when the plugin fails. This lets CM reject an old run's completion after a validation restart.
41
+
19
42
  ## Installation
20
43
 
21
44
  The module is available on Pypi:
@@ -2,6 +2,29 @@
2
2
 
3
3
  This library provides the user with a set of useful tools to easily interact with the FAO SWS (Statistical Working System) REST APIs.
4
4
 
5
+ ## Campaign validation outputs
6
+
7
+ Campaign files use top-level `visibility: shared | internal`, defaulting to shared. Pass
8
+ `visibility="internal"` to `create_file` before uploading internal content. `CampaignFile` reads
9
+ `visibility` and `validation_report_id`; `RunOutput` uses `visibility` instead of `audience`.
10
+ The migration is `audience="both"` → `visibility="shared"`, and
11
+ `audience="internal"` → `visibility="internal"`.
12
+
13
+ `register_validation_output(team_id, report_id, source_file_id, descriptor)` registers an internal
14
+ artifact of the exact report. `validation_outputs` lists it and `download_content_to_file` streams it.
15
+ Every manual harvesting event names one file. Worker methods `claim_manual_harvest`,
16
+ `heartbeat_manual_harvest`, `manual_harvest_child_task` and `complete_manual_harvest` use the file ID,
17
+ request ID and wrapper task ID. Record the child immediately after launch; completion has one outcome.
18
+ Statistical writes use the existing `Datasets.import_file_via_plugin` and
19
+ `Datatables.import_file_via_plugin` methods to start separate Save Data tasks.
20
+
21
+ The packaged report schema supports a DATA ZIP with root `data.csv` and optional observation
22
+ `metadata.csv`, plus explicit separator/quote options. METADATA outputs are independent datatable
23
+ inputs; separate report files are not implicitly paired.
24
+
25
+ Pass `payload.cycleId` from `ValidationTriggered` to `complete_validation(cycle_id=...)`, including
26
+ when the plugin fails. This lets CM reject an old run's completion after a validation restart.
27
+
5
28
  ## Installation
6
29
 
7
30
  The module is available on Pypi:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sws_api_client"
3
- version = "2.26.0"
3
+ version = "3.1.0"
4
4
  description = ""
5
5
  authors = [
6
6
  { name = "Matteo", email = "matteo.terrinoni@fao.org" },
@@ -28,6 +28,7 @@ dev = [
28
28
  "pytest>=8.3.2,<9",
29
29
  "pytest-cov>=5.0.0,<6",
30
30
  "pytest-mock>=3.14.0,<4",
31
+ "jsonschema>=4,<5",
31
32
  ]
32
33
 
33
34
  [build-system]
@@ -58,4 +58,4 @@ from .tasks import PluginPayload, TaskDataset, TaskInfo, TaskManager, TaskRespon
58
58
  from .users import User, Users
59
59
  from .harvester import Harvester
60
60
 
61
- __version__ = "2.26.0"
61
+ __version__ = "3.1.0"
@@ -107,17 +107,17 @@ class RunOutput:
107
107
  Attributes:
108
108
  file_id (int | str): the file already registered and uploaded
109
109
  role (str): what it is within the run — ``report``, ``annotated``, ``log``, …
110
- audience (str): ``internal`` keeps it on the campaign side; ``both`` lets the country read
110
+ visibility (str): ``internal`` keeps it on the campaign side; ``shared`` lets the country read
111
111
  it. UNMARKED MEANS READABLE, so say ``internal`` when it is: the day this is forgotten,
112
112
  a country seeing a log is a smaller failure than a country losing a document it is owed
113
113
  """
114
114
 
115
115
  file_id: Union[int, str]
116
116
  role: str = "report"
117
- audience: str = "both"
117
+ visibility: str = "shared"
118
118
 
119
119
  def to_wire(self) -> Dict[str, Any]:
120
- return {"fileId": self.file_id, "role": self.role, "audience": self.audience}
120
+ return {"fileId": self.file_id, "role": self.role, "visibility": self.visibility}
121
121
 
122
122
 
123
123
  @dataclass
@@ -187,6 +187,8 @@ class CampaignFile:
187
187
  stage: Optional[str] = None
188
188
  storage_uri: Optional[str] = None
189
189
  raw: Dict[str, Any] = field(default_factory=dict)
190
+ visibility: str = "shared"
191
+ validation_report_id: Optional[int] = None
190
192
 
191
193
  @classmethod
192
194
  def from_wire(cls, raw: Dict[str, Any]) -> "CampaignFile":
@@ -199,6 +201,8 @@ class CampaignFile:
199
201
  stage=raw.get("stage"),
200
202
  storage_uri=raw.get("storageUri"),
201
203
  raw=raw,
204
+ visibility=raw.get("visibility", "shared"),
205
+ validation_report_id=raw.get("validationReportId"),
202
206
  )
203
207
 
204
208
 
@@ -321,6 +325,7 @@ class CampaignManager:
321
325
  country: Optional[str] = None,
322
326
  questionnaire_type_id: Optional[Union[int, str]] = None,
323
327
  stage: Optional[str] = "original",
328
+ visibility: str = "shared",
324
329
  ) -> CampaignFile:
325
330
  """Register a file of the campaign — the row, before its bytes.
326
331
 
@@ -330,7 +335,9 @@ class CampaignManager:
330
335
  a new version of the same chain. So a generation run may be repeated without leaving a
331
336
  country's case showing the same questionnaire four times, and no plugin has to be idempotent.
332
337
  """
333
- body: Dict[str, Any] = {"campaignId": campaign_id, "name": name, "type": type}
338
+ if visibility not in ("shared", "internal"):
339
+ raise ValueError("visibility must be shared or internal")
340
+ body: Dict[str, Any] = {"campaignId": campaign_id, "name": name, "type": type, "visibility": visibility}
334
341
  if country:
335
342
  body["country"] = country
336
343
  if questionnaire_type_id is not None:
@@ -340,6 +347,39 @@ class CampaignManager:
340
347
  raw = self._call("POST", "/api/v1/files", params={"teamId": team_id}, json_body=body)
341
348
  return CampaignFile.from_wire(raw)
342
349
 
350
+ def register_validation_output(
351
+ self, team_id: Union[int, str], report_id: Union[int, str],
352
+ source_file_id: Union[int, str], descriptor: Dict[str, Any],
353
+ ) -> CampaignFile:
354
+ """Register an internal artifact of an exact report, preserving its schema descriptor."""
355
+ raw = self._call("POST", f"/api/v1/files/{report_id}/outputs", params={"teamId": team_id},
356
+ json_body={"sourceFileId": source_file_id, "descriptor": descriptor})
357
+ return CampaignFile.from_wire(raw)
358
+
359
+ def validation_outputs(self, team_id: Union[int, str], report_id: Union[int, str]) -> List[CampaignFile]:
360
+ """List internal artifacts of one report (including historical reports)."""
361
+ return [CampaignFile.from_wire(raw) for raw in self._call(
362
+ "GET", f"/api/v1/files/{report_id}/outputs", params={"teamId": team_id})]
363
+
364
+ def claim_manual_harvest(self, team_id, file_id, request_id, task_id, expires_at):
365
+ """Claim one file's request; duplicate deliveries receive HTTP 409."""
366
+ return self._call("POST", f"/api/v1/files/{file_id}/manual-harvest/processing", params={"teamId": team_id},
367
+ json_body={"requestId": request_id, "taskId": task_id, "expiresAt": expires_at})
368
+
369
+ def heartbeat_manual_harvest(self, team_id, file_id, request_id, task_id, expires_at):
370
+ return self._call("PUT", f"/api/v1/files/{file_id}/manual-harvest/status", params={"teamId": team_id},
371
+ json_body={"requestId": request_id, "taskId": task_id, "expiresAt": expires_at})
372
+
373
+ def manual_harvest_child_task(self, team_id, file_id, request_id, task_id, child_task_id, destination):
374
+ """Record the Save Data task before waiting for it, allowing recovery after wrapper failure."""
375
+ return self._call("POST", f"/api/v1/files/{file_id}/manual-harvest/child-task", params={"teamId": team_id},
376
+ json_body={"requestId": request_id, "taskId": task_id, "childTaskId": child_task_id, "destination": destination})
377
+
378
+ def complete_manual_harvest(self, team_id, file_id, request_id, task_id, outcome, reason=None, result=None):
379
+ return self._call("POST", f"/api/v1/files/{file_id}/manual-harvest/complete", params={"teamId": team_id},
380
+ json_body={"requestId": request_id, "taskId": task_id, "outcome": outcome,
381
+ "reason": reason, "result": result or {}})
382
+
343
383
  def upload_content(
344
384
  self,
345
385
  team_id: Union[int, str],
@@ -387,6 +427,20 @@ class CampaignManager:
387
427
  )
388
428
  return response.content
389
429
 
430
+ def download_content_to_file(self, team_id: Union[int, str], file_id: Union[int, str],
431
+ path: Union[str, Path]) -> Path:
432
+ """Stream a stored artifact to disk without materialising the whole file in memory."""
433
+ destination = Path(path)
434
+ with self.sws_client.discoverable.get(
435
+ self.endpoint, f"/api/v1/files/{file_id}/content", params={"teamId": team_id},
436
+ options={"raw_response": True, "raise_on_error": True}, stream=True,
437
+ ) as response:
438
+ with destination.open("wb") as out:
439
+ for chunk in response.iter_content(chunk_size=1024 * 1024):
440
+ if chunk:
441
+ out.write(chunk)
442
+ return destination
443
+
390
444
  # ── validation ────────────────────────────────────────────────────────────
391
445
 
392
446
  def claim_validation(
@@ -433,6 +487,7 @@ class CampaignManager:
433
487
  issues: Optional[List[ValidationIssue]] = None,
434
488
  reason: Optional[str] = None,
435
489
  outputs: Optional[List["RunOutput"]] = None,
490
+ cycle_id: Optional[str] = None,
436
491
  ) -> Dict[str, Any]:
437
492
  """How the check ended, with the figures and the issues a reader will see.
438
493
 
@@ -446,6 +501,10 @@ class CampaignManager:
446
501
 
447
502
  ``outputs`` is everything ELSE the run left behind — an annotated workbook, a log — each with
448
503
  its role and its reader. ``file_report_id`` remains the one a screen opens first.
504
+
505
+ Pass the trigger's ``payload.cycleId`` as ``cycle_id`` on both success and failure. CM
506
+ rejects a completion from an earlier validation after a restart. Omit it only for legacy
507
+ invocations that supplied no cycle identifier.
449
508
  """
450
509
  return self._call(
451
510
  "POST",
@@ -458,6 +517,7 @@ class CampaignManager:
458
517
  "errors": [i.to_wire() for i in (issues or [])],
459
518
  "reason": reason,
460
519
  "outputs": [o.to_wire() for o in (outputs or [])] or None,
520
+ **({"cycleId": cycle_id} if cycle_id is not None else {}),
461
521
  },
462
522
  )
463
523
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "validation-schema-v5.5",
4
- "$comment": "v5.5 = v5.4 with the corrections agreed with the Campaign Manager team, 2026-09-01. (1) Every $ref pointed at fao-validation-plugin-proposal-schema-v5.3, so nothing resolved: they now point into this document. (2) findings[].required was [class, code, location, severity, values]; a code is optional because it is a team's own finer statement, a location is absent when the finding is about something that is not there, and values are absent when it is about no value in particular (a count). It is now [class, severity]. (3) The version patterns read ^\\\\d+\\\\.\\\\d+[\\\\.\\\\d+]?$, whose character class rejected 1.2.3 and accepted 1.2+. (4) submission.file.name rejected spaces and accents, which real questionnaire filenames have. (5) questionnaire.reference_years.items had a minimum and no type. Still open, deliberately not changed here: objects are not closed (additionalProperties), so a misspelt field validates silently; and Output enumerates ten near-identical allOf branches where an if/then on filetype would say the same thing once. (6) Six more required fields a plugin cannot always know: root.outputs (a run that leaves nothing has none), questionnaire.language and .version, submission.submitted_by and .submitted_at (who sent it is the platform's fact, not the validator's), and file.size. (7) questionnaire.kind had minLength 3, which rejects AQ; file.id had minLength 8, which rejects the platform's own ids. A required field a producer must invent is a field that will be invented. (8) findings[].values items were typed [string, integer], which rejects null — the value a finding about a MISSING entry carries — and every decimal. Carrying bad data is this document's job, so they are unconstrained.",
4
+ "$comment": "v5.5 = v5.4 with the corrections agreed with the Campaign Manager team, 2026-09-01. (1) Every $ref pointed at fao-validation-plugin-proposal-schema-v5.3, so nothing resolved: they now point into this document. (2) findings[].required was [class, code, location, severity, values]; a code is optional because it is a team's own finer statement, a location is absent when the finding is about something that is not there, and values are absent when it is about no value in particular (a count). It is now [class, severity]. (3) The version patterns read ^\\\\d+\\\\.\\\\d+[\\\\.\\\\d+]?$, whose character class rejected 1.2.3 and accepted 1.2+. (4) submission.file.name rejected spaces and accents, which real questionnaire filenames have. (5) questionnaire.reference_years.items had a minimum and no type. Still open, deliberately not changed here: objects are not closed (additionalProperties), so a misspelt field validates silently; and Output enumerates ten near-identical allOf branches where an if/then on filetype would say the same thing once. (6) Six more required fields a plugin cannot always know: root.outputs (a run that leaves nothing has none), questionnaire.language and .version, submission.submitted_by and .submitted_at (who sent it is the platform's fact, not the validator's), and file.size. (7) questionnaire.kind had minLength 3, which rejects AQ; file.id had minLength 8, which rejects the platform's own ids. A required field a producer must invent is a field that will be invented. (8) findings[].values items were typed [string, integer], which rejects null \u2014 the value a finding about a MISSING entry carries \u2014 and every decimal. Carrying bad data is this document's job, so they are unconstrained.",
5
5
  "type": "object",
6
6
  "required": [
7
7
  "findings",
@@ -65,7 +65,7 @@
65
65
  }
66
66
  },
67
67
  "values": {
68
- "description": "The values the finding is about: the figure out of range, the flag nobody recognises, null where something required is missing. Deliberately unconstrained — reporting that a value is invalid means carrying the invalid value, so a schema that rejects the document when the data is bad makes it impossible to say the data is bad.",
68
+ "description": "The values the finding is about: the figure out of range, the flag nobody recognises, null where something required is missing. Deliberately unconstrained \u2014 reporting that a value is invalid means carrying the invalid value, so a schema that rejects the document when the data is bad makes it impossible to say the data is bad.",
69
69
  "type": "array",
70
70
  "items": {
71
71
  "type": [
@@ -515,7 +515,7 @@
515
515
  "type": "string"
516
516
  },
517
517
  "kind": {
518
- "$ref": "#/$defs/OutputTargetKind"
518
+ "const": "DATATABLE"
519
519
  }
520
520
  }
521
521
  }
@@ -821,7 +821,7 @@
821
821
  "type": "string"
822
822
  },
823
823
  "kind": {
824
- "$ref": "#/$defs/OutputTargetKind"
824
+ "const": "DATATABLE"
825
825
  }
826
826
  }
827
827
  }
@@ -960,6 +960,65 @@
960
960
  }
961
961
  }
962
962
  ]
963
+ },
964
+ {
965
+ "type": "object",
966
+ "description": "One dataset import package: data.csv and optional observation metadata.csv at the archive root; CSV options apply to both entries.",
967
+ "required": [
968
+ "id",
969
+ "kind",
970
+ "filetype",
971
+ "role",
972
+ "target",
973
+ "separator",
974
+ "delimiter"
975
+ ],
976
+ "properties": {
977
+ "id": {
978
+ "type": "string"
979
+ },
980
+ "kind": {
981
+ "const": "FILE"
982
+ },
983
+ "filetype": {
984
+ "const": "ZIP"
985
+ },
986
+ "role": {
987
+ "const": "DATA"
988
+ },
989
+ "separator": {
990
+ "enum": [
991
+ "\t",
992
+ ",",
993
+ ";"
994
+ ]
995
+ },
996
+ "delimiter": {
997
+ "enum": [
998
+ "\"",
999
+ "'",
1000
+ "`"
1001
+ ]
1002
+ },
1003
+ "target": {
1004
+ "type": "object",
1005
+ "required": [
1006
+ "kind",
1007
+ "id"
1008
+ ],
1009
+ "properties": {
1010
+ "kind": {
1011
+ "enum": [
1012
+ "DATASET",
1013
+ "SESSION"
1014
+ ]
1015
+ },
1016
+ "id": {
1017
+ "type": "string"
1018
+ }
1019
+ }
1020
+ }
1021
+ }
963
1022
  }
964
1023
  ]
965
1024
  },
@@ -138,6 +138,7 @@ class BaseDisseminatedTagTable(TypedDict):
138
138
  layer: TableLayer
139
139
  private: bool
140
140
  debug: bool
141
+ to_upload: bool
141
142
  type: TableType
142
143
  database: str | None
143
144
  table: str | None
File without changes