prisma-api 0.3.9__tar.gz → 0.3.10__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prisma_api
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: A package for interacting with PrISMa Platform APIs. PrISMa is a holistic system for numerical synthesis and assessment of Metal-Organic Frameworks and their applications, for Direct Air Capture of CO2. For more information, see: https://prisma.hw.ac.uk.
5
5
  Author-email: RCCS-CaptureTeam <team@prisma-platform.org>
6
6
  License: GNUv3
@@ -22,6 +22,9 @@ from typing import Any
22
22
  from pathlib import Path
23
23
  from urllib.parse import urlencode
24
24
  import warnings
25
+ import json
26
+ import re
27
+ from datetime import datetime
25
28
 
26
29
 
27
30
  _BASE_PROD = "https://prisma-platform.org/api/v2"
@@ -141,6 +144,76 @@ class PrismaAPIv2:
141
144
  "raw": cif_text,
142
145
  }
143
146
 
147
+ def _coerce_scalar(self, value: Any) -> Any:
148
+ """Best-effort conversion of scalar API values to native Python types."""
149
+ if not isinstance(value, str):
150
+ return value
151
+
152
+ text = value.strip()
153
+ if text == "":
154
+ return value
155
+
156
+ lowered = text.lower()
157
+ if lowered == "true":
158
+ return True
159
+ if lowered == "false":
160
+ return False
161
+
162
+ # Strict integer parsing first to avoid converting floats like '1.0' to int.
163
+ if re.fullmatch(r"[+-]?\d+", text):
164
+ try:
165
+ return int(text)
166
+ except ValueError:
167
+ pass
168
+
169
+ # Support decimal/scientific notation.
170
+ if re.fullmatch(r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?", text):
171
+ try:
172
+ return float(text)
173
+ except ValueError:
174
+ pass
175
+
176
+ # Parse JSON list/dict payloads often returned as strings.
177
+ if (text.startswith("[") and text.endswith("]")) or (
178
+ text.startswith("{") and text.endswith("}")
179
+ ):
180
+ try:
181
+ parsed = json.loads(text)
182
+ if isinstance(parsed, (list, dict)):
183
+ return parsed
184
+ except json.JSONDecodeError:
185
+ pass
186
+
187
+ # Parse ISO-ish datetime strings.
188
+ if any(sep in text for sep in ("-", "T", ":")):
189
+ try:
190
+ return datetime.fromisoformat(text.replace("Z", "+00:00"))
191
+ except ValueError:
192
+ pass
193
+
194
+ return value
195
+
196
+ def _coerce_scope_types(self, payload: Any) -> Any:
197
+ """Normalize scope endpoint payload values to useful runtime types."""
198
+ if isinstance(payload, dict):
199
+ return {k: self._coerce_scalar(v) for k, v in payload.items()}
200
+
201
+ if isinstance(payload, list):
202
+ if payload and all(isinstance(item, dict) for item in payload):
203
+ return [{k: self._coerce_scalar(v) for k, v in row.items()} for row in payload]
204
+ return [self._coerce_scalar(v) for v in payload]
205
+
206
+ if isinstance(payload, pd.DataFrame):
207
+ df = payload.copy()
208
+ for col in df.columns:
209
+ df[col] = df[col].map(self._coerce_scalar)
210
+ non_null = df[col].dropna()
211
+ if len(non_null) > 0 and non_null.map(lambda v: isinstance(v, datetime)).all():
212
+ df[col] = pd.to_datetime(df[col], errors="coerce")
213
+ return df
214
+
215
+ return payload
216
+
144
217
  # ── Health ────────────────────────────────────────────────────────────────
145
218
 
146
219
  def health(self) -> dict:
@@ -1245,6 +1318,16 @@ class PrismaAPIv2:
1245
1318
  """GET /api/v2/sources/{source_id}/"""
1246
1319
  return self._get(f"/sources/{source_id}/")
1247
1320
 
1321
+ def get_scopes(self, name: str | None = None,
1322
+ limit: int = 500, offset: int = 0) -> pd.DataFrame:
1323
+ """GET /api/v2/scopes/"""
1324
+ params = _compact(name=name, limit=limit, offset=offset)
1325
+ return self._coerce_scope_types(self._to_df(self._get("/scopes/", params)))
1326
+
1327
+ def get_scope(self, scope_id: int) -> dict:
1328
+ """GET /api/v2/scopes/{scope_id}/"""
1329
+ return self._coerce_scope_types(self._get(f"/scopes/{scope_id}/"))
1330
+
1248
1331
  def get_sinks(self, name: str | None = None,
1249
1332
  limit: int = 500, offset: int = 0) -> pd.DataFrame:
1250
1333
  """GET /api/v2/sinks/"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prisma_api
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: A package for interacting with PrISMa Platform APIs. PrISMa is a holistic system for numerical synthesis and assessment of Metal-Organic Frameworks and their applications, for Direct Air Capture of CO2. For more information, see: https://prisma.hw.ac.uk.
5
5
  Author-email: RCCS-CaptureTeam <team@prisma-platform.org>
6
6
  License: GNUv3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "prisma_api"
7
- version = "0.3.9"
7
+ version = "0.3.10"
8
8
  description = "A package for interacting with PrISMa Platform APIs. PrISMa is a holistic system for numerical synthesis and assessment of Metal-Organic Frameworks and their applications, for Direct Air Capture of CO2. For more information, see: https://prisma.hw.ac.uk."
9
9
  authors = [
10
10
  {name = "RCCS-CaptureTeam", email = "team@prisma-platform.org"}
@@ -14,6 +14,9 @@ import os
14
14
  import pytest
15
15
  import responses as resp_lib
16
16
  from responses import matchers
17
+ from datetime import datetime
18
+
19
+ import pandas as pd
17
20
 
18
21
  from prisma_api.prisma_api_v2 import PrismaAPIv2, _BASE_PROD
19
22
 
@@ -461,6 +464,7 @@ def test_get_region_detail(api):
461
464
 
462
465
  @pytest.mark.parametrize("method,path,fixture", [
463
466
  ("get_sources", "/sources/", [{"id": 1, "name": "Coal Plant", "short_name": "CP"}]),
467
+ ("get_scopes", "/scopes/", [{"id": 7, "name": "Point Source"}]),
464
468
  ("get_sinks", "/sinks/", [{"id": 2, "name": "North Sea"}]),
465
469
  ("get_transport_scenarios", "/transport-scenarios/", [{"id": 3, "name": "Pipeline 200km"}]),
466
470
  ("get_utilities", "/utilities/", [{"id": 4, "name": "Steam"}]),
@@ -476,6 +480,7 @@ def test_catalog_list_endpoints_return_dataframe(api, method, path, fixture):
476
480
 
477
481
  @pytest.mark.parametrize("method,path,record", [
478
482
  ("get_source", "/sources/1/", {"id": 1, "name": "Coal Plant"}),
483
+ ("get_scope", "/scopes/7/", {"id": 7, "name": "Point Source"}),
479
484
  ("get_sink", "/sinks/2/", {"id": 2, "name": "North Sea"}),
480
485
  ("get_transport_scenario", "/transport-scenarios/3/",{"id": 3, "name": "Pipeline"}),
481
486
  ("get_utility", "/utilities/4/", {"id": 4, "name": "Steam"}),
@@ -820,6 +825,7 @@ def test_get_mea_kpi_detail(api):
820
825
 
821
826
  @pytest.mark.parametrize("method,path,fixture", [
822
827
  ("get_sources", "/sources/", [{"id": 1, "name": "Coal Plant", "short_name": "CP"}]),
828
+ ("get_scopes", "/scopes/", [{"id": 7, "name": "Point Source"}]),
823
829
  ("get_sinks", "/sinks/", [{"id": 2, "name": "North Sea"}]),
824
830
  ("get_transport_scenarios", "/transport-scenarios/", [{"id": 3, "name": "Pipeline 200km"}]),
825
831
  ("get_utilities", "/utilities/", [{"id": 4, "name": "Steam"}]),
@@ -835,6 +841,7 @@ def test_catalog_list_endpoints_return_dataframe(api, method, path, fixture):
835
841
 
836
842
  @pytest.mark.parametrize("method,path,record", [
837
843
  ("get_source", "/sources/1/", {"id": 1, "name": "Coal Plant"}),
844
+ ("get_scope", "/scopes/7/", {"id": 7, "name": "Point Source"}),
838
845
  ("get_sink", "/sinks/2/", {"id": 2, "name": "North Sea"}),
839
846
  ("get_transport_scenario", "/transport-scenarios/3/",{"id": 3, "name": "Pipeline"}),
840
847
  ("get_utility", "/utilities/4/", {"id": 4, "name": "Steam"}),
@@ -865,6 +872,76 @@ def test_get_isotherm_returns_dataframe(api):
865
872
  assert_df_columns(df, "id", "mof", "molecule", "T_ref_K", "sim_or_exp", "good_structure")
866
873
 
867
874
 
875
+ # ── Scopes ───────────────────────────────────────────────────────────────────
876
+
877
+ @resp_lib.activate
878
+ def test_get_scopes_name_filter(api):
879
+ resp_lib.add(
880
+ resp_lib.GET,
881
+ f"{PROD_BASE}/scopes/",
882
+ match=[matchers.query_param_matcher({"name": "point", "limit": "500", "offset": "0"})],
883
+ json=_envelope([{"id": 7, "name": "Point Source"}]),
884
+ status=200,
885
+ )
886
+ df = api.get_scopes(name="point")
887
+ assert df.iloc[0]["name"] == "Point Source"
888
+
889
+
890
+ @resp_lib.activate
891
+ def test_get_scopes_coerces_types(api):
892
+ resp_lib.add(
893
+ resp_lib.GET,
894
+ f"{PROD_BASE}/scopes/",
895
+ json=_envelope([
896
+ {
897
+ "id": "7",
898
+ "name": "Point Source",
899
+ "active": "true",
900
+ "capture_efficiency": "0.935",
901
+ "created_at": "2026-07-01T12:34:56Z",
902
+ "tags": "[\"pilot\", \"uk\"]",
903
+ }
904
+ ]),
905
+ status=200,
906
+ )
907
+
908
+ df = api.get_scopes()
909
+ row = df.iloc[0]
910
+
911
+ assert isinstance(row["id"], int)
912
+ assert isinstance(row["name"], str)
913
+ assert isinstance(row["active"], bool)
914
+ assert isinstance(row["capture_efficiency"], float)
915
+ assert pd.api.types.is_datetime64_any_dtype(df["created_at"])
916
+ assert isinstance(row["tags"], list)
917
+
918
+
919
+ @resp_lib.activate
920
+ def test_get_scope_detail_coerces_types(api):
921
+ resp_lib.add(
922
+ resp_lib.GET,
923
+ f"{PROD_BASE}/scopes/7/",
924
+ json={
925
+ "id": "7",
926
+ "name": "Point Source",
927
+ "active": "false",
928
+ "capture_efficiency": "1.05",
929
+ "created_at": "2026-07-01T12:34:56Z",
930
+ "tags": "[\"a\", \"b\"]",
931
+ },
932
+ status=200,
933
+ )
934
+
935
+ scope = api.get_scope(7)
936
+
937
+ assert isinstance(scope["id"], int)
938
+ assert isinstance(scope["name"], str)
939
+ assert isinstance(scope["active"], bool)
940
+ assert isinstance(scope["capture_efficiency"], float)
941
+ assert isinstance(scope["created_at"], datetime)
942
+ assert isinstance(scope["tags"], list)
943
+
944
+
868
945
  @resp_lib.activate
869
946
  def test_get_isotherm_all_filters_passed(api):
870
947
  expected_params = {
File without changes
File without changes
File without changes