fairagro-middleware-api-client 8.6.2__tar.gz → 8.6.4.dev7__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 (17) hide show
  1. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/PKG-INFO +10 -26
  2. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/README.md +9 -25
  3. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/src/middleware/api_client/api_client.py +17 -12
  4. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/tests/unit/test_client.py +92 -2
  5. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/.gitignore +0 -0
  6. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/example_client_config.yaml +0 -0
  7. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/pyproject.toml +0 -0
  8. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/src/middleware/api_client/__init__.py +0 -0
  9. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/src/middleware/api_client/config.py +0 -0
  10. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/src/middleware/api_client/models.py +0 -0
  11. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/src/middleware/api_client/py.typed +0 -0
  12. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/tests/conftest.py +0 -0
  13. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/tests/integration/conftest.py +0 -0
  14. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/tests/integration/test_create_arcs.py +0 -0
  15. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/tests/unit/test_api_client_config.py +0 -0
  16. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/tests/unit/test_client_config.py +0 -0
  17. {fairagro_middleware_api_client-8.6.2 → fairagro_middleware_api_client-8.6.4.dev7}/tests/unit/test_retry_logic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairagro-middleware-api-client
3
- Version: 8.6.2
3
+ Version: 8.6.4.dev7
4
4
  Summary: The FAIRagro advanced middleware API client
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: httpx>=0.28.1
@@ -60,13 +60,13 @@ async def main():
60
60
  # Send a single ARC
61
61
  response = await client.create_or_update_arc(
62
62
  rdi="my-rdi",
63
- arc=arc,
63
+ arc=arc, # Can be ARC object, dict, or JSON string
64
64
  )
65
65
  print(f"ARC status: {response.status}")
66
66
 
67
67
  # Or run a harvest workflow
68
68
  async def arc_stream():
69
- yield arc
69
+ yield arc # Can yield ARC objects, dicts, or JSON strings
70
70
 
