cascade-cms-rest 3.2.2__tar.gz → 3.3.0__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.2 → cascade_cms_rest-3.3.0}/.github/workflows/release.yml +1 -15
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/AGENTS.md +13 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/CHANGELOG.md +30 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/PKG-INFO +28 -18
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/PUBLISHING.md +10 -17
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/README.md +26 -15
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/pyproject.toml +2 -3
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/src/cascade_cms/cmstypes.py +3 -9
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/src/cascade_cms/driver.py +18 -100
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/src/cascade_cms/operation_logger.py +40 -18
- cascade_cms_rest-3.3.0/src/cascade_cms/redaction.py +16 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/src/cascade_cms/wrapper.py +56 -21
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/tests/edit_test.py +0 -1
- cascade_cms_rest-3.3.0/tests/test_driver.py +35 -0
- cascade_cms_rest-3.3.0/tests/test_logging_exit.py +228 -0
- cascade_cms_rest-3.3.0/tests/test_non200.py +118 -0
- cascade_cms_rest-3.3.0/tests/test_redaction.py +30 -0
- cascade_cms_rest-3.2.2/tests/test_driver.py +0 -14
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/.github/copilot-instructions.md +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/.github/workflows/ci.yml +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/.github/workflows/dependency-review.yml +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/.gitignore +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/LICENSE +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/src/cascade_cms/__init__.py +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/src/cascade_cms/failures.py +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/src/cascade_cms/operations.py +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/src/cascade_cms/py.typed +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/tests/conftest.py +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/tests/test_cmstypes.py +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/tests/test_operation_chains.py +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/tests/test_operation_logger.py +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/tests/test_operations.py +0 -0
- {cascade_cms_rest-3.2.2 → cascade_cms_rest-3.3.0}/tests/test_wrapper.py +0 -0
|
@@ -6,7 +6,7 @@ on:
|
|
|
6
6
|
- "v*.*.*"
|
|
7
7
|
|
|
8
8
|
permissions:
|
|
9
|
-
contents:
|
|
9
|
+
contents: read
|
|
10
10
|
id-token: write
|
|
11
11
|
|
|
12
12
|
jobs:
|
|
@@ -28,20 +28,6 @@ jobs:
|
|
|
28
28
|
name: dist
|
|
29
29
|
path: dist/
|
|
30
30
|
|
|
31
|
-
release:
|
|
32
|
-
needs: build
|
|
33
|
-
runs-on: ubuntu-latest
|
|
34
|
-
steps:
|
|
35
|
-
- uses: actions/download-artifact@v4
|
|
36
|
-
with:
|
|
37
|
-
name: dist
|
|
38
|
-
path: dist/
|
|
39
|
-
- name: Create GitHub release
|
|
40
|
-
uses: softprops/action-gh-release@v2
|
|
41
|
-
with:
|
|
42
|
-
files: dist/*
|
|
43
|
-
generate_release_notes: true
|
|
44
|
-
|
|
45
31
|
publish:
|
|
46
32
|
name: Publish to PyPI
|
|
47
33
|
needs: build
|
|
@@ -26,6 +26,19 @@ skill-authoring workflow and how to rebuild release artifacts — both depend
|
|
|
26
26
|
on the *published* `cascade-cms-rest` package, not a local copy, so nothing
|
|
27
27
|
here needs to stay in sync with them beyond tagging a release.
|
|
28
28
|
|
|
29
|
+
## Logging and console output
|
|
30
|
+
|
|
31
|
+
- `CascadeWrapperBase(environmentVariables, debug=None, *, exit_on_failure=True,
|
|
32
|
+
log_dir=None)` — there is no response cache and no `configurationVariables`.
|
|
33
|
+
- Status lines (`[INIT]`, `[LOG]: <path>`, `[DONE]`, the tally, `[EXIT]`, console `[ERROR]`)
|
|
34
|
+
go to **stderr**; stdout belongs to the caller's script.
|
|
35
|
+
- The logfile records the tally line and, last, `[EXIT-CODE]: <outcome>` (see the README's
|
|
36
|
+
Logging section for the outcomes). Agents verify a run by reading the logfile at the
|
|
37
|
+
`[LOG]` path, not by capturing console output.
|
|
38
|
+
- `log_dir` (explicit parameter) beats a debug config's `log_dir`; default `./logs`. Log
|
|
39
|
+
filenames are `{SERVER}[_debug]_{timestamp}_{n}.log`.
|
|
40
|
+
- The API bearer token is masked (`cascade_cms.redaction.mask_token`) wherever it could surface.
|
|
41
|
+
|
|
29
42
|
## Maintaining the CHANGELOG
|
|
30
43
|
|
|
31
44
|
Each section in `CHANGELOG.md` should include a note at the bottom listing
|
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [3.3.0]
|
|
6
|
+
|
|
7
|
+
### Removed
|
|
8
|
+
- **BREAKING: response caching is removed entirely** (no persistent and no in-memory cache): `CacheHandler`, `default_cache_backend()`, `OperationLogger.log_cache_hit()` and `ResponseParser._cacheable`. No `./cache` directory is created.
|
|
9
|
+
- **BREAKING: `configurationVariables` is removed from `CascadeWrapperBase`** with no deprecation period. `CascadeWrapperBase(environmentVariables, debug=None, *, exit_on_failure=True, log_dir=None)`; `exit_on_failure` is now keyword-only.
|
|
10
|
+
- **BREAKING: `backendConfig` is removed from `CascadeCMSRestDriver`.**
|
|
11
|
+
- **BREAKING: `python-dotenv` is no longer a dependency**, and `aiohttp-client-cache` is dropped. `aiohttp` is now a direct dependency (`>=3.14.3`).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- **BREAKING: status lines go to stderr instead of stdout** (`[INIT]`, `[DEBUG]`, `[DONE]`, the tally, `[EXIT]`, console `[ERROR]`). Anything reading the tally from stdout must read stderr or the logfile.
|
|
15
|
+
- **BREAKING: a non-200 HTTP status is now a `CascadeError` (category CASCADE, message `"<status> <reason>"`)**, no longer a NETWORK failure. The response body is never read or logged.
|
|
16
|
+
- **BREAKING: `ResponseParser.serializer` is now required**; the `RuntimeWarning` branch is gone.
|
|
17
|
+
- **BREAKING: `RequestExecutor.parser` is now required** (the unused default was removed). `RequestExecutor` is exported from `cascade_cms/__init__.py`.
|
|
18
|
+
- Log filenames now include microseconds and a per-process serial (`{SERVER}[_debug]_{timestamp}_{n}.log`), so two wrappers created in the same second never share a file.
|
|
19
|
+
- A batch-level failure's prefix follows its cause: `[NETWORK]` for network causes, `[CASCADE-REST-CMS]` otherwise.
|
|
20
|
+
- `CascadeCMSRestDriver.close()` clears the closed event loop as the current loop.
|
|
21
|
+
- Release process: the tag-triggered workflow no longer creates a GitHub release (PyPI publishing on tag push is unchanged, with `contents: read`), and `PUBLISHING.md` no longer has a manual `twine upload` step.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- `log_dir` keyword-only parameter on `CascadeWrapperBase` (normal and debug mode; created if missing; wins over a debug config's `log_dir`).
|
|
25
|
+
- `[LOG]: <path>` is printed to stderr right after `[INIT]`.
|
|
26
|
+
- The logfile records the tally line and a final `[EXIT-CODE]: <outcome>` line.
|
|
27
|
+
- `cascade_cms.redaction.mask_token()`; the debug logfile masks the `Authorization` header to its last four characters (`Bearer ****abcd`).
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Each wrapper closes and releases its logfile handle on exit.
|
|
31
|
+
- Verified (Python 3.12): Cascade's `Partitioned` session cookie no longer breaks requests now that the cache library is gone; no compatibility patch is needed in the library.
|
|
32
|
+
|
|
33
|
+
**Files changed:** `src/cascade_cms/driver.py`, `src/cascade_cms/wrapper.py`, `src/cascade_cms/operation_logger.py`, `src/cascade_cms/cmstypes.py`, `src/cascade_cms/redaction.py` (added), `pyproject.toml`, `.github/workflows/release.yml`, `PUBLISHING.md`, `README.md`, `AGENTS.md`, `tests/test_driver.py`, `tests/test_non200.py` (added), `tests/test_redaction.py` (added), `tests/test_logging_exit.py` (added), `tests/edit_test.py`
|
|
34
|
+
|
|
5
35
|
## [3.2.2]
|
|
6
36
|
|
|
7
37
|
### Added
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cascade-cms-rest
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.0
|
|
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
|
|
7
7
|
License-Expression: MIT
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Requires-Python: >=3.12
|
|
10
|
-
Requires-Dist: aiohttp
|
|
10
|
+
Requires-Dist: aiohttp>=3.14.3
|
|
11
11
|
Requires-Dist: pydantic>=2
|
|
12
|
-
Requires-Dist: python-dotenv
|
|
13
12
|
Provides-Extra: dev
|
|
14
13
|
Requires-Dist: build; extra == 'dev'
|
|
15
14
|
Requires-Dist: mypy; extra == 'dev'
|
|
@@ -36,13 +35,8 @@ environment_variables = {
|
|
|
36
35
|
"CASCADE_URL": "...",
|
|
37
36
|
"SERVER": "prod", # label used for logfile naming
|
|
38
37
|
}
|
|
39
|
-
configuration_variables = {
|
|
40
|
-
"cache_name": "./cache/cache.sqlite",
|
|
41
|
-
"allowed_codes": (200,),
|
|
42
|
-
"allowed_methods": ("GET",),
|
|
43
|
-
}
|
|
44
38
|
|
|
45
|
-
with CascadeWrapperBase(environment_variables
|
|
39
|
+
with CascadeWrapperBase(environment_variables) as cascade:
|
|
46
40
|
identifier = IdentifierType(identifier="e868f539ac1001062cfa029c4c5df4d0", asset_type="folder")
|
|
47
41
|
cascade.operations.read(identifier)
|
|
48
42
|
results = cascade.submit_requests(Asset)
|
|
@@ -69,7 +63,7 @@ def rewrite(asset):
|
|
|
69
63
|
asset.keywords = "updated"
|
|
70
64
|
return asset
|
|
71
65
|
|
|
72
|
-
with CascadeWrapperBase(environment_variables
|
|
66
|
+
with CascadeWrapperBase(environment_variables) as cascade:
|
|
73
67
|
cascade.operations.read(page_a).edit(page_a, rewrite).publish(page_a)
|
|
74
68
|
cascade.operations.read(page_b).then(report)
|
|
75
69
|
cascade.operations.delete(old_page)
|
|
@@ -114,14 +108,17 @@ manager owns it end to end:
|
|
|
114
108
|
server) — `__exit__` then never raises; read `.success`/`.failed` off the results yourself.
|
|
115
109
|
- The logfile's `!ERROR:` line is prefixed `[NETWORK]` for a network-layer failure or
|
|
116
110
|
`[CASCADE-REST-CMS]` for any other library-side failure; a `CascadeError` or a callback
|
|
117
|
-
exception gets no prefix.
|
|
111
|
+
exception gets no prefix. A batch-level failure takes the prefix that matches its cause.
|
|
112
|
+
- Cascade always answers HTTP 200 with a JSON body; a non-200 status (an HTML page from the
|
|
113
|
+
server layer, e.g. `501 Not Implemented`) is reported as a `CascadeError` whose message is the
|
|
114
|
+
status code and reason. The HTML body is never read or logged.
|
|
118
115
|
- At exit, the console always prints a tally: `"{failed} failed, {succeeded} succeeded"`,
|
|
119
116
|
cumulative across every `submit_requests()` call in the session, with `": reference log for
|
|
120
117
|
details"` appended only when at least one failure came from the API, the network, or the
|
|
121
|
-
library itself (not from a callback-only failure).
|
|
118
|
+
library itself (not from a callback-only failure). The tally is also written to the logfile.
|
|
122
119
|
|
|
123
120
|
```python
|
|
124
|
-
with CascadeWrapperBase(environment_variables
|
|
121
|
+
with CascadeWrapperBase(environment_variables) as cascade:
|
|
125
122
|
cascade.operations.read(identifier)
|
|
126
123
|
results = cascade.submit_requests(Asset)
|
|
127
124
|
for asset in results.success:
|
|
@@ -132,11 +129,24 @@ with CascadeWrapperBase(environment_variables, configuration_variables) as casca
|
|
|
132
129
|
|
|
133
130
|
### Logging
|
|
134
131
|
|
|
135
|
-
`CascadeWrapperBase` accepts an optional
|
|
136
|
-
(the default) runs in **normal mode**: a minimal console (`[INIT]`/`[RUNNING]`/`
|
|
137
|
-
`[DONE]`/`[EXIT]`) plus a simple logfile at `./logs/{SERVER}_{timestamp}.log`. Passing a dict
|
|
132
|
+
`CascadeWrapperBase` accepts an optional second `debug` argument. Leaving it as `None`
|
|
133
|
+
(the default) runs in **normal mode**: a minimal console (`[INIT]`/`[LOG]`/`[RUNNING]`/`n/N succeeded`/
|
|
134
|
+
`[DONE]`/`[EXIT]`) plus a simple logfile at `./logs/{SERVER}_{timestamp}_{n}.log`. Passing a dict
|
|
138
135
|
switches to **debug mode**: a quiet console and a verbose, nested logfile at
|
|
139
|
-
`./logs/{SERVER}_debug_{timestamp}.log` describing every request, response, callback, and error.
|
|
136
|
+
`./logs/{SERVER}_debug_{timestamp}_{n}.log` describing every request, response, callback, and error.
|
|
137
|
+
Each wrapper gets its own file, even when two are created in the same second.
|
|
138
|
+
|
|
139
|
+
- **Status lines go to stderr**, not stdout (`[INIT]`, `[LOG]`, `[DONE]`, the tally, `[EXIT]`,
|
|
140
|
+
console `[ERROR]` lines), so stdout stays free for your script's own output.
|
|
141
|
+
- **`[LOG]: <path>`** is printed right after `[INIT]`, giving the path of this run's logfile.
|
|
142
|
+
- **`log_dir=`** (keyword-only) sets the log directory in normal and debug mode; it is created if
|
|
143
|
+
missing and defaults to `./logs`. If a debug config also sets `log_dir`, the parameter wins.
|
|
144
|
+
- **`[EXIT-CODE]:`** is the last line written to the logfile: `0` for a clean run, `1` when the
|
|
145
|
+
script ends with a failure, `1 (callback exception: <Type>)` / `1 (uncaught exception: <Type>)`
|
|
146
|
+
when an exception ends it, or `n/a (exit_on_failure disabled)`. Read the logfile for the tally
|
|
147
|
+
and exit code rather than capturing console output.
|
|
148
|
+
- In debug mode with `show_network_headers`, the `Authorization` header is masked to its last four
|
|
149
|
+
characters (`Bearer ****abcd`).
|
|
140
150
|
|
|
141
151
|
```python
|
|
142
152
|
debug_config = {
|
|
@@ -150,7 +160,7 @@ debug_config = {
|
|
|
150
160
|
"response_line_limit": 8, # -1 = dump full response body
|
|
151
161
|
}
|
|
152
162
|
|
|
153
|
-
with CascadeWrapperBase(environment_variables,
|
|
163
|
+
with CascadeWrapperBase(environment_variables, debug=debug_config) as cascade:
|
|
154
164
|
...
|
|
155
165
|
```
|
|
156
166
|
|
|
@@ -74,21 +74,16 @@
|
|
|
74
74
|
git push origin master --tags # Push commits + all tags
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
### 6.
|
|
77
|
+
### 6. Publishing Happens Automatically
|
|
78
78
|
|
|
79
|
-
**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
This creates `dist/cascade_cms_rest-3.0.2-py3-none-any.whl` and `.tar.gz`
|
|
79
|
+
Pushing the `v*` tag (step 5) triggers the **Release** workflow
|
|
80
|
+
(`.github/workflows/release.yml`), which builds the sdist and wheel and
|
|
81
|
+
publishes them to PyPI. Do **not** run `twine upload` by hand: the tag
|
|
82
|
+
already publishes via CI, so a manual upload collides ("file already
|
|
83
|
+
exists"), and a stale local `dist/` would re-upload old versions.
|
|
85
84
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
twine upload dist/*
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
You'll be prompted for PyPI credentials (or use `.pypirc` config)
|
|
85
|
+
Watch the workflow run in the GitHub Actions tab, then verify on PyPI
|
|
86
|
+
(below). The workflow does not create a GitHub release.
|
|
92
87
|
|
|
93
88
|
## Verification
|
|
94
89
|
|
|
@@ -112,12 +107,10 @@ git commit -m "Bump version to 3.0.2"
|
|
|
112
107
|
# 4. Create an annotated tag (v-prefix is convention)
|
|
113
108
|
git tag -a v3.0.2 -m "Release version 3.0.2"
|
|
114
109
|
|
|
115
|
-
# 5. Push commits and tags to GitHub
|
|
110
|
+
# 5. Push commits and tags to GitHub; the tag push publishes to PyPI via CI
|
|
116
111
|
git push origin master --tags
|
|
117
112
|
|
|
118
|
-
# 6.
|
|
119
|
-
python -m build
|
|
120
|
-
twine upload dist/*
|
|
113
|
+
# 6. Verify on PyPI (https://pypi.org/project/cascade-cms-rest/)
|
|
121
114
|
```
|
|
122
115
|
|
|
123
116
|
## Troubleshooting
|
|
@@ -15,13 +15,8 @@ environment_variables = {
|
|
|
15
15
|
"CASCADE_URL": "...",
|
|
16
16
|
"SERVER": "prod", # label used for logfile naming
|
|
17
17
|
}
|
|
18
|
-
configuration_variables = {
|
|
19
|
-
"cache_name": "./cache/cache.sqlite",
|
|
20
|
-
"allowed_codes": (200,),
|
|
21
|
-
"allowed_methods": ("GET",),
|
|
22
|
-
}
|
|
23
18
|
|
|
24
|
-
with CascadeWrapperBase(environment_variables
|
|
19
|
+
with CascadeWrapperBase(environment_variables) as cascade:
|
|
25
20
|
identifier = IdentifierType(identifier="e868f539ac1001062cfa029c4c5df4d0", asset_type="folder")
|
|
26
21
|
cascade.operations.read(identifier)
|
|
27
22
|
results = cascade.submit_requests(Asset)
|
|
@@ -48,7 +43,7 @@ def rewrite(asset):
|
|
|
48
43
|
asset.keywords = "updated"
|
|
49
44
|
return asset
|
|
50
45
|
|
|
51
|
-
with CascadeWrapperBase(environment_variables
|
|
46
|
+
with CascadeWrapperBase(environment_variables) as cascade:
|
|
52
47
|
cascade.operations.read(page_a).edit(page_a, rewrite).publish(page_a)
|
|
53
48
|
cascade.operations.read(page_b).then(report)
|
|
54
49
|
cascade.operations.delete(old_page)
|
|
@@ -93,14 +88,17 @@ manager owns it end to end:
|
|
|
93
88
|
server) — `__exit__` then never raises; read `.success`/`.failed` off the results yourself.
|
|
94
89
|
- The logfile's `!ERROR:` line is prefixed `[NETWORK]` for a network-layer failure or
|
|
95
90
|
`[CASCADE-REST-CMS]` for any other library-side failure; a `CascadeError` or a callback
|
|
96
|
-
exception gets no prefix.
|
|
91
|
+
exception gets no prefix. A batch-level failure takes the prefix that matches its cause.
|
|
92
|
+
- Cascade always answers HTTP 200 with a JSON body; a non-200 status (an HTML page from the
|
|
93
|
+
server layer, e.g. `501 Not Implemented`) is reported as a `CascadeError` whose message is the
|
|
94
|
+
status code and reason. The HTML body is never read or logged.
|
|
97
95
|
- At exit, the console always prints a tally: `"{failed} failed, {succeeded} succeeded"`,
|
|
98
96
|
cumulative across every `submit_requests()` call in the session, with `": reference log for
|
|
99
97
|
details"` appended only when at least one failure came from the API, the network, or the
|
|
100
|
-
library itself (not from a callback-only failure).
|
|
98
|
+
library itself (not from a callback-only failure). The tally is also written to the logfile.
|
|
101
99
|
|
|
102
100
|
```python
|
|
103
|
-
with CascadeWrapperBase(environment_variables
|
|
101
|
+
with CascadeWrapperBase(environment_variables) as cascade:
|
|
104
102
|
cascade.operations.read(identifier)
|
|
105
103
|
results = cascade.submit_requests(Asset)
|
|
106
104
|
for asset in results.success:
|
|
@@ -111,11 +109,24 @@ with CascadeWrapperBase(environment_variables, configuration_variables) as casca
|
|
|
111
109
|
|
|
112
110
|
### Logging
|
|
113
111
|
|
|
114
|
-
`CascadeWrapperBase` accepts an optional
|
|
115
|
-
(the default) runs in **normal mode**: a minimal console (`[INIT]`/`[RUNNING]`/`
|
|
116
|
-
`[DONE]`/`[EXIT]`) plus a simple logfile at `./logs/{SERVER}_{timestamp}.log`. Passing a dict
|
|
112
|
+
`CascadeWrapperBase` accepts an optional second `debug` argument. Leaving it as `None`
|
|
113
|
+
(the default) runs in **normal mode**: a minimal console (`[INIT]`/`[LOG]`/`[RUNNING]`/`n/N succeeded`/
|
|
114
|
+
`[DONE]`/`[EXIT]`) plus a simple logfile at `./logs/{SERVER}_{timestamp}_{n}.log`. Passing a dict
|
|
117
115
|
switches to **debug mode**: a quiet console and a verbose, nested logfile at
|
|
118
|
-
`./logs/{SERVER}_debug_{timestamp}.log` describing every request, response, callback, and error.
|
|
116
|
+
`./logs/{SERVER}_debug_{timestamp}_{n}.log` describing every request, response, callback, and error.
|
|
117
|
+
Each wrapper gets its own file, even when two are created in the same second.
|
|
118
|
+
|
|
119
|
+
- **Status lines go to stderr**, not stdout (`[INIT]`, `[LOG]`, `[DONE]`, the tally, `[EXIT]`,
|
|
120
|
+
console `[ERROR]` lines), so stdout stays free for your script's own output.
|
|
121
|
+
- **`[LOG]: <path>`** is printed right after `[INIT]`, giving the path of this run's logfile.
|
|
122
|
+
- **`log_dir=`** (keyword-only) sets the log directory in normal and debug mode; it is created if
|
|
123
|
+
missing and defaults to `./logs`. If a debug config also sets `log_dir`, the parameter wins.
|
|
124
|
+
- **`[EXIT-CODE]:`** is the last line written to the logfile: `0` for a clean run, `1` when the
|
|
125
|
+
script ends with a failure, `1 (callback exception: <Type>)` / `1 (uncaught exception: <Type>)`
|
|
126
|
+
when an exception ends it, or `n/a (exit_on_failure disabled)`. Read the logfile for the tally
|
|
127
|
+
and exit code rather than capturing console output.
|
|
128
|
+
- In debug mode with `show_network_headers`, the `Authorization` header is masked to its last four
|
|
129
|
+
characters (`Bearer ****abcd`).
|
|
119
130
|
|
|
120
131
|
```python
|
|
121
132
|
debug_config = {
|
|
@@ -129,7 +140,7 @@ debug_config = {
|
|
|
129
140
|
"response_line_limit": 8, # -1 = dump full response body
|
|
130
141
|
}
|
|
131
142
|
|
|
132
|
-
with CascadeWrapperBase(environment_variables,
|
|
143
|
+
with CascadeWrapperBase(environment_variables, debug=debug_config) as cascade:
|
|
133
144
|
...
|
|
134
145
|
```
|
|
135
146
|
|
|
@@ -4,15 +4,14 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cascade-cms-rest"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.3.0"
|
|
8
8
|
description = "A typed, async REST client for Hannon Hill Cascade CMS"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.12" # uses `Self`, `TypeAlias`, PEP 604 unions
|
|
12
12
|
authors = [{ name = "Keith Shark" }]
|
|
13
13
|
dependencies = [
|
|
14
|
-
"aiohttp
|
|
15
|
-
"python-dotenv",
|
|
14
|
+
"aiohttp>=3.14.3",
|
|
16
15
|
"pydantic>=2",
|
|
17
16
|
]
|
|
18
17
|
|
|
@@ -1060,31 +1060,25 @@ class ResponseParser[T](BaseModel):
|
|
|
1060
1060
|
"""Parses a raw response body, trying `CascadeError` first and falling
|
|
1061
1061
|
back to `serializer` on the expected success shape.
|
|
1062
1062
|
|
|
1063
|
-
`_content` holds the parsed result (either a `CascadeError` or a `T`)
|
|
1064
|
-
and `_cacheable` is set to True only when the success-path parse
|
|
1065
|
-
succeeds, so error responses are never cached.
|
|
1063
|
+
`_content` holds the parsed result (either a `CascadeError` or a `T`).
|
|
1066
1064
|
"""
|
|
1067
1065
|
|
|
1068
1066
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
1069
1067
|
|
|
1070
|
-
serializer: TypeAdapter[Any] | AssetAdapter
|
|
1068
|
+
serializer: TypeAdapter[Any] | AssetAdapter
|
|
1071
1069
|
_content: T | CascadeError | None = PrivateAttr(default=None)
|
|
1072
|
-
_cacheable: bool = PrivateAttr(default=False)
|
|
1073
1070
|
|
|
1074
1071
|
def __init__(
|
|
1075
1072
|
self,
|
|
1076
1073
|
raw: bytes,
|
|
1077
|
-
serializer: TypeAdapter[Any] | AssetAdapter
|
|
1074
|
+
serializer: TypeAdapter[Any] | AssetAdapter,
|
|
1078
1075
|
**kwargs,
|
|
1079
1076
|
):
|
|
1080
1077
|
super().__init__(serializer=serializer, **kwargs)
|
|
1081
1078
|
try:
|
|
1082
1079
|
self._content = CascadeError.model_validate_json(raw)
|
|
1083
1080
|
except ValidationError:
|
|
1084
|
-
if self.serializer is None:
|
|
1085
|
-
raise RuntimeWarning("No serializer included...")
|
|
1086
1081
|
self._content = self.serializer.validate_json(raw) # type: ignore[assignment]
|
|
1087
|
-
self._cacheable = True
|
|
1088
1082
|
|
|
1089
1083
|
|
|
1090
1084
|
# ----- Parser Functions -----
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import hashlib
|
|
3
3
|
from collections.abc import Callable
|
|
4
|
-
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import dataclass
|
|
5
5
|
from typing import Any, Literal, TypeVar
|
|
6
6
|
|
|
7
7
|
from aiohttp import ClientResponse, ClientSession
|
|
8
|
-
from aiohttp_client_cache import SQLiteBackend
|
|
9
|
-
|
|
10
|
-
# from aiohttp_client_cache.session import CachedSession
|
|
11
|
-
from aiohttp_client_cache.response import CachedResponse
|
|
12
8
|
|
|
13
9
|
from .cmstypes import (
|
|
10
|
+
CascadeError,
|
|
14
11
|
Payloads,
|
|
15
12
|
ResponseParser,
|
|
16
13
|
serialize_payload,
|
|
@@ -20,40 +17,6 @@ from .operation_logger import OperationLogger
|
|
|
20
17
|
T = TypeVar("T")
|
|
21
18
|
|
|
22
19
|
|
|
23
|
-
@dataclass
|
|
24
|
-
class CacheHandler:
|
|
25
|
-
"""Thin wrapper around an aiohttp-client-cache SQLite backend.
|
|
26
|
-
|
|
27
|
-
Only GET responses are ever cached (enforced by the backend's
|
|
28
|
-
`allowed_methods` config) so repeated reads skip the network,
|
|
29
|
-
while POST/PUT requests always hit the server.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
cache_db: SQLiteBackend
|
|
33
|
-
|
|
34
|
-
async def get_response(
|
|
35
|
-
self,
|
|
36
|
-
key: str,
|
|
37
|
-
) -> CachedResponse | None:
|
|
38
|
-
return await self.cache_db.get_response(key)
|
|
39
|
-
|
|
40
|
-
def get_cache_key(
|
|
41
|
-
self,
|
|
42
|
-
method: str,
|
|
43
|
-
url: str,
|
|
44
|
-
) -> str:
|
|
45
|
-
"""Returns a response in cach if it exists"""
|
|
46
|
-
return self.cache_db.create_key(method, url)
|
|
47
|
-
|
|
48
|
-
async def save_response(
|
|
49
|
-
self,
|
|
50
|
-
response: ClientResponse,
|
|
51
|
-
cache_key: str,
|
|
52
|
-
):
|
|
53
|
-
"""Check to see if response is cacheable"""
|
|
54
|
-
return await self.cache_db.save_response(response, cache_key)
|
|
55
|
-
|
|
56
|
-
|
|
57
20
|
@dataclass
|
|
58
21
|
class RequestExecutor[T]:
|
|
59
22
|
"""Represents a single HTTP request and how to parse its response.
|
|
@@ -64,9 +27,7 @@ class RequestExecutor[T]:
|
|
|
64
27
|
|
|
65
28
|
url: str
|
|
66
29
|
method: Literal["GET", "POST", "PUT"]
|
|
67
|
-
parser: Callable[..., ResponseParser[T]]
|
|
68
|
-
default=lambda raw: ResponseParser(raw=raw)
|
|
69
|
-
)
|
|
30
|
+
parser: Callable[..., ResponseParser[T]]
|
|
70
31
|
payload: Payloads | None = None
|
|
71
32
|
identifier: Any = None
|
|
72
33
|
|
|
@@ -88,19 +49,15 @@ class RequestExecutor[T]:
|
|
|
88
49
|
self,
|
|
89
50
|
session: ClientSession,
|
|
90
51
|
sem: asyncio.Semaphore,
|
|
91
|
-
cache: CacheHandler,
|
|
92
52
|
logger: "OperationLogger | None" = None,
|
|
93
53
|
) -> T:
|
|
94
|
-
"""Execute this request
|
|
54
|
+
"""Execute this request and parse its response.
|
|
95
55
|
|
|
96
|
-
|
|
97
|
-
parses the response, and stores it in the cache if parsing marked
|
|
98
|
-
it cacheable. Concurrency across requests is bounded by `sem`.
|
|
56
|
+
Concurrency across requests is bounded by `sem`.
|
|
99
57
|
|
|
100
58
|
Args:
|
|
101
59
|
session: Shared aiohttp session to issue the request on.
|
|
102
60
|
sem: Semaphore limiting concurrent in-flight requests.
|
|
103
|
-
cache: Cache handler used to short-circuit repeated GETs.
|
|
104
61
|
logger: Optional logger for recording request/response detail.
|
|
105
62
|
|
|
106
63
|
Returns:
|
|
@@ -111,23 +68,18 @@ class RequestExecutor[T]:
|
|
|
111
68
|
if self.payload:
|
|
112
69
|
payload_bytes = serialize_payload(self.payload)
|
|
113
70
|
|
|
114
|
-
# Cache the Response
|
|
115
|
-
cache_key = cache.get_cache_key(self.method, self.url)
|
|
116
|
-
already_cached = await cache.get_response(cache_key)
|
|
117
|
-
if already_cached is not None:
|
|
118
|
-
raw_data = await already_cached.read()
|
|
119
|
-
if logger:
|
|
120
|
-
logger.log_cache_hit(self.method, self.url)
|
|
121
|
-
logger.write_response_file(self.log_key, raw_data)
|
|
122
|
-
parsed_response = self.parser(raw_data)
|
|
123
|
-
return parsed_response._content # type: ignore[return-value]
|
|
124
|
-
|
|
125
71
|
async with session.request(
|
|
126
72
|
self.method,
|
|
127
73
|
self.url,
|
|
128
74
|
data=payload_bytes,
|
|
129
75
|
) as response:
|
|
130
|
-
response.
|
|
76
|
+
if response.status != 200:
|
|
77
|
+
# Cascade always answers 200 with a JSON body; anything
|
|
78
|
+
# else is an HTML page from the Tomcat layer. Report the
|
|
79
|
+
# status only — the body is never read, logged or shown.
|
|
80
|
+
return CascadeError( # type: ignore[return-value]
|
|
81
|
+
message=f"{response.status} {response.reason}"
|
|
82
|
+
)
|
|
131
83
|
raw_data = await response.read()
|
|
132
84
|
if logger:
|
|
133
85
|
logger.write_response_file(self.log_key, raw_data)
|
|
@@ -137,29 +89,9 @@ class RequestExecutor[T]:
|
|
|
137
89
|
)
|
|
138
90
|
# parse raw data
|
|
139
91
|
parsed_response = self.parser(raw_data)
|
|
140
|
-
|
|
141
|
-
if parsed_response._cacheable:
|
|
142
|
-
await cache.save_response(
|
|
143
|
-
response,
|
|
144
|
-
cache_key,
|
|
145
|
-
)
|
|
146
92
|
return parsed_response._content # type: ignore[return-value]
|
|
147
93
|
|
|
148
94
|
|
|
149
|
-
def default_cache_backend() -> SQLiteBackend:
|
|
150
|
-
"""Build the default cache backend: SQLite, GET-only, 200s only.
|
|
151
|
-
|
|
152
|
-
Constructed on demand rather than at module scope so that merely
|
|
153
|
-
importing `cascade_cms` does not create a `./cache/` directory in the
|
|
154
|
-
caller's working directory.
|
|
155
|
-
"""
|
|
156
|
-
return SQLiteBackend(
|
|
157
|
-
cache_name="./cache/cache.sqlite",
|
|
158
|
-
allowed_codes=(200,),
|
|
159
|
-
allowed_methods=("GET",),
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
|
|
163
95
|
class CascadeCMSRestDriver:
|
|
164
96
|
"""
|
|
165
97
|
URL builder and executor for Cascade CMS 8 REST API.
|
|
@@ -172,21 +104,18 @@ class CascadeCMSRestDriver:
|
|
|
172
104
|
self,
|
|
173
105
|
apiKey: str,
|
|
174
106
|
cascade_url: str,
|
|
175
|
-
backendConfig: dict[str, Any] | None,
|
|
176
107
|
logger: OperationLogger | None = None,
|
|
177
108
|
):
|
|
178
|
-
"""Set up the aiohttp session
|
|
109
|
+
"""Set up the aiohttp session and event loop for this driver.
|
|
179
110
|
|
|
180
111
|
Creates a dedicated event loop that is reused for the lifetime of
|
|
181
112
|
this driver instance (rather than one per call), so all requests
|
|
182
|
-
|
|
113
|
+
run on the same loop.
|
|
183
114
|
|
|
184
115
|
Args:
|
|
185
116
|
apiKey: Cascade CMS API bearer token.
|
|
186
117
|
cascade_url: Base URL of the Cascade CMS instance (without
|
|
187
118
|
the `/api/v1` suffix, which is appended automatically).
|
|
188
|
-
backendConfig: kwargs forwarded to `SQLiteBackend` to override
|
|
189
|
-
the default cache config, or None to use the default.
|
|
190
119
|
logger: Optional logger for recording operations/errors.
|
|
191
120
|
"""
|
|
192
121
|
|
|
@@ -196,7 +125,7 @@ class CascadeCMSRestDriver:
|
|
|
196
125
|
self.base_url = f"{cascade_url}/api/v1"
|
|
197
126
|
|
|
198
127
|
# Stores response object for debugging purpose. the ClientResponse object contains request infomation as well.
|
|
199
|
-
self.request_response_info:
|
|
128
|
+
self.request_response_info: ClientResponse | None = None
|
|
200
129
|
|
|
201
130
|
# intializing event loop, for re-use for the rest of the session.
|
|
202
131
|
self.eventLoop = asyncio.new_event_loop()
|
|
@@ -208,12 +137,6 @@ class CascadeCMSRestDriver:
|
|
|
208
137
|
"Content-Type": "application/json;charset=UTF-8",
|
|
209
138
|
}
|
|
210
139
|
|
|
211
|
-
self.cache: CacheHandler
|
|
212
|
-
if backendConfig is None:
|
|
213
|
-
self.cache = CacheHandler(default_cache_backend())
|
|
214
|
-
else:
|
|
215
|
-
self.cache = CacheHandler(SQLiteBackend(**backendConfig))
|
|
216
|
-
|
|
217
140
|
# Shared across every chain running on this driver, so concurrency
|
|
218
141
|
# stays bounded no matter how many chains are in flight at once.
|
|
219
142
|
self._semaphore = asyncio.Semaphore(self.MAX_REQUESTS)
|
|
@@ -259,7 +182,7 @@ class CascadeCMSRestDriver:
|
|
|
259
182
|
# job (execute/execute_async), done after this returns a value.
|
|
260
183
|
try:
|
|
261
184
|
return await executor.fetch(
|
|
262
|
-
self.session, self._semaphore, self.
|
|
185
|
+
self.session, self._semaphore, self._logger
|
|
263
186
|
)
|
|
264
187
|
except Exception as exc: # noqa: BLE001 - surfaced as a value, see docstring
|
|
265
188
|
return exc
|
|
@@ -283,17 +206,12 @@ class CascadeCMSRestDriver:
|
|
|
283
206
|
return self.eventLoop.run_until_complete(self.execute_requests(requests))
|
|
284
207
|
|
|
285
208
|
def close(self):
|
|
286
|
-
"""Tear down the aiohttp session
|
|
209
|
+
"""Tear down the aiohttp session and event loop."""
|
|
287
210
|
if getattr(self, "session", None) is not None and not getattr(
|
|
288
211
|
self.session, "closed", False
|
|
289
212
|
):
|
|
290
213
|
self.eventLoop.run_until_complete(self.session.close())
|
|
291
214
|
|
|
292
|
-
if (
|
|
293
|
-
getattr(self, "cache", None) is not None
|
|
294
|
-
and getattr(self.cache, "cache_db", None) is not None
|
|
295
|
-
):
|
|
296
|
-
self.eventLoop.run_until_complete(self.cache.cache_db.close())
|
|
297
|
-
|
|
298
215
|
self.eventLoop.close()
|
|
216
|
+
asyncio.set_event_loop(None)
|
|
299
217
|
return True
|