prisma-api 0.3.7__tar.gz → 0.3.8__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.7
3
+ Version: 0.3.8
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
@@ -21,7 +21,6 @@ Dynamic: license-file
21
21
  # PrISMa API
22
22
 
23
23
  [![CI](https://github.com/RCCS-CaptureTeam/prisma_api/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RCCS-CaptureTeam/prisma_api/actions/workflows/ci.yml)
24
- [![Coverage](https://codecov.io/gh/RCCS-CaptureTeam/prisma_api/graph/badge.svg?branch=main)](https://codecov.io/gh/RCCS-CaptureTeam/prisma_api)
25
24
 
26
25
  Python client for the PrISMa platform APIs.
27
26
 
@@ -195,10 +194,11 @@ All methods below are available on `api.v2`.
195
194
  ### Materials and bundles
196
195
 
197
196
  - `list_materials(name=None, limit=10000)`
198
- - `get_material(material_id)`
197
+ - `get_material(material_id=None, name=None, bundle=['isotherms','zeopp','water_kpis','cif'])` (`bundle=None` returns root-only material detail)
199
198
  - `get_materials_psdi(name=None, limit=500, offset=0)`
200
199
  - `get_material_psdi(material_id)`
201
200
  - `get_material_property_bundle(mof, sim_or_exp=None, good_structure=None, limit=500, offset=0, query=None)`
201
+ - `get_material_bundle(mof, sim_or_exp=None, good_structure=None, limit=500, offset=0, query=None, include_cif=False, include_cif_text=False, cif_timeout=60)`
202
202
  - `preflight_material_check(name)`
203
203
 
204
204
  ### Case bundles and pack builders
@@ -1,7 +1,6 @@
1
1
  # PrISMa API
2
2
 
3
3
  [![CI](https://github.com/RCCS-CaptureTeam/prisma_api/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RCCS-CaptureTeam/prisma_api/actions/workflows/ci.yml)
4
- [![Coverage](https://codecov.io/gh/RCCS-CaptureTeam/prisma_api/graph/badge.svg?branch=main)](https://codecov.io/gh/RCCS-CaptureTeam/prisma_api)
5
4
 
6
5
  Python client for the PrISMa platform APIs.
7
6
 
@@ -175,10 +174,11 @@ All methods below are available on `api.v2`.
175
174
  ### Materials and bundles
176
175
 
177
176
  - `list_materials(name=None, limit=10000)`
178
- - `get_material(material_id)`
177
+ - `get_material(material_id=None, name=None, bundle=['isotherms','zeopp','water_kpis','cif'])` (`bundle=None` returns root-only material detail)
179
178
  - `get_materials_psdi(name=None, limit=500, offset=0)`
180
179
  - `get_material_psdi(material_id)`
181
180
  - `get_material_property_bundle(mof, sim_or_exp=None, good_structure=None, limit=500, offset=0, query=None)`
181
+ - `get_material_bundle(mof, sim_or_exp=None, good_structure=None, limit=500, offset=0, query=None, include_cif=False, include_cif_text=False, cif_timeout=60)`
182
182
  - `preflight_material_check(name)`
183
183
 
184
184
  ### Case bundles and pack builders
@@ -1,4 +1,4 @@
1
- __version__ = "0.3.7"
1
+ __version__ = "0.3.8"
2
2
 
3
3
 
4
4
  from prisma_api.prisma_api import prisma_api as init # Main prisma_api class for initialisation
@@ -19,11 +19,13 @@ from __future__ import annotations
19
19
  import pandas as pd
20
20
  import requests
21
21
  from typing import Any
22
+ from pathlib import Path
22
23
  from urllib.parse import urlencode
23
24
  import warnings
24
25
 
25
26
 
26
27
  _BASE_PROD = "https://prisma-platform.org/api/v2"
28
+ _DEFAULT_BUNDLE = object()
27
29
 
28
30
 
29
31
  class PrismaAPIv2:
@@ -120,6 +122,25 @@ class PrismaAPIv2:
120
122
  d = {**d, "cif_url": f"{base}{d['cif_url']}"}
121
123
  return d
122
124
 
125
+ def _parse_cif_text(self, cif_text: str) -> dict[str, Any]:
126
+ """Parse CIF text into a compact structured dict for notebook-friendly use."""
127
+ lines = cif_text.splitlines()
128
+ fields: dict[str, str] = {}
129
+ for line in lines:
130
+ s = line.strip()
131
+ if not s or not s.startswith("_") or " " not in s:
132
+ continue
133
+ key, value = s.split(None, 1)
134
+ fields[key] = value.strip()
135
+
136
+ return {
137
+ "line_count": len(lines),
138
+ "field_count": len(fields),
139
+ "fields": fields,
140
+ "preview": lines[:20],
141
+ "raw": cif_text,
142
+ }
143
+
123
144
  # ── Health ────────────────────────────────────────────────────────────────
124
145
 
125
146
  def health(self) -> dict:
@@ -250,13 +271,339 @@ class PrismaAPIv2:
250
271
  print(f"{len(all_records)} materials loaded from {server}")
251
272
  return self._resolve_cif_url_df(self._to_df({"results": all_records}))
252
273
 
253
- def get_material(self, material_id: int) -> dict:
274
+ def list_cifs(self, tag: str | None = None) -> list[str]:
254
275
  """
255
- GET /api/v2/materials/{material_id}/
276
+ GET /api/v2/list_cifs/
277
+
278
+ Dev-only endpoint (not yet available on the production server).
279
+
280
+ Args:
281
+ tag: Optional tag to filter CIFs by. If omitted, all CIFs are returned.
282
+
283
+ Returns:
284
+ List of MOF names matching ``tag``.
285
+ """
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
+ response = self._get("/cifs/", _compact(tag=tag))
292
+ records = response.get("results", response) if isinstance(response, dict) else response
293
+ records = records or []
294
+ return [r["mof"] if isinstance(r, dict) else r for r in records]
295
+
296
+ def get_cifs(self, mof: str | list[str],
297
+ structured: bool = True,
298
+ pandas: bool = True,
299
+ save_dir: str | None = None) -> dict | list[dict] | requests.Response | list[requests.Response] | str | list[str]:
300
+ """
301
+ GET /api/v2/cifs/files/
302
+
303
+ Dev-only endpoint (not yet available on the production server).
304
+
305
+ Args:
306
+ mof: One MOF name (str) or list of MOF names.
307
+ structured: Request structured JSON response from the API.
308
+ Defaults to ``True``.
309
+ pandas: Convert pandas-suitable structured fields to
310
+ ``pd.DataFrame`` when ``structured=True``.
311
+ Defaults to ``True``.
312
+ save_dir: Optional directory to save downloaded CIF attachment(s).
313
+ Only used when ``structured=False``.
314
+
315
+ Returns:
316
+ If ``structured=True``: nested dict for single MOF or list of nested
317
+ dicts for multiple MOFs, with null-valued keys removed and tabular
318
+ list-of-dict sections converted to ``pd.DataFrame``.
319
+ If ``structured=False``: streamed CIF response(s), or saved path(s)
320
+ when ``save_dir`` is provided.
321
+ """
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
+ if structured and save_dir is not None:
329
+ raise ValueError("save_dir is only supported when structured=False.")
330
+
331
+ output_dir = Path(save_dir).expanduser() if save_dir is not None else None
332
+ if output_dir is not None:
333
+ output_dir.mkdir(parents=True, exist_ok=True)
334
+
335
+ # Field-aware conversion keeps nested structures readable while
336
+ # still promoting genuinely tabular sections to DataFrames.
337
+ _TABULAR_FIELD_NAMES = {
338
+ "tags",
339
+ "elements",
340
+ "citations",
341
+ "references",
342
+ "authors",
343
+ "atom_site",
344
+ "atom_sites",
345
+ "symmetry_operations",
346
+ "symmetry_equiv_pos",
347
+ "bonds",
348
+ "angles",
349
+ "torsions",
350
+ "contacts",
351
+ "void_fractions",
352
+ "channels",
353
+ "coordination",
354
+ "topology",
355
+ }
256
356
 
257
- Returns a dict with material detail including element composition.
357
+ def _is_scalar(value: Any) -> bool:
358
+ return isinstance(value, (str, int, float, bool)) or value is None
359
+
360
+ def _looks_like_tabular_records(records: list[dict]) -> bool:
361
+ if not records:
362
+ return False
363
+ keys = [set(r.keys()) for r in records if isinstance(r, dict)]
364
+ if len(keys) != len(records):
365
+ return False
366
+ shared = set.intersection(*keys) if keys else set()
367
+ if not shared:
368
+ return False
369
+ scalar_hits = 0
370
+ total = 0
371
+ for row in records:
372
+ for value in row.values():
373
+ total += 1
374
+ if _is_scalar(value):
375
+ scalar_hits += 1
376
+ return total > 0 and (scalar_hits / total) >= 0.8
377
+
378
+ def _should_convert_to_df(path: tuple[str, ...], records: list[dict]) -> bool:
379
+ if not pandas:
380
+ return False
381
+ if not _looks_like_tabular_records(records):
382
+ return False
383
+ field_name = path[-1].lower() if path else ""
384
+ if field_name in {"atoms", "geom_bonds", "commit_history"}:
385
+ return True
386
+ if field_name in _TABULAR_FIELD_NAMES:
387
+ return True
388
+ if field_name.endswith(("_table", "_rows", "_records", "_sites")):
389
+ return True
390
+ if field_name.startswith(("atoms_", "bond_", "symmetry_", "cell_")):
391
+ return True
392
+ return False
393
+
394
+ def _clean_and_convert(value: Any, path: tuple[str, ...] = ()) -> Any:
395
+ if isinstance(value, dict):
396
+ cleaned = {
397
+ k: _clean_and_convert(v, path + (k,))
398
+ for k, v in value.items()
399
+ if v is not None
400
+ }
401
+ return cleaned
402
+ if isinstance(value, list):
403
+ cleaned_list = [_clean_and_convert(v, path) for v in value]
404
+ if cleaned_list and all(isinstance(x, dict) for x in cleaned_list):
405
+ if _should_convert_to_df(path, cleaned_list):
406
+ return pd.DataFrame(cleaned_list)
407
+ return cleaned_list
408
+ return value
409
+
410
+ def _extract_filename(resp: requests.Response, fallback_mof: str) -> str:
411
+ cd = resp.headers.get("Content-Disposition", "")
412
+ marker = "filename="
413
+ if marker in cd:
414
+ filename = cd.split(marker, 1)[1].strip().strip('"')
415
+ if filename:
416
+ return filename
417
+ return f"{fallback_mof}.cif"
418
+
419
+ def _one(mof_name: str) -> dict | requests.Response | str:
420
+ if not isinstance(mof_name, str) or not mof_name.strip():
421
+ raise TypeError("Each 'mof' value must be a non-empty string.")
422
+ mof_name = mof_name.strip()
423
+ url = f"{self._base_url()}/cifs/files/"
424
+ resp = requests.get(
425
+ url,
426
+ params={"mof": mof_name, "structured": str(structured).lower()},
427
+ headers=self._headers(),
428
+ timeout=120,
429
+ stream=not structured,
430
+ )
431
+ resp.raise_for_status()
432
+
433
+ if structured:
434
+ return _clean_and_convert(resp.json())
435
+
436
+ if output_dir is not None:
437
+ filename = _extract_filename(resp, mof_name)
438
+ target = output_dir / filename
439
+ with target.open("wb") as f:
440
+ for chunk in resp.iter_content(chunk_size=8192):
441
+ if chunk:
442
+ f.write(chunk)
443
+ resp.close()
444
+ return str(target)
445
+ return resp
446
+
447
+ if isinstance(mof, str):
448
+ return _one(mof)
449
+ if isinstance(mof, list):
450
+ return [_one(name) for name in mof]
451
+ raise TypeError("'mof' must be a string or list of strings.")
452
+
453
+ def get_material(
454
+ self,
455
+ material_id: int | None = None,
456
+ *,
457
+ name: str | list[str] | None = None,
458
+ bundle: list[str] | None | object = _DEFAULT_BUNDLE,
459
+ sim_or_exp: str | None = None,
460
+ good_structure: bool | None = None,
461
+ limit: int = 500,
462
+ offset: int = 0,
463
+ include_cif_text: bool = False,
464
+ cif_timeout: int = 60,
465
+ ) -> dict | list[dict]:
258
466
  """
259
- return self._resolve_cif_url_dict(self._get(f"/materials/{material_id}/"))
467
+ GET /api/v2/materials/{material_id}/
468
+
469
+ Return one or more material detail records, optionally bundled with
470
+ related isotherms / Zeo++ / water KPIs / CIF metadata.
471
+
472
+ Resolution modes:
473
+ * ``material_id`` (int): fetch one material by PK.
474
+ * ``name`` (str): fetch one material by name match resolution.
475
+ * ``name`` (list[str]): fetch many materials, one per name.
476
+
477
+ Bundle behavior:
478
+ * omitted ``bundle``: include all bundles
479
+ ``['isotherms', 'zeopp', 'water_kpis', 'cif']``
480
+ * ``bundle=None``: include no additional bundled data (root only)
481
+ * explicit list: include only requested bundle sections.
482
+
483
+ Returns:
484
+ dict for a single material lookup; list[dict] for ``name=list[str]``.
485
+ """
486
+
487
+ def _resolve_one_by_name(material_name: str) -> dict:
488
+ matches = self._get(
489
+ "/materials/",
490
+ _compact(name=material_name, limit=50),
491
+ ).get("results", [])
492
+ exact_matches = [m for m in matches if m.get("name") == material_name]
493
+ candidates = exact_matches if exact_matches else matches
494
+ if len(candidates) > 1:
495
+ names = [m.get("name") for m in candidates]
496
+ raise ValueError(
497
+ f"'{material_name}' matched {len(candidates)} materials: {names}. "
498
+ "Use a more specific name."
499
+ )
500
+ if not candidates:
501
+ raise ValueError(f"No material matched '{material_name}'.")
502
+ return candidates[0]
503
+
504
+ def _as_records_local(result: Any) -> list[dict]:
505
+ if isinstance(result, pd.DataFrame):
506
+ return result.to_dict(orient="records")
507
+ if isinstance(result, list):
508
+ return result
509
+ return []
510
+
511
+ def _resolve_bundle_list(bundle_arg: list[str] | None | object) -> list[str]:
512
+ if bundle_arg is _DEFAULT_BUNDLE:
513
+ selected = ["isotherms", "zeopp", "water_kpis", "cif"]
514
+ elif bundle_arg is None:
515
+ selected = []
516
+ else:
517
+ selected = [str(b).strip().lower() for b in bundle_arg]
518
+ allowed = {"isotherms", "zeopp", "water_kpis", "cif"}
519
+ unknown = [b for b in selected if b not in allowed]
520
+ if unknown:
521
+ raise ValueError(
522
+ f"Unknown bundle key(s): {unknown}. "
523
+ "Allowed: ['isotherms','zeopp','water_kpis','cif']"
524
+ )
525
+ return selected
526
+
527
+ def _build_material_payload(base_record: dict, selected_bundles: list[str]) -> dict:
528
+ root = self._resolve_cif_url_dict(self._get(f"/materials/{int(base_record['id'])}/"))
529
+ out = dict(root)
530
+ mof_name = root.get("name")
531
+
532
+ if "isotherms" in selected_bundles:
533
+ out["isotherms"] = _as_records_local(self.get_isotherm(
534
+ mof=mof_name,
535
+ sim_or_exp=sim_or_exp,
536
+ good_structure=good_structure,
537
+ limit=limit,
538
+ offset=offset,
539
+ ))
540
+
541
+ if "zeopp" in selected_bundles:
542
+ zeopp_sim = _as_records_local(self.get_carbon_zeopp(
543
+ mof=mof_name,
544
+ good_structure=good_structure,
545
+ limit=limit,
546
+ offset=offset,
547
+ ))
548
+ zeopp_exp = _as_records_local(self.get_carbon_zeopp_experimental(
549
+ mof=mof_name,
550
+ limit=limit,
551
+ offset=offset,
552
+ ))
553
+ out["zeopp"] = [
554
+ {**r, "_zeopp_source": "simulated"} for r in zeopp_sim
555
+ ] + [
556
+ {**r, "_zeopp_source": "experimental"} for r in zeopp_exp
557
+ ]
558
+
559
+ if "water_kpis" in selected_bundles:
560
+ out["water_kpis"] = _as_records_local(self.get_water_kpis(
561
+ mof=mof_name,
562
+ sim_or_exp=sim_or_exp,
563
+ good_structure=good_structure,
564
+ limit=limit,
565
+ offset=offset,
566
+ ))
567
+
568
+ if "cif" in selected_bundles:
569
+ material_psdi = self.get_material_psdi(int(base_record["id"]))
570
+ cif_url = material_psdi.get("cif_url") or root.get("cif_url")
571
+ cif_filename = material_psdi.get("cif_filename")
572
+ if not cif_filename and isinstance(cif_url, str) and cif_url:
573
+ cif_filename = cif_url.rsplit("/", 1)[-1]
574
+
575
+ cif_payload: dict[str, Any] = {
576
+ "url": cif_url,
577
+ "filename": cif_filename,
578
+ }
579
+ if include_cif_text and isinstance(cif_url, str) and cif_url:
580
+ resp = requests.get(cif_url, headers=self._headers(), timeout=cif_timeout)
581
+ resp.raise_for_status()
582
+ cif_payload["text"] = self._parse_cif_text(resp.text)
583
+ out["cif"] = cif_payload
584
+
585
+ return out
586
+
587
+ if (material_id is None) == (name is None):
588
+ raise ValueError("Provide exactly one of material_id or name")
589
+
590
+ selected_bundles = _resolve_bundle_list(bundle)
591
+
592
+ if material_id is not None:
593
+ base = {"id": int(material_id)}
594
+ return _build_material_payload(base, selected_bundles)
595
+
596
+ if isinstance(name, str):
597
+ base = _resolve_one_by_name(name)
598
+ return _build_material_payload(base, selected_bundles)
599
+
600
+ if isinstance(name, list):
601
+ if not all(isinstance(n, str) for n in name):
602
+ raise TypeError("name list must contain only strings")
603
+ bases = [_resolve_one_by_name(n) for n in name]
604
+ return [_build_material_payload(b, selected_bundles) for b in bases]
605
+
606
+ raise TypeError("name must be a string or list of strings")
260
607
 
261
608
  def get_materials_psdi(self, name: str | None = None,
262
609
  limit: int = 500, offset: int = 0) -> pd.DataFrame:
@@ -293,12 +640,13 @@ class PrismaAPIv2:
293
640
  """
294
641
  return self._resolve_cif_url_dict(self._get(f"/materials-psdi/{material_id}/"))
295
642
 
296
- def get_material_property_bundle(self, mof: str,
643
+ def get_material_property_bundle(self, mof: str | None = None,
297
644
  sim_or_exp: str | None = None,
298
645
  good_structure: bool | None = None,
299
646
  limit: int = 500,
300
647
  offset: int = 0,
301
- query: dict[str, dict[str, Any]] | None = None) -> dict:
648
+ query: dict[str, dict[str, Any]] | None = None,
649
+ name: str | None = None) -> dict:
302
650
  """
303
651
  Fetch all science data for a given MOF in a single call.
304
652
 
@@ -308,6 +656,7 @@ class PrismaAPIv2:
308
656
 
309
657
  Args:
310
658
  mof: MOF name (substring match applied to all sub-queries).
659
+ Kept for backward compatibility; ``name`` is an alias.
311
660
  sim_or_exp: 'sim' or 'exp' filter for isotherms and water KPIs.
312
661
  good_structure: Good-structure filter for isotherms, water KPIs
313
662
  and simulated Zeo++.
@@ -333,6 +682,12 @@ class PrismaAPIv2:
333
682
  ValueError: if the name matches more than one material — use an
334
683
  exact name or a more specific substring.
335
684
  """
685
+ if mof is None and name is None:
686
+ raise TypeError("Provide either 'mof' or 'name'.")
687
+ if mof is not None and name is not None:
688
+ raise TypeError("Provide only one of 'mof' or 'name', not both.")
689
+ mof = name if mof is None else mof
690
+
336
691
  if query is not None and not isinstance(query, dict):
337
692
  raise TypeError("query must be a dict[str, dict] or None")
338
693
 
@@ -426,6 +781,101 @@ class PrismaAPIv2:
426
781
  print(f" {key:25s}: {len(val)} records")
427
782
  return bundle
428
783
 
784
+ def get_material_bundle(
785
+ self,
786
+ mof: str,
787
+ sim_or_exp: str | None = None,
788
+ good_structure: bool | None = None,
789
+ limit: int = 500,
790
+ offset: int = 0,
791
+ query: dict[str, dict[str, Any]] | None = None,
792
+ include_cif: bool = False,
793
+ include_cif_text: bool = False,
794
+ cif_timeout: int = 60,
795
+ ) -> dict:
796
+ """
797
+ Fetch material detail, PSDI detail, and science bundle in one call flow.
798
+
799
+ Args:
800
+ mof: MOF name.
801
+ sim_or_exp: Optional 'sim' or 'exp' filter for bundle endpoints.
802
+ good_structure: Optional structure-quality filter for bundle endpoints.
803
+ limit: Max records per bundle endpoint.
804
+ offset: Pagination offset for bundle endpoints.
805
+ query: Advanced per-endpoint filters forwarded to
806
+ ``get_material_property_bundle``.
807
+ include_cif: If True, include CIF metadata (URL/filename).
808
+ include_cif_text: If True, download and include CIF text content.
809
+ cif_timeout: Timeout in seconds for CIF download.
810
+
811
+ Returns:
812
+ dict with keys:
813
+ 'material' – output of ``get_material``
814
+ 'material_psdi' – output of ``get_material_psdi``
815
+ 'property_bundle' – output of ``get_material_property_bundle``
816
+ 'cif' – optional CIF metadata plus structured text dict
817
+
818
+ Raises:
819
+ ValueError: if the name resolves to multiple materials or no material.
820
+ """
821
+ matches = self._get(
822
+ "/materials/",
823
+ _compact(name=mof, limit=50),
824
+ ).get("results", [])
825
+
826
+ exact_matches = [m for m in matches if m.get("name") == mof]
827
+ candidates = exact_matches if exact_matches else matches
828
+ if len(candidates) > 1:
829
+ names = [m.get("name") for m in candidates]
830
+ raise ValueError(
831
+ f"'{mof}' matched {len(candidates)} materials: {names}. "
832
+ "Use a more specific name."
833
+ )
834
+ if not candidates:
835
+ raise ValueError(f"No material matched '{mof}'.")
836
+
837
+ selected = candidates[0]
838
+ material_id = int(selected["id"])
839
+ true_name = selected.get("name", mof)
840
+
841
+ material = self.get_material(material_id, bundle=None)
842
+ material_psdi = self.get_material_psdi(material_id)
843
+ property_bundle = self.get_material_property_bundle(
844
+ true_name,
845
+ sim_or_exp=sim_or_exp,
846
+ good_structure=good_structure,
847
+ limit=limit,
848
+ offset=offset,
849
+ query=query,
850
+ )
851
+
852
+ result: dict[str, Any] = {
853
+ "material": material,
854
+ "material_psdi": material_psdi,
855
+ "property_bundle": property_bundle,
856
+ }
857
+
858
+ if include_cif:
859
+ cif_url = material_psdi.get("cif_url") or material.get("cif_url")
860
+ cif_filename = material_psdi.get("cif_filename")
861
+ if not cif_filename and isinstance(cif_url, str) and cif_url:
862
+ cif_filename = cif_url.rsplit("/", 1)[-1]
863
+
864
+ cif_payload: dict[str, Any] = {
865
+ "url": cif_url,
866
+ "filename": cif_filename,
867
+ }
868
+
869
+ if include_cif_text and isinstance(cif_url, str) and cif_url:
870
+ resp = requests.get(cif_url, headers=self._headers(), timeout=cif_timeout)
871
+ resp.raise_for_status()
872
+ cif_payload["text"] = self._parse_cif_text(resp.text)
873
+
874
+ result["cif"] = cif_payload
875
+ else:
876
+ result["cif"] = None
877
+ return result
878
+
429
879
  def preflight_material_check(self, name: str) -> bool:
430
880
  """
431
881
  Check whether a material with the given name exists in the database.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prisma_api
3
- Version: 0.3.7
3
+ Version: 0.3.8
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
@@ -21,7 +21,6 @@ Dynamic: license-file
21
21
  # PrISMa API
22
22
 
23
23
  [![CI](https://github.com/RCCS-CaptureTeam/prisma_api/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/RCCS-CaptureTeam/prisma_api/actions/workflows/ci.yml)
24
- [![Coverage](https://codecov.io/gh/RCCS-CaptureTeam/prisma_api/graph/badge.svg?branch=main)](https://codecov.io/gh/RCCS-CaptureTeam/prisma_api)
25
24
 
26
25
  Python client for the PrISMa platform APIs.
27
26
 
@@ -195,10 +194,11 @@ All methods below are available on `api.v2`.
195
194
  ### Materials and bundles
196
195
 
197
196
  - `list_materials(name=None, limit=10000)`
198
- - `get_material(material_id)`
197
+ - `get_material(material_id=None, name=None, bundle=['isotherms','zeopp','water_kpis','cif'])` (`bundle=None` returns root-only material detail)
199
198
  - `get_materials_psdi(name=None, limit=500, offset=0)`
200
199
  - `get_material_psdi(material_id)`
201
200
  - `get_material_property_bundle(mof, sim_or_exp=None, good_structure=None, limit=500, offset=0, query=None)`
201
+ - `get_material_bundle(mof, sim_or_exp=None, good_structure=None, limit=500, offset=0, query=None, include_cif=False, include_cif_text=False, cif_timeout=60)`
202
202
  - `preflight_material_check(name)`
203
203
 
204
204
  ### Case bundles and pack builders
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "prisma_api"
7
- version = "0.3.7"
7
+ version = "0.3.8"
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"}
@@ -234,11 +234,66 @@ def test_get_material_detail(api):
234
234
  detail = {"id": 1, "name": "ABEXEM", "cif_url": "/media/ABEXEM.cif",
235
235
  "elements": [{"symbol": "C", "mass_fraction": 0.45}]}
236
236
  resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials/1/", json=detail, status=200)
237
- result = api.get_material(1)
237
+ result = api.get_material(1, bundle=None)
238
238
  assert result["name"] == "ABEXEM"
239
239
  assert len(result["elements"]) == 1
240
240
 
241
241
 
242
+ @resp_lib.activate
243
+ def test_get_material_default_bundle_from_id(api):
244
+ detail = {"id": 1, "name": "HKUST", "cif_url": "/media/HKUST.cif", "elements": []}
245
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials/1/", json=detail, status=200)
246
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/isotherms/",
247
+ json=_envelope([{"id": 10, "mof": "HKUST"}]), status=200)
248
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/carbon-zeopp/",
249
+ json=_envelope([{"id": 20, "mof": "HKUST"}]), status=200)
250
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/carbon-zeopp-experimental/",
251
+ json=_envelope([{"id": 21, "mof": "HKUST"}]), status=200)
252
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/water-kpis/",
253
+ json=_envelope([{"id": 30, "mof": "HKUST", "Molecule": 1}]), status=200)
254
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials-psdi/1/",
255
+ json={"id": 1, "name": "HKUST", "cif_url": "/media/HKUST.cif", "cif_filename": "HKUST.cif"},
256
+ status=200)
257
+
258
+ result = api.get_material(1)
259
+ assert "isotherms" in result
260
+ assert "zeopp" in result
261
+ assert "water_kpis" in result
262
+ assert "cif" in result
263
+ assert result["zeopp"][0]["_zeopp_source"] in {"simulated", "experimental"}
264
+
265
+
266
+ @resp_lib.activate
267
+ def test_get_material_by_name_string_root_only(api):
268
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials/",
269
+ match=[matchers.query_param_matcher({"name": "HKUST", "limit": "50"})],
270
+ json=_envelope([{"id": 1, "name": "HKUST"}]), status=200)
271
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials/1/",
272
+ json={"id": 1, "name": "HKUST", "cif_url": "/media/HKUST.cif", "elements": []}, status=200)
273
+
274
+ result = api.get_material(name="HKUST", bundle=None)
275
+ assert result["id"] == 1
276
+ assert result["name"] == "HKUST"
277
+
278
+
279
+ @resp_lib.activate
280
+ def test_get_material_by_name_list(api):
281
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials/",
282
+ match=[matchers.query_param_matcher({"name": "HKUST", "limit": "50"})],
283
+ json=_envelope([{"id": 1, "name": "HKUST"}]), status=200)
284
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials/",
285
+ match=[matchers.query_param_matcher({"name": "ABEXEM", "limit": "50"})],
286
+ json=_envelope([{"id": 2, "name": "ABEXEM"}]), status=200)
287
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials/1/",
288
+ json={"id": 1, "name": "HKUST", "cif_url": "/media/HKUST.cif", "elements": []}, status=200)
289
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials/2/",
290
+ json={"id": 2, "name": "ABEXEM", "cif_url": "/media/ABEXEM.cif", "elements": []}, status=200)
291
+
292
+ result = api.get_material(name=["HKUST", "ABEXEM"], bundle=None)
293
+ assert isinstance(result, list)
294
+ assert [r["name"] for r in result] == ["HKUST", "ABEXEM"]
295
+
296
+
242
297
  @resp_lib.activate
243
298
  def test_list_materials_empty(api):
244
299
  resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials/", json=_envelope([]), status=200)
@@ -855,6 +910,21 @@ def test_get_material_property_bundle_returns_dict(api):
855
910
  }
