subspacecomputing 0.1.0__tar.gz → 0.1.2__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 (20) hide show
  1. {subspacecomputing-0.1.0/subspacecomputing.egg-info → subspacecomputing-0.1.2}/PKG-INFO +13 -2
  2. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/README.md +12 -1
  3. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/pyproject.toml +2 -1
  4. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/setup.py +1 -1
  5. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/subspacecomputing/__init__.py +1 -1
  6. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/subspacecomputing/client.py +66 -19
  7. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/subspacecomputing/errors.py +16 -4
  8. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/subspacecomputing/utils/pandas_integration.py +1 -1
  9. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2/subspacecomputing.egg-info}/PKG-INFO +13 -2
  10. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/subspacecomputing.egg-info/SOURCES.txt +2 -1
  11. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/tests/test_client_unit.py +47 -0
  12. subspacecomputing-0.1.2/tests/test_pandas_integration.py +41 -0
  13. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/LICENSE +0 -0
  14. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/MANIFEST.in +0 -0
  15. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/setup.cfg +0 -0
  16. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/subspacecomputing/utils/__init__.py +0 -0
  17. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/subspacecomputing.egg-info/dependency_links.txt +0 -0
  18. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/subspacecomputing.egg-info/requires.txt +0 -0
  19. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/subspacecomputing.egg-info/top_level.txt +0 -0
  20. {subspacecomputing-0.1.0 → subspacecomputing-0.1.2}/tests/test_client_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: subspacecomputing
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Python SDK for Subspace Computing Engine API (by Beausoft)
5
5
  Home-page: https://www.subspacecomputing.com/developer
6
6
  Author: Beausoft
@@ -49,6 +49,17 @@ bsce = BSCE(api_key='your-api-key-here')
49
49
  # bsce = BSCE(api_key='your-api-key-here', base_url='http://localhost:8000')
50
50
  ```
51
51
 
52
+ ### Teams (`X-Team-Id`)
53
+
54
+ Keys can carry a `default_team_id` from the portal. If the user is in **several** teams, some endpoints require an explicit team: pass `team_id=...` or call `set_team_id` so the SDK sends `X-Team-Id`.
55
+
56
+ If you omit it when required, the API returns **400** with `error.reason` **`team_context_required`**. Use `ValidationError` and read `exc.reason`.
57
+
58
+ ```python
59
+ bsce = BSCE(api_key="...", team_id="00000000-0000-0000-0000-000000000000")
60
+ bsce.set_team_id(None) # clear override for following requests
61
+ ```
62
+
52
63
  ### Simple Projection (1 scenario)
53
64
 
54
65
  ```python
@@ -245,7 +256,7 @@ if quota:
245
256
 
246
257
  Check out the full documentation at https://www.subspacecomputing.com/developer
247
258
 
248
- API reference is available at https://api.subspacecomputing.com/docs
259
+ API reference is available at https://www.subspacecomputing.com/docs
249
260
 
250
261
  For support, reach out to contact@beausoft.ca
251
262
 
@@ -22,6 +22,17 @@ bsce = BSCE(api_key='your-api-key-here')
22
22
  # bsce = BSCE(api_key='your-api-key-here', base_url='http://localhost:8000')
23
23
  ```
24
24
 
25
+ ### Teams (`X-Team-Id`)
26
+
27
+ Keys can carry a `default_team_id` from the portal. If the user is in **several** teams, some endpoints require an explicit team: pass `team_id=...` or call `set_team_id` so the SDK sends `X-Team-Id`.
28
+
29
+ If you omit it when required, the API returns **400** with `error.reason` **`team_context_required`**. Use `ValidationError` and read `exc.reason`.
30
+
31
+ ```python
32
+ bsce = BSCE(api_key="...", team_id="00000000-0000-0000-0000-000000000000")
33
+ bsce.set_team_id(None) # clear override for following requests
34
+ ```
35
+
25
36
  ### Simple Projection (1 scenario)
26
37
 
27
38
  ```python
@@ -218,7 +229,7 @@ if quota:
218
229
 
219
230
  Check out the full documentation at https://www.subspacecomputing.com/developer
220
231
 
221
- API reference is available at https://api.subspacecomputing.com/docs
232
+ API reference is available at https://www.subspacecomputing.com/docs
222
233
 
