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.
- {prisma_api-0.4.5 → prisma_api-0.4.6}/PKG-INFO +11 -1
- {prisma_api-0.4.5 → prisma_api-0.4.6}/README.md +10 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/prisma_api/prisma_api_v2.py +369 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/prisma_api.egg-info/PKG-INFO +11 -1
- {prisma_api-0.4.5 → prisma_api-0.4.6}/pyproject.toml +1 -1
- {prisma_api-0.4.5 → prisma_api-0.4.6}/tests/test_prisma_api_v2.py +266 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/LICENSE +0 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/prisma_api/__init__.py +0 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/prisma_api/config.py +0 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/prisma_api/prisma_api.py +0 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/prisma_api.egg-info/SOURCES.txt +0 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/prisma_api.egg-info/dependency_links.txt +0 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/prisma_api.egg-info/requires.txt +0 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/prisma_api.egg-info/top_level.txt +0 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/setup.cfg +0 -0
- {prisma_api-0.4.5 → prisma_api-0.4.6}/tests/test_prisma_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: prisma_api
|
|
3
|
-
Version: 0.4.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|