prisma-api 0.4.5__tar.gz → 0.4.6__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.5
3
+ Version: 0.4.6
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
@@ -213,6 +213,15 @@ All methods below are available on `api.v2`.
213
213
  - `build_case_pack(case_id, scenario_id=None)`
214
214
  - `list_case_packs(source=None, sink=None, region=None, study=None, include_scenarios=False, limit=100, offset=0)`
215
215
 
216
+ ### Case-study library (PrISMa_Platform sync)
217
+
218
+ - `upsert_case_study_library(document, dry_run=False, max_request_bytes=2_400_000, raise_on_error=False, timeout=600)` — sends a `prisma_v2.case_study_library.bundle.v1` document, split into requests if needed; replaces the pipeline 11 ZIP upload
219
+ - `get_case_study_library_bundle(sections=None, timeout=300)`
220
+ - `get_case_study_bundle(case_id, timeout=120)`
221
+ - `latest_case_study_library_sync()` — `None` before the first sync
222
+ - `list_case_study_library_sync_runs(status=None, limit=50, git_commit=None, offset=0)`
223
+ - `list_stale_case_study_library_records()`
224
+
216
225
  ### Catalog and reference data
217
226
 
218
227
  - `get_molecules(name=None, limit=500, offset=0)`
@@ -408,3 +417,4 @@ pytest tests/ -v --cov=prisma_api --cov-report=term-missing
408
417
  - Local development routing is selected with `prisma_api.init(local_dev=True)`; the default targets production
409
418
  - 0.4.4: the `adsorption-isotherm` endpoints and the 400/notice handling need prisma_cloud >= 0.6.16
410
419
  - 0.4.5: H2 isotherms go through `upsert_adsorption_isotherm` (prisma_cloud >= 0.6.16); the H2-only methods and the `isotherm_H2s` collection key are deprecated. New `upsert_autoprism_cifs` uses the existing bundle upsert endpoint. AutoPrism getters take `match="exact"`.
420
+ - 0.4.6: case-study library sync methods (`upsert_case_study_library` and the export, sync-run and stale-record readers) for the prisma_cloud `case-study-library` endpoints, which replace ingest pipeline 11.
@@ -193,6 +193,15 @@ All methods below are available on `api.v2`.
193
193
  - `build_case_pack(case_id, scenario_id=None)`
194
194
  - `list_case_packs(source=None, sink=None, region=None, study=None, include_scenarios=False, limit=100, offset=0)`
195
195
 
196
+ ### Case-study library (PrISMa_Platform sync)
197
+
198
+ - `upsert_case_study_library(document, dry_run=False, max_request_bytes=2_400_000, raise_on_error=False, timeout=600)` — sends a `prisma_v2.case_study_library.bundle.v1` document, split into requests if needed; replaces the pipeline 11 ZIP upload
199
+ - `get_case_study_library_bundle(sections=None, timeout=300)`
200
+ - `get_case_study_bundle(case_id, timeout=120)`
201
+ - `latest_case_study_library_sync()` — `None` before the first sync
202
+ - `list_case_study_library_sync_runs(status=None, limit=50, git_commit=None, offset=0)`
203
+ - `list_stale_case_study_library_records()`
204
+
196
205
  ### Catalog and reference data
197
206
 
198
207
  - `get_molecules(name=None, limit=500, offset=0)`
@@ -388,3 +397,4 @@ pytest tests/ -v --cov=prisma_api --cov-report=term-missing
388
397
  - Local development routing is selected with `prisma_api.init(local_dev=True)`; the default targets production
389
398
  - 0.4.4: the `adsorption-isotherm` endpoints and the 400/notice handling need prisma_cloud >= 0.6.16
390
399
  - 0.4.5: H2 isotherms go through `upsert_adsorption_isotherm` (prisma_cloud >= 0.6.16); the H2-only methods and the `isotherm_H2s` collection key are deprecated. New `upsert_autoprism_cifs` uses the existing bundle upsert endpoint. AutoPrism getters take `match="exact"`.
