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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prisma_api
3
- Version: 0.4.2
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
- __version__ = "0.3.9"
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
- """Return existing config or create it interactively if missing."""
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 now controls target DB explicitly at init time.
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(), timeout=120)
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
- """Normalise write payloads to list[dict] while preserving all fields."""
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
- """Build meta_provenance from local repository and pyproject metadata."""
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
- try:
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
- repo_name = Path(repo_root).name if repo_root else Path(__file__).resolve().parents[1].name
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
- repo_name
1539
- if branch in (None, "main")
1540
- else f"{repo_name} (branch: {branch})"
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 = Path(__file__).resolve().parents[1] / "pyproject.toml"
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 repository remote URL used for source provenance, if available."""
1568
- try:
1569
- cp = subprocess.run(
1570
- ["git", "remote", "get-url", "origin"],
1571
- cwd=Path(__file__).resolve().parents[1],
1572
- check=False,
1573
- capture_output=True,
1574
- text=True,
1575
- )
1576
- except OSError:
1577
- return None
1578
-
1579
- if cp.returncode != 0:
1580
- return None
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
- value = cp.stdout.strip()
1583
- return value or None
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) -> 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(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
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
- Automatically normalises ``meta_provenance`` using local repo metadata.
2391
- """
2392
- if isinstance(payload, dict) and isinstance(payload.get("adsorption_singlepoints"), list):
2393
- records = self._payload_to_records(payload["adsorption_singlepoints"])
2394
- else:
2395
- records = self._payload_to_records(payload)
2396
- meta = {
2397
- **self._autoprism_meta_provenance(),
2398
- "source_repo_url": self._autoprism_source_repo_url(),
2399
- }
2400
- enriched = [
2401
- {
2402
- **record,
2403
- "meta_provenance": meta,
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(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
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
- Automatically normalises ``meta_provenance`` using local repo metadata.
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
- meta = {
2450
- **self._autoprism_meta_provenance(),
2451
- "source_repo_url": self._autoprism_source_repo_url(),
2452
- }
2453
- enriched = [
2454
- {
2455
- **record,
2456
- "meta_provenance": meta,
2457
- }
2458
- if isinstance(record, dict)
2459
- else record
2460
- for record in records
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(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
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
- Automatically normalises ``meta_provenance`` using local repo metadata.
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
- meta = self._autoprism_meta_provenance()
2512
- enriched = [
2513
- {
2514
- **record,
2515
- "meta_provenance": meta,
2516
- }
2517
- if isinstance(record, dict)
2518
- else record
2519
- for record in records
2520
- ]
2521
- return self._put("/isotherm-h2/", enriched)
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(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
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
- Automatically normalises ``meta_provenance`` using local repo metadata.
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
- meta = self._autoprism_meta_provenance()
2568
- enriched = [
2569
- {
2570
- **record,
2571
- "meta_provenance": meta,
2572
- }
2573
- if isinstance(record, dict)
2574
- else record
2575
- for record in records
2576
- ]
2577
- return self._put("/mofchecker/", enriched)
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(self, payload: pd.DataFrame | list[dict] | dict) -> dict:
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
- Automatically normalises ``meta_provenance`` using local repo metadata.
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
- meta = self._autoprism_meta_provenance()
2615
- enriched = [
2616
- {
2617
- **record,
2618
- "meta_provenance": meta,
2619
- }
2620
- if isinstance(record, dict)
2621
- else record
2622
- for record in records
2623
- ]
2624
- return self._put("/zeopp-metrics/", enriched)
2625
-
2626
- def upsert_autoprism_collection(self, payload: dict[str, Any]) -> dict[str, Any]:
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
- Notes:
2636
- - Top-level ``meta_provenance`` is ignored.
2637
- - Each section is passed to its dedicated upsert wrapper so section-
2638
- specific provenance normalization rules remain in one place.
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
- section_handlers: list[tuple[str, str, Any]] = [
2650
- ("computation_runs", "upsert_computation_runs", payload.get("computation_runs")),
2651
- ("adsorption_singlepoints", "upsert_adsorption_singlepoint", payload.get("adsorption_singlepoints")),
2652
- ("heat_capacities", "upsert_heat_capacity", payload.get("heat_capacities")),
2653
- ("isotherm_H2s", "upsert_isotherm_h2", payload.get("isotherm_H2s")),
2654
- ("mofchecker", "upsert_mofchecker", payload.get("mofchecker")),
2655
- ("zeopp_metrics", "upsert_zeopp_metrics", payload.get("zeopp_metrics")),
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, section_payload in section_handlers:
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
- result = method(section_payload)
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
- return {
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.2
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.2"
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
- monkeypatch.setattr(api, "upsert_computation_runs", lambda payload: {"created": 1, "updated": 0})
1707
- monkeypatch.setattr(api, "upsert_adsorption_singlepoint", lambda payload: {"created": 2, "updated": 0})
1708
- monkeypatch.setattr(api, "upsert_heat_capacity", lambda payload: {"created": 0, "updated": 3})
1709
- monkeypatch.setattr(api, "upsert_isotherm_h2", lambda payload: {"created": 4, "updated": 1})
1710
- monkeypatch.setattr(api, "upsert_mofchecker", lambda payload: {"created": 5, "updated": 0})
1711
- monkeypatch.setattr(api, "upsert_zeopp_metrics", lambda payload: {"created": 6, "updated": 2})
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": {"source_repo": "ignored"},
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