prisma-api 0.3.6__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.6
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
@@ -20,8 +20,7 @@ Dynamic: license-file
20
20
 
21
21
  # PrISMa API
22
22
 
23
- ![Tests](https://img.shields.io/badge/tests-passing-brightgreen)
24
- ![Coverage](https://img.shields.io/badge/test%20coverage-passing-brightgreen)
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)
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
- ![Tests](https://img.shields.io/badge/tests-passing-brightgreen)
4
- ![Coverage](https://img.shields.io/badge/test%20coverage-passing-brightgreen)
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)
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.6"
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,10 +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
24
+ import warnings
23
25
 
24
26
 
25
27
  _BASE_PROD = "https://prisma-platform.org/api/v2"
28
+ _DEFAULT_BUNDLE = object()
26
29
 
27
30
 
28
31
  class PrismaAPIv2:
@@ -119,6 +122,25 @@ class PrismaAPIv2:
119
122
  d = {**d, "cif_url": f"{base}{d['cif_url']}"}
120
123
  return d
121
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
+
122
144
  # ── Health ────────────────────────────────────────────────────────────────
123
145
 
124
146
  def health(self) -> dict:
@@ -144,7 +166,7 @@ class PrismaAPIv2:
144
166
  def upsert_flowsheets(
145
167
  self,
146
168
  flowsheets: pd.DataFrame | list[dict],
147
- screening_analysis_name: str,
169
+ screening_analysis_name: str | None = None,
148
170
  on_exists: str = "append",
149
171
  appendix: str = "_v4",
150
172
  ) -> dict:
@@ -157,8 +179,10 @@ class PrismaAPIv2:
157
179
 
158
180
  Args:
159
181
  flowsheets: DataFrame or list of dict payload records.
160
- screening_analysis_name: Required analysis identifier forwarded to the
161
- API for upsert attribution/routing.
182
+ screening_analysis_name: Optional analysis identifier forwarded to the
183
+ API for upsert attribution/routing. For production uploads this
184
+ should be set and should match one of the nested screening
185
+ analysis names returned by ``list_case_studies()``.
162
186
  on_exists: Conflict mode; one of ``'append'`` or ``'overwrite'``.
163
187
  appendix: Suffix used only in append mode (default ``'_v4'``).
164
188
 
@@ -167,30 +191,33 @@ class PrismaAPIv2:
167
191
  """
168
192
  if on_exists not in ("append", "overwrite"):
169
193
  raise ValueError("on_exists must be 'append' or 'overwrite'")
170
- if not screening_analysis_name or not screening_analysis_name.strip():
171
- raise ValueError("screening_analysis_name must be a non-empty string")
172
194
 
173
195
  records = (
174
196
  flowsheets.to_dict(orient="records")
175
197
  if isinstance(flowsheets, pd.DataFrame)
176
198
  else flowsheets
177
199
  )
200
+ cleaned_screening_name = (screening_analysis_name or "").strip()
178
201
 
179
- if on_exists == "overwrite":
180
- query = urlencode(
181
- {
182
- "on_exists": "overwrite",
183
- "screening_analysis_name": screening_analysis_name,
184
- }
185
- )
202
+ query_params: dict[str, str] = (
203
+ {"on_exists": "overwrite"}
204
+ if on_exists == "overwrite"
205
+ else {"on_exists": "append", "appendix": appendix}
206
+ )
207
+
208
+ if cleaned_screening_name:
209
+ query_params["screening_analysis_name"] = cleaned_screening_name
186
210
  else:
187
- query = urlencode(
188
- {
189
- "on_exists": "append",
190
- "appendix": appendix,
191
- "screening_analysis_name": screening_analysis_name,
192
- }
211
+ warnings.warn(
212
+ "Uploading flowsheets without screening_analysis_name is intended "
213
+ "for experimentation only and is not recommended for production. "
214
+ "Provide screening_analysis_name matching one of the nested "
215
+ "screening analysis names returned by list_case_studies().",
216
+ UserWarning,
217
+ stacklevel=2,
193
218
  )
219
+
220
+ query = urlencode(query_params)
194
221
  path = f"/flowsheets/upsert/?{query}"
195
222
 
196
223
  return self._put(path, records)
@@ -244,13 +271,339 @@ class PrismaAPIv2:
244
271
  print(f"{len(all_records)} materials loaded from {server}")
245
272
  return self._resolve_cif_url_df(self._to_df({"results": all_records}))
246
273
 
247
- def get_material(self, material_id: int) -> dict:
274
+ def list_cifs(self, tag: str | None = None) -> list[str]:
248
275
  """
249
- 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``.
250
314
 
251
- Returns a dict with material detail including element composition.
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
+ }
356
+
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]:
252
466
  """
253
- 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")
254
607
 
255
608
  def get_materials_psdi(self, name: str | None = None,
256
609
  limit: int = 500, offset: int = 0) -> pd.DataFrame:
@@ -287,12 +640,13 @@ class PrismaAPIv2:
287
640
  """
288
641
  return self._resolve_cif_url_dict(self._get(f"/materials-psdi/{material_id}/"))
289
642
 
290
- def get_material_property_bundle(self, mof: str,
643
+ def get_material_property_bundle(self, mof: str | None = None,
291
644
  sim_or_exp: str | None = None,
292
645
  good_structure: bool | None = None,
293
646
  limit: int = 500,
294
647
  offset: int = 0,
295
- query: dict[str, dict[str, Any]] | None = None) -> dict:
648
+ query: dict[str, dict[str, Any]] | None = None,
649
+ name: str | None = None) -> dict:
296
650
  """
297
651
  Fetch all science data for a given MOF in a single call.
298
652
 
@@ -302,6 +656,7 @@ class PrismaAPIv2:
302
656
 
303
657
  Args:
304
658
  mof: MOF name (substring match applied to all sub-queries).
659
+ Kept for backward compatibility; ``name`` is an alias.
305
660
  sim_or_exp: 'sim' or 'exp' filter for isotherms and water KPIs.
306
661
  good_structure: Good-structure filter for isotherms, water KPIs
307
662
  and simulated Zeo++.
@@ -327,6 +682,12 @@ class PrismaAPIv2:
327
682
  ValueError: if the name matches more than one material — use an
328
683
  exact name or a more specific substring.
329
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
+
330
691
  if query is not None and not isinstance(query, dict):
331
692
  raise TypeError("query must be a dict[str, dict] or None")
332
693
 
@@ -420,6 +781,101 @@ class PrismaAPIv2:
420
781
  print(f" {key:25s}: {len(val)} records")
421
782
  return bundle
422
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
+
423
879
  def preflight_material_check(self, name: str) -> bool:
424
880
  """
425
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.6
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
@@ -20,8 +20,7 @@ Dynamic: license-file
20
20
 
21
21
  # PrISMa API
22
22
 
23
- ![Tests](https://img.shields.io/badge/tests-passing-brightgreen)
24
- ![Coverage](https://img.shields.io/badge/test%20coverage-passing-brightgreen)
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)
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.6"
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):
@@ -1571,11 +1763,31 @@ def test_upsert_flowsheets_rejects_invalid_on_exists(api):
1571
1763
  )
1572
1764
 
1573
1765
 
1574
- @pytest.mark.skipif(
1575
- os.getenv("PRISMA_API_PRODUCTION_BYPASS", "").lower() == "true",
1576
- reason="Bypassed in production mode",
1577
- )
1578
- def test_upsert_flowsheets_rejects_empty_screening_analysis_name(api):
1766
+ @resp_lib.activate
1767
+ @_SKIP_UPSERT_FLOWSHEET_IN_CI
1768
+ def test_upsert_flowsheets_allows_missing_screening_analysis_name_with_warning(api):
1769
+ resp_lib.add(
1770
+ resp_lib.PUT,
1771
+ f"{PROD_BASE}/flowsheets/upsert/?on_exists=append&appendix=_v4",
1772
+ json={"created": 1, "updated": 0},
1773
+ status=200,
1774
+ )
1579
1775
  payload = _load_upsert_flowsheet_payload()
1580
- with pytest.raises(ValueError):
1581
- api.upsert_flowsheets(payload, screening_analysis_name=" ")
1776
+ with pytest.warns(UserWarning, match="for experimentation only"):
1777
+ result = api.upsert_flowsheets(payload)
1778
+ assert result["created"] == 1
1779
+
1780
+
1781
+ @resp_lib.activate
1782
+ @_SKIP_UPSERT_FLOWSHEET_IN_CI
1783
+ def test_upsert_flowsheets_blank_screening_analysis_name_warns_and_omits_query_param(api):
1784
+ resp_lib.add(
1785
+ resp_lib.PUT,
1786
+ f"{PROD_BASE}/flowsheets/upsert/?on_exists=overwrite",
1787
+ json={"created": 0, "updated": 1},
1788
+ status=200,
1789
+ )
1790
+ payload = _load_upsert_flowsheet_payload()
1791
+ with pytest.warns(UserWarning, match="list_case_studies"):
1792
+ result = api.upsert_flowsheets(payload, screening_analysis_name=" ", on_exists="overwrite")
1793
+ assert result["updated"] == 1
File without changes
File without changes