cascade-cms-rest 3.2.0__tar.gz → 3.2.2__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.
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/CHANGELOG.md +28 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/PKG-INFO +5 -5
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/README.md +4 -4
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/pyproject.toml +1 -1
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/cmstypes.py +135 -93
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/failures.py +2 -2
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/operation_logger.py +5 -4
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/operations.py +73 -10
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/wrapper.py +3 -3
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_cmstypes.py +132 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_operation_chains.py +55 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_operation_logger.py +35 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_wrapper.py +49 -3
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/.github/copilot-instructions.md +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/.github/workflows/ci.yml +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/.github/workflows/dependency-review.yml +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/.github/workflows/release.yml +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/.gitignore +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/AGENTS.md +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/LICENSE +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/PUBLISHING.md +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/__init__.py +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/driver.py +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/py.typed +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/conftest.py +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/edit_test.py +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_driver.py +0 -0
- {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_operations.py +0 -0
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [3.2.2]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `create()` and `delete()` accept a callable, like `edit()`: it receives the previous node's result and returns the `NewAsset`(s) / identifier(s), so `read → then → create → delete` can be one chain.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- `ListElements` now accepts the `audits` key, so `readAudits` responses parse.
|
|
12
|
+
- `WorkflowAction.next_id` now validates from the `nextId` key Cascade sends.
|
|
13
|
+
- A deferred (callable-payload) node that resolves to several requests now keeps every result instead of only the first, so partial failures no longer stop the chain (also applies to `edit`).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **`ChainResults.ok` renamed to `ChainResults.success`** (`.failed` is unchanged; no `.ok` alias is kept). The batch console line still reports `N/M succeeded`, where N is the number of chains without a failure, i.e. `len(results.success)` for single-result chains.
|
|
17
|
+
- **Snake_case field names on every Pydantic model.** camelCase is now only the wire name: accepted when validating (`validation_alias`) and still written on requests (`serialization_alias`), so request bodies are unchanged. Renamed: `PageConfiguration.pageRegions` → `page_regions`; `CheckedOutAsset.workingCopyIdentifier` → `working_copy_identifier`; `SearchInformation` `siteName`/`searchTerms`/`searchFields`/`searchTypes` → `site_name`/`search_terms`/`search_fields`/`search_types`; `auditParameters` `auditType`/`startDate`/`endDate` → `audit_type`/`start_date`/`end_date`; `workflowSettingsPayload` `apply*ToChildren` → `apply_*_to_children`. Existing `alias=` fields moved to explicit validation/serialization aliases so type checkers see the snake_case constructor names. `NewAsset` now validates by alias as well as by name (`site_id=` and `siteId=` both work). The former `TypedDict` shapes are now frozen/validated Pydantic models with snake_case attributes: `PathBase`/`Path` (`site_id`, `site_name`; `Path` also exposes `get_id`/`get_type`/`get_path`), `WorkflowSettingsModel`, `Entries`, `AccessRightsModel` (`all_level`), `WorkflowAction`, `WorkflowSteps`, `Audit`. Dict-style access (`path["siteName"]`, `payload.body["identifier"]`) becomes attribute access, and `PathBase.site_id` now serializes as bare hex like `IdentifierType`.
|
|
18
|
+
- **`auditParameters` no longer takes an `IdentifierType`.** `readAudits` now takes plain strings via `username`, `groupname`, or `rolename`; at least one is required. Auditing by entity identifier is no longer supported through this payload.
|
|
19
|
+
|
|
20
|
+
**Files changed:** `src/cascade_cms/cmstypes.py`, `src/cascade_cms/operations.py`, `src/cascade_cms/failures.py`, `src/cascade_cms/wrapper.py`, `README.md`, `tests/test_cmstypes.py`, `tests/test_operation_chains.py`, `tests/test_wrapper.py`, `pyproject.toml`
|
|
21
|
+
|
|
22
|
+
## [3.2.1]
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- `OperationLogger.log_python_error()`'s console `[ERROR]` line now also
|
|
26
|
+
carries the `[NETWORK]`/`[CASCADE-REST-CMS]` prefix for a batch-level
|
|
27
|
+
failure — per A1, the prefix was already applied to the logfile's
|
|
28
|
+
`!ERROR:` line in 3.2.0, but not to the console line.
|
|
29
|
+
|
|
30
|
+
**Files changed:** `src/cascade_cms/operation_logger.py`,
|
|
31
|
+
`tests/test_operation_logger.py`, `tests/test_wrapper.py`
|
|
32
|
+
|
|
5
33
|
## [3.2.0]
|
|
6
34
|
|
|
7
35
|
Failure handling moves out of user scripts and into the library. No more
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cascade-cms-rest
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.2
|
|
4
4
|
Summary: A typed, async REST client for Hannon Hill Cascade CMS
|
|
5
5
|
Project-URL: Homepage, https://github.com/Sharkdroid/py-cascade-cms
|
|
6
6
|
Author: Keith Shark
|
|
@@ -84,7 +84,7 @@ with CascadeWrapperBase(environment_variables, configuration_variables) as casca
|
|
|
84
84
|
- **Failures are values, not gaps.** A chain stops at its first failure and that object lands in
|
|
85
85
|
the results: a `CascadeError` when the API rejects a request, or the exception a callback
|
|
86
86
|
raised. Other chains are unaffected. `submit_requests()` returns a `ChainResults` — still a
|
|
87
|
-
list, in that same chain-creation order — with `.
|
|
87
|
+
list, in that same chain-creation order — with `.success` (results from chains that didn't fail)
|
|
88
88
|
and `.failed` (a `ChainFailure` per failed chain, naming the exact step and its category) added.
|
|
89
89
|
There's no need for an `isinstance(result, CascadeError)` check or a `try/except` around
|
|
90
90
|
`submit_requests()` — `CascadeWrapperBase` owns whether a failure ends the script; see
|
|
@@ -102,7 +102,7 @@ Failure handling lives in the library, not in your script. `CascadeWrapperBase`'
|
|
|
102
102
|
manager owns it end to end:
|
|
103
103
|
|
|
104
104
|
- No `try/except` around `submit_requests()`, and no `isinstance()` checks in your script —
|
|
105
|
-
read `.
|
|
105
|
+
read `.success`/`.failed` off the `ChainResults` it returns instead.
|
|
106
106
|
- If the batch itself breaks (not an individual chain — e.g. the driver's event loop fails),
|
|
107
107
|
`submit_requests()` raises `CascadeBatchError` rather than returning an empty list.
|
|
108
108
|
- At `with`-block exit, with `exit_on_failure=True` (the default), any recorded failure ends the
|
|
@@ -111,7 +111,7 @@ manager owns it end to end:
|
|
|
111
111
|
`CascadeError`/network/library-side failure raises `SystemExit(1)` with no extra traceback,
|
|
112
112
|
since it was already logged.
|
|
113
113
|
- Pass `exit_on_failure=False` to embed the wrapper in a longer-lived process (e.g. an MCP
|
|
114
|
-
server) — `__exit__` then never raises; read `.
|
|
114
|
+
server) — `__exit__` then never raises; read `.success`/`.failed` off the results yourself.
|
|
115
115
|
- The logfile's `!ERROR:` line is prefixed `[NETWORK]` for a network-layer failure or
|
|
116
116
|
`[CASCADE-REST-CMS]` for any other library-side failure; a `CascadeError` or a callback
|
|
117
117
|
exception gets no prefix.
|
|
@@ -124,7 +124,7 @@ manager owns it end to end:
|
|
|
124
124
|
with CascadeWrapperBase(environment_variables, configuration_variables) as cascade:
|
|
125
125
|
cascade.operations.read(identifier)
|
|
126
126
|
results = cascade.submit_requests(Asset)
|
|
127
|
-
for asset in results.
|
|
127
|
+
for asset in results.success:
|
|
128
128
|
... # only successful reads
|
|
129
129
|
# If anything failed, execution never reaches here — the with block already
|
|
130
130
|
# raised (or exited) on the way out.
|
|
@@ -63,7 +63,7 @@ with CascadeWrapperBase(environment_variables, configuration_variables) as casca
|
|
|
63
63
|
- **Failures are values, not gaps.** A chain stops at its first failure and that object lands in
|
|
64
64
|
the results: a `CascadeError` when the API rejects a request, or the exception a callback
|
|
65
65
|
raised. Other chains are unaffected. `submit_requests()` returns a `ChainResults` — still a
|
|
66
|
-
list, in that same chain-creation order — with `.
|
|
66
|
+
list, in that same chain-creation order — with `.success` (results from chains that didn't fail)
|
|
67
67
|
and `.failed` (a `ChainFailure` per failed chain, naming the exact step and its category) added.
|
|
68
68
|
There's no need for an `isinstance(result, CascadeError)` check or a `try/except` around
|
|
69
69
|
`submit_requests()` — `CascadeWrapperBase` owns whether a failure ends the script; see
|
|
@@ -81,7 +81,7 @@ Failure handling lives in the library, not in your script. `CascadeWrapperBase`'
|
|
|
81
81
|
manager owns it end to end:
|
|
82
82
|
|
|
83
83
|
- No `try/except` around `submit_requests()`, and no `isinstance()` checks in your script —
|
|
84
|
-
read `.
|
|
84
|
+
read `.success`/`.failed` off the `ChainResults` it returns instead.
|
|
85
85
|
- If the batch itself breaks (not an individual chain — e.g. the driver's event loop fails),
|
|
86
86
|
`submit_requests()` raises `CascadeBatchError` rather than returning an empty list.
|
|
87
87
|
- At `with`-block exit, with `exit_on_failure=True` (the default), any recorded failure ends the
|
|
@@ -90,7 +90,7 @@ manager owns it end to end:
|
|
|
90
90
|
`CascadeError`/network/library-side failure raises `SystemExit(1)` with no extra traceback,
|
|
91
91
|
since it was already logged.
|
|
92
92
|
- Pass `exit_on_failure=False` to embed the wrapper in a longer-lived process (e.g. an MCP
|
|
93
|
-
server) — `__exit__` then never raises; read `.
|
|
93
|
+
server) — `__exit__` then never raises; read `.success`/`.failed` off the results yourself.
|
|
94
94
|
- The logfile's `!ERROR:` line is prefixed `[NETWORK]` for a network-layer failure or
|
|
95
95
|
`[CASCADE-REST-CMS]` for any other library-side failure; a `CascadeError` or a callback
|
|
96
96
|
exception gets no prefix.
|
|
@@ -103,7 +103,7 @@ manager owns it end to end:
|
|
|
103
103
|
with CascadeWrapperBase(environment_variables, configuration_variables) as cascade:
|
|
104
104
|
cascade.operations.read(identifier)
|
|
105
105
|
results = cascade.submit_requests(Asset)
|
|
106
|
-
for asset in results.
|
|
106
|
+
for asset in results.success:
|
|
107
107
|
... # only successful reads
|
|
108
108
|
# If anything failed, execution never reaches here — the with block already
|
|
109
109
|
# raised (or exited) on the way out.
|
|
@@ -8,9 +8,7 @@ from typing import (
|
|
|
8
8
|
ClassVar,
|
|
9
9
|
Literal,
|
|
10
10
|
NamedTuple,
|
|
11
|
-
NotRequired,
|
|
12
11
|
Self,
|
|
13
|
-
TypedDict,
|
|
14
12
|
TypeVar,
|
|
15
13
|
cast,
|
|
16
14
|
)
|
|
@@ -198,20 +196,30 @@ def set_checkedout(key: str):
|
|
|
198
196
|
# ----- PATH TYPES -----
|
|
199
197
|
|
|
200
198
|
|
|
201
|
-
class PathBase(
|
|
199
|
+
class PathBase(BaseModel):
|
|
202
200
|
"""
|
|
203
201
|
Base class for Path object
|
|
204
202
|
|
|
205
203
|
Attributes:
|
|
206
204
|
path (str): The path string
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
siteName (str):
|
|
205
|
+
site_id (uuid): unique identifier of the site associated with it (optional)
|
|
206
|
+
site_name (str): name of the site associated with it (optional)
|
|
210
207
|
"""
|
|
211
208
|
|
|
209
|
+
model_config = ConfigDict(frozen=True, populate_by_name=True)
|
|
210
|
+
|
|
212
211
|
path: str
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
site_id: uuid.UUID | None = Field(
|
|
213
|
+
default=None, validation_alias="siteId", serialization_alias="siteId"
|
|
214
|
+
)
|
|
215
|
+
site_name: str | None = Field(
|
|
216
|
+
default=None, validation_alias="siteName", serialization_alias="siteName"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Cascade rejects dashed UUIDs for identifiers - serialize as bare hex.
|
|
220
|
+
@field_serializer("site_id")
|
|
221
|
+
def serialize_site_id(self, value: uuid.UUID | None) -> str | None:
|
|
222
|
+
return value.hex if value is not None else None
|
|
215
223
|
|
|
216
224
|
|
|
217
225
|
class Path(PathBase):
|
|
@@ -222,7 +230,20 @@ class Path(PathBase):
|
|
|
222
230
|
asset_type (AssetTypes): (Required) the type of the asset
|
|
223
231
|
"""
|
|
224
232
|
|
|
225
|
-
asset_type:
|
|
233
|
+
asset_type: AssetTypes
|
|
234
|
+
|
|
235
|
+
# Duck-typed surface shared with IdentifierType (logging reads these).
|
|
236
|
+
@property
|
|
237
|
+
def get_id(self) -> None:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def get_type(self) -> str:
|
|
242
|
+
return str(self.asset_type)
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def get_path(self) -> str:
|
|
246
|
+
return self.path
|
|
226
247
|
|
|
227
248
|
|
|
228
249
|
# ===== PAYLOAD MODELS (Request Data) =====
|
|
@@ -277,7 +298,7 @@ class SimplePayload(BaseModel):
|
|
|
277
298
|
return value
|
|
278
299
|
|
|
279
300
|
aliased = {
|
|
280
|
-
(fields_info[name].alias or name): dump(value)
|
|
301
|
+
(fields_info[name].serialization_alias or fields_info[name].alias or name): dump(value)
|
|
281
302
|
for name, value in self.__dict__.items()
|
|
282
303
|
if name in fields_info
|
|
283
304
|
}
|
|
@@ -296,15 +317,15 @@ class NewAsset(SimplePayload):
|
|
|
296
317
|
model_config = ConfigDict(
|
|
297
318
|
extra="allow",
|
|
298
319
|
validate_by_name=True,
|
|
299
|
-
validate_by_alias=
|
|
320
|
+
validate_by_alias=True,
|
|
300
321
|
)
|
|
301
322
|
|
|
302
323
|
name: str
|
|
303
324
|
asset_type: AssetTypes
|
|
304
|
-
site_name: str | None = Field(default=None,
|
|
305
|
-
site_id: uuid.UUID | None = Field(default=None,
|
|
306
|
-
parent_folder_path: str | None = Field(default=None,
|
|
307
|
-
parent_folder_id: uuid.UUID | None = Field(default=None,
|
|
325
|
+
site_name: str | None = Field(default=None, validation_alias="siteName", serialization_alias="siteName")
|
|
326
|
+
site_id: uuid.UUID | None = Field(default=None, validation_alias="siteId", serialization_alias="siteId")
|
|
327
|
+
parent_folder_path: str | None = Field(default=None, validation_alias="parentFolderPath", serialization_alias="parentFolderPath")
|
|
328
|
+
parent_folder_id: uuid.UUID | None = Field(default=None, validation_alias="parentFolderId", serialization_alias="parentFolderId")
|
|
308
329
|
|
|
309
330
|
@field_serializer("site_id", "parent_folder_id")
|
|
310
331
|
def serialize_uuid_as_hex(self, value: uuid.UUID | None) -> str | None:
|
|
@@ -350,8 +371,8 @@ class IdentifierType(BaseModel):
|
|
|
350
371
|
populate_by_name=True,
|
|
351
372
|
)
|
|
352
373
|
|
|
353
|
-
identifier: Annotated[uuid.UUID, Field(
|
|
354
|
-
asset_type: Annotated[AssetTypes, Field(default=...,
|
|
374
|
+
identifier: Annotated[uuid.UUID, Field(validation_alias='id', serialization_alias='id')]
|
|
375
|
+
asset_type: Annotated[AssetTypes, Field(default=..., validation_alias="type", serialization_alias="type")]
|
|
355
376
|
recycled: Annotated[bool | None, Field(default=None)] = None
|
|
356
377
|
path: Annotated[PathBase | None, Field(default=None)] = None
|
|
357
378
|
|
|
@@ -364,20 +385,18 @@ class IdentifierType(BaseModel):
|
|
|
364
385
|
@property
|
|
365
386
|
def get_path(self):
|
|
366
387
|
if self.path is not None:
|
|
367
|
-
return self.path
|
|
388
|
+
return self.path.path
|
|
368
389
|
|
|
369
390
|
@property
|
|
370
391
|
def get_sitename(self):
|
|
371
392
|
if self.path is not None:
|
|
372
|
-
return self.path
|
|
393
|
+
return self.path.site_name
|
|
373
394
|
|
|
374
395
|
@property
|
|
375
396
|
def get_site_id(self):
|
|
376
|
-
#
|
|
377
|
-
# so pydantic may not populate the key at all — plain indexing would
|
|
378
|
-
# KeyError on that legitimate case.
|
|
397
|
+
# site_id is optional and defaults to None when the response omits it.
|
|
379
398
|
if self.path is not None:
|
|
380
|
-
return self.path.
|
|
399
|
+
return self.path.site_id
|
|
381
400
|
|
|
382
401
|
@property
|
|
383
402
|
def get_id(self):
|
|
@@ -416,9 +435,9 @@ def resolve_identifier(identifier: "IdentifierType | Path") -> tuple[str, ...]:
|
|
|
416
435
|
"""
|
|
417
436
|
if isinstance(identifier, IdentifierType):
|
|
418
437
|
return (str(identifier.get_type), str(identifier.get_id))
|
|
419
|
-
if identifier.
|
|
420
|
-
raise ValueError("Path identifiers require
|
|
421
|
-
return (str(identifier
|
|
438
|
+
if identifier.site_name is None:
|
|
439
|
+
raise ValueError("Path identifiers require site_name to build the request URL")
|
|
440
|
+
return (str(identifier.asset_type), identifier.site_name, identifier.path)
|
|
422
441
|
|
|
423
442
|
|
|
424
443
|
class AssetLogIdentifier(NamedTuple):
|
|
@@ -484,7 +503,9 @@ class PageConfiguration(BaseModel):
|
|
|
484
503
|
)
|
|
485
504
|
|
|
486
505
|
name: str
|
|
487
|
-
|
|
506
|
+
page_regions: list[PageRegion] = Field(
|
|
507
|
+
validation_alias="pageRegions", serialization_alias="pageRegions"
|
|
508
|
+
)
|
|
488
509
|
|
|
489
510
|
|
|
490
511
|
"""
|
|
@@ -492,50 +513,68 @@ Used to retrieve the workflow deinitions on assets
|
|
|
492
513
|
"""
|
|
493
514
|
|
|
494
515
|
|
|
495
|
-
class WorkflowSettingsModel(
|
|
516
|
+
class WorkflowSettingsModel(BaseModel):
|
|
517
|
+
model_config = ConfigDict(populate_by_name=True, validate_assignment=True)
|
|
518
|
+
|
|
496
519
|
identifier: IdentifierType
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
520
|
+
workflow_definitions: list[IdentifierType] = Field(
|
|
521
|
+
validation_alias="workflowDefinitions", serialization_alias="workflowDefinitions"
|
|
522
|
+
)
|
|
523
|
+
inherited_workflow_definitions: list[IdentifierType] = Field(
|
|
524
|
+
validation_alias="inheritedWorkflowDefinitions", serialization_alias="inheritedWorkflowDefinitions"
|
|
525
|
+
)
|
|
526
|
+
inherit_workflows: bool = Field(validation_alias="inheritWorkflows", serialization_alias="inheritWorkflows")
|
|
527
|
+
require_workflow: bool = Field(validation_alias="requireWorkflow", serialization_alias="requireWorkflow")
|
|
501
528
|
|
|
502
529
|
|
|
503
530
|
class workflowSettingsPayload(SimplePayload):
|
|
504
531
|
|
|
505
|
-
body: WorkflowSettingsModel = Field(...,
|
|
506
|
-
|
|
507
|
-
|
|
532
|
+
body: WorkflowSettingsModel = Field(..., validation_alias="workflowSettings", serialization_alias="workflowSettings")
|
|
533
|
+
apply_inherit_workflows_to_children: bool | None = Field(
|
|
534
|
+
default=False, validation_alias="applyInheritWorkflowsToChildren", serialization_alias="applyInheritWorkflowsToChildren"
|
|
535
|
+
)
|
|
536
|
+
apply_require_workflow_to_children: bool | None = Field(
|
|
537
|
+
default=False, validation_alias="applyRequireWorkflowToChildren", serialization_alias="applyRequireWorkflowToChildren"
|
|
538
|
+
)
|
|
508
539
|
# __model__ = WorkflowSettingsModel
|
|
509
540
|
|
|
510
541
|
|
|
511
|
-
class Entries(
|
|
542
|
+
class Entries(BaseModel):
|
|
543
|
+
model_config = ConfigDict(populate_by_name=True, validate_assignment=True)
|
|
544
|
+
|
|
512
545
|
level: Literal["none", "read", "write"]
|
|
513
|
-
entry_type:
|
|
546
|
+
entry_type: IdentityTypes = Field(validation_alias="type", serialization_alias="type")
|
|
514
547
|
name: str
|
|
515
548
|
|
|
516
549
|
|
|
517
|
-
class AccessRightsModel(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
550
|
+
class AccessRightsModel(BaseModel):
|
|
551
|
+
model_config = ConfigDict(populate_by_name=True, validate_assignment=True)
|
|
552
|
+
|
|
553
|
+
user_id: IdentifierType = Field(validation_alias="identifier", serialization_alias="identifier")
|
|
554
|
+
acl_entries: list[Entries] = Field(validation_alias="aclEntries", serialization_alias="aclEntries")
|
|
555
|
+
all_level: Literal["none", "read", "write"] = Field(validation_alias="allLevel", serialization_alias="allLevel")
|
|
521
556
|
|
|
522
557
|
|
|
523
558
|
class accessRightsInformationPayload(SimplePayload):
|
|
524
|
-
body: AccessRightsModel = Field(default=...,
|
|
525
|
-
apply_to_children: bool | None = Field(default=False,
|
|
559
|
+
body: AccessRightsModel = Field(default=..., validation_alias="accessRightsInformation", serialization_alias="accessRightsInformation")
|
|
560
|
+
apply_to_children: bool | None = Field(default=False, validation_alias="applyToChildren", serialization_alias="applyToChildren")
|
|
526
561
|
|
|
527
562
|
|
|
528
|
-
class WorkflowAction(
|
|
529
|
-
|
|
563
|
+
class WorkflowAction(BaseModel):
|
|
564
|
+
model_config = ConfigDict(frozen=True, populate_by_name=True)
|
|
565
|
+
|
|
566
|
+
action_identifier: str = Field(validation_alias="identifier", serialization_alias="identifier")
|
|
530
567
|
label: str
|
|
531
|
-
action_type:
|
|
532
|
-
next_id: uuid.UUID
|
|
568
|
+
action_type: str = Field(validation_alias="actionType", serialization_alias="actionType")
|
|
569
|
+
next_id: uuid.UUID = Field(validation_alias="nextId", serialization_alias="nextId")
|
|
570
|
+
|
|
533
571
|
|
|
572
|
+
class WorkflowSteps(BaseModel):
|
|
573
|
+
model_config = ConfigDict(frozen=True, populate_by_name=True)
|
|
534
574
|
|
|
535
|
-
|
|
536
|
-
step_identifier: Annotated[str, Field(alias="identifier")]
|
|
575
|
+
step_identifier: str = Field(validation_alias="identifier", serialization_alias="identifier")
|
|
537
576
|
label: str
|
|
538
|
-
step_type:
|
|
577
|
+
step_type: str = Field(validation_alias="stepType", serialization_alias="stepType")
|
|
539
578
|
actions: list[WorkflowAction]
|
|
540
579
|
owner: str | None
|
|
541
580
|
|
|
@@ -546,17 +585,19 @@ class workflowInformation(BaseModel):
|
|
|
546
585
|
|
|
547
586
|
model_config = ConfigDict(frozen=True)
|
|
548
587
|
|
|
549
|
-
related_entity: Annotated[IdentifierType, Field(
|
|
550
|
-
current_step: Annotated[str, Field(
|
|
588
|
+
related_entity: Annotated[IdentifierType, Field(validation_alias="relatedEntity", serialization_alias="relatedEntity")]
|
|
589
|
+
current_step: Annotated[str, Field(validation_alias="currentStep", serialization_alias="currentStep")]
|
|
551
590
|
ordered_steps: list[WorkflowSteps]
|
|
552
591
|
unordered_steps: list[WorkflowSteps]
|
|
553
592
|
start_date: datetime
|
|
554
593
|
end_date: datetime
|
|
555
594
|
name: str
|
|
556
|
-
workflow_info_id: Annotated[uuid.UUID, Field(
|
|
595
|
+
workflow_info_id: Annotated[uuid.UUID, Field(validation_alias="workflowInfoId", serialization_alias="workflowInfoId")]
|
|
557
596
|
|
|
558
597
|
|
|
559
|
-
class Audit(
|
|
598
|
+
class Audit(BaseModel):
|
|
599
|
+
model_config = ConfigDict(frozen=True)
|
|
600
|
+
|
|
560
601
|
user: str
|
|
561
602
|
action: AuditTypes
|
|
562
603
|
identifier: IdentifierType
|
|
@@ -726,7 +767,7 @@ class Asset:
|
|
|
726
767
|
return config
|
|
727
768
|
|
|
728
769
|
try:
|
|
729
|
-
region = next((r for r in config.
|
|
770
|
+
region = next((r for r in config.page_regions if r.name == page_region), None)
|
|
730
771
|
except (KeyError, AttributeError) as e:
|
|
731
772
|
raise KeyError(f"Missing required field 'pageRegions' or 'name' in PageRegion: {e}")
|
|
732
773
|
|
|
@@ -761,12 +802,12 @@ class Message(SimplePayload):
|
|
|
761
802
|
|
|
762
803
|
model_config = ConfigDict(populate_by_name=True)
|
|
763
804
|
|
|
764
|
-
m_from: Annotated[str, Field(
|
|
765
|
-
m_to: Annotated[str, Field(
|
|
766
|
-
m_subject: Annotated[str, Field(
|
|
767
|
-
m_date: Annotated[datetime, Field(
|
|
768
|
-
m_id: Annotated[uuid.UUID, Field(
|
|
769
|
-
marked: str = Field("unread",
|
|
805
|
+
m_from: Annotated[str, Field(validation_alias="from", serialization_alias="from", exclude=True)]
|
|
806
|
+
m_to: Annotated[str, Field(validation_alias="to", serialization_alias="to", exclude=True)]
|
|
807
|
+
m_subject: Annotated[str, Field(validation_alias="subject", serialization_alias="subject", exclude=True)]
|
|
808
|
+
m_date: Annotated[datetime, Field(validation_alias="date", serialization_alias="date", exclude=True)]
|
|
809
|
+
m_id: Annotated[uuid.UUID, Field(validation_alias="id", serialization_alias="id", exclude=True)]
|
|
810
|
+
marked: str = Field("unread", validation_alias="markType", serialization_alias="markType")
|
|
770
811
|
|
|
771
812
|
@field_validator("m_date", mode="after")
|
|
772
813
|
@classmethod
|
|
@@ -782,7 +823,7 @@ class CheckedOutAsset(BaseModel):
|
|
|
782
823
|
|
|
783
824
|
model_config = ConfigDict(frozen=True)
|
|
784
825
|
|
|
785
|
-
|
|
826
|
+
working_copy_identifier: IdentifierType = Field(validation_alias="workingCopyIdentifier", serialization_alias="workingCopyIdentifier")
|
|
786
827
|
|
|
787
828
|
|
|
788
829
|
class ListElements(BaseModel):
|
|
@@ -798,6 +839,7 @@ class ListElements(BaseModel):
|
|
|
798
839
|
"messages",
|
|
799
840
|
"relationships",
|
|
800
841
|
"sites",
|
|
842
|
+
"audits",
|
|
801
843
|
)
|
|
802
844
|
)
|
|
803
845
|
|
|
@@ -827,12 +869,14 @@ class CascadeSuccess(BaseModel):
|
|
|
827
869
|
class SearchInformation(SimplePayload):
|
|
828
870
|
"""Payload for the `search` operation."""
|
|
829
871
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
872
|
+
site_name: str = Field(validation_alias="siteName", serialization_alias="siteName")
|
|
873
|
+
search_terms: str = Field(validation_alias="searchTerms", serialization_alias="searchTerms")
|
|
874
|
+
search_fields: list[FieldsSearchTypes] | list[Literal[""]] = Field(
|
|
875
|
+
validation_alias="searchFields", serialization_alias="searchFields",
|
|
833
876
|
default_factory=lambda: [cast(Literal[""], "")]
|
|
834
877
|
)
|
|
835
|
-
|
|
878
|
+
search_types: list[AssetTypes] | list[Literal[""]] = Field(
|
|
879
|
+
validation_alias="searchTypes", serialization_alias="searchTypes",
|
|
836
880
|
default_factory=lambda: [cast(Literal[""], "")]
|
|
837
881
|
)
|
|
838
882
|
|
|
@@ -847,18 +891,18 @@ class preference(SimplePayload):
|
|
|
847
891
|
class deleteParameters(SimplePayload):
|
|
848
892
|
"""Payload for the `delete` operation."""
|
|
849
893
|
|
|
850
|
-
do_workflow: bool = Field(
|
|
851
|
-
destinations_identifiers: list[IdentifierType] = Field(
|
|
894
|
+
do_workflow: bool = Field(validation_alias="doWorkflow", serialization_alias="doWorkflow")
|
|
895
|
+
destinations_identifiers: list[IdentifierType] = Field(validation_alias="destinations", serialization_alias="destinations")
|
|
852
896
|
unpublish: bool = True
|
|
853
897
|
|
|
854
898
|
|
|
855
899
|
class copyParameters(SimplePayload):
|
|
856
900
|
"""Payload for the `copy` operation."""
|
|
857
901
|
|
|
858
|
-
do_workflow: Annotated[bool, Field(
|
|
859
|
-
new_name: Annotated[str, Field(default=...,
|
|
902
|
+
do_workflow: Annotated[bool, Field(validation_alias="doWorkflow", serialization_alias="doWorkflow")]
|
|
903
|
+
new_name: Annotated[str, Field(default=..., validation_alias="newName", serialization_alias="newName")]
|
|
860
904
|
destination_container_identifier: Annotated[
|
|
861
|
-
IdentifierType, Field(
|
|
905
|
+
IdentifierType, Field(validation_alias="destinationContainerIdentifier", serialization_alias="destinationContainerIdentifier") # required
|
|
862
906
|
]
|
|
863
907
|
|
|
864
908
|
|
|
@@ -866,11 +910,11 @@ class moveParameters(SimplePayload):
|
|
|
866
910
|
"""Payload for the `move` operation."""
|
|
867
911
|
|
|
868
912
|
destinations: list[IdentifierType]
|
|
869
|
-
do_workflow: bool = Field(
|
|
913
|
+
do_workflow: bool = Field(validation_alias="doWorkflow", serialization_alias="doWorkflow")
|
|
870
914
|
destination_container_identifier: IdentifierType = Field(
|
|
871
|
-
|
|
915
|
+
validation_alias="destinationContainerIdentifier", serialization_alias="destinationContainerIdentifier"
|
|
872
916
|
)
|
|
873
|
-
new_name: str = Field(default="",
|
|
917
|
+
new_name: str = Field(default="", validation_alias="newName", serialization_alias="newName") # empty new name means no rename
|
|
874
918
|
unpublish: bool = True
|
|
875
919
|
|
|
876
920
|
|
|
@@ -889,36 +933,34 @@ class Comment(SimplePayload):
|
|
|
889
933
|
class SiteCopyParameter(SimplePayload):
|
|
890
934
|
"""Payload for the `siteCopy` operation."""
|
|
891
935
|
|
|
892
|
-
original_sitename: str | IdentifierType = Field(
|
|
893
|
-
new_sitename: str = Field(
|
|
936
|
+
original_sitename: str | IdentifierType = Field(validation_alias="originalSiteName", serialization_alias="originalSiteName")
|
|
937
|
+
new_sitename: str = Field(validation_alias="newSiteName", serialization_alias="newSiteName")
|
|
894
938
|
|
|
895
939
|
|
|
896
940
|
class workflowTransitionInformation(SimplePayload):
|
|
897
941
|
"""Payload for the `performWorkflowTransition` operation."""
|
|
898
942
|
|
|
899
|
-
workflow_identifier: Annotated[uuid.UUID, Field(
|
|
900
|
-
action_identifier: Annotated[str, Field(
|
|
901
|
-
transition_comment: str | None = Field(
|
|
943
|
+
workflow_identifier: Annotated[uuid.UUID, Field(validation_alias="workflowId", serialization_alias="workflowId")]
|
|
944
|
+
action_identifier: Annotated[str, Field(validation_alias="actionIdentifier", serialization_alias="actionIdentifier")]
|
|
945
|
+
transition_comment: str | None = Field(validation_alias="transitionComment", serialization_alias="transitionComment")
|
|
902
946
|
|
|
903
947
|
|
|
904
948
|
class auditParameters(SimplePayload):
|
|
905
949
|
"""Payload for the `readAudits` operation."""
|
|
906
950
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
endDate: datetime | None = Field(default=None)
|
|
951
|
+
audit_type: AuditTypes = Field(validation_alias="auditType", serialization_alias="auditType")
|
|
952
|
+
by_username: str | None = Field(default=None, validation_alias="username", serialization_alias="username")
|
|
953
|
+
by_group: str | None = Field(default=None, validation_alias="groupname", serialization_alias="groupname")
|
|
954
|
+
by_role: str | None = Field(default=None, validation_alias="rolename", serialization_alias="rolename")
|
|
955
|
+
start_date: datetime | None = Field(default=None, validation_alias="startDate", serialization_alias="startDate")
|
|
956
|
+
end_date: datetime | None = Field(default=None, validation_alias="endDate", serialization_alias="endDate")
|
|
914
957
|
|
|
915
|
-
#
|
|
916
|
-
@
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
return identifier
|
|
958
|
+
# Cascade needs at least one of username / groupname / rolename
|
|
959
|
+
@model_validator(mode="after")
|
|
960
|
+
def requires_username_group_or_role(self) -> Self:
|
|
961
|
+
if not (self.by_username or self.by_group or self.by_role):
|
|
962
|
+
raise ValueError("Provide at least one of username, groupname, or rolename.")
|
|
963
|
+
return self
|
|
922
964
|
|
|
923
965
|
"""
|
|
924
966
|
def toJson(self) -> str:
|
|
@@ -84,7 +84,7 @@ class ChainResults[T](list[T]):
|
|
|
84
84
|
broken out.
|
|
85
85
|
|
|
86
86
|
Indexing, iteration, and `len()` behave exactly like a plain list —
|
|
87
|
-
`.
|
|
87
|
+
`.success` and `.failed` are added properties computed from the failure
|
|
88
88
|
record recorded for each chain at execution time.
|
|
89
89
|
"""
|
|
90
90
|
|
|
@@ -97,7 +97,7 @@ class ChainResults[T](list[T]):
|
|
|
97
97
|
self._failures: list[ChainFailure | None] = list(failures or [])
|
|
98
98
|
|
|
99
99
|
@property
|
|
100
|
-
def
|
|
100
|
+
def success(self) -> list[T]:
|
|
101
101
|
"""Results from chains that did not fail, typed from `result_type`."""
|
|
102
102
|
return [
|
|
103
103
|
result
|
|
@@ -423,13 +423,14 @@ class OperationLogger:
|
|
|
423
423
|
def log_python_error(self, exc: Exception, prefix: str = "") -> None:
|
|
424
424
|
"""Log a Python exception outside of chain context. See `log_cascade_error`.
|
|
425
425
|
|
|
426
|
-
`prefix` (e.g. `"[CASCADE-REST-CMS] "`) is prepended to the
|
|
427
|
-
`!ERROR:` message — used
|
|
428
|
-
|
|
426
|
+
`prefix` (e.g. `"[CASCADE-REST-CMS] "`) is prepended to both the
|
|
427
|
+
console `[ERROR]` line and the logfile's `!ERROR:` message — used
|
|
428
|
+
for a batch-level failure (D6/A1), left empty for a cleanup-time
|
|
429
|
+
error, which isn't a classified failure.
|
|
429
430
|
"""
|
|
430
431
|
exc_type = type(exc).__name__
|
|
431
432
|
exc_msg = str(exc)
|
|
432
|
-
self._console(f"[ERROR]: {exc_type} — check log")
|
|
433
|
+
self._console(f"[ERROR]: {prefix}{exc_type} — check log")
|
|
433
434
|
|
|
434
435
|
tb = traceback.extract_tb(exc.__traceback__)
|
|
435
436
|
frame_info = tb[-1] if tb else None
|
|
@@ -255,11 +255,28 @@ class OperationChain:
|
|
|
255
255
|
|
|
256
256
|
def delete(
|
|
257
257
|
self,
|
|
258
|
-
identifier: IdentifierType
|
|
258
|
+
identifier: IdentifierType
|
|
259
|
+
| Path
|
|
260
|
+
| Callable[[Any], IdentifierType | Path | list[IdentifierType | Path]],
|
|
259
261
|
payload: deleteParameters | None = None,
|
|
260
262
|
parser=parse_success,
|
|
261
263
|
) -> Self:
|
|
262
|
-
"""Append a POST `delete/{type}/{id-or-path}` step for an asset.
|
|
264
|
+
"""Append a POST `delete/{type}/{id-or-path}` step for an asset.
|
|
265
|
+
|
|
266
|
+
`identifier` may be a callable, in which case it is invoked with the
|
|
267
|
+
previous node's result when the chain runs and returns the
|
|
268
|
+
identifier (or list of identifiers) to delete — that is how
|
|
269
|
+
`read → then(...) → create → delete` works in one chain. A list from
|
|
270
|
+
a callable is one multi-request node (partial failures stay in the
|
|
271
|
+
result list instead of stopping the chain, like a multi `create`).
|
|
272
|
+
"""
|
|
273
|
+
if callable(identifier):
|
|
274
|
+
builder = partial(
|
|
275
|
+
self._build_delete_requests, parser=parser, payload=payload, resolver=identifier
|
|
276
|
+
)
|
|
277
|
+
return self._add_operation(
|
|
278
|
+
"delete", None, payload=payload, parser=parser, builder=builder
|
|
279
|
+
)
|
|
263
280
|
return self._identifier_operation(
|
|
264
281
|
op_name="delete",
|
|
265
282
|
method="POST",
|
|
@@ -268,9 +285,55 @@ class OperationChain:
|
|
|
268
285
|
parser=parser,
|
|
269
286
|
)
|
|
270
287
|
|
|
271
|
-
def
|
|
272
|
-
|
|
288
|
+
def _build_delete_requests(
|
|
289
|
+
self,
|
|
290
|
+
previous: Any,
|
|
291
|
+
*,
|
|
292
|
+
parser: Any,
|
|
293
|
+
payload: deleteParameters | None,
|
|
294
|
+
resolver: Callable[[Any], Any],
|
|
295
|
+
) -> list[RequestExecutor]:
|
|
296
|
+
"""Build the delete requests from a callable's resolved identifier(s)."""
|
|
297
|
+
resolved = resolver(previous)
|
|
298
|
+
identifiers = resolved if isinstance(resolved, list) else [resolved]
|
|
299
|
+
if self._asset_identifier is None and identifiers:
|
|
300
|
+
self._asset_identifier = identifiers[0]
|
|
301
|
+
return [
|
|
302
|
+
RequestExecutor(
|
|
303
|
+
self._driver._build_url("delete", *resolve_identifier(ident)),
|
|
304
|
+
"POST",
|
|
305
|
+
parser,
|
|
306
|
+
payload=payload,
|
|
307
|
+
identifier=ident,
|
|
308
|
+
)
|
|
309
|
+
for ident in identifiers
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
def create(
|
|
313
|
+
self,
|
|
314
|
+
payload: list[NewAsset] | NewAsset | Callable[[Any], NewAsset | list[NewAsset]],
|
|
315
|
+
parser=None,
|
|
316
|
+
) -> Self:
|
|
317
|
+
"""Append a POST `create` step for one or more new assets.
|
|
318
|
+
|
|
319
|
+
`payload` may be a callable, in which case it is invoked with the
|
|
320
|
+
previous node's result when the chain runs and returns the new
|
|
321
|
+
asset(s) to create.
|
|
322
|
+
"""
|
|
323
|
+
if callable(payload):
|
|
324
|
+
builder = partial(self._build_create_requests, resolver=payload)
|
|
325
|
+
return self._add_operation(
|
|
326
|
+
"create", None, payload=payload, parser=parser, builder=builder
|
|
327
|
+
)
|
|
273
328
|
multi = isinstance(payload, list)
|
|
329
|
+
requests = self._build_create_requests(None, resolver=payload)
|
|
330
|
+
return self._add_operation(
|
|
331
|
+
"create", requests, payload=payload, parser=parser, multi=multi
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _build_create_requests(self, previous: Any, *, resolver: Any) -> list[RequestExecutor]:
|
|
335
|
+
"""Build the create requests, resolving a callable payload if needed."""
|
|
336
|
+
payload = resolver(previous) if callable(resolver) else resolver
|
|
274
337
|
assets: list[NewAsset] = payload if isinstance(payload, list) else [payload]
|
|
275
338
|
|
|
276
339
|
requests: list[RequestExecutor] = []
|
|
@@ -286,10 +349,7 @@ class OperationChain:
|
|
|
286
349
|
parser=bound_parser, # This now only expects (raw)
|
|
287
350
|
)
|
|
288
351
|
)
|
|
289
|
-
|
|
290
|
-
return self._add_operation(
|
|
291
|
-
"create", requests, payload=payload, parser=parser, multi=multi
|
|
292
|
-
)
|
|
352
|
+
return requests
|
|
293
353
|
|
|
294
354
|
def edit(
|
|
295
355
|
self,
|
|
@@ -488,7 +548,6 @@ class OperationChain:
|
|
|
488
548
|
return self._add_operation(
|
|
489
549
|
"readAudits",
|
|
490
550
|
[request],
|
|
491
|
-
identifier=payload.by_identifier,
|
|
492
551
|
payload=payload,
|
|
493
552
|
parser=parser,
|
|
494
553
|
)
|
|
@@ -559,7 +618,7 @@ class OperationChain:
|
|
|
559
618
|
parser=parse_success,
|
|
560
619
|
) -> Self:
|
|
561
620
|
"""Append a POST `editWorkflowSettings/{type}/{id}` step for an asset."""
|
|
562
|
-
id_fields = payload.body
|
|
621
|
+
id_fields = payload.body.identifier
|
|
563
622
|
url = self._driver._build_url(
|
|
564
623
|
"editWorkflowSettings",
|
|
565
624
|
id_fields.get_type,
|
|
@@ -653,6 +712,10 @@ class OperationChain:
|
|
|
653
712
|
if requests is None:
|
|
654
713
|
builder = data["builder"]
|
|
655
714
|
requests = builder(previous)
|
|
715
|
+
# A callable payload only reveals its size now: several requests
|
|
716
|
+
# means a multi node (keep every result, never stop on one error).
|
|
717
|
+
if len(requests) > 1 and node.operation_data is not None:
|
|
718
|
+
node.operation_data["multi"] = True
|
|
656
719
|
return requests
|
|
657
720
|
|
|
658
721
|
def _resolve_operation_result(self, node: Node, raw: Any, step: int) -> tuple[Any, bool]:
|
|
@@ -34,13 +34,13 @@ class CascadeWrapperBase:
|
|
|
34
34
|
code — code written after the `with` block does NOT run in that case.
|
|
35
35
|
Pass `exit_on_failure=False` (e.g. when embedding this in a longer-lived
|
|
36
36
|
process, such as an MCP server) to have `__exit__` never raise; results
|
|
37
|
-
stay available to the caller as values via `.
|
|
37
|
+
stay available to the caller as values via `.success`/`.failed`.
|
|
38
38
|
|
|
39
39
|
Use as:
|
|
40
40
|
with CascadeWrapperBase(env_vars, config_vars) as cascade:
|
|
41
41
|
cascade.operations.read(identifier)
|
|
42
42
|
results = cascade.submit_requests()
|
|
43
|
-
# results.
|
|
43
|
+
# results.success / results.failed — no isinstance() checks needed.
|
|
44
44
|
"""
|
|
45
45
|
|
|
46
46
|
def __enter__(self):
|
|
@@ -158,7 +158,7 @@ class CascadeWrapperBase:
|
|
|
158
158
|
other chain.
|
|
159
159
|
|
|
160
160
|
Returns a `ChainResults` — a list, in **chain-creation order** (so
|
|
161
|
-
`results[0]` belongs to the first chain built), with `.
|
|
161
|
+
`results[0]` belongs to the first chain built), with `.success` (results
|
|
162
162
|
from chains that didn't fail) and `.failed` (a `ChainFailure` per
|
|
163
163
|
failed chain, naming the exact step and its category) added. Raw
|
|
164
164
|
values (a `CascadeError`, or the exception a callback raised) stay
|
|
@@ -232,3 +232,135 @@ def test_asset_root_container_id_known_and_unknown_types():
|
|
|
232
232
|
)
|
|
233
233
|
assert site.root_container_id("sharedfield") is None
|
|
234
234
|
assert site.root_container_id("template") is None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_audit_parameters_accept_plain_strings():
|
|
238
|
+
from cascade_cms.cmstypes import auditParameters
|
|
239
|
+
|
|
240
|
+
for kw, key in (("username", "username"), ("groupname", "groupname"), ("rolename", "rolename")):
|
|
241
|
+
params = auditParameters(audit_type="login", **{kw: "x"})
|
|
242
|
+
assert params.model_dump(by_alias=True)["auditParameters"][key] == "x"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_audit_parameters_require_a_name():
|
|
246
|
+
import pytest
|
|
247
|
+
from pydantic import ValidationError
|
|
248
|
+
|
|
249
|
+
from cascade_cms.cmstypes import auditParameters
|
|
250
|
+
|
|
251
|
+
with pytest.raises(ValidationError):
|
|
252
|
+
auditParameters(audit_type="login")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_new_asset_accepts_snake_and_camel_and_serializes_camel():
|
|
256
|
+
import json
|
|
257
|
+
|
|
258
|
+
from cascade_cms.cmstypes import NewAsset
|
|
259
|
+
|
|
260
|
+
sid = "8b320f55ac1001062545a6d2562cee4b"
|
|
261
|
+
snake = NewAsset(name="n", asset_type="page", site_id=sid, parent_folder_path="/")
|
|
262
|
+
camel = NewAsset(name="n", asset_type="page", siteId=sid, parentFolderPath="/")
|
|
263
|
+
for asset in (snake, camel):
|
|
264
|
+
body = json.loads(asset.dump_json())["asset"]["page"]
|
|
265
|
+
assert body["siteId"] == sid
|
|
266
|
+
assert body["parentFolderPath"] == "/"
|
|
267
|
+
assert "site_id" not in body
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_search_information_snake_fields_serialize_camel():
|
|
271
|
+
from cascade_cms.cmstypes import SearchInformation
|
|
272
|
+
|
|
273
|
+
dumped = SearchInformation(site_name="s", search_terms="t").model_dump(by_alias=True)
|
|
274
|
+
body = dumped["searchInformation"]
|
|
275
|
+
assert body["siteName"] == "s" and body["searchTerms"] == "t"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_path_accepts_snake_and_camel_and_stays_hashable():
|
|
279
|
+
from cascade_cms.cmstypes import Path as CascadePath
|
|
280
|
+
|
|
281
|
+
camel = CascadePath(path="/a", siteName="s", asset_type="page")
|
|
282
|
+
snake = CascadePath(path="/a", site_name="s", asset_type="page")
|
|
283
|
+
assert camel == snake and hash(camel) == hash(snake)
|
|
284
|
+
assert camel.get_type == "page" and camel.get_id is None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_identifier_type_nested_path_is_model():
|
|
288
|
+
ident = IdentifierType(
|
|
289
|
+
id="8b320f55ac1001062545a6d2562cee4b",
|
|
290
|
+
type="page",
|
|
291
|
+
path={"path": "a/b", "siteName": "s", "siteId": "9c431066bd21120736f6b7e3673dff5c"},
|
|
292
|
+
)
|
|
293
|
+
assert ident.get_path == "a/b" and ident.get_sitename == "s"
|
|
294
|
+
dumped = ident.model_dump(by_alias=True)["path"]
|
|
295
|
+
assert dumped["siteName"] == "s"
|
|
296
|
+
assert dumped["siteId"] == "9c431066bd21120736f6b7e3673dff5c"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_workflow_settings_payload_roundtrip():
|
|
300
|
+
from cascade_cms.cmstypes import workflowSettingsPayload
|
|
301
|
+
|
|
302
|
+
ident = {"id": "8b320f55ac1001062545a6d2562cee4b", "type": "folder"}
|
|
303
|
+
payload = workflowSettingsPayload.model_validate(
|
|
304
|
+
{
|
|
305
|
+
"workflowSettings": {
|
|
306
|
+
"identifier": ident,
|
|
307
|
+
"workflowDefinitions": [],
|
|
308
|
+
"inheritedWorkflowDefinitions": [],
|
|
309
|
+
"inheritWorkflows": True,
|
|
310
|
+
"requireWorkflow": False,
|
|
311
|
+
},
|
|
312
|
+
"applyInheritWorkflowsToChildren": True,
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
assert payload.body.identifier.get_type == "folder"
|
|
316
|
+
assert payload.body.inherit_workflows is True
|
|
317
|
+
body = payload.model_dump(by_alias=True)["workflowSettingsPayload"]
|
|
318
|
+
assert body["workflowSettings"]["inheritWorkflows"] is True
|
|
319
|
+
assert body["applyInheritWorkflowsToChildren"] is True
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_access_rights_and_audit_models_validate():
|
|
323
|
+
from cascade_cms.cmstypes import AccessRightsModel, Audit
|
|
324
|
+
|
|
325
|
+
rights = AccessRightsModel.model_validate(
|
|
326
|
+
{
|
|
327
|
+
"identifier": {"id": "8b320f55ac1001062545a6d2562cee4b", "type": "user"},
|
|
328
|
+
"aclEntries": [{"level": "read", "type": "group", "name": "g"}],
|
|
329
|
+
"allLevel": "none",
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
assert rights.acl_entries[0].entry_type == "group" and rights.all_level == "none"
|
|
333
|
+
audit = Audit.model_validate(
|
|
334
|
+
{
|
|
335
|
+
"user": "u",
|
|
336
|
+
"action": "login",
|
|
337
|
+
"identifier": {"id": "8b320f55ac1001062545a6d2562cee4b", "type": "user"},
|
|
338
|
+
"date": "2026-01-01T00:00:00Z",
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
assert audit.user == "u"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_list_elements_accepts_audits_key():
|
|
345
|
+
from cascade_cms.cmstypes import Audit
|
|
346
|
+
|
|
347
|
+
ident = {"id": "8b320f55ac1001062545a6d2562cee4b", "type": "user"}
|
|
348
|
+
result = ListElements.model_validate(
|
|
349
|
+
{
|
|
350
|
+
"audits": [
|
|
351
|
+
{"user": "u", "action": "login", "identifier": ident, "date": "2026-01-01T00:00:00Z"}
|
|
352
|
+
]
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
assert isinstance(result.elements[0], Audit)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_workflow_action_next_id_alias():
|
|
359
|
+
from cascade_cms.cmstypes import WorkflowAction
|
|
360
|
+
|
|
361
|
+
nid = "9c431066bd21120736f6b7e3673dff5c"
|
|
362
|
+
for key in ("nextId", "next_id"):
|
|
363
|
+
action = WorkflowAction.model_validate(
|
|
364
|
+
{"identifier": "a", "label": "l", "actionType": "t", key: nid}
|
|
365
|
+
)
|
|
366
|
+
assert action.next_id.hex == nid
|
|
@@ -1120,3 +1120,58 @@ class TestEndToEndLoggedOutput:
|
|
|
1120
1120
|
assert expected_line in lines
|
|
1121
1121
|
assert f"{expected_line} -> Asset" not in lines
|
|
1122
1122
|
assert "1/1 succeeded" in lines
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
# ============================================================================
|
|
1126
|
+
# Callable create()/delete() payloads
|
|
1127
|
+
# ============================================================================
|
|
1128
|
+
|
|
1129
|
+
class TestCallableCreateDelete:
|
|
1130
|
+
"""read → create(fn) → delete(fn) in one chain."""
|
|
1131
|
+
|
|
1132
|
+
def test_create_then_delete_resolve_from_previous_result(self, operations, mock_driver):
|
|
1133
|
+
mock_driver._build_url.return_value = "http://cascade/api/x"
|
|
1134
|
+
operations._logger.is_debug = False
|
|
1135
|
+
read_id = IdentifierType(id=ID_ONE, type="page")
|
|
1136
|
+
new_asset = MagicMock(asset_type="page")
|
|
1137
|
+
created = make_asset(id=ID_TWO)
|
|
1138
|
+
source = make_asset(id=ID_ONE, name="src")
|
|
1139
|
+
seen = {}
|
|
1140
|
+
mock_driver._submitRequests.side_effect = [
|
|
1141
|
+
[source],
|
|
1142
|
+
[created],
|
|
1143
|
+
[CascadeSuccess()],
|
|
1144
|
+
]
|
|
1145
|
+
|
|
1146
|
+
def build_create(previous):
|
|
1147
|
+
seen["create"] = previous
|
|
1148
|
+
return new_asset
|
|
1149
|
+
|
|
1150
|
+
def pick_delete(previous):
|
|
1151
|
+
seen["delete"] = previous
|
|
1152
|
+
return read_id
|
|
1153
|
+
|
|
1154
|
+
chain = operations.read(read_id).create(build_create).delete(pick_delete)
|
|
1155
|
+
result = chain.execute(mock_driver)
|
|
1156
|
+
|
|
1157
|
+
assert [n.operation_type for n in walk(chain)] == ["read", "create", "delete"]
|
|
1158
|
+
assert seen["create"] is source
|
|
1159
|
+
assert seen["delete"] is created
|
|
1160
|
+
assert isinstance(result, CascadeSuccess)
|
|
1161
|
+
delete_requests = mock_driver._submitRequests.call_args_list[2].args[0]
|
|
1162
|
+
assert len(delete_requests) == 1
|
|
1163
|
+
assert delete_requests[0].method == "POST"
|
|
1164
|
+
|
|
1165
|
+
def test_callable_returning_list_keeps_every_result(self, operations, mock_driver):
|
|
1166
|
+
ids = [IdentifierType(id=ID_ONE, type="page"), IdentifierType(id=ID_TWO, type="page")]
|
|
1167
|
+
err = CascadeError(success=False, message="nope")
|
|
1168
|
+
mock_driver._submitRequests.side_effect = [
|
|
1169
|
+
[make_asset(id=ID_ONE)],
|
|
1170
|
+
[CascadeSuccess(), err],
|
|
1171
|
+
]
|
|
1172
|
+
|
|
1173
|
+
chain = operations.read(ids[0]).delete(lambda previous: ids)
|
|
1174
|
+
result = chain.execute(mock_driver)
|
|
1175
|
+
|
|
1176
|
+
assert len(mock_driver._submitRequests.call_args_list[1].args[0]) == 2
|
|
1177
|
+
assert result == [CascadeSuccess(), err]
|
|
@@ -347,6 +347,41 @@ class TestBatchFraming:
|
|
|
347
347
|
assert logger._failed_count == 1
|
|
348
348
|
|
|
349
349
|
|
|
350
|
+
class TestExitTallyLine:
|
|
351
|
+
"""D9: log_exit()'s printed tally line, exact strings."""
|
|
352
|
+
|
|
353
|
+
def _console(self, logger: OperationLogger) -> io.StringIO:
|
|
354
|
+
buffer = io.StringIO()
|
|
355
|
+
logger._console_logger.handlers[0].stream = buffer
|
|
356
|
+
return buffer
|
|
357
|
+
|
|
358
|
+
def test_all_succeeded(self, tmp_path):
|
|
359
|
+
logger = OperationLogger(server="test", debug_config={"log_dir": str(tmp_path)})
|
|
360
|
+
buffer = self._console(logger)
|
|
361
|
+
logger.log_batch_start()
|
|
362
|
+
logger.log_batch_end(succeeded=5, total=5, has_reportable_failure=False)
|
|
363
|
+
logger.log_exit()
|
|
364
|
+
assert "0 failed, 5 succeeded" in buffer.getvalue()
|
|
365
|
+
assert "reference log for details" not in buffer.getvalue()
|
|
366
|
+
|
|
367
|
+
def test_reportable_failure_appends_suffix(self, tmp_path):
|
|
368
|
+
logger = OperationLogger(server="test", debug_config={"log_dir": str(tmp_path)})
|
|
369
|
+
buffer = self._console(logger)
|
|
370
|
+
logger.log_batch_start()
|
|
371
|
+
logger.log_batch_end(succeeded=3, total=5, has_reportable_failure=True)
|
|
372
|
+
logger.log_exit()
|
|
373
|
+
assert "2 failed, 3 succeeded: reference log for details" in buffer.getvalue()
|
|
374
|
+
|
|
375
|
+
def test_callback_only_failure_has_no_suffix(self, tmp_path):
|
|
376
|
+
logger = OperationLogger(server="test", debug_config={"log_dir": str(tmp_path)})
|
|
377
|
+
buffer = self._console(logger)
|
|
378
|
+
logger.log_batch_start()
|
|
379
|
+
logger.log_batch_end(succeeded=4, total=5, has_reportable_failure=False)
|
|
380
|
+
logger.log_exit()
|
|
381
|
+
assert "1 failed, 4 succeeded" in buffer.getvalue()
|
|
382
|
+
assert "reference log for details" not in buffer.getvalue()
|
|
383
|
+
|
|
384
|
+
|
|
350
385
|
# ============================================================================
|
|
351
386
|
# Normal (non-debug) mode
|
|
352
387
|
# ============================================================================
|
|
@@ -4,6 +4,7 @@ Test suite for the D5/D8/D9 error-handling redesign: failure classification,
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import asyncio
|
|
7
|
+
import io
|
|
7
8
|
import subprocess
|
|
8
9
|
import sys
|
|
9
10
|
import textwrap
|
|
@@ -114,13 +115,13 @@ class TestChainResults:
|
|
|
114
115
|
message="nope",
|
|
115
116
|
)
|
|
116
117
|
results = ChainResults(["good", failure.error], [None, failure])
|
|
117
|
-
assert results.
|
|
118
|
+
assert results.success == ["good"]
|
|
118
119
|
assert results.failed == [failure]
|
|
119
120
|
|
|
120
121
|
def test_empty_results(self):
|
|
121
122
|
results = ChainResults()
|
|
122
123
|
assert list(results) == []
|
|
123
|
-
assert results.
|
|
124
|
+
assert results.success == []
|
|
124
125
|
assert results.failed == []
|
|
125
126
|
|
|
126
127
|
|
|
@@ -180,7 +181,7 @@ class TestSubmitRequestsChainResults:
|
|
|
180
181
|
|
|
181
182
|
assert isinstance(results, ChainResults)
|
|
182
183
|
assert len(results) == 2
|
|
183
|
-
assert results.
|
|
184
|
+
assert results.success == [asset1]
|
|
184
185
|
assert len(results.failed) == 1
|
|
185
186
|
assert results.failed[0].category == FailureCategory.CASCADE
|
|
186
187
|
|
|
@@ -203,6 +204,51 @@ class TestSubmitRequestsChainResults:
|
|
|
203
204
|
assert wrapper._callback_failures == [results.failed[0]]
|
|
204
205
|
assert wrapper._has_reportable_failure is False
|
|
205
206
|
|
|
207
|
+
def test_other_chains_finish_before_callback_exception_is_raised(self):
|
|
208
|
+
"""Chain 1's callback raises; chain 2 has no callback and should
|
|
209
|
+
still complete and land in `.success` — a callback exception stops only
|
|
210
|
+
its own chain."""
|
|
211
|
+
driver = StubDriver([[make_asset(id=ID_ONE)], [make_asset(id=ID_TWO)]])
|
|
212
|
+
wrapper = make_wrapper(driver)
|
|
213
|
+
|
|
214
|
+
def boom(_asset):
|
|
215
|
+
raise ValueError("bad asset")
|
|
216
|
+
|
|
217
|
+
wrapper.operations.read(IdentifierType(id=ID_ONE, type="page")).then(boom)
|
|
218
|
+
wrapper.operations.read(IdentifierType(id=ID_TWO, type="page"))
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
results = wrapper.submit_requests()
|
|
222
|
+
finally:
|
|
223
|
+
driver.eventLoop.close()
|
|
224
|
+
|
|
225
|
+
assert len(results.success) == 1
|
|
226
|
+
assert results.success[0].get("id") == ID_TWO
|
|
227
|
+
assert len(results.failed) == 1
|
|
228
|
+
assert results.failed[0].category == FailureCategory.CALLBACK
|
|
229
|
+
|
|
230
|
+
def test_batch_error_console_line_carries_prefix(self, tmp_path):
|
|
231
|
+
logger = OperationLogger(server="TESTSRV", debug_config={"log_dir": str(tmp_path)})
|
|
232
|
+
buffer = io.StringIO()
|
|
233
|
+
logger._console_logger.handlers[0].stream = buffer
|
|
234
|
+
|
|
235
|
+
driver = StubDriver([])
|
|
236
|
+
wrapper = make_wrapper(driver, logger=logger)
|
|
237
|
+
wrapper.operations.read(IdentifierType(id=ID_ONE, type="page"))
|
|
238
|
+
|
|
239
|
+
async def _broken_execute_chains(chains, executor=None):
|
|
240
|
+
raise RuntimeError("event loop broke")
|
|
241
|
+
|
|
242
|
+
wrapper._execute_chains = _broken_execute_chains
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
with pytest.raises(CascadeBatchError):
|
|
246
|
+
wrapper.submit_requests()
|
|
247
|
+
finally:
|
|
248
|
+
driver.eventLoop.close()
|
|
249
|
+
|
|
250
|
+
assert "[ERROR]: [CASCADE-REST-CMS] RuntimeError — check log" in buffer.getvalue()
|
|
251
|
+
|
|
206
252
|
def test_empty_queue_returns_empty_chain_results(self):
|
|
207
253
|
driver = StubDriver([])
|
|
208
254
|
wrapper = make_wrapper(driver)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|