71
71
  harvest = await client.harvest_arcs(
72
72
  rdi="my-rdi",
@@ -94,14 +94,14 @@ asyncio.run(main())
94
94
 
95
95
  ## API Methods
96
96
 
97
- ### `create_or_update_arc(rdi: str, arc: ARC | dict) -> ArcResult`
97
+ ### `create_or_update_arc(rdi: str, arc: ARC | dict | str) -> ArcResult`
98
98
 
99
99
  Create or update one ARC in the Middleware API.
100
100
 
101
101
  **Parameters:**
102
102
 
103
103
  - `rdi` (str): The RDI identifier (e.g., "edaphobase").
104
- - `arc` (ARC | dict): ARC object from arctrl or pre-serialised RO-Crate dict.
104
+ - `arc` (ARC | dict | str): ARC object from arctrl, pre-serialised RO-Crate dict, or JSON string.
105
105
 
106
106
  **Returns:**
107
107
 
@@ -109,7 +109,7 @@ Create or update one ARC in the Middleware API.
109
109
 
110
110
  **Raises:**
111
111
 
112
- - `ApiClientError`: If the request fails due to HTTP errors or network issues.
112
+ - `ApiClientError`: If the request fails due to HTTP errors, network issues, or invalid JSON.
113
113
 
114
114
  **Example:**
115
115
 
@@ -121,17 +121,18 @@ arc = ARC.from_arc_investigation(inv)
121
121
 
122
122
  response = await client.create_or_update_arc(
123
123
  rdi="edaphobase",
124
- arc=arc,
124
+ arc=arc, # Can also be dict or JSON string
125
125
  )
126
126
  ```
127
127
 
128
- ### `harvest_arcs(rdi: str, arcs: AsyncIterator[ARC | dict], expected_datasets: int | None = None) -> HarvestResult`
128
+ ### `harvest_arcs(rdi: str, arcs: AsyncIterator[ARC | dict | str], expected_datasets: int | None = None) -> HarvestResult`
129
129
 
130
130
  Convenience workflow to create a harvest, upload all ARCs from an async iterator, and complete the harvest.
131
131
 
132
132
  - Uses `config.max_concurrency` by default.
133
133
  - Continues on item-level submission errors and skips failed items.
134
134
  - Cancels the harvest only for catastrophic errors.
135
+ - Supports ARC objects, pre-serialised RO-Crate dicts, and JSON strings.
135
136
 
136
137
  All errors are raised as `ApiClientError` exceptions:
137
138
 
@@ -141,24 +142,7 @@ from middleware.api_client import ApiClientError
141
142
  try:
142
143
  response = await client.create_or_update_arc(
143
144
  rdi="my-rdi",
144
- arc=arc,
145
+ arc=arc, # Can be ARC object, dict, or JSON string
145
146
  )
146
147
  except ApiClientError as e:
147
148
  print(f"API Error: {e}")
148
- ```
149
-
150
- ## Configuration via Environment Variables
151
-
152
- You can override configuration values using environment variables:
153
-
154
- ```bash
155
- export API_URL="https://production-api:8000"
156
- export CLIENT_CERT_PATH="/secure/certs/prod-cert.pem"
157
- export CLIENT_KEY_PATH="/secure/certs/prod-key.pem"
158
- ```
159
-
160
- Or use Docker secrets in `/run/secrets/`.
161
-
162
- ## License
163
-
164
- This is part of the FAIRagro Advanced Middleware project.
@@ -51,13 +51,13 @@ async def main():
51
51
  # Send a single ARC
52
52
  response = await client.create_or_update_arc(
53
53
  rdi="my-rdi",
54
- arc=arc,
54
+ arc=arc, # Can be ARC object, dict, or JSON string
55
55
  )
56
56
  print(f"ARC status: {response.status}")
57
57
 
58
58
  # Or run a harvest workflow
59
59
  async def arc_stream():
60
- yield arc
60
+ yield arc # Can yield ARC objects, dicts, or JSON strings
61
61
 
62
62
  harvest = await client.harvest_arcs(
63
63
  rdi="my-rdi",
@@ -85,14 +85,14 @@ asyncio.run(main())
85
85
 
86
86
  ## API Methods
87
87
 
88
- ### `create_or_update_arc(rdi: str, arc: ARC | dict) -> ArcResult`
88
+ ### `create_or_update_arc(rdi: str, arc: ARC | dict | str) -> ArcResult`
89
89
 
90
90
  Create or update one ARC in the Middleware API.
91
91
 
92
92
  **Parameters:**
93
93
 
94
94
  - `rdi` (str): The RDI identifier (e.g., "edaphobase").
95
- - `arc` (ARC | dict): ARC object from arctrl or pre-serialised RO-Crate dict.
95
+ - `arc` (ARC | dict | str): ARC object from arctrl, pre-serialised RO-Crate dict, or JSON string.
96
96
 
97
97
  **Returns:**
98
98
 
@@ -100,7 +100,7 @@ Create or update one ARC in the Middleware API.
100
100
 
101
101
  **Raises:**
102
102
 
103
- - `ApiClientError`: If the request fails due to HTTP errors or network issues.
103
+ - `ApiClientError`: If the request fails due to HTTP errors, network issues, or invalid JSON.
104
104
 
105
105
  **Example:**
106
106
 
@@ -112,17 +112,18 @@ arc = ARC.from_arc_investigation(inv)
112
112
 
113
113
  response = await client.create_or_update_arc(
114
114
  rdi="edaphobase",
115
- arc=arc,
115
+ arc=arc, # Can also be dict or JSON string
116
116
  )
117
117
  ```
118
118
 
119
- ### `harvest_arcs(rdi: str, arcs: AsyncIterator[ARC | dict], expected_datasets: int | None = None) -> HarvestResult`
119
+ ### `harvest_arcs(rdi: str, arcs: AsyncIterator[ARC | dict | str], expected_datasets: int | None = None) -> HarvestResult`
120
120
 
121
121
  Convenience workflow to create a harvest, upload all ARCs from an async iterator, and complete the harvest.
122
122
 
123
123
  - Uses `config.max_concurrency` by default.
124
124
  - Continues on item-level submission errors and skips failed items.
125
125
  - Cancels the harvest only for catastrophic errors.
126
+ - Supports ARC objects, pre-serialised RO-Crate dicts, and JSON strings.
126
127
 
127
128
  All errors are raised as `ApiClientError` exceptions:
128
129
 
@@ -132,24 +133,7 @@ from middleware.api_client import ApiClientError
132
133
  try:
133
134
  response = await client.create_or_update_arc(
134
135
  rdi="my-rdi",
135
- arc=arc,
136
+ arc=arc, # Can be ARC object, dict, or JSON string
136
137
  )
137
138
  except ApiClientError as e:
138
139
  print(f"API Error: {e}")
139
- ```
140
-
141
- ## Configuration via Environment Variables
142
-
143
- You can override configuration values using environment variables:
144
-
145
- ```bash
146
- export API_URL="https://production-api:8000"
147
- export CLIENT_CERT_PATH="/secure/certs/prod-cert.pem"
148
- export CLIENT_KEY_PATH="/secure/certs/prod-key.pem"
149
- ```
150
-
151
- Or use Docker secrets in `/run/secrets/`.
152
-
153
- ## License
154
-
155
- This is part of the FAIRagro Advanced Middleware project.
@@ -262,13 +262,13 @@ class ApiClient:
262
262
  async def _submit_arcs_parallel(
263
263
  self,
264
264
  harvest_id: str,
265
- arcs: "AsyncGenerator[ARC | dict[str, Any], None] | AsyncIterator[ARC | dict[str, Any]]",
265
+ arcs: "AsyncGenerator[ARC | dict[str, Any] | str, None] | AsyncIterator[ARC | dict[str, Any] | str]",
266
266
  ) -> int:
267
267
  """Submit all ARCs in bounded parallelism and return number of skipped ARC submissions."""
268
268
  pending_tasks: set[asyncio.Task[None]] = set()
269
269
  failed_submissions = 0
270
270
 
271
- async def submit_one(arc_item: "ARC | dict[str, Any]") -> None:
271
+ async def submit_one(arc_item: "ARC | dict[str, Any] | str") -> None:
272
272
  await self.submit_arc_in_harvest(harvest_id, arc_item)
273
273
 
274
274
  async for arc in arcs:
@@ -446,10 +446,15 @@ class ApiClient:
446
446
  # ------------------------------------------------------------------
447
447
 
448
448
  @classmethod
449
- def _serialize_arc(cls, arc: "ARC | dict[str, Any]") -> dict[str, Any]:
450
- """Serialize an ARC object to a plain RO-Crate JSON dict."""
449
+ def _serialize_arc(cls, arc: "ARC | dict[str, Any] | str") -> dict[str, Any]:
450
+ """Serialize an ARC object, dict, or JSON string to a plain RO-Crate JSON dict."""
451
451
  if isinstance(arc, dict):
452
452
  return arc
453
+ if isinstance(arc, str):
454
+ try:
455
+ return cast(dict[str, Any], json.loads(arc))
456
+ except json.JSONDecodeError as e:
457
+ raise ApiClientError(f"Invalid JSON string provided for ARC: {e}") from e
453
458
  return cast(dict[str, Any], json.loads(arc.ToROCrateJsonString()))
454
459
 
455
460
  @classmethod
@@ -473,7 +478,7 @@ class ApiClient:
473
478
  async def create_or_update_arc(
474
479
  self,
475
480
  rdi: str,
476
- arc: "ARC | dict[str, Any]",
481
+ arc: "ARC | dict[str, Any] | str",
477
482
  ) -> ArcResult:
478
483
  """Create or update an ARC.
479
484
 
@@ -483,7 +488,7 @@ class ApiClient:
483
488
 
484
489
  Args:
485
490
  rdi: RDI identifier.
486
- arc: ARC object or a pre-serialised RO-Crate JSON dict.
491
+ arc: ARC object, a pre-serialised RO-Crate JSON dict, or a JSON string.
487
492
 
488
493
  Returns:
489
494
  :class:`ArcResult` with the result of the operation.
@@ -579,7 +584,7 @@ class ApiClient:
579
584
  async def submit_arc_in_harvest(
580
585
  self,
581
586
  harvest_id: str,
582
- arc: "ARC | dict[str, Any]",
587
+ arc: "ARC | dict[str, Any] | str",
583
588
  ) -> ArcResult:
584
589
  """Submit an ARC within an active harvest run.
585
590
 
@@ -588,7 +593,7 @@ class ApiClient:
588
593
 
589
594
  Args:
590
595
  harvest_id: Harvest identifier.
591
- arc: ARC object or a pre-serialised RO-Crate JSON dict.
596
+ arc: ARC object, a pre-serialised RO-Crate JSON dict, or a JSON string.
592
597
 
593
598
  Returns:
594
599
  :class:`ArcResult` with the result of the operation.
@@ -601,7 +606,7 @@ class ApiClient:
601
606
  async def harvest_arcs(
602
607
  self,
603
608
  rdi: str,
604
- arcs: "AsyncGenerator[ARC | dict[str, Any], None] | AsyncIterator[ARC | dict[str, Any]]",
609
+ arcs: "AsyncGenerator[ARC | dict[str, Any] | str, None] | AsyncIterator[ARC | dict[str, Any] | str]",
605
610
  expected_datasets: int | None = None,
606
611
  ) -> HarvestResult:
607
612
  """Create a harvest, upload all ARCs from an async generator, then complete it.
@@ -618,8 +623,8 @@ class ApiClient:
618
623
 
619
624
  Args:
620
625
  rdi: RDI identifier for the harvest.
621
- arcs: Async generator or async iterator yielding ARC objects or
622
- pre-serialised RO-Crate dicts.
626
+ arcs: Async generator or async iterator yielding ARC objects,
627
+ pre-serialised RO-Crate dicts, or JSON strings.
623
628
  expected_datasets: Optional hint about the total number of ARCs.
624
629
 
625
630
  Returns:
@@ -631,7 +636,7 @@ class ApiClient:
631
636
 
632
637
  Example::
633
638
 
634
- async def my_arcs() -> AsyncGenerator[dict, None]:
639
+ async def my_arcs() -> AsyncGenerator[dict | str, None]:
635
640
  for arc in source:
636
641
  yield arc
637
642
 
@@ -226,6 +226,28 @@ async def test_create_or_update_arc_with_dict(client_config: Config) -> None:
226
226
  assert isinstance(response, ArcResult)
227
227
 
228
228
 
229
+ @pytest.mark.asyncio
230
+ @respx.mock
231
+ async def test_create_or_update_arc_with_json_string(client_config: Config) -> None:
232
+ """Test create_or_update_arc with a JSON string."""
233
+ route = respx.post(f"{client_config.api_url}v3/arcs").mock(
234
+ return_value=httpx.Response(http.HTTPStatus.OK, json=_ARC_RESPONSE)
235
+ )
236
+ async with ApiClient(client_config) as client:
237
+ response = await client.create_or_update_arc(rdi="test-rdi", arc='{"id": "mock-arc"}')
238
+ assert route.called
239
+ assert isinstance(response, ArcResult)
240
+ assert response.arc_id == "arc-123"
241
+
242
+
243
+ @pytest.mark.asyncio
244
+ async def test_create_or_update_arc_with_invalid_json_string(client_config: Config) -> None:
245
+ """Test create_or_update_arc with an invalid JSON string."""
246
+ async with ApiClient(client_config) as client:
247
+ with pytest.raises(ApiClientError, match="Invalid JSON string provided for ARC"):
248
+ await client.create_or_update_arc(rdi="test-rdi", arc='{"id": "mock-arc"')
249
+
250
+
229
251
  @pytest.mark.asyncio
230
252
  @respx.mock
231
253
  async def test_create_or_update_arc_http_error(client_config: Config) -> None:
@@ -495,13 +517,35 @@ async def test_submit_arc_in_harvest_invalid_response(client_config: Config) ->
495
517
  await client.submit_arc_in_harvest("harvest-456", arc={"id": "mock"})
496
518
 
497
519
 
520
+ @pytest.mark.asyncio
521
+ @respx.mock
522
+ async def test_submit_arc_in_harvest_with_json_string(client_config: Config) -> None:
523
+ """Test submit_arc_in_harvest with a JSON string."""
524
+ route = respx.post(f"{client_config.api_url}v3/harvests/harvest-456/arcs").mock(
525
+ return_value=httpx.Response(http.HTTPStatus.OK, json=_ARC_RESPONSE)
526
+ )
527
+ async with ApiClient(client_config) as client:
528
+ response = await client.submit_arc_in_harvest("harvest-456", arc='{"id": "mock-arc"}')
529
+ assert route.called
530
+ assert isinstance(response, ArcResult)
531
+ assert response.arc_id == "arc-123"
532
+
533
+
534
+ @pytest.mark.asyncio
535
+ async def test_submit_arc_in_harvest_with_invalid_json_string(client_config: Config) -> None:
536
+ """Test submit_arc_in_harvest with an invalid JSON string."""
537
+ async with ApiClient(client_config) as client:
538
+ with pytest.raises(ApiClientError, match="Invalid JSON string provided for ARC"):
539
+ await client.submit_arc_in_harvest("harvest-456", arc='{"id": "mock-arc"')
540
+
541
+
498
542
  # ---------------------------------------------------------------------------
499
543
  # harvest_arcs
500
544
  # ---------------------------------------------------------------------------
501
545
 
502
546
 
503
- async def _arc_gen(*arcs: "dict[str, Any]") -> AsyncGenerator["dict[str, Any]", None]:
504
- """Yield the provided arc dicts as an async generator."""
547
+ async def _arc_gen(*arcs: "dict[str, Any] | str | ARC") -> AsyncGenerator["dict[str, Any] | str | ARC", None]:
548
+ """Yield the provided arc dicts, JSON strings, or ARC objects as an async generator."""
505
549
  for arc in arcs:
506
550
  yield arc
507
551
 
@@ -650,6 +694,52 @@ async def test_harvest_arcs_cancels_on_catastrophic_error(client_config: Config)
650
694
  assert cancel_route.called
651
695
 
652
696
 
697
+ @pytest.mark.asyncio
698
+ @respx.mock
699
+ async def test_harvest_arcs_with_json_string(client_config: Config) -> None:
700
+ """harvest_arcs supports JSON strings in async generator."""
701
+ completed_response = {**_HARVEST_RESPONSE, "status": "COMPLETED", "completed_at": "2024-01-01T01:00:00Z"}
702
+ respx.post(f"{client_config.api_url}v3/harvests").mock(
703
+ return_value=httpx.Response(http.HTTPStatus.OK, json=_HARVEST_RESPONSE)
704
+ )
705
+ respx.post(f"{client_config.api_url}v3/harvests/harvest-456/arcs").mock(
706
+ return_value=httpx.Response(http.HTTPStatus.OK, json=_ARC_RESPONSE)
707
+ )
708
+ respx.post(f"{client_config.api_url}v3/harvests/harvest-456/complete").mock(
709
+ return_value=httpx.Response(http.HTTPStatus.OK, json=completed_response)
710
+ )
711
+
712
+ arcs = _arc_gen(
713
+ '{"id": "arc-1-string"}',
714
+ {"id": "arc-2-dict"},
715
+ ARC.from_arc_investigation(ArcInvestigation.create(identifier="test", title="Test")),
716
+ )
717
+ async with ApiClient(client_config) as client:
718
+ result = await client.harvest_arcs("test-rdi", arcs, expected_datasets=3)
719
+
720
+ assert isinstance(result, HarvestResult)
721
+ assert result.status == "COMPLETED"
722
+
723
+
724
+ @pytest.mark.asyncio
725
+ @respx.mock
726
+ async def test_harvest_arcs_with_invalid_json_string(client_config: Config) -> None:
727
+ """harvest_arcs raises ApiClientError when JSON string is invalid."""
728
+ # Mock the harvest creation endpoint to prevent actual HTTP requests
729
+ respx.post(f"{client_config.api_url}v3/harvests").mock(
730
+ return_value=httpx.Response(http.HTTPStatus.OK, json=_HARVEST_RESPONSE)
731
+ )
732
+ # Mock the harvest cancellation endpoint
733
+ respx.delete(f"{client_config.api_url}v3/harvests/harvest-456").mock(
734
+ return_value=httpx.Response(http.HTTPStatus.NO_CONTENT)
735
+ )
736
+
737
+ async with ApiClient(client_config) as client:
738
+ arcs = _arc_gen('{"id": "arc-1"') # Single invalid JSON string
739
+ with pytest.raises(ApiClientError, match="Invalid JSON string provided for ARC"):
740
+ await client.harvest_arcs("test-rdi", arcs)
741
+
742
+
653
743
  @pytest.mark.asyncio
654
744
  @respx.mock
655
745
  async def test_harvest_arcs_cancel_failure_does_not_mask_original_error(client_config: Config) -> None: