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.
- {prisma_api-0.3.7 → prisma_api-0.3.8}/PKG-INFO +3 -3
- {prisma_api-0.3.7 → prisma_api-0.3.8}/README.md +2 -2
- {prisma_api-0.3.7 → prisma_api-0.3.8}/prisma_api/__init__.py +1 -1
- {prisma_api-0.3.7 → prisma_api-0.3.8}/prisma_api/prisma_api_v2.py +456 -6
- {prisma_api-0.3.7 → prisma_api-0.3.8}/prisma_api.egg-info/PKG-INFO +3 -3
- {prisma_api-0.3.7 → prisma_api-0.3.8}/pyproject.toml +1 -1
- {prisma_api-0.3.7 → prisma_api-0.3.8}/tests/test_prisma_api_v2.py +195 -1
- {prisma_api-0.3.7 → prisma_api-0.3.8}/LICENSE +0 -0
- {prisma_api-0.3.7 → prisma_api-0.3.8}/prisma_api/config.py +0 -0
- {prisma_api-0.3.7 → prisma_api-0.3.8}/prisma_api/prisma_api.py +0 -0
- {prisma_api-0.3.7 → prisma_api-0.3.8}/prisma_api.egg-info/SOURCES.txt +0 -0
- {prisma_api-0.3.7 → prisma_api-0.3.8}/prisma_api.egg-info/dependency_links.txt +0 -0
- {prisma_api-0.3.7 → prisma_api-0.3.8}/prisma_api.egg-info/requires.txt +0 -0
- {prisma_api-0.3.7 → prisma_api-0.3.8}/prisma_api.egg-info/top_level.txt +0 -0
- {prisma_api-0.3.7 → prisma_api-0.3.8}/setup.cfg +0 -0
- {prisma_api-0.3.7 → prisma_api-0.3.8}/tests/test_prisma_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: prisma_api
|
|
3
|
-
Version: 0.3.
|
|
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
|
[](https://github.com/RCCS-CaptureTeam/prisma_api/actions/workflows/ci.yml)
|
|
24
|
-
[](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
|
[](https://github.com/RCCS-CaptureTeam/prisma_api/actions/workflows/ci.yml)
|
|
4
|
-
[](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
|
|
@@ -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
|
|
274
|
+
def list_cifs(self, tag: str | None = None) -> list[str]:
|
|
254
275
|
"""
|
|
255
|
-
GET /api/v2/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
[](https://github.com/RCCS-CaptureTeam/prisma_api/actions/workflows/ci.yml)
|
|
24
|
-
[](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
|
+
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|