400
+ - 0.4.6: case-study library sync methods (`upsert_case_study_library` and the export, sync-run and stale-record readers) for the prisma_cloud `case-study-library` endpoints, which replace ingest pipeline 11.
@@ -127,6 +127,27 @@ _BUNDLE_READONLY_SECTIONS = {
127
127
  # Read-bundle keys with no write meaning — dropped silently by the endpoint.
128
128
  _BUNDLE_IGNORED_KEYS = ("_schema", "sections", "counts")
129
129
 
130
+ # Case-study library bundle (replaces ingest pipeline 11). PrISMa_Platform
131
+ # composes the document; this client only sends it, split into requests.
132
+ _CSL_SCHEMA = "prisma_v2.case_study_library.bundle.v1"
133
+ # Document sections in dependency order: a later section may reference rows an
134
+ # earlier one defines. ``deleted_source_files`` always goes last.
135
+ _CSL_SECTIONS = (
136
+ "references", "economic_data", "sources", "sinks", "regions", "utilities",
137
+ "subsystems", "transport_scenarios", "transports", "process_conditions",
138
+ "contactor_configurations", "process_configurations", "equipment",
139
+ "cost_indices", "constants", "mea", "mea_kpis", "case_studies",
140
+ "deleted_source_files",
141
+ )
142
+ # Django's DATA_UPLOAD_MAX_MEMORY_SIZE: larger bodies get an HTML 400.
143
+ _CSL_SERVER_MAX_BYTES = 2_621_440
144
+ _CSL_DEFAULT_MAX_BYTES = 2_400_000
145
+ # Integer totals in each upsert response, summed across requests.
146
+ _CSL_SUMMED = ("m2m_links_changed", "property_sets_replaced",
147
+ "owned_row_sets_replaced", "commit_links_created")
148
+ _CSL_LISTS = ("case_studies", "renamed", "deleted_source_files",
149
+ "stale_records", "absent_records")
150
+
130
151
 
131
152
  class PrismaAPIv2:
132
153
  """
@@ -3667,6 +3688,200 @@ class PrismaAPIv2:
3667
3688
  params = _compact(name=name, limit=limit, offset=offset)
3668
3689
  return self._to_df(self._get("/case-studies/", params))
3669
3690
 
3691
+ # ── Case-study library (PrISMa_Platform sync) ─────────────────────────────
3692
+
3693
+ def upsert_case_study_library(
3694
+ self,
3695
+ document: dict,
3696
+ dry_run: bool = False,
3697
+ max_request_bytes: int = _CSL_DEFAULT_MAX_BYTES,
3698
+ raise_on_error: bool = False,
3699
+ timeout: int = 600,
3700
+ ) -> dict:
3701
+ """
3702
+ Send a ``prisma_v2.case_study_library.bundle.v1`` document, split into
3703
+ requests if needed.
3704
+
3705
+ ``PUT /api/v2/case-study-library/bundle/upsert/``. PrISMa_Platform
3706
+ composes the document; this method doesn't reshape it. Nodes, key order
3707
+ and values are sent as given (NaN/inf become ``null``, which JSON
3708
+ requires).
3709
+
3710
+ Args:
3711
+ document: The bundle document: ``_schema``, ``package`` (with a
3712
+ non-empty ``git_commit``), ``complete`` and any of the
3713
+ sections. ``_schema`` is filled in if missing.
3714
+ dry_run: Validate and apply, report, then roll back
3715
+ (``?dry_run=true``). A dry run split across requests can't see
3716
+ rows an earlier request defines, so references between them may
3717
+ be reported as unknown (a warning says so).
3718
+ max_request_bytes: Largest request body. The server rejects bodies
3719
+ over 2.5 MB before the view runs.
3720
+ raise_on_error: Raise ``PrismaUpsertError`` when a request is
3721
+ rejected. Otherwise a ``PrismaRowErrorWarning`` is emitted and
3722
+ the merged result returned.
3723
+ timeout: Per-request timeout in seconds. Each request is written in
3724
+ one transaction, so allow minutes.
3725
+
3726
+ Returns:
3727
+ The merged result of every request sent::
3728
+
3729
+ {"dry_run": False, "requests": 1, "requests_planned": 1,
3730
+ "counts": {"CaseStudy": {"created": 1, "updated": 0, "unchanged": 0}, ...},
3731
+ "case_studies": [{"name": ..., "id": ..., "source_file": ...,
3732
+ "missing_links": []}],
3733
+ "renamed": [], "deleted_source_files": [], "stale_records": [],
3734
+ "absent_records": [], "m2m_links_changed": 2, ...,
3735
+ "sync_runs": [1], "errors": [], "failed_request": None}
3736
+
3737
+ ``errors`` is the server's ``[{"at": <path in the document>,
3738
+ "error": <message>}]`` list from the rejected request.
3739
+ ``failed_request`` is that request's 0-based index.
3740
+
3741
+ Splitting:
3742
+ A document that fits is sent as one request. Otherwise sections
3743
+ are grouped into requests in dependency order (``references`` first,
3744
+ ``case_studies`` then ``deleted_source_files`` last). Every request
3745
+ carries the same ``_schema`` and ``complete``. A section is split
3746
+ across requests only when ``complete`` is not true, since with
3747
+ ``complete: true`` each section in a request is the whole table.
3748
+
3749
+ Every request but the last carries ``package.git_commit`` set to
3750
+ ``package.base_commit`` (or ``""`` on a full run), so the sync
3751
+ history only reaches HEAD once the last request is applied. If a
3752
+ sync stops partway, ``latest_case_study_library_sync()`` still
3753
+ points at the base commit and re-running the same sync converges.
3754
+
3755
+ Sending stops at the first rejected request. Requests already sent
3756
+ stay applied; all writes are upserts.
3757
+
3758
+ Raises:
3759
+ TypeError: *document* is not a dict, or a section too large for
3760
+ one request is not a list.
3761
+ ValueError: a wrong ``_schema``, a missing ``package.git_commit``,
3762
+ a *max_request_bytes* over the server limit, a complete section
3763
+ too large for one request (send references instead of nested
3764
+ copies), or a single node too large for one request.
3765
+ PrismaUpsertError: a request was rejected and *raise_on_error*.
3766
+ requests.HTTPError: 403 (bad API key) and other non-400 failures.
3767
+ """
3768
+ if not isinstance(document, dict):
3769
+ raise TypeError("document must be a dict")
3770
+ if max_request_bytes > _CSL_SERVER_MAX_BYTES:
3771
+ raise ValueError(
3772
+ f"max_request_bytes={max_request_bytes} is over the server's "
3773
+ f"{_CSL_SERVER_MAX_BYTES}-byte request limit"
3774
+ )
3775
+ doc = dict(document)
3776
+ if doc.get("_schema") is None:
3777
+ doc = {"_schema": _CSL_SCHEMA, **doc}
3778
+ if doc["_schema"] != _CSL_SCHEMA:
3779
+ raise ValueError(f"_schema must be {_CSL_SCHEMA!r}, got {doc['_schema']!r}")
3780
+ package = doc.get("package")
3781
+ commit = package.get("git_commit") if isinstance(package, dict) else None
3782
+ if not isinstance(commit, str) or not commit.strip():
3783
+ raise ValueError(
3784
+ "package.git_commit must be a non-empty string: it is where the "
3785
+ "next incremental sync starts"
3786
+ )
3787
+
3788
+ bodies = _split_case_study_library(doc, max_request_bytes)
3789
+ planned = len(bodies)
3790
+ if dry_run and planned > 1:
3791
+ warnings.warn(
3792
+ f"Dry run split into {planned} requests. Each is rolled back, so a "
3793
+ "request can't resolve references to rows only an earlier request "
3794
+ "defines; 'unknown reference' errors may be spurious.",
3795
+ UserWarning, stacklevel=2,
3796
+ )
3797
+
3798
+ merged: dict[str, Any] = {
3799
+ "dry_run": dry_run, "requests": 0, "requests_planned": planned,
3800
+ "counts": {}, **{k: [] for k in _CSL_LISTS}, **{k: 0 for k in _CSL_SUMMED},
3801
+ "sync_runs": [], "errors": [], "failed_request": None,
3802
+ }
3803
+ params = {"dry_run": "true"} if dry_run else None
3804
+ headers = self._headers()
3805
+ for index, body in enumerate(bodies):
3806
+ merged["requests"] += 1
3807
+ resp = requests.put(self._url("/case-study-library/bundle/upsert/"),
3808
+ params=params, data=body, headers=headers,
3809
+ timeout=timeout)
3810
+ if resp.status_code == 400:
3811
+ merged["errors"] = _csl_errors(resp, len(body))
3812
+ merged["failed_request"] = index
3813
+ break
3814
+ resp.raise_for_status()
3815
+ _merge_csl_result(merged, resp.json())
3816
+
3817
+ if merged["errors"]:
3818
+ _report_csl_errors(merged, raise_on_error)
3819
+ return merged
3820
+
3821
+ def get_case_study_library_bundle(self, sections: list[str] | str | None = None,
3822
+ timeout: int = 300) -> dict:
3823
+ """
3824
+ Export the case-study library.
3825
+
3826
+ ``GET /api/v2/case-study-library/bundle/``. Case studies reference
3827
+ shared objects by natural key instead of repeating them. The export can
3828
+ be sent back unchanged with :meth:`upsert_case_study_library`, which
3829
+ reports every row as ``unchanged``.
3830
+
3831
+ Args:
3832
+ sections: Section names to export (list or comma-separated string),
3833
+ e.g. ``['sources', 'sinks']``. Default: all.
3834
+ timeout: Request timeout in seconds.
3835
+ """
3836
+ params = _compact(sections=_join_csl_sections(sections))
3837
+ return self._send("get", "/case-study-library/bundle/", params=params,
3838
+ timeout=timeout).json()
3839
+
3840
+ def get_case_study_bundle(self, case_id: int, timeout: int = 120) -> dict:
3841
+ """
3842
+ ``GET /api/v2/cases/{case_id}/bundle/``: one case study with its
3843
+ source, sink, region, utilities, transport and subsystems nested in
3844
+ full, as a library bundle document. 404 for an unknown *case_id*.
3845
+ """
3846
+ return self._send("get", f"/cases/{int(case_id)}/bundle/", timeout=timeout).json()
3847
+
3848
+ def latest_case_study_library_sync(self) -> dict | None:
3849
+ """
3850
+ ``GET /api/v2/case-study-library/sync-runs/latest/``: the last applied
3851
+ sync, or ``None`` before the first. Its ``git_commit`` is the base of
3852
+ the next incremental sync; an empty one means do a full run.
3853
+ """
3854
+ try:
3855
+ return self._send("get", "/case-study-library/sync-runs/latest/").json()
3856
+ except requests.HTTPError as exc:
3857
+ if exc.response is not None and exc.response.status_code == 404:
3858
+ return None
3859
+ raise
3860
+
3861
+ def list_case_study_library_sync_runs(self, status: str | None = None,
3862
+ limit: int = 50,
3863
+ git_commit: str | None = None,
3864
+ offset: int = 0) -> list[dict]:
3865
+ """
3866
+ ``GET /api/v2/case-study-library/sync-runs/``: sync history, newest first.
3867
+
3868
+ Args:
3869
+ status: ``'success'``, ``'partial'`` or ``'failed'``.
3870
+ limit: Maximum number of runs.
3871
+ git_commit: Commit prefix filter.
3872
+ offset: Pagination offset.
3873
+ """
3874
+ params = _compact(status=status, git_commit=git_commit, limit=limit, offset=offset)
3875
+ return self._get("/case-study-library/sync-runs/", params).get("results", [])
3876
+
3877
+ def list_stale_case_study_library_records(self) -> list[dict]:
3878
+ """
3879
+ ``GET /api/v2/case-study-library/stale/``: records whose upstream source
3880
+ files have all been deleted, as ``[{model, id, label, paths}]``. They
3881
+ are kept (scenarios and outputs may depend on them) for review.
3882
+ """
3883
+ return self._get("/case-study-library/stale/").get("results", [])
3884
+
3670
3885
  def get_scenarios(self,
3671
3886
  case_id: int | None = None,
3672
3887
  name: str | None = None,
@@ -4436,3 +4651,157 @@ def _filename_from_disposition(disposition: str | None) -> str | None:
4436
4651
  return None
4437
4652
  match = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', disposition)
4438
4653
  return match.group(1).strip() if match else None
4654
+
4655
+
4656
+ def _join_csl_sections(value: str | list[str] | None) -> str | None:
4657
+ """Validate case-study library section names and join them for ``?sections=``."""
4658
+ if value is None:
4659
+ return None
4660
+ items = value.split(",") if isinstance(value, str) else list(value)
4661
+ names = [str(v).strip() for v in items if str(v).strip()]
4662
+ valid = _CSL_SECTIONS[:-1] # deleted_source_files is write-only
4663
+ unknown = [n for n in names if n not in valid]
4664
+ if unknown:
4665
+ raise ValueError(f"Unknown case-study library section(s) {unknown}. "
4666
+ f"Valid sections: {list(valid)}")
4667
+ return ",".join(names) or None
4668
+
4669
+
4670
+ def _csl_encode(value: Any) -> bytes:
4671
+ """Compact UTF-8 JSON as sent: key order kept, NaN/inf -> null."""
4672
+ return json.dumps(_json_safe(value), ensure_ascii=False, separators=(",", ":"),
4673
+ allow_nan=False).encode("utf-8")
4674
+
4675
+
4676
+ def _csl_member_size(key: str, value: Any) -> int:
4677
+ """Encoded size of ``"key":value`` inside a JSON object."""
4678
+ return len(_csl_encode(key)) + 1 + len(_csl_encode(value))
4679
+
4680
+
4681
+ def _csl_object_size(member_sizes: list[int]) -> int:
4682
+ """Encoded size of a JSON object with members of the given sizes."""
4683
+ return 2 + sum(member_sizes) + max(len(member_sizes) - 1, 0)
4684
+
4685
+
4686
+ def _split_case_study_library(doc: dict, max_bytes: int) -> list[bytes]:
4687
+ """
4688
+ Encode a library document as one or more request bodies of at most
4689
+ *max_bytes* each.
4690
+
4691
+ Sections are grouped in dependency order; a section is split across
4692
+ requests only when ``complete`` is not true. Every request but the last
4693
+ carries ``package.git_commit = package.base_commit`` (or ``""``), so the
4694
+ server's sync history reaches HEAD only once the whole document is applied.
4695
+ """
4696
+ whole = _csl_encode(doc)
4697
+ if len(whole) <= max_bytes:
4698
+ return [whole]
4699
+
4700
+ complete = doc.get("complete") is True
4701
+ envelope = {k: v for k, v in doc.items() if k not in _CSL_SECTIONS}
4702
+ package = envelope["package"]
4703
+ interim = {**envelope,
4704
+ "package": {**package, "git_commit": package.get("base_commit") or ""}}
4705
+ env_sizes = [max(_csl_member_size(k, envelope[k]), _csl_member_size(k, interim[k]))
4706
+ for k in envelope]
4707
+
4708
+ def fits(member_sizes: list[int]) -> bool:
4709
+ return _csl_object_size(env_sizes + member_sizes) <= max_bytes
4710
+
4711
+ # Units: one section, or one slice of a section, small enough to send alone.
4712
+ units: list[tuple[str, Any, int]] = []
4713
+ for name in _CSL_SECTIONS:
4714
+ if name not in doc:
4715
+ continue
4716
+ nodes = doc[name]
4717
+ size = _csl_member_size(name, nodes)
4718
+ if fits([size]):
4719
+ units.append((name, nodes, size))
4720
+ continue
4721
+ if complete:
4722
+ raise ValueError(
4723
+ f"Section '{name}' ({size} bytes) doesn't fit in one request of "
4724
+ f"{max_bytes} bytes, and with complete=true it can't be split: each "
4725
+ "section in a request is treated as the whole table. Send shared "
4726
+ "objects as references instead of nested copies."
4727
+ )
4728
+ if not isinstance(nodes, list):
4729
+ raise TypeError(f"Section '{name}' ({size} bytes) doesn't fit in one "
4730
+ f"request of {max_bytes} bytes and is not a list.")
4731
+ key_size = len(_csl_encode(name)) + 1
4732
+ chunk: list = []
4733
+ chunk_size = 2 # "[]"
4734
+ for index, node in enumerate(nodes):
4735
+ node_size = len(_csl_encode(node))
4736
+ if not fits([key_size + 2 + node_size]):
4737
+ raise ValueError(f"{name}[{index}] ({node_size} bytes) doesn't fit in "
4738
+ f"one request of {max_bytes} bytes.")
4739
+ added = node_size + (1 if chunk else 0)
4740
+ if chunk and not fits([key_size + chunk_size + added]):
4741
+ units.append((name, chunk, key_size + chunk_size))
4742
+ chunk, chunk_size, added = [], 2, node_size
4743
+ chunk.append(node)
4744
+ chunk_size += added
4745
+ if chunk:
4746
+ units.append((name, chunk, key_size + chunk_size))
4747
+
4748
+ # Pack units into requests in order; slices of one section never share one.
4749
+ groups: list[list[tuple[str, Any, int]]] = [[]]
4750
+ for unit in units:
4751
+ current = groups[-1]
4752
+ if current and (any(u[0] == unit[0] for u in current)
4753
+ or not fits([u[2] for u in current] + [unit[2]])):
4754
+ groups.append(current := [])
4755
+ current.append(unit)
4756
+
4757
+ bodies = []
4758
+ for index, group in enumerate(groups):
4759
+ env = envelope if index == len(groups) - 1 else interim
4760
+ bodies.append(_csl_encode({**env, **{name: nodes for name, nodes, _ in group}}))
4761
+ return bodies
4762
+
4763
+
4764
+ def _csl_errors(resp: requests.Response, body_bytes: int) -> list[dict]:
4765
+ """``[{at, error}]`` from a 400 upsert response."""
4766
+ try:
4767
+ body = resp.json()
4768
+ except ValueError:
4769
+ body = None
4770
+ if not isinstance(body, dict):
4771
+ # Django rejects oversized bodies with an HTML 400 before the view runs.
4772
+ return [{"at": None, "error": f"request too large ({body_bytes} bytes); "
4773
+ "lower max_request_bytes"}]
4774
+ if isinstance(body.get("errors"), list):
4775
+ return body["errors"]
4776
+ message = body.get("error") or body.get("detail") or json.dumps(body)
4777
+ return [{"at": None, "error": str(message)}]
4778
+
4779
+
4780
+ def _merge_csl_result(merged: dict, result: dict) -> None:
4781
+ """Fold one upsert response into the running result."""
4782
+ for model, counts in (result.get("counts") or {}).items():
4783
+ total = merged["counts"].setdefault(model, {"created": 0, "updated": 0, "unchanged": 0})
4784
+ for key, value in counts.items():
4785
+ total[key] = total.get(key, 0) + int(value or 0)
4786
+ for key in _CSL_LISTS:
4787
+ merged[key].extend(result.get(key) or [])
4788
+ for key in _CSL_SUMMED:
4789
+ merged[key] += int(result.get(key) or 0)
4790
+ if result.get("sync_run") is not None:
4791
+ merged["sync_runs"].append(result["sync_run"])
4792
+
4793
+
4794
+ def _report_csl_errors(merged: dict, raise_on_error: bool) -> None:
4795
+ """Warn about, or raise for, a rejected case-study library request."""
4796
+ errors, index = merged["errors"], merged["failed_request"]
4797
+ preview = "; ".join(f"{e.get('at') or '-'}: {e.get('error')}" if isinstance(e, dict) else str(e)
4798
+ for e in errors[:_ROW_ERROR_PREVIEW])
4799
+ message = (f"case-study library upsert: request {index + 1} of "
4800
+ f"{merged['requests_planned']} rejected, nothing in it was written")
4801
+ if index and not merged["dry_run"]:
4802
+ message += (f"; requests 1-{index} stayed applied (re-run the same sync "
4803
+ "to converge)")
4804
+ message += f". {len(errors)} error(s). First: {preview}"
4805
+ if raise_on_error:
4806
+ raise PrismaUpsertError(message, result=merged)
4807
+ warnings.warn(message, PrismaRowErrorWarning, stacklevel=3)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prisma_api
3
- Version: 0.4.5
3
+ Version: 0.4.6
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
@@ -213,6 +213,15 @@ All methods below are available on `api.v2`.
213
213
  - `build_case_pack(case_id, scenario_id=None)`
214
214
  - `list_case_packs(source=None, sink=None, region=None, study=None, include_scenarios=False, limit=100, offset=0)`
215
215
 
216
+ ### Case-study library (PrISMa_Platform sync)
217
+
218
+ - `upsert_case_study_library(document, dry_run=False, max_request_bytes=2_400_000, raise_on_error=False, timeout=600)` — sends a `prisma_v2.case_study_library.bundle.v1` document, split into requests if needed; replaces the pipeline 11 ZIP upload
219
+ - `get_case_study_library_bundle(sections=None, timeout=300)`
220
+ - `get_case_study_bundle(case_id, timeout=120)`
221
+ - `latest_case_study_library_sync()` — `None` before the first sync
222
+ - `list_case_study_library_sync_runs(status=None, limit=50, git_commit=None, offset=0)`
223
+ - `list_stale_case_study_library_records()`
224
+
216
225
  ### Catalog and reference data
217
226
 
218
227
  - `get_molecules(name=None, limit=500, offset=0)`
@@ -408,3 +417,4 @@ pytest tests/ -v --cov=prisma_api --cov-report=term-missing
408
417
  - Local development routing is selected with `prisma_api.init(local_dev=True)`; the default targets production
409
418
  - 0.4.4: the `adsorption-isotherm` endpoints and the 400/notice handling need prisma_cloud >= 0.6.16
410
419
  - 0.4.5: H2 isotherms go through `upsert_adsorption_isotherm` (prisma_cloud >= 0.6.16); the H2-only methods and the `isotherm_H2s` collection key are deprecated. New `upsert_autoprism_cifs` uses the existing bundle upsert endpoint. AutoPrism getters take `match="exact"`.
420
+ - 0.4.6: case-study library sync methods (`upsert_case_study_library` and the export, sync-run and stale-record readers) for the prisma_cloud `case-study-library` endpoints, which replace ingest pipeline 11.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "prisma_api"
7
- version = "0.4.5"
7
+ version = "0.4.6"
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"}
@@ -3861,3 +3861,269 @@ def test_collection_uploads_cifs_first(no_git_meta):
3861
3861
  assert result["totals"]["created"] == 4
3862
3862
  assert result["totals"]["new_structures"] == ["NEWMOF"]
3863
3863
  assert [w.category for w in caught].count(PrismaNewStructureWarning) == 1
3864
+
3865
+
3866
+ # ── 0.4.6: case-study library bundle (PrISMa_Platform sync) ───────────────────
3867
+
3868
+ _CSL_URL = f"{PROD_BASE}/case-study-library/bundle/upsert/"
3869
+ _CSL_HEAD = "a" * 40
3870
+ _CSL_BASE = "b" * 40
3871
+ _CSL_FIXTURE = ("reference_data/prisma_cloud/example_payloads/"
3872
+ "case_study_library_bundle_DAC_carbfix_NO_heatpump_80.json")
3873
+
3874
+
3875
+ def _csl_doc(complete: bool = False, **sections) -> dict:
3876
+ return {"package": {"name": "PrISMa_Platform@aaaa", "git_repo": "repo",
3877
+ "git_commit": _CSL_HEAD, "base_commit": _CSL_BASE},
3878
+ "complete": complete, **sections}
3879
+
3880
+
3881
+ def _csl_nodes(prefix: str, n: int, pad: int = 200) -> list[dict]:
3882
+ return [{"natural_key": {"slug": f"{prefix}{i}"},
3883
+ "fields": {"slug": f"{prefix}{i}", "notes": "x" * pad, "lat": 1.5}}
3884
+ for i in range(n)]
3885
+
3886
+
3887
+ def _csl_response(model: str = "Source", created: int = 1, run: int | None = 1) -> dict:
3888
+ body = {"_schema": "prisma_v2.case_study_library.bundle.upsert.v1", "dry_run": False,
3889
+ "case_studies": [], "renamed": [],
3890
+ "counts": {model: {"created": created, "updated": 0, "unchanged": 0}},
3891
+ "m2m_links_changed": 1, "property_sets_replaced": 2,
3892
+ "owned_row_sets_replaced": 0, "commit_links_created": 3,
3893
+ "deleted_source_files": [], "stale_records": [], "absent_records": []}
3894
+ if run is not None:
3895
+ body["sync_run"] = run
3896
+ return body
3897
+
3898
+
3899
+ def _sent(call) -> dict:
3900
+ return json.loads(call.request.body)
3901
+
3902
+
3903
+ @resp_lib.activate
3904
+ def test_csl_upsert_single_request_fills_schema_and_keeps_document(api):
3905
+ resp_lib.add(resp_lib.PUT, _CSL_URL, json=_csl_response(), status=200)
3906
+ doc = _csl_doc(sources=_csl_nodes("s", 2))
3907
+ original = json.loads(json.dumps(doc))
3908
+
3909
+ result = api.upsert_case_study_library(doc)
3910
+
3911
+ assert len(resp_lib.calls) == 1
3912
+ body = _sent(resp_lib.calls[0])
3913
+ assert body["_schema"] == "prisma_v2.case_study_library.bundle.v1"
3914
+ assert body["package"]["git_commit"] == _CSL_HEAD
3915
+ assert body["sources"] == original["sources"]
3916
+ assert doc == original # caller's document untouched
3917
+ assert "dry_run" not in resp_lib.calls[0].request.url
3918
+ assert result["requests"] == 1
3919
+ assert result["counts"] == {"Source": {"created": 1, "updated": 0, "unchanged": 0}}
3920
+ assert result["sync_runs"] == [1]
3921
+ assert result["errors"] == [] and result["failed_request"] is None
3922
+
3923
+
3924
+ @resp_lib.activate
3925
+ def test_csl_upsert_dry_run_param(api):
3926
+ resp_lib.add(resp_lib.PUT, _CSL_URL, json=_csl_response(run=None), status=200,
3927
+ match=[matchers.query_param_matcher({"dry_run": "true"})])
3928
+ result = api.upsert_case_study_library(_csl_doc(sources=[]), dry_run=True)
3929
+ assert result["dry_run"] is True and result["sync_runs"] == []
3930
+
3931
+
3932
+ @pytest.mark.parametrize("change, match", [
3933
+ ({"_schema": "prisma_v2.other.v1"}, "_schema must be"),
3934
+ ({"package": {"name": "x"}}, "git_commit"),
3935
+ ({"package": {"git_commit": " "}}, "git_commit"),
3936
+ ({"package": None}, "git_commit"),
3937
+ ])
3938
+ def test_csl_upsert_checks_envelope(api, change, match):
3939
+ with pytest.raises(ValueError, match=match):
3940
+ api.upsert_case_study_library({**_csl_doc(), **change})
3941
+
3942
+
3943
+ def test_csl_upsert_rejects_limit_over_server_max(api):
3944
+ with pytest.raises(ValueError, match="over the server"):
3945
+ api.upsert_case_study_library(_csl_doc(), max_request_bytes=3_000_000)
3946
+
3947
+
3948
+ @resp_lib.activate
3949
+ def test_csl_upsert_splits_sections_in_dependency_order(api):
3950
+ resp_lib.add(resp_lib.PUT, _CSL_URL, json=_csl_response(), status=200)
3951
+ doc = _csl_doc(
3952
+ complete=True,
3953
+ case_studies=_csl_nodes("cs", 5),
3954
+ deleted_source_files=[{"natural_key": {"original_filename": "gone.yaml"}}],
3955
+ sinks=_csl_nodes("k", 5),
3956
+ references=_csl_nodes("r", 5),
3957
+ )
3958
+ limit = 2600 # each section fits alone; no two fit together
3959
+ result = api.upsert_case_study_library(doc, max_request_bytes=limit)
3960
+
3961
+ bodies = [_sent(c) for c in resp_lib.calls]
3962
+ sections = [[k for k in b if k not in ("_schema", "package", "complete")] for b in bodies]
3963
+ assert sections == [["references"], ["sinks"], ["case_studies", "deleted_source_files"]]
3964
+ assert all(len(c.request.body) <= limit for c in resp_lib.calls)
3965
+ assert all(b["complete"] is True for b in bodies)
3966
+ # Only the last request moves the sync history to HEAD.
3967
+ assert [b["package"]["git_commit"] for b in bodies] == [_CSL_BASE, _CSL_BASE, _CSL_HEAD]
3968
+ assert result["requests"] == result["requests_planned"] == 3
3969
+ assert result["counts"]["Source"]["created"] == 3
3970
+ assert result["commit_links_created"] == 9
3971
+ assert result["sync_runs"] == [1, 1, 1]
3972
+
3973
+
3974
+ @resp_lib.activate
3975
+ def test_csl_upsert_interim_commit_empty_on_full_run_without_base(api):
3976
+ resp_lib.add(resp_lib.PUT, _CSL_URL, json=_csl_response(), status=200)
3977
+ doc = _csl_doc(complete=True, sinks=_csl_nodes("k", 5), sources=_csl_nodes("s", 5))
3978
+ del doc["package"]["base_commit"]
3979
+ api.upsert_case_study_library(doc, max_request_bytes=2600)
3980
+ assert [_sent(c)["package"]["git_commit"] for c in resp_lib.calls] == ["", _CSL_HEAD]
3981
+
3982
+
3983
+ def test_csl_upsert_complete_section_too_large_raises(api):
3984
+ doc = _csl_doc(complete=True, sources=_csl_nodes("s", 20))
3985
+ with pytest.raises(ValueError, match="complete=true it can't be split"):
3986
+ api.upsert_case_study_library(doc, max_request_bytes=2000)
3987
+
3988
+
3989
+ @resp_lib.activate
3990
+ def test_csl_upsert_splits_incomplete_section_by_node(api):
3991
+ resp_lib.add(resp_lib.PUT, _CSL_URL, json=_csl_response(), status=200)
3992
+ nodes = _csl_nodes("s", 20)
3993
+ api.upsert_case_study_library(_csl_doc(sources=nodes), max_request_bytes=2000)
3994
+ bodies = [_sent(c) for c in resp_lib.calls]
3995
+ assert len(bodies) > 1
3996
+ assert all(len(c.request.body) <= 2000 for c in resp_lib.calls)
3997
+ assert [n for b in bodies for n in b["sources"]] == nodes
3998
+
3999
+
4000
+ def test_csl_upsert_single_node_too_large_raises(api):
4001
+ doc = _csl_doc(sources=_csl_nodes("s", 1, pad=5000))
4002
+ with pytest.raises(ValueError, match=r"sources\[0\]"):
4003
+ api.upsert_case_study_library(doc, max_request_bytes=2000)
4004
+
4005
+
4006
+ @resp_lib.activate
4007
+ def test_csl_upsert_stops_at_first_rejected_request(api):
4008
+ from prisma_api import PrismaRowErrorWarning
4009
+ errors = [{"at": "sinks[0].fields", "error": "Unknown field 'colour'"}]
4010
+ resp_lib.add(resp_lib.PUT, _CSL_URL, json=_csl_response(), status=200)
4011
+ resp_lib.add(resp_lib.PUT, _CSL_URL, status=400,
4012
+ json={"_schema": "prisma_v2.case_study_library.bundle.upsert.v1", "errors": errors})
4013
+ doc = _csl_doc(complete=True, references=_csl_nodes("r", 5), sinks=_csl_nodes("k", 5),
4014
+ case_studies=_csl_nodes("cs", 5))
4015
+
4016
+ with pytest.warns(PrismaRowErrorWarning, match="request 2 of 3 rejected.*requests 1-1 stayed applied"):
4017
+ result = api.upsert_case_study_library(doc, max_request_bytes=2600)
4018
+
4019
+ assert len(resp_lib.calls) == 2
4020
+ assert result["errors"] == errors
4021
+ assert result["failed_request"] == 1
4022
+ assert result["requests"] == 2 and result["requests_planned"] == 3
4023
+ assert result["sync_runs"] == [1]
4024
+
4025
+
4026
+ @resp_lib.activate
4027
+ def test_csl_upsert_raise_on_error(api):
4028
+ from prisma_api import PrismaUpsertError
4029
+ resp_lib.add(resp_lib.PUT, _CSL_URL, status=400,
4030
+ json={"errors": [{"at": "case_studies[0]", "error": "unknown reference"}]})
4031
+ with pytest.raises(PrismaUpsertError, match="unknown reference") as info:
4032
+ api.upsert_case_study_library(_csl_doc(case_studies=[]), raise_on_error=True)
4033
+ assert info.value.result["failed_request"] == 0
4034
+
4035
+
4036
+ @resp_lib.activate
4037
+ def test_csl_upsert_html_400_is_request_too_large(api):
4038
+ resp_lib.add(resp_lib.PUT, _CSL_URL, status=400, body="<html>Bad Request</html>",
4039
+ content_type="text/html")
4040
+ with pytest.warns(UserWarning, match="request too large"):
4041
+ result = api.upsert_case_study_library(_csl_doc())
4042
+ assert result["errors"][0]["at"] is None
4043
+
4044
+
4045
+ @resp_lib.activate
4046
+ def test_csl_upsert_write_failure_error_string(api):
4047
+ resp_lib.add(resp_lib.PUT, _CSL_URL, status=400, json={"error": "deadlock"})
4048
+ with pytest.warns(UserWarning, match="deadlock"):
4049
+ result = api.upsert_case_study_library(_csl_doc())
4050
+ assert result["errors"] == [{"at": None, "error": "deadlock"}]
4051
+
4052
+
4053
+ @resp_lib.activate
4054
+ def test_csl_upsert_403_raises(api):
4055
+ resp_lib.add(resp_lib.PUT, _CSL_URL, status=403, json={"detail": "bad key"})
4056
+ with pytest.raises(requests.HTTPError):
4057
+ api.upsert_case_study_library(_csl_doc())
4058
+
4059
+
4060
+ @resp_lib.activate
4061
+ def test_csl_dry_run_split_warns(api):
4062
+ resp_lib.add(resp_lib.PUT, _CSL_URL, json=_csl_response(run=None), status=200)
4063
+ doc = _csl_doc(sinks=_csl_nodes("k", 5), sources=_csl_nodes("s", 5))
4064
+ with pytest.warns(UserWarning, match="Dry run split into 2 requests"):
4065
+ api.upsert_case_study_library(doc, dry_run=True, max_request_bytes=2600)
4066
+
4067
+
4068
+ @resp_lib.activate
4069
+ def test_csl_get_library_bundle_sections(api):
4070
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/case-study-library/bundle/",
4071
+ json={"_schema": "prisma_v2.case_study_library.bundle.v1"},
4072
+ match=[matchers.query_param_matcher({"sections": "sources,sinks"})])
4073
+ assert api.get_case_study_library_bundle(["sources", "sinks"])["_schema"]
4074
+ with pytest.raises(ValueError, match="Unknown case-study library section"):
4075
+ api.get_case_study_library_bundle("sources,deleted_source_files")
4076
+
4077
+
4078
+ @resp_lib.activate
4079
+ def test_csl_get_case_study_bundle(api):
4080
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/cases/7/bundle/", json={"case_studies": [{}]})
4081
+ assert api.get_case_study_bundle(7) == {"case_studies": [{}]}
4082
+
4083
+
4084
+ @resp_lib.activate
4085
+ def test_csl_latest_sync(api):
4086
+ url = f"{PROD_BASE}/case-study-library/sync-runs/latest/"
4087
+ resp_lib.add(resp_lib.GET, url, status=404, json={"detail": "none"})
4088
+ assert api.latest_case_study_library_sync() is None
4089
+ resp_lib.replace(resp_lib.GET, url, json={"id": 3, "git_commit": _CSL_HEAD})
4090
+ assert api.latest_case_study_library_sync()["git_commit"] == _CSL_HEAD
4091
+ resp_lib.replace(resp_lib.GET, url, status=403, json={"detail": "bad key"})
4092
+ with pytest.raises(requests.HTTPError):
4093
+ api.latest_case_study_library_sync()
4094
+
4095
+
4096
+ @resp_lib.activate
4097
+ def test_csl_list_sync_runs_and_stale(api):
4098
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/case-study-library/sync-runs/",
4099
+ json={"count": 1, "results": [{"id": 1}]},
4100
+ match=[matchers.query_param_matcher(
4101
+ {"status": "success", "limit": "5", "offset": "0"})])
4102
+ assert api.list_case_study_library_sync_runs(status="success", limit=5) == [{"id": 1}]
4103
+ stale = [{"model": "Sink", "id": 4, "label": "Old", "paths": ["case_studies/sinks/old.yaml"]}]
4104
+ resp_lib.add(resp_lib.GET, f"{PROD_BASE}/case-study-library/stale/",
4105
+ json={"count": 1, "results": stale})
4106
+ assert api.list_stale_case_study_library_records() == stale
4107
+
4108
+
4109
+ @pytest.mark.skipif(not os.path.exists(_CSL_FIXTURE),
4110
+ reason=f"Local fixture {_CSL_FIXTURE} not available")
4111
+ @resp_lib.activate
4112
+ def test_csl_reference_payload_split_reassembles(api):
4113
+ with open(_CSL_FIXTURE, encoding="utf-8") as f:
4114
+ doc = json.load(f)
4115
+ resp_lib.add(resp_lib.PUT, _CSL_URL, json=_csl_response(), status=200)
4116
+
4117
+ api.upsert_case_study_library(doc)
4118
+ assert len(resp_lib.calls) == 1
4119
+ assert _sent(resp_lib.calls[0]) == doc
4120
+
4121
+ resp_lib.calls.reset()
4122
+ api.upsert_case_study_library(doc, max_request_bytes=55_000) # compact: ~76 kB
4123
+ bodies = [_sent(c) for c in resp_lib.calls]
4124
+ assert len(bodies) > 1
4125
+ rebuilt = {k: v for b in bodies for k, v in b.items()
4126
+ if k not in ("_schema", "package", "complete")}
4127
+ assert rebuilt == {k: v for k, v in doc.items()
4128
+ if k not in ("_schema", "package", "complete")}
4129
+ assert bodies[-1]["package"] == doc["package"]
File without changes
File without changes