856
911
 
857
912
 
913
+ @resp_lib.activate
914
+ def test_get_material_property_bundle_accepts_name_keyword(api):
915
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/materials/",
916
+ json=_envelope([{"id": 1, "name": "HKUST"}]), status=200)
917
+ for path in ("/isotherms/", "/carbon-zeopp/",
918
+ "/carbon-zeopp-experimental/", "/water-kpis/"):
919
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}{path}",
920
+ json=_envelope([{"id": 1, "mof": "HKUST"}]), status=200)
921
+
922
+ bundle = api.get_material_property_bundle(name="HKUST")
923
+ assert set(bundle.keys()) == {
924
+ "isotherms", "zeopp_simulated", "zeopp_experimental", "water_kpis"
925
+ }
926
+
927
+
858
928
  @resp_lib.activate
859
929
  def test_get_material_property_bundle_filters_forwarded(api):
860
930
  """sim_or_exp and good_structure must be forwarded to isotherms and water KPIs."""
@@ -941,6 +1011,120 @@ def test_get_material_property_bundle_supports_query_dict(api):
941
1011
  )
942
1012
 
943
1013
 
1014
+ @resp_lib.activate
1015
+ def test_get_material_bundle_includes_text(api, monkeypatch):
1016
+ resp_lib.add(
1017
+ resp_lib.GET,
1018
+ f"{PROD_BASE}/materials/",
1019
+ match=[matchers.query_param_matcher({"name": "HKUST", "limit": "50"})],
1020
+ json=_envelope([{"id": 1, "name": "HKUST", "cif_url": "/media/HKUST.cif"}]),
1021
+ status=200,
1022
+ )
1023
+ resp_lib.add(
1024
+ resp_lib.GET,
1025
+ f"{PROD_BASE}/materials/1/",
1026
+ json={"id": 1, "name": "HKUST", "cif_url": "/media/HKUST.cif"},
1027
+ status=200,
1028
+ )
1029
+ resp_lib.add(
1030
+ resp_lib.GET,
1031
+ f"{PROD_BASE}/materials-psdi/1/",
1032
+ json={"id": 1, "name": "HKUST", "cif_url": "/media/HKUST.cif", "cif_filename": "HKUST.cif"},
1033
+ status=200,
1034
+ )
1035
+ resp_lib.add(
1036
+ resp_lib.GET,
1037
+ "https://prisma-platform.org/media/HKUST.cif",
1038
+ body="data_test\n_cell_length_a 10.0",
1039
+ status=200,
1040
+ content_type="text/plain",
1041
+ )
1042
+
1043
+ expected_bundle = {
1044
+ "isotherms": [],
1045
+ "zeopp_simulated": [],
1046
+ "zeopp_experimental": [],
1047
+ "water_kpis": [],
1048
+ }
1049
+
1050
+ def _fake_bundle(*args, **kwargs):
1051
+ return expected_bundle
1052
+
1053
+ monkeypatch.setattr(api, "get_material_property_bundle", _fake_bundle)
1054
+
1055
+ result = api.get_material_bundle("HKUST", include_cif=True, include_cif_text=True)
1056
+
1057
+ assert result["material"]["id"] == 1
1058
+ assert result["material_psdi"]["cif_filename"] == "HKUST.cif"
1059
+ assert result["property_bundle"] == expected_bundle
1060
+ assert result["cif"]["url"] == "https://prisma-platform.org/media/HKUST.cif"
1061
+ assert isinstance(result["cif"]["text"], dict)
1062
+ assert result["cif"]["text"]["line_count"] == 2
1063
+ assert result["cif"]["text"]["fields"]["_cell_length_a"] == "10.0"
1064
+ assert "data_test" in result["cif"]["text"]["raw"]
1065
+
1066
+
1067
+ @resp_lib.activate
1068
+ def test_get_material_bundle_raises_on_ambiguous_name(api):
1069
+ resp_lib.add(
1070
+ resp_lib.GET,
1071
+ f"{PROD_BASE}/materials/",
1072
+ match=[matchers.query_param_matcher({"name": "HK", "limit": "50"})],
1073
+ json=_envelope([
1074
+ {"id": 1, "name": "HKUST"},
1075
+ {"id": 2, "name": "HK-MOF-2"},
1076
+ ]),
1077
+ status=200,
1078
+ )
1079
+
1080
+ with pytest.raises(ValueError, match="matched 2 materials"):
1081
+ api.get_material_bundle("HK")
1082
+
1083
+
1084
+ @resp_lib.activate
1085
+ def test_get_material_bundle_can_skip_cif(api, monkeypatch):
1086
+ resp_lib.add(
1087
+ resp_lib.GET,
1088
+ f"{PROD_BASE}/materials/",
1089
+ match=[matchers.query_param_matcher({"name": "HKUST", "limit": "50"})],
1090
+ json=_envelope([{"id": 1, "name": "HKUST", "cif_url": "/media/HKUST.cif"}]),
1091
+ status=200,
1092
+ )
1093
+ resp_lib.add(
1094
+ resp_lib.GET,
1095
+ f"{PROD_BASE}/materials/1/",
1096
+ json={"id": 1, "name": "HKUST", "cif_url": "/media/HKUST.cif"},
1097
+ status=200,
1098
+ )
1099
+ resp_lib.add(
1100
+ resp_lib.GET,
1101
+ f"{PROD_BASE}/materials-psdi/1/",
1102
+ json={"id": 1, "name": "HKUST", "cif_url": "/media/HKUST.cif", "cif_filename": "HKUST.cif"},
1103
+ status=200,
1104
+ )
1105
+
1106
+ expected_bundle = {
1107
+ "isotherms": [],
1108
+ "zeopp_simulated": [],
1109
+ "zeopp_experimental": [],
1110
+ "water_kpis": [],
1111
+ }
1112
+
1113
+ def _fake_bundle(*args, **kwargs):
1114
+ return expected_bundle
1115
+
1116
+ monkeypatch.setattr(api, "get_material_property_bundle", _fake_bundle)
1117
+
1118
+ result = api.get_material_bundle(
1119
+ "HKUST",
1120
+ include_cif=False,
1121
+ include_cif_text=True,
1122
+ )
1123
+
1124
+ assert result["property_bundle"] == expected_bundle
1125
+ assert result["cif"] is None
1126
+
1127
+
944
1128
  # ── preflight_material_check ──────────────────────────────────────────────────
