prisma-api 0.3.8__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.8
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
@@ -1,4 +1,4 @@
1
- __version__ = "0.3.8"
1
+ __version__ = "0.3.9"
2
2
 
3
3
 
4
4
  from prisma_api.prisma_api import prisma_api as init # Main prisma_api class for initialisation
@@ -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:
@@ -275,7 +348,7 @@ class PrismaAPIv2:
275
348
  """
276
349
  GET /api/v2/list_cifs/
277
350
 
278
- Dev-only endpoint (not yet available on the production server).
351
+ List CIF-backed MOF records.
279
352
 
280
353
  Args:
281
354
  tag: Optional tag to filter CIFs by. If omitted, all CIFs are returned.
@@ -283,11 +356,6 @@ class PrismaAPIv2:
283
356
  Returns:
284
357
  List of MOF names matching ``tag``.
285
358
  """
286
- if not self._dev:
287
- raise RuntimeError(
288
- "list_cifs() is only available on the dev server. "
289
- "Initialise with dev=True (e.g. prisma_api.init(local_dev=True))."
290
- )
291
359
  response = self._get("/cifs/", _compact(tag=tag))
292
360
  records = response.get("results", response) if isinstance(response, dict) else response
293
361
  records = records or []
@@ -300,7 +368,7 @@ class PrismaAPIv2:
300
368
  """
301
369
  GET /api/v2/cifs/files/
302
370
 
303
- Dev-only endpoint (not yet available on the production server).
371
+ Fetch CIF payload(s) for one or more MOF names.
304
372
 
305
373
  Args:
306
374
  mof: One MOF name (str) or list of MOF names.
@@ -319,12 +387,6 @@ class PrismaAPIv2:
319
387
  If ``structured=False``: streamed CIF response(s), or saved path(s)
320
388
  when ``save_dir`` is provided.
321
389
  """
322
- if not self._dev:
323
- raise RuntimeError(
324
- "get_cifs() is only available on the dev server. "
325
- "Initialise with dev=True (e.g. prisma_api.init(local_dev=True))."
326
- )
327
-
328
390
  if structured and save_dir is not None:
329
391
  raise ValueError("save_dir is only supported when structured=False.")
330
392
 
@@ -1256,6 +1318,16 @@ class PrismaAPIv2:
1256
1318
  """GET /api/v2/sources/{source_id}/"""
1257
1319
  return self._get(f"/sources/{source_id}/")
1258
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
+
1259
1331
  def get_sinks(self, name: str | None = None,
1260
1332
  limit: int = 500, offset: int = 0) -> pd.DataFrame:
1261
1333
  """GET /api/v2/sinks/"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prisma_api
3
- Version: 0.3.8
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.8"
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