223
234
  For support, reach out to contact@beausoft.ca
224
235
 
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "subspacecomputing"
9
- version = "0.1.0"
9
+ version = "0.1.2"
10
10
  description = "Python SDK for Subspace Computing Engine API (by Beausoft)"
11
11
  authors = [{name = "Beausoft", email = "contact@beausoft.ca"}]
12
12
  readme = "README.md"
@@ -25,6 +25,7 @@ dev = [
25
25
  "fastapi>=0.100.0", # Pour tests d'intégration
26
26
  ]
27
27
 
28
+ # No Git repository URL: built from a private monorepo; only public product URLs are published on PyPI.
28
29
  [project.urls]
29
30
  Documentation = "https://www.subspacecomputing.com/developer"
30
31
  Homepage = "https://www.subspacecomputing.com/developer"
@@ -13,7 +13,7 @@ except FileNotFoundError:
13
13
 
14
14
  setup(
15
15
  name="subspacecomputing",
16
- version="0.1.0",
16
+ version="0.1.2",
17
17
  description="Python SDK for Subspace Computing Engine API (by Beausoft)",
18
18
  long_description=long_description,
19
19
  long_description_content_type="text/markdown",
@@ -13,7 +13,7 @@ from .errors import (
13
13
  ValidationError,
14
14
  )
15
15
 
16
- __version__ = "0.1.0"
16
+ __version__ = "0.1.2"
17
17
  __all__ = [
18
18
  "BSCE",
19
19
  "BSCEError",
@@ -26,6 +26,8 @@ class BSCE:
26
26
  api_key: str,
27
27
  base_url: Optional[str] = None,
28
28
  timeout: Optional[int] = None,
29
+ *,
30
+ team_id: Optional[str] = None,
29
31
  ):
30
32
  """
31
33
  Initialize the BSCE client.
@@ -36,6 +38,7 @@ class BSCE:
36
38
  base_url: API base URL (optional, defaults to https://api.subspacecomputing.com).
37
39
  Only useful for local testing or custom environments.
38
40
  timeout: Request timeout in seconds (optional, defaults to 60).
41
+ team_id: Optional team UUID for header ``X-Team-Id`` (override default_team_id on the key).
39
42
 
40
43
  Raises:
41
44
  ValueError: If api_key is empty or None.
@@ -47,13 +50,25 @@ class BSCE:
47
50
  self.api_key = api_key.strip()
48
51
  self.base_url = (base_url or "https://api.subspacecomputing.com").rstrip("/")
49
52
  self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
53
+ self._team_id = (team_id or "").strip() or None
50
54
  self.session = requests.Session()
51
- self.session.headers.update({
55
+ headers = {
52
56
  "X-API-Key": self.api_key,
53
57
  "Content-Type": "application/json",
54
- })
58
+ }
59
+ if self._team_id:
60
+ headers["X-Team-Id"] = self._team_id
61
+ self.session.headers.update(headers)
55
62
  self.last_response = None # Store last response for header access
56
63
 
64
+ def set_team_id(self, team_id: Optional[str] = None) -> None:
65
+ """Set or clear ``X-Team-Id`` for subsequent requests."""
66
+ self._team_id = (team_id or "").strip() or None
67
+ if self._team_id:
68
+ self.session.headers["X-Team-Id"] = self._team_id
69
+ else:
70
+ self.session.headers.pop("X-Team-Id", None)
71
+
57
72
  def _handle_response(self, response: requests.Response):
58
73
  """
59
74
  Handle HTTP errors and raise appropriate exceptions.
@@ -140,15 +155,15 @@ class BSCE:
140
155
  scenario_id: int,
141
156
  ) -> Dict[str, Any]:
142
157
  """
143
- Rejoue un scénario spécifique d'une simulation précédente.
158
+ Replay a specific scenario from a previous simulation.
144
159
 
145
160
  Args:
146
- original_spec: SP Model complet de la simulation initiale
147
- original_seeds: Objet seeds de la réponse de simulate() (seeds.global, seeds.scenarios)
148
- scenario_id: Index du scénario à rejouer (0 à scenarios-1)
161
+ original_spec: Full SP Model from the initial simulation
162
+ original_seeds: Seeds object from simulate() response (seeds.global, seeds.scenarios)
163
+ scenario_id: Index of the scenario to replay (0 to scenarios-1)
149
164
 
150
165
  Returns:
151
- Réponse simulate pour ce scénario unique (final_values, sample_path_s0, etc.)
166
+ Simulate response for this single scenario (final_values, sample_path_s0, etc.)
152
167
 
153
168
  Example:
154
169
  result = client.simulate(spec)
@@ -401,23 +416,24 @@ class BSCE:
401
416
 
402
417
  def replay_run(self, run_id: str, scenario_id: int) -> Dict[str, Any]:
403
418
  """
404
- Rejoue un scénario à partir d'une run stockée (nécessite seeds persistés).
419
+ Replay a scenario from a stored run (requires persisted seeds).
405
420
 
406
421
  Args:
407
- run_id: ID de la run (créée avec meta.store_seeds_for_replay: true)
408
- scenario_id: Index du scénario à rejouer (0 à scenarios-1)
422
+ run_id: Run ID (created with meta.store_seeds_for_replay: true)
423
+ scenario_id: Index of the scenario to replay (0 to scenarios-1)
409
424
 
410
425
  Returns:
411
- Réponse simulate pour ce scénario unique (final_values, sample_path_s0, etc.)
426
+ Simulate response for this single scenario (final_values, sample_path_s0, etc.)
412
427
 
413
428
  Raises:
414
- ValidationError: Si seeds non stockés ou scenario_id hors limites
415
- AuthenticationError: Si clé API invalide
429
+ ValidationError: If seeds not stored or scenario_id out of range
430
+ AuthenticationError: If API key invalid
416
431
 
417
432
  Example:
418
- result = client.simulate(spec) # avec store_seeds_for_replay: true
433
+ result = client.simulate(spec) # with store_seeds_for_replay: true
419
434
  replay = client.replay_run(result["run_id"], scenario_id=12)
420
435
  """
436
+ # API expects run_id and scenario_id in URL path; no request body required
421
437
  response = self.session.post(
422
438
  f"{self.base_url}/projection-runs/{run_id}/replay/{scenario_id}",
423
439
  timeout=self.timeout,
@@ -444,8 +460,39 @@ class BSCE:
444
460
  self._handle_response(response)
445
461
  # 204 No Content, no body
446
462
 
463
+ def get_run_artifact(self, artifact_id: str) -> Dict[str, Any]:
464
+ """
465
+ Fetch a run artifact by id (authZ: view on parent projection).
466
+
467
+ Args:
468
+ artifact_id: run_artifacts.id
469
+
470
+ Returns:
471
+ Artifact payload (may include final_values or url).
472
+ """
473
+ response = self.session.get(
474
+ f"{self.base_url}/run-artifacts/{artifact_id}",
475
+ timeout=self.timeout,
476
+ )
477
+ self.last_response = response
478
+ self._handle_response(response)
479
+ return response.json()
480
+
447
481
  # Helper methods for accessing rate limit and quota info
448
482
 
483
+ @staticmethod
484
+ def _safe_int(val: Optional[str]) -> Optional[int]:
485
+ """
486
+ Safely convert a header value to int.
487
+ Returns None for None, 'unlimited', or non-numeric strings.
488
+ """
489
+ if val is None or val == "unlimited":
490
+ return None
491
+ try:
492
+ return int(val)
493
+ except (ValueError, TypeError):
494
+ return None
495
+
449
496
  def get_rate_limit_info(self) -> Optional[Dict[str, Any]]:
450
497
  """
451
498
  Get rate limit information from the last response.
@@ -460,8 +507,8 @@ class BSCE:
460
507
  if limit is None and remaining is None:
461
508
  return None
462
509
  return {
463
- "limit": int(limit) if limit else None,
464
- "remaining": int(remaining) if remaining else None,
510
+ "limit": self._safe_int(limit),
511
+ "remaining": self._safe_int(remaining),
465
512
  }
466
513
 
467
514
  def get_quota_info(self) -> Optional[Dict[str, Any]]:
@@ -479,7 +526,7 @@ class BSCE:
479
526
  if limit is None and remaining is None and used is None:
