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.
Files changed (28) hide show
  1. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/CHANGELOG.md +28 -0
  2. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/PKG-INFO +5 -5
  3. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/README.md +4 -4
  4. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/pyproject.toml +1 -1
  5. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/cmstypes.py +135 -93
  6. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/failures.py +2 -2
  7. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/operation_logger.py +5 -4
  8. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/operations.py +73 -10
  9. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/wrapper.py +3 -3
  10. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_cmstypes.py +132 -0
  11. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_operation_chains.py +55 -0
  12. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_operation_logger.py +35 -0
  13. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_wrapper.py +49 -3
  14. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/.github/copilot-instructions.md +0 -0
  15. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/.github/workflows/ci.yml +0 -0
  16. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/.github/workflows/dependency-review.yml +0 -0
  17. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/.github/workflows/release.yml +0 -0
  18. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/.gitignore +0 -0
  19. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/AGENTS.md +0 -0
  20. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/LICENSE +0 -0
  21. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/PUBLISHING.md +0 -0
  22. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/__init__.py +0 -0
  23. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/driver.py +0 -0
  24. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/src/cascade_cms/py.typed +0 -0
  25. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/conftest.py +0 -0
  26. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/edit_test.py +0 -0
  27. {cascade_cms_rest-3.2.0 → cascade_cms_rest-3.2.2}/tests/test_driver.py +0 -0
  28. {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.0
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 `.ok` (results from chains that didn't fail)
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 `.ok`/`.failed` off the `ChainResults` it returns instead.
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 `.ok`/`.failed` off the results yourself.
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.ok:
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 `.ok` (results from chains that didn't fail)
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 `.ok`/`.failed` off the `ChainResults` it returns instead.
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 `.ok`/`.failed` off the results yourself.
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.ok:
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cascade-cms-rest"
7
- version = "3.2.0"
7
+ version = "3.2.2"
8
8
  description = "A typed, async REST client for Hannon Hill Cascade CMS"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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(TypedDict):
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
- siteId (uuid_string): unique identifier string
208
- of the site associated with it
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
- siteId: NotRequired[uuid.UUID]
214
- siteName: Annotated[str | None, Field(default=None)]
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: Literal[AssetTypes]
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=False,
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, alias="siteName")
305
- site_id: uuid.UUID | None = Field(default=None, alias="siteId")
306
- parent_folder_path: str | None = Field(default=None, alias="parentFolderPath")
307
- parent_folder_id: uuid.UUID | None = Field(default=None, alias="parentFolderId")
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(alias='id')]
354
- asset_type: Annotated[AssetTypes, Field(default=..., alias="type")]
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["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["siteName"]
393
+ return self.path.site_name
373
394
 
374
395
  @property
375
396
  def get_site_id(self):
376
- # siteId is NotRequired (unlike siteName, it has no Field default),
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.get("siteId")
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.get("siteName") is None:
420
- raise ValueError("Path identifiers require siteName to build the request URL")
421
- return (str(identifier["asset_type"]), str(identifier["siteName"]), str(identifier["path"]))
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
- pageRegions: list[PageRegion]
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(TypedDict):
516
+ class WorkflowSettingsModel(BaseModel):
517
+ model_config = ConfigDict(populate_by_name=True, validate_assignment=True)
518
+
496
519
  identifier: IdentifierType
497
- workflowDefinitions: list[IdentifierType]
498
- inheritedWorkflowDefinitions: list[IdentifierType]
499
- inheritWorkflows: bool
500
- requireWorkflow: bool
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(..., alias="workflowSettings")
506
- applyInheritWorkflowsToChildren: bool | None = False
507
- applyRequireWorkflowToChildren: bool | None = False
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(TypedDict):
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: Annotated[IdentityTypes, Field(alias="type")]
546
+ entry_type: IdentityTypes = Field(validation_alias="type", serialization_alias="type")
514
547
  name: str
515
548
 
516
549
 
517
- class AccessRightsModel(TypedDict):
518
- user_id: Annotated[IdentifierType, Field(alias="identifier")]
519
- acl_entries: Annotated[list[Entries], Field(alias="aclEntries")]
520
- allLevel: Literal["none", "read", "write"]
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=..., alias="accessRightsInformation")
525
- apply_to_children: bool | None = Field(default=False, alias="applyToChildren")
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(TypedDict):
529
- action_identifier: Annotated[str, Field(alias="identifier")]
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: Annotated[str, Field(alias="actionType")]
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
- class WorkflowSteps(TypedDict):
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: Annotated[str, Field(alias="stepType")]
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(alias="relatedEntity")]
550
- current_step: Annotated[str, Field(alias="currentStep")]
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(alias="workflowInfoId")]
595
+ workflow_info_id: Annotated[uuid.UUID, Field(validation_alias="workflowInfoId", serialization_alias="workflowInfoId")]
557
596
 