945
1129
 
946
1130
  @resp_lib.activate
@@ -1451,6 +1635,10 @@ def test_api_key_header_sent(api):
1451
1635
  # ── Flowsheets ────────────────────────────────────────────────────────────────
1452
1636
 
1453
1637
  _UPSERT_FLOWSHEET_FIXTURE = "reference_data/prisma_v2/dac_min_2026-07-01.json"
1638
+ _SKIP_UPSERT_FLOWSHEET_IN_CI = pytest.mark.skipif(
1639
+ os.getenv("CI", "").lower() == "true",
1640
+ reason="Upsert flowsheet fixture tests are disabled in CI",
1641
+ )
1454
1642
 
1455
1643
 
1456
1644
  def _load_upsert_flowsheet_payload() -> list[dict]:
@@ -1515,6 +1703,7 @@ def test_get_flowsheet_bundle_custom_name_routes_path(api):
1515
1703
 
1516
1704
 
1517
1705
  @resp_lib.activate
1706
+ @_SKIP_UPSERT_FLOWSHEET_IN_CI
1518
1707
  def test_upsert_flowsheets_default_append_mode(api):
1519
1708
  resp_lib.add(
1520
1709
  resp_lib.PUT,
@@ -1528,6 +1717,7 @@ def test_upsert_flowsheets_default_append_mode(api):
1528
1717
 
1529
1718
 
1530
1719
  @resp_lib.activate
1720
+ @_SKIP_UPSERT_FLOWSHEET_IN_CI
1531
1721
  def test_upsert_flowsheets_overwrite_mode(api):
1532
1722
  resp_lib.add(
1533
1723
  resp_lib.PUT,
@@ -1545,6 +1735,7 @@ def test_upsert_flowsheets_overwrite_mode(api):
1545
1735
 
1546
1736
 
1547
1737
  @resp_lib.activate
1738
+ @_SKIP_UPSERT_FLOWSHEET_IN_CI
1548
1739
  def test_upsert_flowsheets_uses_dev_mode_base_url(dev_api):
1549
1740
  resp_lib.add(
1550
1741
  resp_lib.PUT,
@@ -1561,6 +1752,7 @@ def test_upsert_flowsheets_uses_dev_mode_base_url(dev_api):
1561
1752
  assert result["created"] == 1
1562
1753
 
1563
1754
 
1755
+ @_SKIP_UPSERT_FLOWSHEET_IN_CI
1564
1756
  def test_upsert_flowsheets_rejects_invalid_on_exists(api):
1565
1757
  payload = _load_upsert_flowsheet_payload()
1566
1758
  with pytest.raises(ValueError):
@@ -1572,6 +1764,7 @@ def test_upsert_flowsheets_rejects_invalid_on_exists(api):
1572
1764
 
1573
1765
 
1574
1766
  @resp_lib.activate
1767
+ @_SKIP_UPSERT_FLOWSHEET_IN_CI
1575
1768
  def test_upsert_flowsheets_allows_missing_screening_analysis_name_with_warning(api):
1576
1769
  resp_lib.add(
1577
1770
  resp_lib.PUT,
@@ -1586,6 +1779,7 @@ def test_upsert_flowsheets_allows_missing_screening_analysis_name_with_warning(a
1586
1779
 
1587
1780
 
1588
1781
  @resp_lib.activate
1782
+ @_SKIP_UPSERT_FLOWSHEET_IN_CI
1589
1783
  def test_upsert_flowsheets_blank_screening_analysis_name_warns_and_omits_query_param(api):
1590
1784
  resp_lib.add(
1591
1785
  resp_lib.PUT,
File without changes
File without changes