prisma-api 0.4.2__tar.gz → 0.4.3__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.4.2 → prisma_api-0.4.3}/PKG-INFO +1 -1
- {prisma_api-0.4.2 → prisma_api-0.4.3}/prisma_api/__init__.py +6 -1
- {prisma_api-0.4.2 → prisma_api-0.4.3}/prisma_api/config.py +39 -1
- {prisma_api-0.4.2 → prisma_api-0.4.3}/prisma_api/prisma_api.py +13 -2
- {prisma_api-0.4.2 → prisma_api-0.4.3}/prisma_api/prisma_api_v2.py +299 -171
- {prisma_api-0.4.2 → prisma_api-0.4.3}/prisma_api.egg-info/PKG-INFO +1 -1
- {prisma_api-0.4.2 → prisma_api-0.4.3}/pyproject.toml +1 -1
- {prisma_api-0.4.2 → prisma_api-0.4.3}/tests/test_prisma_api.py +36 -0
- {prisma_api-0.4.2 → prisma_api-0.4.3}/tests/test_prisma_api_v2.py +153 -17
- {prisma_api-0.4.2 → prisma_api-0.4.3}/LICENSE +0 -0
- {prisma_api-0.4.2 → prisma_api-0.4.3}/README.md +0 -0
- {prisma_api-0.4.2 → prisma_api-0.4.3}/prisma_api.egg-info/SOURCES.txt +0 -0
- {prisma_api-0.4.2 → prisma_api-0.4.3}/prisma_api.egg-info/dependency_links.txt +0 -0
- {prisma_api-0.4.2 → prisma_api-0.4.3}/prisma_api.egg-info/requires.txt +0 -0
- {prisma_api-0.4.2 → prisma_api-0.4.3}/prisma_api.egg-info/top_level.txt +0 -0
- {prisma_api-0.4.2 → prisma_api-0.4.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: prisma_api
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: A package for interacting with PrISMa Platform APIs. PrISMa is a holistic system for numerical synthesis and assessment of Metal-Organic Frameworks and their applications, for Direct Air Capture of CO2. For more information, see: https://prisma.hw.ac.uk.
|
|
5
5
|
Author-email: RCCS-CaptureTeam <team@prisma-platform.org>
|
|
6
6
|
License: GNUv3
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
__version__ = _version("prisma_api")
|
|
5
|
+
except PackageNotFoundError: # running from a source tree without installing
|
|
6
|
+
__version__ = "0+unknown"
|
|
2
7
|
|
|
3
8
|
|
|
4
9
|
from prisma_api.prisma_api import prisma_api as init # Main prisma_api class for initialisation
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
1
3
|
from pathlib import Path
|
|
2
4
|
import pandas as pd
|
|
3
5
|
import yaml
|
|
@@ -54,6 +56,12 @@ def create_config_file(api_key: str = None):
|
|
|
54
56
|
raise ImportError("PyYAML is required. Install with: pip install pyyaml")
|
|
55
57
|
|
|
56
58
|
if not api_key:
|
|
59
|
+
if not sys.stdin or not sys.stdin.isatty():
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
f"No PrISMa config file at {get_config_path()} and no API key in the "
|
|
62
|
+
f"environment. Set {', '.join(ENV_VARS)} (at least PRISMA_API_KEY), "
|
|
63
|
+
"or run interactively once to create the config file."
|
|
64
|
+
)
|
|
57
65
|
val = input("Enter your PrISMa API key: ").strip()
|
|
58
66
|
api_key = val
|
|
59
67
|
|
|
@@ -67,10 +75,40 @@ def create_config_file(api_key: str = None):
|
|
|
67
75
|
yaml.safe_dump(cfg, f, sort_keys=False)
|
|
68
76
|
return cfg
|
|
69
77
|
|
|
78
|
+
# Environment variables read when no config file exists (or use_config_file=False)
|
|
79
|
+
ENV_VARS = ("PRISMA_API_KEY", "PRISMA_API_DEV_API_KEY", "PRISMA_API_DEV_HOST_PORT")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def config_from_env():
|
|
83
|
+
"""
|
|
84
|
+
Build a config dict from PRISMA_API_* environment variables.
|
|
85
|
+
|
|
86
|
+
Returns None if none of them is set.
|
|
87
|
+
"""
|
|
88
|
+
if not any(os.getenv(name) for name in ENV_VARS):
|
|
89
|
+
return None
|
|
90
|
+
prod_key = os.getenv("PRISMA_API_KEY", "")
|
|
91
|
+
return {
|
|
92
|
+
"api_key": prod_key,
|
|
93
|
+
"dev_api_key": os.getenv("PRISMA_API_DEV_API_KEY", prod_key),
|
|
94
|
+
"dev_host_port": os.getenv("PRISMA_API_DEV_HOST_PORT", "8000"),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
70
98
|
# Get or create config - intended use by initialisation of main prisma_api class
|
|
71
99
|
def get_or_create_config():
|
|
72
|
-
"""
|
|
100
|
+
"""
|
|
101
|
+
Return the config, in order of preference:
|
|
102
|
+
|
|
103
|
+
1. config.yaml, if it exists;
|
|
104
|
+
2. PRISMA_API_* environment variables (nothing is written to disk);
|
|
105
|
+
3. an interactive prompt, which creates config.yaml. Without a terminal
|
|
106
|
+
(e.g. CI) this raises instead of blocking on input().
|
|
107
|
+
"""
|
|
73
108
|
cfg = load_config()
|
|
109
|
+
if cfg is not None:
|
|
110
|
+
return cfg
|
|
111
|
+
cfg = config_from_env()
|
|
74
112
|
if cfg is not None:
|
|
75
113
|
return cfg
|
|
76
114
|
return create_config_file()
|
|
@@ -25,11 +25,15 @@ def _safe_nan_check(x):
|
|
|
25
25
|
# prisma_api main class
|
|
26
26
|
class prisma_api():
|
|
27
27
|
|
|
28
|
-
def __init__(self, use_config_file=True, local_dev: bool = False
|
|
28
|
+
def __init__(self, use_config_file=True, local_dev: bool = False,
|
|
29
|
+
upload_timeout: int = 120):
|
|
29
30
|
|
|
30
31
|
# Initialise `prisma_api` object with api_key location
|
|
31
32
|
self.verbose = False
|
|
32
33
|
# Initialise `prisma_api` object with api_key location
|
|
34
|
+
# use_config_file=True: config.yaml, else PRISMA_API_* env vars, else
|
|
35
|
+
# an interactive prompt (raises instead when there is no terminal).
|
|
36
|
+
# use_config_file=False: env vars only.
|
|
33
37
|
if use_config_file:
|
|
34
38
|
cfg = get_or_create_config()
|
|
35
39
|
prod_key = cfg.get('api_key', '')
|
|
@@ -40,7 +44,9 @@ class prisma_api():
|
|
|
40
44
|
dev_key = os.getenv('PRISMA_API_DEV_API_KEY', prod_key)
|
|
41
45
|
dev_host_port = os.getenv('PRISMA_API_DEV_HOST_PORT', '8000')
|
|
42
46
|
|
|
43
|
-
# local_dev
|
|
47
|
+
# local_dev controls the target DB at init time:
|
|
48
|
+
# local_dev=False (default) -> PRODUCTION (prisma-platform.org)
|
|
49
|
+
# local_dev=True -> local dev server on localhost:<dev_host_port>
|
|
44
50
|
self.dev = bool(local_dev)
|
|
45
51
|
self.dev_host_port = dev_host_port
|
|
46
52
|
self.key = dev_key if self.dev else prod_key
|
|
@@ -50,8 +56,13 @@ class prisma_api():
|
|
|
50
56
|
dev=self.dev,
|
|
51
57
|
dev_host_port=getattr(self, 'dev_host_port', ''),
|
|
52
58
|
return_format=getattr(self, '_return_format', 'json'),
|
|
59
|
+
upload_timeout=upload_timeout,
|
|
53
60
|
)
|
|
54
61
|
|
|
62
|
+
# Make the target explicit so test uploads don't go to production by mistake.
|
|
63
|
+
target = "LOCAL DEV" if self.dev else "PRODUCTION"
|
|
64
|
+
print(f"prisma_api: connected to {target} API at {self.v2._base_url()}")
|
|
65
|
+
|
|
55
66
|
def set_return_format(self, fmt: str) -> None:
|
|
56
67
|
"""
|
|
57
68
|
Set the output format for all v2 list endpoints.
|
|
@@ -26,12 +26,26 @@ import copy
|
|
|
26
26
|
import json
|
|
27
27
|
from collections import Counter
|
|
28
28
|
import re
|
|
29
|
+
import math
|
|
29
30
|
import subprocess
|
|
30
|
-
from datetime import datetime
|
|
31
|
+
from datetime import date, datetime
|
|
32
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
_BASE_PROD = "https://prisma-platform.org/api/v2"
|
|
34
36
|
_DEFAULT_BUNDLE = object()
|
|
37
|
+
_DEFAULT_UPLOAD_TIMEOUT = 120
|
|
38
|
+
|
|
39
|
+
# AutoPrism table wrappers: method name -> (endpoint, wrapper key in the
|
|
40
|
+
# collection payload). The wrapper key is also accepted by the single-table
|
|
41
|
+
# upserts, e.g. ``{"zeopp_metrics": [...]}``.
|
|
42
|
+
_AUTOPRISM_TABLES = {
|
|
43
|
+
"upsert_adsorption_singlepoint": ("/adsorption-singlepoint/", "adsorption_singlepoints"),
|
|
44
|
+
"upsert_heat_capacity": ("/heat-capacity/", "heat_capacities"),
|
|
45
|
+
"upsert_isotherm_h2": ("/isotherm-h2/", "isotherm_H2s"),
|
|
46
|
+
"upsert_mofchecker": ("/mofchecker/", "mofchecker"),
|
|
47
|
+
"upsert_zeopp_metrics": ("/zeopp-metrics/", "zeopp_metrics"),
|
|
48
|
+
}
|
|
35
49
|
|
|
36
50
|
# Material bundle sections, in the order the API emits them. Every section is a
|
|
37
51
|
# list except ``mof_h2``, which is a single object or None.
|
|
@@ -67,11 +81,15 @@ class PrismaAPIv2:
|
|
|
67
81
|
"""
|
|
68
82
|
|
|
69
83
|
def __init__(self, key: str, dev: bool = False, dev_host_port: str = "",
|
|
70
|
-
return_format: str = "json"
|
|
84
|
+
return_format: str = "json",
|
|
85
|
+
upload_timeout: int = _DEFAULT_UPLOAD_TIMEOUT):
|
|
71
86
|
self._key = key
|
|
72
87
|
self._dev = dev
|
|
73
88
|
self._dev_host_port = dev_host_port
|
|
74
89
|
self._return_format = return_format # 'dataframe' | 'json'
|
|
90
|
+
# Default timeout (seconds) for PUT upserts; override per call with
|
|
91
|
+
# ``timeout=`` on the upsert methods that accept it.
|
|
92
|
+
self.upload_timeout = upload_timeout
|
|
75
93
|
|
|
76
94
|
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
77
95
|
|
|
@@ -154,10 +172,11 @@ class PrismaAPIv2:
|
|
|
154
172
|
resp.raise_for_status()
|
|
155
173
|
return resp
|
|
156
174
|
|
|
157
|
-
def _put(self, path: str, data: list) -> dict:
|
|
158
|
-
"""PUT (upsert) request."""
|
|
159
|
-
resp = requests.put(self._url(path), json=data,
|
|
160
|
-
headers=self._headers(),
|
|
175
|
+
def _put(self, path: str, data: list, timeout: int | None = None) -> dict:
|
|
176
|
+
"""PUT (upsert) request. NaN/inf and numpy values are made JSON-safe."""
|
|
177
|
+
resp = requests.put(self._url(path), json=_json_safe(data),
|
|
178
|
+
headers=self._headers(),
|
|
179
|
+
timeout=timeout or self.upload_timeout)
|
|
161
180
|
resp.raise_for_status()
|
|
162
181
|
return resp.json()
|
|
163
182
|
|
|
@@ -1503,45 +1522,44 @@ class PrismaAPIv2:
|
|
|
1503
1522
|
return []
|
|
1504
1523
|
|
|
1505
1524
|
def _payload_to_records(self, payload: pd.DataFrame | list[dict] | dict) -> list[dict]:
|
|
1506
|
-
"""
|
|
1525
|
+
"""
|
|
1526
|
+
Normalise write payloads to list[dict] while preserving all fields.
|
|
1527
|
+
|
|
1528
|
+
NaN/±inf/``pd.NA``/``NaT`` become ``None`` and numpy scalars become
|
|
1529
|
+
plain Python values, so the result is valid JSON.
|
|
1530
|
+
"""
|
|
1507
1531
|
if isinstance(payload, pd.DataFrame):
|
|
1508
|
-
return payload.to_dict(orient="records")
|
|
1532
|
+
return _json_safe(payload.to_dict(orient="records"))
|
|
1509
1533
|
if isinstance(payload, dict):
|
|
1510
|
-
return [payload]
|
|
1534
|
+
return [_json_safe(payload)]
|
|
1511
1535
|
if isinstance(payload, list):
|
|
1512
|
-
return payload
|
|
1536
|
+
return _json_safe(payload)
|
|
1513
1537
|
raise TypeError("payload must be a DataFrame, dict, or list[dict]")
|
|
1514
1538
|
|
|
1515
|
-
def _autoprism_meta_provenance(self) -> dict[str, str | None]:
|
|
1516
|
-
"""
|
|
1539
|
+
def _autoprism_meta_provenance(self, repo_dir: str | Path | None = None) -> dict[str, str | None]:
|
|
1540
|
+
"""
|
|
1541
|
+
Build meta_provenance from the caller's git repository.
|
|
1542
|
+
|
|
1543
|
+
Git runs in *repo_dir*, or the current working directory if omitted —
|
|
1544
|
+
i.e. the program doing the upload, not prisma_api itself. The
|
|
1545
|
+
semantic version comes from that repository's ``pyproject.toml``.
|
|
1546
|
+
"""
|
|
1547
|
+
cwd = Path(repo_dir) if repo_dir is not None else Path.cwd()
|
|
1517
1548
|
|
|
1518
1549
|
def _git(args: list[str]) -> str | None:
|
|
1519
|
-
|
|
1520
|
-
cp = subprocess.run(
|
|
1521
|
-
["git", *args],
|
|
1522
|
-
cwd=Path(__file__).resolve().parents[1],
|
|
1523
|
-
check=False,
|
|
1524
|
-
capture_output=True,
|
|
1525
|
-
text=True,
|
|
1526
|
-
)
|
|
1527
|
-
except OSError:
|
|
1528
|
-
return None
|
|
1529
|
-
if cp.returncode != 0:
|
|
1530
|
-
return None
|
|
1531
|
-
value = cp.stdout.strip()
|
|
1532
|
-
return value or None
|
|
1550
|
+
return _git_output(args, cwd)
|
|
1533
1551
|
|
|
1534
1552
|
repo_root = _git(["rev-parse", "--show-toplevel"])
|
|
1535
|
-
|
|
1553
|
+
root = Path(repo_root) if repo_root else cwd.resolve()
|
|
1536
1554
|
branch = _git(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
1537
1555
|
source_repo = (
|
|
1538
|
-
|
|
1539
|
-
if branch in (None, "main")
|
|
1540
|
-
else f"{
|
|
1556
|
+
root.name
|
|
1557
|
+
if branch in (None, "main", "HEAD")
|
|
1558
|
+
else f"{root.name} (branch: {branch})"
|
|
1541
1559
|
)
|
|
1542
1560
|
|
|
1543
1561
|
pyproject_version: str | None = None
|
|
1544
|
-
pyproject_path =
|
|
1562
|
+
pyproject_path = root / "pyproject.toml"
|
|
1545
1563
|
if pyproject_path.exists():
|
|
1546
1564
|
text = pyproject_path.read_text(encoding="utf-8")
|
|
1547
1565
|
try:
|
|
@@ -1563,24 +1581,40 @@ class PrismaAPIv2:
|
|
|
1563
1581
|
"source_commit_hash": _git(["rev-parse", "HEAD"]),
|
|
1564
1582
|
}
|
|
1565
1583
|
|
|
1566
|
-
def _autoprism_source_repo_url(self) -> str | None:
|
|
1567
|
-
"""Return the
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1584
|
+
def _autoprism_source_repo_url(self, repo_dir: str | Path | None = None) -> str | None:
|
|
1585
|
+
"""Return the caller's ``origin`` remote URL with any credentials removed."""
|
|
1586
|
+
cwd = Path(repo_dir) if repo_dir is not None else Path.cwd()
|
|
1587
|
+
url = _git_output(["remote", "get-url", "origin"], cwd)
|
|
1588
|
+
return _strip_url_credentials(url) if url else None
|
|
1589
|
+
|
|
1590
|
+
def _resolve_meta_provenance(self, meta_provenance: dict | None = None,
|
|
1591
|
+
repo_dir: str | Path | None = None) -> dict:
|
|
1592
|
+
"""Return *meta_provenance* as given, or derive it from git in *repo_dir*."""
|
|
1593
|
+
if meta_provenance is not None:
|
|
1594
|
+
if not isinstance(meta_provenance, dict):
|
|
1595
|
+
raise TypeError("meta_provenance must be a dict or None")
|
|
1596
|
+
return _json_safe(dict(meta_provenance))
|
|
1597
|
+
return {
|
|
1598
|
+
**self._autoprism_meta_provenance(repo_dir),
|
|
1599
|
+
"source_repo_url": self._autoprism_source_repo_url(repo_dir),
|
|
1600
|
+
}
|
|
1581
1601
|
|
|
1582
|
-
|
|
1583
|
-
|
|
1602
|
+
def _upsert_autoprism_table(self, method_name: str,
|
|
1603
|
+
payload: pd.DataFrame | list[dict] | dict,
|
|
1604
|
+
meta_provenance: dict | None,
|
|
1605
|
+
repo_dir: str | Path | None,
|
|
1606
|
+
timeout: int | None) -> dict:
|
|
1607
|
+
"""Shared body of the AutoPrism table upserts: unwrap, stamp provenance, PUT."""
|
|
1608
|
+
path, wrapper_key = _AUTOPRISM_TABLES[method_name]
|
|
1609
|
+
if isinstance(payload, dict) and isinstance(payload.get(wrapper_key), list):
|
|
1610
|
+
payload = payload[wrapper_key]
|
|
1611
|
+
records = self._payload_to_records(payload)
|
|
1612
|
+
meta = self._resolve_meta_provenance(meta_provenance, repo_dir)
|
|
1613
|
+
enriched = [
|
|
1614
|
+
{**record, "meta_provenance": meta} if isinstance(record, dict) else record
|
|
1615
|
+
for record in records
|
|
1616
|
+
]
|
|
1617
|
+
return self._put(path, enriched, timeout=timeout)
|
|
1584
1618
|
|
|
1585
1619
|
def _properties_for(self, object_id: int, limit: int = 2000) -> list[dict]:
|
|
1586
1620
|
"""Return all Property records linked to *object_id* via GenericForeignKey.
|
|
@@ -2342,13 +2376,15 @@ class PrismaAPIv2:
|
|
|
2342
2376
|
"""GET /api/v2/computation-runs/{run_id}/"""
|
|
2343
2377
|
return self._get(f"/computation-runs/{run_id}/")
|
|
2344
2378
|
|
|
2345
|
-
def upsert_computation_runs(self, payload: pd.DataFrame | list[dict] | dict
|
|
2379
|
+
def upsert_computation_runs(self, payload: pd.DataFrame | list[dict] | dict,
|
|
2380
|
+
timeout: int | None = None) -> dict:
|
|
2346
2381
|
"""
|
|
2347
2382
|
PUT /api/v2/computation-runs/
|
|
2348
2383
|
|
|
2349
2384
|
Accepts one object or many objects and forwards all provided fields.
|
|
2350
2385
|
"""
|
|
2351
|
-
return self._put("/computation-runs/", self._payload_to_records(payload)
|
|
2386
|
+
return self._put("/computation-runs/", self._payload_to_records(payload),
|
|
2387
|
+
timeout=timeout)
|
|
2352
2388
|
|
|
2353
2389
|
def get_adsorption_singlepoint(
|
|
2354
2390
|
self,
|
|
@@ -2382,31 +2418,32 @@ class PrismaAPIv2:
|
|
|
2382
2418
|
"""GET /api/v2/adsorption-singlepoint/{row_id}/"""
|
|
2383
2419
|
return self._get(f"/adsorption-singlepoint/{row_id}/")
|
|
2384
2420
|
|
|
2385
|
-
def upsert_adsorption_singlepoint(
|
|
2421
|
+
def upsert_adsorption_singlepoint(
|
|
2422
|
+
self,
|
|
2423
|
+
payload: pd.DataFrame | list[dict] | dict,
|
|
2424
|
+
meta_provenance: dict | None = None,
|
|
2425
|
+
repo_dir: str | Path | None = None,
|
|
2426
|
+
timeout: int | None = None,
|
|
2427
|
+
) -> dict:
|
|
2386
2428
|
"""
|
|
2387
2429
|
PUT /api/v2/adsorption-singlepoint/
|
|
2388
2430
|
|
|
2389
|
-
Accepts one object or many objects.
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
records
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
if isinstance(record, dict)
|
|
2406
|
-
else record
|
|
2407
|
-
for record in records
|
|
2408
|
-
]
|
|
2409
|
-
return self._put("/adsorption-singlepoint/", enriched)
|
|
2431
|
+
Accepts one object or many objects. Every row's ``meta_provenance`` is
|
|
2432
|
+
replaced with the resolved provenance.
|
|
2433
|
+
|
|
2434
|
+
Args:
|
|
2435
|
+
payload: DataFrame, one record, a list of records, or
|
|
2436
|
+
``{"adsorption_singlepoints": [...]}``.
|
|
2437
|
+
meta_provenance: Provenance stamped on every row, used as-is. If
|
|
2438
|
+
omitted it is derived from git in *repo_dir* (default: the
|
|
2439
|
+
current working directory), with credentials removed from the
|
|
2440
|
+
remote URL.
|
|
2441
|
+
repo_dir: Repository to read provenance from when
|
|
2442
|
+
*meta_provenance* is not given.
|
|
2443
|
+
timeout: Request timeout in seconds (default: ``upload_timeout``).
|
|
2444
|
+
"""
|
|
2445
|
+
return self._upsert_autoprism_table("upsert_adsorption_singlepoint", payload,
|
|
2446
|
+
meta_provenance, repo_dir, timeout)
|
|
2410
2447
|
|
|
2411
2448
|
def get_heat_capacity(
|
|
2412
2449
|
self,
|
|
@@ -2434,32 +2471,32 @@ class PrismaAPIv2:
|
|
|
2434
2471
|
"""GET /api/v2/heat-capacity/{row_id}/"""
|
|
2435
2472
|
return self._get(f"/heat-capacity/{row_id}/")
|
|
2436
2473
|
|
|
2437
|
-
def upsert_heat_capacity(
|
|
2474
|
+
def upsert_heat_capacity(
|
|
2475
|
+
self,
|
|
2476
|
+
payload: pd.DataFrame | list[dict] | dict,
|
|
2477
|
+
meta_provenance: dict | None = None,
|
|
2478
|
+
repo_dir: str | Path | None = None,
|
|
2479
|
+
timeout: int | None = None,
|
|
2480
|
+
) -> dict:
|
|
2438
2481
|
"""
|
|
2439
2482
|
PUT /api/v2/heat-capacity/
|
|
2440
2483
|
|
|
2441
|
-
Accepts one object or many objects.
|
|
2442
|
-
|
|
2443
|
-
"""
|
|
2444
|
-
if isinstance(payload, dict) and isinstance(payload.get("heat_capacities"), list):
|
|
2445
|
-
records = self._payload_to_records(payload["heat_capacities"])
|
|
2446
|
-
else:
|
|
2447
|
-
records = self._payload_to_records(payload)
|
|
2484
|
+
Accepts one object or many objects. Every row's ``meta_provenance`` is
|
|
2485
|
+
replaced with the resolved provenance.
|
|
2448
2486
|
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
return self._put("/heat-capacity/", enriched)
|
|
2487
|
+
Args:
|
|
2488
|
+
payload: DataFrame, one record, a list of records, or
|
|
2489
|
+
``{"heat_capacities": [...]}``.
|
|
2490
|
+
meta_provenance: Provenance stamped on every row, used as-is. If
|
|
2491
|
+
omitted it is derived from git in *repo_dir* (default: the
|
|
2492
|
+
current working directory), with credentials removed from the
|
|
2493
|
+
remote URL.
|
|
2494
|
+
repo_dir: Repository to read provenance from when
|
|
2495
|
+
*meta_provenance* is not given.
|
|
2496
|
+
timeout: Request timeout in seconds (default: ``upload_timeout``).
|
|
2497
|
+
"""
|
|
2498
|
+
return self._upsert_autoprism_table("upsert_heat_capacity", payload,
|
|
2499
|
+
meta_provenance, repo_dir, timeout)
|
|
2463
2500
|
|
|
2464
2501
|
def get_isotherm_h2(
|
|
2465
2502
|
self,
|
|
@@ -2496,29 +2533,32 @@ class PrismaAPIv2:
|
|
|
2496
2533
|
"""GET /api/v2/isotherm-h2/{row_id}/"""
|
|
2497
2534
|
return self._get(f"/isotherm-h2/{row_id}/")
|
|
2498
2535
|
|
|
2499
|
-
def upsert_isotherm_h2(
|
|
2536
|
+
def upsert_isotherm_h2(
|
|
2537
|
+
self,
|
|
2538
|
+
payload: pd.DataFrame | list[dict] | dict,
|
|
2539
|
+
meta_provenance: dict | None = None,
|
|
2540
|
+
repo_dir: str | Path | None = None,
|
|
2541
|
+
timeout: int | None = None,
|
|
2542
|
+
) -> dict:
|
|
2500
2543
|
"""
|
|
2501
2544
|
PUT /api/v2/isotherm-h2/
|
|
2502
2545
|
|
|
2503
|
-
Accepts one object or many objects.
|
|
2504
|
-
|
|
2505
|
-
"""
|
|
2506
|
-
if isinstance(payload, dict) and isinstance(payload.get("isotherm_H2s"), list):
|
|
2507
|
-
records = self._payload_to_records(payload["isotherm_H2s"])
|
|
2508
|
-
else:
|
|
2509
|
-
records = self._payload_to_records(payload)
|
|
2546
|
+
Accepts one object or many objects. Every row's ``meta_provenance`` is
|
|
2547
|
+
replaced with the resolved provenance.
|
|
2510
2548
|
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2549
|
+
Args:
|
|
2550
|
+
payload: DataFrame, one record, a list of records, or
|
|
2551
|
+
``{"isotherm_H2s": [...]}``.
|
|
2552
|
+
meta_provenance: Provenance stamped on every row, used as-is. If
|
|
2553
|
+
omitted it is derived from git in *repo_dir* (default: the
|
|
2554
|
+
current working directory), with credentials removed from the
|
|
2555
|
+
remote URL.
|
|
2556
|
+
repo_dir: Repository to read provenance from when
|
|
2557
|
+
*meta_provenance* is not given.
|
|
2558
|
+
timeout: Request timeout in seconds (default: ``upload_timeout``).
|
|
2559
|
+
"""
|
|
2560
|
+
return self._upsert_autoprism_table("upsert_isotherm_h2", payload,
|
|
2561
|
+
meta_provenance, repo_dir, timeout)
|
|
2522
2562
|
|
|
2523
2563
|
def get_mofchecker(
|
|
2524
2564
|
self,
|
|
@@ -2552,29 +2592,32 @@ class PrismaAPIv2:
|
|
|
2552
2592
|
"""GET /api/v2/mofchecker/{row_id}/"""
|
|
2553
2593
|
return self._get(f"/mofchecker/{row_id}/")
|
|
2554
2594
|
|
|
2555
|
-
def upsert_mofchecker(
|
|
2595
|
+
def upsert_mofchecker(
|
|
2596
|
+
self,
|
|
2597
|
+
payload: pd.DataFrame | list[dict] | dict,
|
|
2598
|
+
meta_provenance: dict | None = None,
|
|
2599
|
+
repo_dir: str | Path | None = None,
|
|
2600
|
+
timeout: int | None = None,
|
|
2601
|
+
) -> dict:
|
|
2556
2602
|
"""
|
|
2557
2603
|
PUT /api/v2/mofchecker/
|
|
2558
2604
|
|
|
2559
|
-
Accepts one object or many objects.
|
|
2560
|
-
|
|
2561
|
-
"""
|
|
2562
|
-
if isinstance(payload, dict) and isinstance(payload.get("mofchecker"), list):
|
|
2563
|
-
records = self._payload_to_records(payload["mofchecker"])
|
|
2564
|
-
else:
|
|
2565
|
-
records = self._payload_to_records(payload)
|
|
2605
|
+
Accepts one object or many objects. Every row's ``meta_provenance`` is
|
|
2606
|
+
replaced with the resolved provenance.
|
|
2566
2607
|
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2608
|
+
Args:
|
|
2609
|
+
payload: DataFrame, one record, a list of records, or
|
|
2610
|
+
``{"mofchecker": [...]}``.
|
|
2611
|
+
meta_provenance: Provenance stamped on every row, used as-is. If
|
|
2612
|
+
omitted it is derived from git in *repo_dir* (default: the
|
|
2613
|
+
current working directory), with credentials removed from the
|
|
2614
|
+
remote URL.
|
|
2615
|
+
repo_dir: Repository to read provenance from when
|
|
2616
|
+
*meta_provenance* is not given.
|
|
2617
|
+
timeout: Request timeout in seconds (default: ``upload_timeout``).
|
|
2618
|
+
"""
|
|
2619
|
+
return self._upsert_autoprism_table("upsert_mofchecker", payload,
|
|
2620
|
+
meta_provenance, repo_dir, timeout)
|
|
2578
2621
|
|
|
2579
2622
|
def get_zeopp_metrics(
|
|
2580
2623
|
self,
|
|
@@ -2599,31 +2642,41 @@ class PrismaAPIv2:
|
|
|
2599
2642
|
"""GET /api/v2/zeopp-metrics/{row_id}/"""
|
|
2600
2643
|
return self._get(f"/zeopp-metrics/{row_id}/")
|
|
2601
2644
|
|
|
2602
|
-
def upsert_zeopp_metrics(
|
|
2645
|
+
def upsert_zeopp_metrics(
|
|
2646
|
+
self,
|
|
2647
|
+
payload: pd.DataFrame | list[dict] | dict,
|
|
2648
|
+
meta_provenance: dict | None = None,
|
|
2649
|
+
repo_dir: str | Path | None = None,
|
|
2650
|
+
timeout: int | None = None,
|
|
2651
|
+
) -> dict:
|
|
2603
2652
|
"""
|
|
2604
2653
|
PUT /api/v2/zeopp-metrics/
|
|
2605
2654
|
|
|
2606
|
-
Accepts one object or many objects.
|
|
2607
|
-
|
|
2608
|
-
"""
|
|
2609
|
-
if isinstance(payload, dict) and isinstance(payload.get("zeopp_metrics"), list):
|
|
2610
|
-
records = self._payload_to_records(payload["zeopp_metrics"])
|
|
2611
|
-
else:
|
|
2612
|
-
records = self._payload_to_records(payload)
|
|
2655
|
+
Accepts one object or many objects. Every row's ``meta_provenance`` is
|
|
2656
|
+
replaced with the resolved provenance.
|
|
2613
2657
|
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2658
|
+
Args:
|
|
2659
|
+
payload: DataFrame, one record, a list of records, or
|
|
2660
|
+
``{"zeopp_metrics": [...]}``.
|
|
2661
|
+
meta_provenance: Provenance stamped on every row, used as-is. If
|
|
2662
|
+
omitted it is derived from git in *repo_dir* (default: the
|
|
2663
|
+
current working directory), with credentials removed from the
|
|
2664
|
+
remote URL.
|
|
2665
|
+
repo_dir: Repository to read provenance from when
|
|
2666
|
+
*meta_provenance* is not given.
|
|
2667
|
+
timeout: Request timeout in seconds (default: ``upload_timeout``).
|
|
2668
|
+
"""
|
|
2669
|
+
return self._upsert_autoprism_table("upsert_zeopp_metrics", payload,
|
|
2670
|
+
meta_provenance, repo_dir, timeout)
|
|
2671
|
+
|
|
2672
|
+
def upsert_autoprism_collection(
|
|
2673
|
+
self,
|
|
2674
|
+
payload: dict[str, Any],
|
|
2675
|
+
meta_provenance: dict | None = None,
|
|
2676
|
+
repo_dir: str | Path | None = None,
|
|
2677
|
+
timeout: int | None = None,
|
|
2678
|
+
raise_on_error: bool = False,
|
|
2679
|
+
) -> dict[str, Any]:
|
|
2627
2680
|
"""
|
|
2628
2681
|
Upsert multiple AutoPrism sections from a single combined payload.
|
|
2629
2682
|
|
|
@@ -2632,10 +2685,18 @@ class PrismaAPIv2:
|
|
|
2632
2685
|
computation_runs, adsorption_singlepoints, heat_capacities,
|
|
2633
2686
|
isotherm_H2s, mofchecker, zeopp_metrics, meta_provenance
|
|
2634
2687
|
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2688
|
+
Provenance (stamped on every row of the five AutoPrism tables), in
|
|
2689
|
+
order of precedence:
|
|
2690
|
+
1. the *meta_provenance* argument;
|
|
2691
|
+
2. the payload's top-level ``meta_provenance``;
|
|
2692
|
+
3. derived from git in *repo_dir* (default: current directory).
|
|
2693
|
+
|
|
2694
|
+
Error handling:
|
|
2695
|
+
By default a failing section does not raise: it is reported with
|
|
2696
|
+
``status: "error"`` and ``overall_status`` becomes
|
|
2697
|
+
``"partial_failure"``. Check ``overall_status``, or pass
|
|
2698
|
+
``raise_on_error=True`` to raise ``RuntimeError`` after all
|
|
2699
|
+
sections have been attempted.
|
|
2639
2700
|
|
|
2640
2701
|
Returns:
|
|
2641
2702
|
dict with keys:
|
|
@@ -2646,13 +2707,14 @@ class PrismaAPIv2:
|
|
|
2646
2707
|
if not isinstance(payload, dict):
|
|
2647
2708
|
raise TypeError("payload must be a dict matching the AutoPrism collection shape")
|
|
2648
2709
|
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
("
|
|
2710
|
+
if meta_provenance is None:
|
|
2711
|
+
meta_provenance = payload.get("meta_provenance")
|
|
2712
|
+
# Resolve once so every section carries identical provenance.
|
|
2713
|
+
meta = self._resolve_meta_provenance(meta_provenance, repo_dir)
|
|
2714
|
+
|
|
2715
|
+
section_handlers: list[tuple[str, str]] = [
|
|
2716
|
+
("computation_runs", "upsert_computation_runs"),
|
|
2717
|
+
*((key, method) for method, (_, key) in _AUTOPRISM_TABLES.items()),
|
|
2656
2718
|
]
|
|
2657
2719
|
|
|
2658
2720
|
sections: dict[str, dict[str, Any]] = {}
|
|
@@ -2660,7 +2722,8 @@ class PrismaAPIv2:
|
|
|
2660
2722
|
total_updated = 0
|
|
2661
2723
|
failed = 0
|
|
2662
2724
|
|
|
2663
|
-
for section_name, method_name
|
|
2725
|
+
for section_name, method_name in section_handlers:
|
|
2726
|
+
section_payload = payload.get(section_name)
|
|
2664
2727
|
if section_payload is None:
|
|
2665
2728
|
sections[section_name] = {
|
|
2666
2729
|
"status": "skipped",
|
|
@@ -2670,7 +2733,10 @@ class PrismaAPIv2:
|
|
|
2670
2733
|
|
|
2671
2734
|
try:
|
|
2672
2735
|
method = getattr(self, method_name)
|
|
2673
|
-
|
|
2736
|
+
if method_name in _AUTOPRISM_TABLES:
|
|
2737
|
+
result = method(section_payload, meta_provenance=meta, timeout=timeout)
|
|
2738
|
+
else:
|
|
2739
|
+
result = method(section_payload, timeout=timeout)
|
|
2674
2740
|
created = int(result.get("created", 0)) if isinstance(result, dict) else 0
|
|
2675
2741
|
updated = int(result.get("updated", 0)) if isinstance(result, dict) else 0
|
|
2676
2742
|
total_created += created
|
|
@@ -2688,7 +2754,7 @@ class PrismaAPIv2:
|
|
|
2688
2754
|
"error": str(exc),
|
|
2689
2755
|
}
|
|
2690
2756
|
|
|
2691
|
-
|
|
2757
|
+
summary = {
|
|
2692
2758
|
"sections": sections,
|
|
2693
2759
|
"totals": {
|
|
2694
2760
|
"created": total_created,
|
|
@@ -2697,6 +2763,14 @@ class PrismaAPIv2:
|
|
|
2697
2763
|
},
|
|
2698
2764
|
"overall_status": "ok" if failed == 0 else "partial_failure",
|
|
2699
2765
|
}
|
|
2766
|
+
if raise_on_error and failed:
|
|
2767
|
+
errors = "; ".join(
|
|
2768
|
+
f"{name}: {info['error']}"
|
|
2769
|
+
for name, info in sections.items()
|
|
2770
|
+
if info["status"] == "error"
|
|
2771
|
+
)
|
|
2772
|
+
raise RuntimeError(f"AutoPrism upsert failed for {failed} section(s): {errors}")
|
|
2773
|
+
return summary
|
|
2700
2774
|
|
|
2701
2775
|
def get_autoprism_collection(
|
|
2702
2776
|
self,
|
|
@@ -2763,10 +2837,7 @@ class PrismaAPIv2:
|
|
|
2763
2837
|
records = self._as_records(value)
|
|
2764
2838
|
return [r for r in records if isinstance(r, dict)]
|
|
2765
2839
|
|
|
2766
|
-
meta =
|
|
2767
|
-
**self._autoprism_meta_provenance(),
|
|
2768
|
-
"source_repo_url": self._autoprism_source_repo_url(),
|
|
2769
|
-
}
|
|
2840
|
+
meta = self._resolve_meta_provenance()
|
|
2770
2841
|
|
|
2771
2842
|
def _safe_fetch(section: str, fetcher, **kwargs) -> pd.DataFrame | list[dict]:
|
|
2772
2843
|
try:
|
|
@@ -3314,6 +3385,63 @@ def _compact(**kwargs) -> dict:
|
|
|
3314
3385
|
return {k: v for k, v in kwargs.items() if v is not None}
|
|
3315
3386
|
|
|
3316
3387
|
|
|
3388
|
+
def _json_safe(value: Any) -> Any:
|
|
3389
|
+
"""
|
|
3390
|
+
Recursively convert *value* into something ``json.dumps(allow_nan=False)``
|
|
3391
|
+
accepts: NaN/±inf/``pd.NA``/``NaT`` -> None, numpy scalars and arrays ->
|
|
3392
|
+
Python values, timestamps -> ISO strings.
|
|
3393
|
+
"""
|
|
3394
|
+
if isinstance(value, dict):
|
|
3395
|
+
return {k: _json_safe(v) for k, v in value.items()}
|
|
3396
|
+
if isinstance(value, (list, tuple)):
|
|
3397
|
+
return [_json_safe(v) for v in value]
|
|
3398
|
+
if value is None or isinstance(value, (str, bool)):
|
|
3399
|
+
return value
|
|
3400
|
+
if isinstance(value, float):
|
|
3401
|
+
return value if math.isfinite(value) else None
|
|
3402
|
+
if isinstance(value, int):
|
|
3403
|
+
return value
|
|
3404
|
+
if value is pd.NA or value is pd.NaT:
|
|
3405
|
+
return None
|
|
3406
|
+
if isinstance(value, (pd.Timestamp, datetime, date)):
|
|
3407
|
+
return value.isoformat()
|
|
3408
|
+
tolist = getattr(value, "tolist", None) # numpy scalars and arrays
|
|
3409
|
+
if callable(tolist):
|
|
3410
|
+
return _json_safe(tolist())
|
|
3411
|
+
try:
|
|
3412
|
+
if pd.isna(value):
|
|
3413
|
+
return None
|
|
3414
|
+
except (TypeError, ValueError):
|
|
3415
|
+
pass
|
|
3416
|
+
return value
|
|
3417
|
+
|
|
3418
|
+
|
|
3419
|
+
def _git_output(args: list[str], cwd: Path) -> str | None:
|
|
3420
|
+
"""Run ``git <args>`` in *cwd*; return stripped stdout, or None on failure."""
|
|
3421
|
+
try:
|
|
3422
|
+
cp = subprocess.run(
|
|
3423
|
+
["git", *args],
|
|
3424
|
+
cwd=cwd,
|
|
3425
|
+
check=False,
|
|
3426
|
+
capture_output=True,
|
|
3427
|
+
text=True,
|
|
3428
|
+
)
|
|
3429
|
+
except OSError:
|
|
3430
|
+
return None
|
|
3431
|
+
if cp.returncode != 0:
|
|
3432
|
+
return None
|
|
3433
|
+
return cp.stdout.strip() or None
|
|
3434
|
+
|
|
3435
|
+
|
|
3436
|
+
def _strip_url_credentials(url: str) -> str:
|
|
3437
|
+
"""Drop ``user:token@`` from an http(s) remote URL (CI checkouts embed tokens)."""
|
|
3438
|
+
parts = urlsplit(url)
|
|
3439
|
+
if parts.scheme in ("http", "https") and "@" in parts.netloc:
|
|
3440
|
+
host = parts.netloc.rsplit("@", 1)[1]
|
|
3441
|
+
return urlunsplit(parts._replace(netloc=host))
|
|
3442
|
+
return url
|
|
3443
|
+
|
|
3444
|
+
|
|
3317
3445
|
def _as_material_names(value: str | list[str] | None) -> list[str]:
|
|
3318
3446
|
"""Normalise a name argument to a list of non-empty names."""
|
|
3319
3447
|
if value is None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: prisma_api
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: A package for interacting with PrISMa Platform APIs. PrISMa is a holistic system for numerical synthesis and assessment of Metal-Organic Frameworks and their applications, for Direct Air Capture of CO2. For more information, see: https://prisma.hw.ac.uk.
|
|
5
5
|
Author-email: RCCS-CaptureTeam <team@prisma-platform.org>
|
|
6
6
|
License: GNUv3
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "prisma_api"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.3"
|
|
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"}
|
|
@@ -380,3 +380,39 @@ def test_get_materials_data_empty_data_list():
|
|
|
380
380
|
df = result.get("data") if "data" in result else result.get("simulated")
|
|
381
381
|
if df is not None:
|
|
382
382
|
assert isinstance(df, pd.DataFrame)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ── Config fallback (no config file) ──────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
def test_get_or_create_config_falls_back_to_env(monkeypatch):
|
|
388
|
+
from prisma_api import config
|
|
389
|
+
monkeypatch.setattr(config, "load_config", lambda: None)
|
|
390
|
+
monkeypatch.setenv("PRISMA_API_KEY", "env-prod")
|
|
391
|
+
monkeypatch.delenv("PRISMA_API_DEV_API_KEY", raising=False)
|
|
392
|
+
monkeypatch.setenv("PRISMA_API_DEV_HOST_PORT", "9000")
|
|
393
|
+
monkeypatch.setattr("builtins.input", lambda *a: pytest.fail("must not prompt"))
|
|
394
|
+
|
|
395
|
+
cfg = config.get_or_create_config()
|
|
396
|
+
|
|
397
|
+
assert cfg == {"api_key": "env-prod", "dev_api_key": "env-prod", "dev_host_port": "9000"}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_get_or_create_config_raises_without_tty(monkeypatch):
|
|
401
|
+
from prisma_api import config
|
|
402
|
+
monkeypatch.setattr(config, "load_config", lambda: None)
|
|
403
|
+
for name in config.ENV_VARS:
|
|
404
|
+
monkeypatch.delenv(name, raising=False)
|
|
405
|
+
monkeypatch.setattr(config.sys.stdin, "isatty", lambda: False, raising=False)
|
|
406
|
+
monkeypatch.setattr("builtins.input", lambda *a: pytest.fail("must not prompt"))
|
|
407
|
+
|
|
408
|
+
with pytest.raises(RuntimeError, match="PRISMA_API_KEY"):
|
|
409
|
+
config.get_or_create_config()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_version_matches_installed_metadata():
|
|
413
|
+
import prisma_api as pkg
|
|
414
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
415
|
+
try:
|
|
416
|
+
assert pkg.__version__ == version("prisma_api")
|
|
417
|
+
except PackageNotFoundError:
|
|
418
|
+
assert pkg.__version__ == "0+unknown"
|
|
@@ -1549,8 +1549,8 @@ def test_upsert_autoprism_tables_accept_dataframes_and_pass_all_fields(api, monk
|
|
|
1549
1549
|
"source_commit_hash": "724b0306f6b21f953fba21e424cdda230525862f",
|
|
1550
1550
|
}
|
|
1551
1551
|
fake_repo_url = "https://github.com/AutoPrism/AutoPrism"
|
|
1552
|
-
monkeypatch.setattr(api, "_autoprism_meta_provenance", lambda: fake_meta)
|
|
1553
|
-
monkeypatch.setattr(api, "_autoprism_source_repo_url", lambda: fake_repo_url)
|
|
1552
|
+
monkeypatch.setattr(api, "_autoprism_meta_provenance", lambda repo_dir=None: fake_meta)
|
|
1553
|
+
monkeypatch.setattr(api, "_autoprism_source_repo_url", lambda repo_dir=None: fake_repo_url)
|
|
1554
1554
|
|
|
1555
1555
|
adsorption_df = pd.DataFrame([
|
|
1556
1556
|
{
|
|
@@ -1595,15 +1595,15 @@ def test_upsert_autoprism_tables_accept_dataframes_and_pass_all_fields(api, monk
|
|
|
1595
1595
|
}]
|
|
1596
1596
|
isotherm_h2_expected = [{
|
|
1597
1597
|
**isotherm_h2_df.to_dict(orient="records")[0],
|
|
1598
|
-
"meta_provenance": fake_meta,
|
|
1598
|
+
"meta_provenance": {**fake_meta, "source_repo_url": fake_repo_url},
|
|
1599
1599
|
}]
|
|
1600
1600
|
mofchecker_expected = [{
|
|
1601
1601
|
**mofchecker_df.to_dict(orient="records")[0],
|
|
1602
|
-
"meta_provenance": fake_meta,
|
|
1602
|
+
"meta_provenance": {**fake_meta, "source_repo_url": fake_repo_url},
|
|
1603
1603
|
}]
|
|
1604
1604
|
zeopp_expected = [{
|
|
1605
1605
|
**zeopp_df.to_dict(orient="records")[0],
|
|
1606
|
-
"meta_provenance": fake_meta,
|
|
1606
|
+
"meta_provenance": {**fake_meta, "source_repo_url": fake_repo_url},
|
|
1607
1607
|
}]
|
|
1608
1608
|
|
|
1609
1609
|
resp_lib.add(
|
|
@@ -1657,7 +1657,8 @@ def test_upsert_zeopp_metrics_accepts_wrapped_payload_and_overwrites_meta(api, m
|
|
|
1657
1657
|
"source_repo_tag": "0.1.0",
|
|
1658
1658
|
"source_commit_hash": "724b0306f6b21f953fba21e424cdda230525862f",
|
|
1659
1659
|
}
|
|
1660
|
-
monkeypatch.setattr(api, "_autoprism_meta_provenance", lambda: fake_meta)
|
|
1660
|
+
monkeypatch.setattr(api, "_autoprism_meta_provenance", lambda repo_dir=None: fake_meta)
|
|
1661
|
+
monkeypatch.setattr(api, "_autoprism_source_repo_url", lambda repo_dir=None: None)
|
|
1661
1662
|
|
|
1662
1663
|
payload = {
|
|
1663
1664
|
"zeopp_metrics": [
|
|
@@ -1687,7 +1688,7 @@ def test_upsert_zeopp_metrics_accepts_wrapped_payload_and_overwrites_meta(api, m
|
|
|
1687
1688
|
"md5": "aaaaaaaa",
|
|
1688
1689
|
"probe": "N2",
|
|
1689
1690
|
"Di": 6.4,
|
|
1690
|
-
"meta_provenance": fake_meta,
|
|
1691
|
+
"meta_provenance": {**fake_meta, "source_repo_url": None},
|
|
1691
1692
|
}]
|
|
1692
1693
|
|
|
1693
1694
|
resp_lib.add(
|
|
@@ -1703,13 +1704,22 @@ def test_upsert_zeopp_metrics_accepts_wrapped_payload_and_overwrites_meta(api, m
|
|
|
1703
1704
|
|
|
1704
1705
|
|
|
1705
1706
|
def test_upsert_autoprism_collection_dispatches_sections(api, monkeypatch):
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1707
|
+
calls = {}
|
|
1708
|
+
|
|
1709
|
+
def _fake(name, result):
|
|
1710
|
+
def _method(payload, **kwargs):
|
|
1711
|
+
calls[name] = kwargs
|
|
1712
|
+
return result
|
|
1713
|
+
return _method
|
|
1714
|
+
|
|
1715
|
+
monkeypatch.setattr(api, "upsert_computation_runs", _fake("computation_runs", {"created": 1, "updated": 0}))
|
|
1716
|
+
monkeypatch.setattr(api, "upsert_adsorption_singlepoint", _fake("adsorption", {"created": 2, "updated": 0}))
|
|
1717
|
+
monkeypatch.setattr(api, "upsert_heat_capacity", _fake("heat", {"created": 0, "updated": 3}))
|
|
1718
|
+
monkeypatch.setattr(api, "upsert_isotherm_h2", _fake("h2", {"created": 4, "updated": 1}))
|
|
1719
|
+
monkeypatch.setattr(api, "upsert_mofchecker", _fake("mofchecker", {"created": 5, "updated": 0}))
|
|
1720
|
+
monkeypatch.setattr(api, "upsert_zeopp_metrics", _fake("zeopp", {"created": 6, "updated": 2}))
|
|
1721
|
+
|
|
1722
|
+
top_level_meta = {"source_repo": "AutoPrism", "source_commit_hash": "abc123"}
|
|
1713
1723
|
payload = {
|
|
1714
1724
|
"computation_runs": [{"id": "run-1"}],
|
|
1715
1725
|
"adsorption_singlepoints": [{"id": 1}],
|
|
@@ -1717,7 +1727,7 @@ def test_upsert_autoprism_collection_dispatches_sections(api, monkeypatch):
|
|
|
1717
1727
|
"isotherm_H2s": [{"id": 3}],
|
|
1718
1728
|
"mofchecker": [{"id": 4}],
|
|
1719
1729
|
"zeopp_metrics": [{"id": 5}],
|
|
1720
|
-
"meta_provenance":
|
|
1730
|
+
"meta_provenance": top_level_meta,
|
|
1721
1731
|
}
|
|
1722
1732
|
|
|
1723
1733
|
result = api.upsert_autoprism_collection(payload)
|
|
@@ -1728,12 +1738,33 @@ def test_upsert_autoprism_collection_dispatches_sections(api, monkeypatch):
|
|
|
1728
1738
|
assert result["totals"]["failed_sections"] == 0
|
|
1729
1739
|
assert result["sections"]["zeopp_metrics"]["status"] == "ok"
|
|
1730
1740
|
assert result["sections"]["mofchecker"]["status"] == "ok"
|
|
1741
|
+
# Top-level meta_provenance is passed down to every AutoPrism table.
|
|
1742
|
+
for name in ("adsorption", "heat", "h2", "mofchecker", "zeopp"):
|
|
1743
|
+
assert calls[name]["meta_provenance"] == top_level_meta
|
|
1744
|
+
assert "meta_provenance" not in calls["computation_runs"]
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
def test_upsert_autoprism_collection_argument_meta_overrides_payload(api, monkeypatch):
|
|
1748
|
+
seen = {}
|
|
1749
|
+
|
|
1750
|
+
def _capture(payload, **kwargs):
|
|
1751
|
+
seen.update(kwargs)
|
|
1752
|
+
return {"created": 1, "updated": 0}
|
|
1753
|
+
|
|
1754
|
+
monkeypatch.setattr(api, "upsert_zeopp_metrics", _capture)
|
|
1755
|
+
override = {"source_repo": "explicit"}
|
|
1756
|
+
api.upsert_autoprism_collection(
|
|
1757
|
+
{"zeopp_metrics": [{"id": 5}], "meta_provenance": {"source_repo": "payload"}},
|
|
1758
|
+
meta_provenance=override,
|
|
1759
|
+
)
|
|
1760
|
+
assert seen["meta_provenance"] == override
|
|
1731
1761
|
|
|
1732
1762
|
|
|
1733
1763
|
def test_upsert_autoprism_collection_reports_skips_and_errors(api, monkeypatch):
|
|
1734
|
-
monkeypatch.setattr(api, "upsert_computation_runs", lambda payload: {"created": 1, "updated": 0})
|
|
1764
|
+
monkeypatch.setattr(api, "upsert_computation_runs", lambda payload, **kw: {"created": 1, "updated": 0})
|
|
1765
|
+
monkeypatch.setattr(api, "_resolve_meta_provenance", lambda meta=None, repo_dir=None: meta or {})
|
|
1735
1766
|
|
|
1736
|
-
def _raise_error(payload):
|
|
1767
|
+
def _raise_error(payload, **kwargs):
|
|
1737
1768
|
raise RuntimeError("boom")
|
|
1738
1769
|
|
|
1739
1770
|
monkeypatch.setattr(api, "upsert_zeopp_metrics", _raise_error)
|
|
@@ -1753,6 +1784,111 @@ def test_upsert_autoprism_collection_reports_skips_and_errors(api, monkeypatch):
|
|
|
1753
1784
|
assert result["sections"]["adsorption_singlepoints"]["status"] == "skipped"
|
|
1754
1785
|
assert result["sections"]["zeopp_metrics"]["status"] == "error"
|
|
1755
1786
|
|
|
1787
|
+
with pytest.raises(RuntimeError, match="zeopp_metrics: boom"):
|
|
1788
|
+
api.upsert_autoprism_collection(payload, raise_on_error=True)
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
@resp_lib.activate
|
|
1792
|
+
def test_upsert_explicit_meta_provenance_used_as_is(api, monkeypatch):
|
|
1793
|
+
def _no_git(*args, **kwargs):
|
|
1794
|
+
raise AssertionError("git provenance must not be derived when meta_provenance is given")
|
|
1795
|
+
|
|
1796
|
+
monkeypatch.setattr(api, "_autoprism_meta_provenance", _no_git)
|
|
1797
|
+
meta = {"source_repo": "AutoPrism", "source_commit_hash": "abc123"}
|
|
1798
|
+
resp_lib.add(
|
|
1799
|
+
resp_lib.PUT,
|
|
1800
|
+
f"{PROD_BASE}/isotherm-h2/",
|
|
1801
|
+
match=[matchers.json_params_matcher([{"structure": "X", "meta_provenance": meta}])],
|
|
1802
|
+
json={"created": 1, "updated": 0},
|
|
1803
|
+
)
|
|
1804
|
+
assert api.upsert_isotherm_h2({"structure": "X"}, meta_provenance=meta)["created"] == 1
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
def _init_git_repo(path, remote):
|
|
1808
|
+
import subprocess
|
|
1809
|
+
run = lambda *a: subprocess.run(["git", *a], cwd=path, check=True, capture_output=True)
|
|
1810
|
+
run("init", "-q", "-b", "main")
|
|
1811
|
+
run("config", "user.email", "t@example.com")
|
|
1812
|
+
run("config", "user.name", "t")
|
|
1813
|
+
(path / "pyproject.toml").write_text('[project]\nname = "caller"\nversion = "9.8.7"\n')
|
|
1814
|
+
run("add", ".")
|
|
1815
|
+
run("commit", "-q", "-m", "init")
|
|
1816
|
+
run("tag", "v9.8.7")
|
|
1817
|
+
run("remote", "add", "origin", remote)
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
def test_meta_provenance_describes_caller_repo_not_prisma_api(api, tmp_path, monkeypatch):
|
|
1821
|
+
repo = tmp_path / "caller_repo"
|
|
1822
|
+
repo.mkdir()
|
|
1823
|
+
_init_git_repo(repo, "https://x-access-token:SECRET@github.com/org/caller_repo.git")
|
|
1824
|
+
monkeypatch.chdir(repo)
|
|
1825
|
+
|
|
1826
|
+
meta = api._resolve_meta_provenance()
|
|
1827
|
+
|
|
1828
|
+
assert meta["source_repo"] == "caller_repo"
|
|
1829
|
+
assert meta["source_repo_semantic_version"] == "9.8.7"
|
|
1830
|
+
assert meta["source_repo_tag"] == "v9.8.7"
|
|
1831
|
+
assert meta["source_commit_hash"] and len(meta["source_commit_hash"]) == 40
|
|
1832
|
+
assert meta["source_repo_url"] == "https://github.com/org/caller_repo.git"
|
|
1833
|
+
assert "SECRET" not in json.dumps(meta)
|
|
1834
|
+
|
|
1835
|
+
# repo_dir= takes precedence over the working directory.
|
|
1836
|
+
monkeypatch.chdir(tmp_path)
|
|
1837
|
+
assert api._resolve_meta_provenance(repo_dir=repo)["source_repo"] == "caller_repo"
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
@pytest.mark.parametrize("url, expected", [
|
|
1841
|
+
("https://x-access-token:tok@github.com/o/r.git", "https://github.com/o/r.git"),
|
|
1842
|
+
("https://user@github.com/o/r", "https://github.com/o/r"),
|
|
1843
|
+
("https://github.com/o/r", "https://github.com/o/r"),
|
|
1844
|
+
("git@github.com:o/r.git", "git@github.com:o/r.git"),
|
|
1845
|
+
])
|
|
1846
|
+
def test_strip_url_credentials(url, expected):
|
|
1847
|
+
from prisma_api.prisma_api_v2 import _strip_url_credentials
|
|
1848
|
+
assert _strip_url_credentials(url) == expected
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
def test_payload_to_records_is_strict_json_safe(api):
|
|
1852
|
+
import numpy as np
|
|
1853
|
+
df = pd.DataFrame({
|
|
1854
|
+
"structure": ["A", "B", None],
|
|
1855
|
+
"value": [1.0, np.nan, np.inf],
|
|
1856
|
+
"count": pd.array([1, None, 3], dtype="Int64"),
|
|
1857
|
+
"when": [pd.Timestamp("2026-01-01"), pd.NaT, pd.Timestamp("2026-01-02")],
|
|
1858
|
+
"flag": [np.bool_(True), np.bool_(False), np.bool_(True)],
|
|
1859
|
+
})
|
|
1860
|
+
records = api._payload_to_records(df)
|
|
1861
|
+
json.dumps(records, allow_nan=False)
|
|
1862
|
+
assert records[1]["value"] is None
|
|
1863
|
+
assert records[2]["value"] is None
|
|
1864
|
+
assert records[1]["count"] is None
|
|
1865
|
+
assert records[1]["when"] is None
|
|
1866
|
+
assert records[0]["flag"] is True
|
|
1867
|
+
|
|
1868
|
+
nested = api._payload_to_records([{"a": float("nan"), "b": {"c": np.float64("-inf"), "d": [np.int64(2)]}}])
|
|
1869
|
+
json.dumps(nested, allow_nan=False)
|
|
1870
|
+
assert nested == [{"a": None, "b": {"c": None, "d": [2]}}]
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
@resp_lib.activate
|
|
1874
|
+
def test_put_body_has_no_nan_and_honours_timeout(api, monkeypatch):
|
|
1875
|
+
import numpy as np
|
|
1876
|
+
captured = {}
|
|
1877
|
+
real_put = requests.put
|
|
1878
|
+
|
|
1879
|
+
def _spy(url, **kwargs):
|
|
1880
|
+
captured["timeout"] = kwargs.get("timeout")
|
|
1881
|
+
return real_put(url, **kwargs)
|
|
1882
|
+
|
|
1883
|
+
monkeypatch.setattr(requests, "put", _spy)
|
|
1884
|
+
resp_lib.add(resp_lib.PUT, f"{PROD_BASE}/region-costs/", json={"created": 1})
|
|
1885
|
+
api.upload_timeout = 999
|
|
1886
|
+
api.upsert_region_costs(pd.DataFrame({"region": ["UK"], "cost": [np.nan]}))
|
|
1887
|
+
|
|
1888
|
+
body = resp_lib.calls[0].request.body
|
|
1889
|
+
json.loads(body, parse_constant=lambda c: pytest.fail(f"non-JSON constant {c}"))
|
|
1890
|
+
assert captured["timeout"] == 999
|
|
1891
|
+
|
|
1756
1892
|
|
|
1757
1893
|
def test_get_autoprism_collection_allows_empty_payloads(api, monkeypatch):
|
|
1758
1894
|
monkeypatch.setattr(api, "get_computation_runs", lambda **kwargs: None)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|