558
597
 
559
- class Audit(TypedDict):
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.pageRegions if r.name == page_region), None)
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(alias="from", exclude=True)]
765
- m_to: Annotated[str, Field(alias="to", exclude=True)]
766
- m_subject: Annotated[str, Field(alias="subject", exclude=True)]
767
- m_date: Annotated[datetime, Field(alias="date", exclude=True)]
768
- m_id: Annotated[uuid.UUID, Field(alias="id", exclude=True)]
769
- marked: str = Field("unread", alias="markType")
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
- workingCopyIdentifier: IdentifierType
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
- siteName: str
831
- searchTerms: str
832
- searchFields: list[FieldsSearchTypes] | list[Literal[""]] = Field(
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
- searchTypes: list[AssetTypes] | list[Literal[""]] = Field(
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(alias="doWorkflow")
851
- destinations_identifiers: list[IdentifierType] = Field(alias="destinations")
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(alias="doWorkflow")]
859
- new_name: Annotated[str, Field(default=..., alias="newName")]
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(alias="destinationContainerIdentifier") # required
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(alias="doWorkflow")
913
+ do_workflow: bool = Field(validation_alias="doWorkflow", serialization_alias="doWorkflow")
870
914
  destination_container_identifier: IdentifierType = Field(
871
- alias="destinationContainerIdentifier"
915
+ validation_alias="destinationContainerIdentifier", serialization_alias="destinationContainerIdentifier"
872
916
  )
873
- new_name: str = Field(default="", alias="newName") # empty new name means no rename
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(alias="originalSiteName")
893
- new_sitename: str = Field(alias="newSiteName")
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(alias="workflowId")]
900
- action_identifier: Annotated[str, Field(alias="actionIdentifier")]
901
- transition_comment: str | None = Field(alias="transitionComment")
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
- auditType: AuditTypes
908
- by_identifier: IdentifierType = Field(alias="identifier")
909
- by_username: str | None = Field(default=None, alias="username")
910
- by_group: str | None = Field(default=None, alias="groupname")
911
- by_role: str | None = Field(default=None, alias="rolename")
912
- startDate: datetime | None = Field(default=None)
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
- # make sure its only user, group, role
916
- @field_validator("by_identifier", mode="after")
917
- @classmethod
918
- def is_admin_entity(cls, identifier: IdentifierType) -> IdentifierType:
919
- if identifier.get_type not in {"user", "role", "group"}:
920
- raise ValueError("Identifier needs to be either user, role, or group.")
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
- `.ok` and `.failed` are added properties computed from the failure
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 ok(self) -> list[T]:
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 for a batch-level failure (D6/A1), left
428
- empty for a cleanup-time error, which isn't a classified failure.
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 | Path,
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 create(self, payload: list[NewAsset] | NewAsset, parser=None) -> Self:
272
- """Append a POST `create` step for one or more new assets."""
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["identifier"]
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 `.ok`/`.failed`.
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.ok / results.failed — no isinstance() checks needed.
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 `.ok` (results
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.ok == ["good"]
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.ok == []
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.ok == [asset1]
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)