480
527
  return None
481
528
  return {
482
- "limit": int(limit) if limit and limit != "unlimited" else None,
483
- "remaining": int(remaining) if remaining else None,
484
- "used": int(used) if used else None,
529
+ "limit": self._safe_int(limit),
530
+ "remaining": self._safe_int(remaining),
531
+ "used": self._safe_int(used),
485
532
  }
@@ -3,7 +3,7 @@ Custom exceptions for the Subspace Computing SDK.
3
3
  """
4
4
 
5
5
  import requests
6
- from typing import Optional
6
+ from typing import Any, Optional
7
7
 
8
8
 
9
9
  class BSCEError(Exception):
@@ -13,19 +13,31 @@ class BSCEError(Exception):
13
13
  self.message = message
14
14
  self.response = response
15
15
  self.status_code = response.status_code if response else None
16
- self.detail = None
16
+ self.detail: Any = None
17
+ self._body: Optional[dict] = None
17
18
 
18
- # Try to extract error details from the response
19
19
  if response is not None:
20
20
  try:
21
21
  error_data = response.json()
22
22
  if isinstance(error_data, dict):
23
+ self._body = error_data
23
24
  self.detail = error_data.get("detail", error_data.get("message"))
24
- except (ValueError, KeyError):
25
+ except ValueError:
25
26
  self.detail = response.text
26
27
 
27
28
  super().__init__(self.message)
28
29
 
30
+ @property
31
+ def reason(self) -> Optional[str]:
32
+ """Clé stable contrat §6.8 si le corps est ``{\"error\": {\"reason\": ...}}``."""
33
+ if not self._body:
34
+ return None
35
+ err = self._body.get("error")
36
+ if isinstance(err, dict):
37
+ r = err.get("reason")
38
+ return str(r) if r is not None else None
39
+ return None
40
+
29
41
  def __str__(self):
30
42
  if self.detail:
31
43
  return f"{self.message}: {self.detail}"
@@ -14,7 +14,7 @@ def batch_response_to_dataframe(response: dict):
14
14
  import pandas as pd
15
15
 
16
16
  rows = []
17
- for entity in response.get("entities", []):
17
+ for entity in response.get("entities") or []:
18
18
  row = {"entity_id": entity.get("_entity_id", "")}
19
19
  fv = entity.get("final_values", {})
20
20
  for k, v in fv.items():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: subspacecomputing
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Python SDK for Subspace Computing Engine API (by Beausoft)
5
5
  Home-page: https://www.subspacecomputing.com/developer
6
6
  Author: Beausoft
@@ -49,6 +49,17 @@ bsce = BSCE(api_key='your-api-key-here')
49
49
  # bsce = BSCE(api_key='your-api-key-here', base_url='http://localhost:8000')
50
50
  ```
51
51
 
52
+ ### Teams (`X-Team-Id`)
53
+
54
+ Keys can carry a `default_team_id` from the portal. If the user is in **several** teams, some endpoints require an explicit team: pass `team_id=...` or call `set_team_id` so the SDK sends `X-Team-Id`.
55
+
56
+ If you omit it when required, the API returns **400** with `error.reason` **`team_context_required`**. Use `ValidationError` and read `exc.reason`.
57
+
58
+ ```python
59
+ bsce = BSCE(api_key="...", team_id="00000000-0000-0000-0000-000000000000")
60
+ bsce.set_team_id(None) # clear override for following requests
61
+ ```
62
+
52
63
  ### Simple Projection (1 scenario)
53
64
 
54
65
  ```python
@@ -245,7 +256,7 @@ if quota:
245
256
 
246
257
  Check out the full documentation at https://www.subspacecomputing.com/developer
247
258
 
248
- API reference is available at https://api.subspacecomputing.com/docs
259
+ API reference is available at https://www.subspacecomputing.com/docs
249
260
 
250
261
  For support, reach out to contact@beausoft.ca
251
262
 
@@ -14,4 +14,5 @@ subspacecomputing.egg-info/top_level.txt
14
14
  subspacecomputing/utils/__init__.py
15
15
  subspacecomputing/utils/pandas_integration.py
16
16
  tests/test_client_integration.py
17
- tests/test_client_unit.py
17
+ tests/test_client_unit.py
18
+ tests/test_pandas_integration.py
@@ -331,3 +331,50 @@ class TestBatchParameters:
331
331
  assert 'aggregations' in call_args[1]['json']
332
332
  assert result['aggregations']['capital_total'] == 3000.0
333
333
 
334
+
335
+ class TestRateLimitQuotaInfo:
336
+ """Tests for get_rate_limit_info and get_quota_info with _safe_int."""
337
+
338
+ def test_get_rate_limit_info_unlimited(self, api_key, base_url):
339
+ """get_rate_limit_info handles 'unlimited' without crashing."""
340
+ bsce = BSCE(api_key=api_key, base_url=base_url)
341
+ mock_response = Mock()
342
+ mock_response.headers = {"X-RateLimit-Limit": "unlimited", "X-RateLimit-Remaining": "999"}
343
+ bsce.last_response = mock_response
344
+
345
+ result = bsce.get_rate_limit_info()
346
+ assert result["limit"] is None
347
+ assert result["remaining"] == 999
348
+
349
+ def test_get_quota_info_unlimited(self, api_key, base_url):
350
+ """get_quota_info handles 'unlimited' in X-Quota-Limit."""
351
+ bsce = BSCE(api_key=api_key, base_url=base_url)
352
+ mock_response = Mock()
353
+ mock_response.headers = {
354
+ "X-Quota-Limit": "unlimited",
355
+ "X-Quota-Remaining": "100",
356
+ "X-Quota-Used": "50",
357
+ }
358
+ bsce.last_response = mock_response
359
+
360
+ result = bsce.get_quota_info()
361
+ assert result["limit"] is None
362
+ assert result["remaining"] == 100
363
+ assert result["used"] == 50
364
+
365
+ def test_get_quota_info_invalid_values(self, api_key, base_url):
366
+ """get_quota_info handles non-numeric values gracefully."""
367
+ bsce = BSCE(api_key=api_key, base_url=base_url)
368
+ mock_response = Mock()
369
+ mock_response.headers = {
370
+ "X-Quota-Limit": "N/A",
371
+ "X-Quota-Remaining": "remaining",
372
+ "X-Quota-Used": "50",
373
+ }
374
+ bsce.last_response = mock_response
375
+
376
+ result = bsce.get_quota_info()
377
+ assert result["limit"] is None
378
+ assert result["remaining"] is None
379
+ assert result["used"] == 50
380
+
@@ -0,0 +1,41 @@
1
+ """
2
+ Unit tests for pandas integration utilities.
3
+ """
4
+
5
+ import pytest
6
+
7
+ pandas = pytest.importorskip("pandas")
8
+
9
+ from subspacecomputing.utils.pandas_integration import batch_response_to_dataframe
10
+
11
+
12
+ class TestBatchResponseToDataframe:
13
+ """Tests for batch_response_to_dataframe."""
14
+
15
+ def test_entities_null_does_not_crash(self):
16
+ """batch_response_to_dataframe handles entities=null without TypeError."""
17
+ response = {"entities": None, "aggregations": {}, "summary": {}}
18
+ df = batch_response_to_dataframe(response)
19
+ assert len(df) == 0
20
+
21
+ def test_entities_missing_uses_empty_list(self):
22
+ """batch_response_to_dataframe handles missing entities key."""
23
+ response = {"aggregations": {}, "summary": {}}
24
+ df = batch_response_to_dataframe(response)
25
+ assert len(df) == 0
26
+
27
+ def test_entities_normal(self):
28
+ """batch_response_to_dataframe converts entities to DataFrame."""
29
+ response = {
30
+ "entities": [
31
+ {"_entity_id": "e1", "final_values": {"capital": 1000, "taux": 0.05}},
32
+ {"_entity_id": "e2", "final_values": {"capital": 2000, "taux": 0.06}},
33
+ ],
34
+ "aggregations": {},
35
+ "summary": {},
36
+ }
37
+ df = batch_response_to_dataframe(response)
38
+ assert len(df) == 2
39
+ assert list(df.columns) == ["entity_id", "capital", "taux"]
40
+ assert df["entity_id"].tolist() == ["e1", "e2"]
41
+ assert df["capital"].tolist() == [1000, 2000]