ccdirectives 0.2.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.
- ccdirectives-0.2.0/.gitattributes +1 -0
- ccdirectives-0.2.0/.gitignore +16 -0
- ccdirectives-0.2.0/CHANGELOG.md +42 -0
- ccdirectives-0.2.0/CONTRIBUTING.md +19 -0
- ccdirectives-0.2.0/LICENSE +21 -0
- ccdirectives-0.2.0/PKG-INFO +198 -0
- ccdirectives-0.2.0/README.md +161 -0
- ccdirectives-0.2.0/pyproject.toml +74 -0
- ccdirectives-0.2.0/requirements-dev.txt +3 -0
- ccdirectives-0.2.0/scripts/check.ps1 +16 -0
- ccdirectives-0.2.0/scripts/fetch_upstream.py +39 -0
- ccdirectives-0.2.0/scripts/release.ps1 +28 -0
- ccdirectives-0.2.0/src/ccdirectives/__init__.py +34 -0
- ccdirectives-0.2.0/src/ccdirectives/__main__.py +9 -0
- ccdirectives-0.2.0/src/ccdirectives/_build.py +63 -0
- ccdirectives-0.2.0/src/ccdirectives/_chars.py +66 -0
- ccdirectives-0.2.0/src/ccdirectives/_cli.py +108 -0
- ccdirectives-0.2.0/src/ccdirectives/_errors.py +16 -0
- ccdirectives-0.2.0/src/ccdirectives/_fresh.py +161 -0
- ccdirectives-0.2.0/src/ccdirectives/_head.py +57 -0
- ccdirectives-0.2.0/src/ccdirectives/_model.py +175 -0
- ccdirectives-0.2.0/src/ccdirectives/_parse.py +99 -0
- ccdirectives-0.2.0/src/ccdirectives/_registry.py +64 -0
- ccdirectives-0.2.0/src/ccdirectives/_scan.py +118 -0
- ccdirectives-0.2.0/src/ccdirectives/_sf.py +197 -0
- ccdirectives-0.2.0/src/ccdirectives/_sfdict.py +111 -0
- ccdirectives-0.2.0/src/ccdirectives/_targeted.py +131 -0
- ccdirectives-0.2.0/src/ccdirectives/py.typed +0 -0
- ccdirectives-0.2.0/tests/conftest.py +60 -0
- ccdirectives-0.2.0/tests/test_build.py +83 -0
- ccdirectives-0.2.0/tests/test_cli.py +89 -0
- ccdirectives-0.2.0/tests/test_contract.py +44 -0
- ccdirectives-0.2.0/tests/test_docs.py +90 -0
- ccdirectives-0.2.0/tests/test_e2e_wheel.py +116 -0
- ccdirectives-0.2.0/tests/test_fresh.py +146 -0
- ccdirectives-0.2.0/tests/test_model.py +107 -0
- ccdirectives-0.2.0/tests/test_parse.py +145 -0
- ccdirectives-0.2.0/tests/test_review_fixes.py +122 -0
- ccdirectives-0.2.0/tests/test_roundtrip.py +91 -0
- ccdirectives-0.2.0/tests/test_sf_corpus.py +118 -0
- ccdirectives-0.2.0/tests/test_sources.py +167 -0
- ccdirectives-0.2.0/tests/test_strict.py +75 -0
- ccdirectives-0.2.0/tests/test_targeted.py +141 -0
- ccdirectives-0.2.0/tests/test_typing.py +98 -0
- ccdirectives-0.2.0/tests/upstream/MANIFEST.json +209 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.venv/
|
|
4
|
+
venv/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.mypy_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
htmlcov/
|
|
13
|
+
|
|
14
|
+
# Third-party reference files, fetched locally by scripts/fetch_upstream.py; only the manifest is tracked.
|
|
15
|
+
tests/upstream/*
|
|
16
|
+
!tests/upstream/MANIFEST.json
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.0]
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Strict mode: `parse(header, strict=True, context=...)` raises `CacheControlError` with the
|
|
14
|
+
offset for syntax outside RFC 9111 section 5.2, control characters, repeated directives,
|
|
15
|
+
malformed delta-seconds, arguments on directives that take none and field lists that are
|
|
16
|
+
not field names.
|
|
17
|
+
- Several field lines can be passed as a list.
|
|
18
|
+
- Freshness helpers: `assess` and `Freshness`, `freshness_lifetime`, `current_age` and
|
|
19
|
+
`may_store`, covering RFC 9111 section 4.2, RFC 5861 and request directives.
|
|
20
|
+
- Targeted fields (RFC 9213): `parse_targeted`, `build_targeted` and `select`, on a
|
|
21
|
+
structured field dictionary parser checked against the httpwg test corpus.
|
|
22
|
+
- `REGISTERED_DIRECTIVES`, `REQUEST_DIRECTIVES`, `RESPONSE_DIRECTIVES`, `DELTA_MAX`,
|
|
23
|
+
`CacheControl.duplicates`, `extensions()`, `has()`, `seconds()`, `no_cache_fields` and
|
|
24
|
+
`private_fields`.
|
|
25
|
+
- A `ccdirectives` command that prints the policy of a header value or a response head.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- delta-seconds accept ASCII digits only (no sign, underscore or other digits) and values
|
|
30
|
+
above 2147483648 become 2147483648.
|
|
31
|
+
- The first occurrence of a repeated directive wins instead of the last.
|
|
32
|
+
- `no-cache=""` and `private=""` count as present; a bare `max-stale` reads as unlimited.
|
|
33
|
+
- An unterminated quote no longer swallows the directives after it.
|
|
34
|
+
- CR, LF and NUL in a value become spaces; names fold ASCII letters only.
|
|
35
|
+
- `build` refuses names that are not tokens, control characters, non-ASCII text, negative
|
|
36
|
+
numbers and malformed delta-seconds, and writes field lists in quoted-string form.
|
|
37
|
+
|
|
38
|
+
## [0.1.0]
|
|
39
|
+
|
|
40
|
+
- Parse a Cache-Control header into a typed, case-insensitive `CacheControl` structure.
|
|
41
|
+
- Build a header string back from a `CacheControl`, quoting and escaping arguments when needed.
|
|
42
|
+
- Typed accessors for the common directives, with unknown directives preserved verbatim.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for taking the time. This project keeps a small, predictable shape.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install -r requirements-dev.txt
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Before you open a pull request
|
|
12
|
+
|
|
13
|
+
- Keep public API in `src/ccdirectives/__init__.py`; internals live in `_`-prefixed modules.
|
|
14
|
+
- Add a test for the behaviour you change. Tests inject time, disk and network; they
|
|
15
|
+
never touch the real thing.
|
|
16
|
+
- Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
|
|
17
|
+
- One logical change per commit, imperative subject line.
|
|
18
|
+
|
|
19
|
+
Open an issue first if the change is large or shifts the public API.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ccdirectives
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Parse, validate and build HTTP Cache-Control and CDN-Cache-Control, and decide freshness, typed and zero-dependency
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/ccdirectives
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/ccdirectives/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/ccdirectives/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: cache-control,caching,cdn-cache-control,freshness,headers,http,rfc5861,rfc9111,rfc9213
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
27
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.10
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build; extra == 'dev'
|
|
32
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
35
|
+
Requires-Dist: twine; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# ccdirectives
|
|
39
|
+
|
|
40
|
+
Parse, validate and build the HTTP `Cache-Control` header (RFC 9111) and targeted fields
|
|
41
|
+
such as `CDN-Cache-Control` (RFC 9213), and decide freshness, typed and zero-dependency.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
pip install ccdirectives
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
Read what a response allows:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from ccdirectives import parse
|
|
55
|
+
|
|
56
|
+
cc = parse('max-age=600, no-cache="Set-Cookie", x-vendor=custom, MAX-AGE=5')
|
|
57
|
+
print(cc.max_age, cc.no_cache, cc.no_cache_fields)
|
|
58
|
+
# 600 True ('set-cookie',)
|
|
59
|
+
print(cc["x-vendor"], cc.extensions(), sorted(cc.duplicates))
|
|
60
|
+
# custom ('x-vendor',) ['max-age']
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Names compare case-insensitively (ASCII letters only), delta-seconds become ints, and the
|
|
64
|
+
first occurrence of a repeated directive wins, as RFC 9111 section 4.2.1 says; repeated
|
|
65
|
+
names are listed in `duplicates`. delta-seconds are ASCII digits only, and a value too
|
|
66
|
+
large to represent is read as 2147483648, the "forever" of section 1.2.2:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from ccdirectives import parse
|
|
70
|
+
|
|
71
|
+
print(parse("max-age=99999999999").max_age, parse("max-age=-5").max_age)
|
|
72
|
+
# 2147483648 None
|
|
73
|
+
print(parse("max-stale").max_stale, parse(["max-age=60", "public"]).public)
|
|
74
|
+
# 2147483648 True
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The default parser never raises on content: what it cannot read is skipped, and a broken
|
|
78
|
+
quote cannot hide the directives after it. Strict mode validates instead, with the rules
|
|
79
|
+
of a request or a response when you pass `context`. It holds a header to what a recipient
|
|
80
|
+
must accept, so `max-age="60"` passes although a sender should not write it:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from ccdirectives import CacheControlError, parse
|
|
84
|
+
|
|
85
|
+
print(parse('private="a, x="open, no-store').no_store)
|
|
86
|
+
# True
|
|
87
|
+
try:
|
|
88
|
+
parse("max-age=60, no-store=1", strict=True, context="response")
|
|
89
|
+
except CacheControlError as error:
|
|
90
|
+
print(error)
|
|
91
|
+
# 'no-store' takes no argument at offset 12
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`build` writes a value back and refuses what the grammar cannot carry, such as a line
|
|
95
|
+
break in an argument, a negative age or a name that is not a token. delta-seconds go out
|
|
96
|
+
as tokens and field lists as quoted strings, so the output always passes strict mode:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from ccdirectives import CacheControl, build
|
|
100
|
+
|
|
101
|
+
print(build(CacheControl({"max-age": 3600, "private": "Set-Cookie", "immutable": True})))
|
|
102
|
+
# max-age=3600, private="Set-Cookie", immutable
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Freshness
|
|
106
|
+
|
|
107
|
+
`assess` applies RFC 9111 section 4.2 and RFC 5861 to a stored response of a given age,
|
|
108
|
+
optionally narrowed by the request directives:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from ccdirectives import assess, parse
|
|
112
|
+
|
|
113
|
+
response = parse("max-age=600, stale-while-revalidate=30, stale-if-error=1200")
|
|
114
|
+
verdict = assess(response, age=620)
|
|
115
|
+
print(verdict.fresh, verdict.reusable, verdict.stale_while_revalidate, verdict.stale_if_error)
|
|
116
|
+
# False False True True
|
|
117
|
+
print(assess(response, age=620, request=parse("max-stale=60")).reusable)
|
|
118
|
+
# True
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`freshness_lifetime` and `current_age` expose the two calculations on their own (Expires
|
|
122
|
+
and Date are passed as seconds since the epoch), and `may_store` says whether the
|
|
123
|
+
directives let a private or shared cache store a response at all.
|
|
124
|
+
|
|
125
|
+
### Targeted fields
|
|
126
|
+
|
|
127
|
+
Targeted fields are structured field dictionaries (RFC 9651) with their own error rules:
|
|
128
|
+
an empty or invalid field is ignored. `select` walks a cache's target list and falls back
|
|
129
|
+
on `Cache-Control`:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from ccdirectives import assess, select
|
|
133
|
+
|
|
134
|
+
headers = {"Cache-Control": "max-age=60, s-maxage=120", "CDN-Cache-Control": "max-age=600"}
|
|
135
|
+
field, policy = select(headers, ["CDN-Cache-Control"])
|
|
136
|
+
print(field, assess(policy, age=0, shared=True).lifetime)
|
|
137
|
+
# CDN-Cache-Control 600
|
|
138
|
+
broken = {"CDN-Cache-Control": "max-age=1.5", "Cache-Control": "no-store"}
|
|
139
|
+
print(select(broken, ["CDN-Cache-Control"]))
|
|
140
|
+
# ('CDN-Cache-Control', CacheControl(directives={}, duplicates=frozenset()))
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
A `max-age` that is a Decimal breaks the directive's rules, so RFC 9213 says not to use
|
|
144
|
+
it; the field itself is valid, so it still wins over `Cache-Control`.
|
|
145
|
+
|
|
146
|
+
### Command line
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
$ curl -sI https://example.com/ | ccdirectives --shared
|
|
150
|
+
$ ccdirectives --strict --age 30 "max-age=60, stale-while-revalidate=30"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
It reads a header value, or a response head on standard input (the last response of a
|
|
154
|
+
redirect chain), and prints the chosen field, its directives and the freshness verdict as
|
|
155
|
+
JSON. From a head it takes the age from `Age` and, when `Cache-Control` applies, the
|
|
156
|
+
lifetime fallback from `Expires` and `Date`. `--target` sets the target list; it defaults
|
|
157
|
+
to `CDN-Cache-Control` with `--shared`. The exit status is 1 for a value `--strict`
|
|
158
|
+
rejects and 2 for a usage error.
|
|
159
|
+
|
|
160
|
+
## API
|
|
161
|
+
|
|
162
|
+
| Name | What it does |
|
|
163
|
+
| --- | --- |
|
|
164
|
+
| `parse(header, *, strict=False, context=None)` | Read a field value or a list of field lines into a `CacheControl` |
|
|
165
|
+
| `build(cc)` | Write a `CacheControl` as a field value |
|
|
166
|
+
| `CacheControl` | Ordered, case-insensitive mapping with typed accessors and `duplicates` |
|
|
167
|
+
| `DirectiveValue` | `int`, `str` or `bool`, the type of a directive value |
|
|
168
|
+
| `CacheControlError` | `ValueError` with `reason` and `offset` for strict mode and `build` |
|
|
169
|
+
| `parse_targeted(value)` | Read a targeted field, or None when RFC 9213 says to ignore it |
|
|
170
|
+
| `build_targeted(cc)` | Write a `CacheControl` as a structured field dictionary |
|
|
171
|
+
| `select(headers, targets)` | The field name and policy a cache with that target list follows |
|
|
172
|
+
| `assess(response, *, age, ...)` | A `Freshness` verdict for a stored response |
|
|
173
|
+
| `Freshness` | Lifetime, age, fresh, reusable and the two stale windows |
|
|
174
|
+
| `freshness_lifetime(response, ...)` | Lifetime by the rules of RFC 9111 section 4.2.1 |
|
|
175
|
+
| `current_age(...)` | Age by the rules of RFC 9111 section 4.2.3 |
|
|
176
|
+
| `may_store(response, ...)` | Whether the directives allow a cache to store the response |
|
|
177
|
+
| `DELTA_MAX` | 2147483648, the value an overflowing delta-seconds becomes |
|
|
178
|
+
| `REGISTERED_DIRECTIVES` | The names in the IANA HTTP Cache Directive Registry |
|
|
179
|
+
| `REQUEST_DIRECTIVES`, `RESPONSE_DIRECTIVES` | The registered names defined for each direction |
|
|
180
|
+
|
|
181
|
+
## Why
|
|
182
|
+
|
|
183
|
+
Every HTTP client, server and cache has to read `Cache-Control`, yet the parsers that
|
|
184
|
+
exist are framework-bound: `werkzeug.http.parse_cache_control_header` ties you to Werkzeug
|
|
185
|
+
and `webob.cachecontrol` to WebOb, while packages such as `CacheControl` and `hishel` are
|
|
186
|
+
full cache transports, not directive codecs. None of them reads `CDN-Cache-Control` as the
|
|
187
|
+
structured field it is. This library is the codec and the freshness arithmetic: a typed
|
|
188
|
+
structure in, a correct header out, and nothing else to install.
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT. See [LICENSE](LICENSE).
|
|
193
|
+
|
|
194
|
+
## Links
|
|
195
|
+
|
|
196
|
+
- Website: https://lianes8studio.com
|
|
197
|
+
- GitHub: https://github.com/JaviLianes8
|
|
198
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# ccdirectives
|
|
2
|
+
|
|
3
|
+
Parse, validate and build the HTTP `Cache-Control` header (RFC 9111) and targeted fields
|
|
4
|
+
such as `CDN-Cache-Control` (RFC 9213), and decide freshness, typed and zero-dependency.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
pip install ccdirectives
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
Read what a response allows:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from ccdirectives import parse
|
|
18
|
+
|
|
19
|
+
cc = parse('max-age=600, no-cache="Set-Cookie", x-vendor=custom, MAX-AGE=5')
|
|
20
|
+
print(cc.max_age, cc.no_cache, cc.no_cache_fields)
|
|
21
|
+
# 600 True ('set-cookie',)
|
|
22
|
+
print(cc["x-vendor"], cc.extensions(), sorted(cc.duplicates))
|
|
23
|
+
# custom ('x-vendor',) ['max-age']
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Names compare case-insensitively (ASCII letters only), delta-seconds become ints, and the
|
|
27
|
+
first occurrence of a repeated directive wins, as RFC 9111 section 4.2.1 says; repeated
|
|
28
|
+
names are listed in `duplicates`. delta-seconds are ASCII digits only, and a value too
|
|
29
|
+
large to represent is read as 2147483648, the "forever" of section 1.2.2:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from ccdirectives import parse
|
|
33
|
+
|
|
34
|
+
print(parse("max-age=99999999999").max_age, parse("max-age=-5").max_age)
|
|
35
|
+
# 2147483648 None
|
|
36
|
+
print(parse("max-stale").max_stale, parse(["max-age=60", "public"]).public)
|
|
37
|
+
# 2147483648 True
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The default parser never raises on content: what it cannot read is skipped, and a broken
|
|
41
|
+
quote cannot hide the directives after it. Strict mode validates instead, with the rules
|
|
42
|
+
of a request or a response when you pass `context`. It holds a header to what a recipient
|
|
43
|
+
must accept, so `max-age="60"` passes although a sender should not write it:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from ccdirectives import CacheControlError, parse
|
|
47
|
+
|
|
48
|
+
print(parse('private="a, x="open, no-store').no_store)
|
|
49
|
+
# True
|
|
50
|
+
try:
|
|
51
|
+
parse("max-age=60, no-store=1", strict=True, context="response")
|
|
52
|
+
except CacheControlError as error:
|
|
53
|
+
print(error)
|
|
54
|
+
# 'no-store' takes no argument at offset 12
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`build` writes a value back and refuses what the grammar cannot carry, such as a line
|
|
58
|
+
break in an argument, a negative age or a name that is not a token. delta-seconds go out
|
|
59
|
+
as tokens and field lists as quoted strings, so the output always passes strict mode:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from ccdirectives import CacheControl, build
|
|
63
|
+
|
|
64
|
+
print(build(CacheControl({"max-age": 3600, "private": "Set-Cookie", "immutable": True})))
|
|
65
|
+
# max-age=3600, private="Set-Cookie", immutable
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Freshness
|
|
69
|
+
|
|
70
|
+
`assess` applies RFC 9111 section 4.2 and RFC 5861 to a stored response of a given age,
|
|
71
|
+
optionally narrowed by the request directives:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from ccdirectives import assess, parse
|
|
75
|
+
|
|
76
|
+
response = parse("max-age=600, stale-while-revalidate=30, stale-if-error=1200")
|
|
77
|
+
verdict = assess(response, age=620)
|
|
78
|
+
print(verdict.fresh, verdict.reusable, verdict.stale_while_revalidate, verdict.stale_if_error)
|
|
79
|
+
# False False True True
|
|
80
|
+
print(assess(response, age=620, request=parse("max-stale=60")).reusable)
|
|
81
|
+
# True
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`freshness_lifetime` and `current_age` expose the two calculations on their own (Expires
|
|
85
|
+
and Date are passed as seconds since the epoch), and `may_store` says whether the
|
|
86
|
+
directives let a private or shared cache store a response at all.
|
|
87
|
+
|
|
88
|
+
### Targeted fields
|
|
89
|
+
|
|
90
|
+
Targeted fields are structured field dictionaries (RFC 9651) with their own error rules:
|
|
91
|
+
an empty or invalid field is ignored. `select` walks a cache's target list and falls back
|
|
92
|
+
on `Cache-Control`:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from ccdirectives import assess, select
|
|
96
|
+
|
|
97
|
+
headers = {"Cache-Control": "max-age=60, s-maxage=120", "CDN-Cache-Control": "max-age=600"}
|
|
98
|
+
field, policy = select(headers, ["CDN-Cache-Control"])
|
|
99
|
+
print(field, assess(policy, age=0, shared=True).lifetime)
|
|
100
|
+
# CDN-Cache-Control 600
|
|
101
|
+
broken = {"CDN-Cache-Control": "max-age=1.5", "Cache-Control": "no-store"}
|
|
102
|
+
print(select(broken, ["CDN-Cache-Control"]))
|
|
103
|
+
# ('CDN-Cache-Control', CacheControl(directives={}, duplicates=frozenset()))
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A `max-age` that is a Decimal breaks the directive's rules, so RFC 9213 says not to use
|
|
107
|
+
it; the field itself is valid, so it still wins over `Cache-Control`.
|
|
108
|
+
|
|
109
|
+
### Command line
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
$ curl -sI https://example.com/ | ccdirectives --shared
|
|
113
|
+
$ ccdirectives --strict --age 30 "max-age=60, stale-while-revalidate=30"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
It reads a header value, or a response head on standard input (the last response of a
|
|
117
|
+
redirect chain), and prints the chosen field, its directives and the freshness verdict as
|
|
118
|
+
JSON. From a head it takes the age from `Age` and, when `Cache-Control` applies, the
|
|
119
|
+
lifetime fallback from `Expires` and `Date`. `--target` sets the target list; it defaults
|
|
120
|
+
to `CDN-Cache-Control` with `--shared`. The exit status is 1 for a value `--strict`
|
|
121
|
+
rejects and 2 for a usage error.
|
|
122
|
+
|
|
123
|
+
## API
|
|
124
|
+
|
|
125
|
+
| Name | What it does |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| `parse(header, *, strict=False, context=None)` | Read a field value or a list of field lines into a `CacheControl` |
|
|
128
|
+
| `build(cc)` | Write a `CacheControl` as a field value |
|
|
129
|
+
| `CacheControl` | Ordered, case-insensitive mapping with typed accessors and `duplicates` |
|
|
130
|
+
| `DirectiveValue` | `int`, `str` or `bool`, the type of a directive value |
|
|
131
|
+
| `CacheControlError` | `ValueError` with `reason` and `offset` for strict mode and `build` |
|
|
132
|
+
| `parse_targeted(value)` | Read a targeted field, or None when RFC 9213 says to ignore it |
|
|
133
|
+
| `build_targeted(cc)` | Write a `CacheControl` as a structured field dictionary |
|
|
134
|
+
| `select(headers, targets)` | The field name and policy a cache with that target list follows |
|
|
135
|
+
| `assess(response, *, age, ...)` | A `Freshness` verdict for a stored response |
|
|
136
|
+
| `Freshness` | Lifetime, age, fresh, reusable and the two stale windows |
|
|
137
|
+
| `freshness_lifetime(response, ...)` | Lifetime by the rules of RFC 9111 section 4.2.1 |
|
|
138
|
+
| `current_age(...)` | Age by the rules of RFC 9111 section 4.2.3 |
|
|
139
|
+
| `may_store(response, ...)` | Whether the directives allow a cache to store the response |
|
|
140
|
+
| `DELTA_MAX` | 2147483648, the value an overflowing delta-seconds becomes |
|
|
141
|
+
| `REGISTERED_DIRECTIVES` | The names in the IANA HTTP Cache Directive Registry |
|
|
142
|
+
| `REQUEST_DIRECTIVES`, `RESPONSE_DIRECTIVES` | The registered names defined for each direction |
|
|
143
|
+
|
|
144
|
+
## Why
|
|
145
|
+
|
|
146
|
+
Every HTTP client, server and cache has to read `Cache-Control`, yet the parsers that
|
|
147
|
+
exist are framework-bound: `werkzeug.http.parse_cache_control_header` ties you to Werkzeug
|
|
148
|
+
and `webob.cachecontrol` to WebOb, while packages such as `CacheControl` and `hishel` are
|
|
149
|
+
full cache transports, not directive codecs. None of them reads `CDN-Cache-Control` as the
|
|
150
|
+
structured field it is. This library is the codec and the freshness arithmetic: a typed
|
|
151
|
+
structure in, a correct header out, and nothing else to install.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT. See [LICENSE](LICENSE).
|
|
156
|
+
|
|
157
|
+
## Links
|
|
158
|
+
|
|
159
|
+
- Website: https://lianes8studio.com
|
|
160
|
+
- GitHub: https://github.com/JaviLianes8
|
|
161
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ccdirectives"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Parse, validate and build HTTP Cache-Control and CDN-Cache-Control, and decide freshness, typed and zero-dependency"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
|
|
14
|
+
keywords = ["cache-control", "cdn-cache-control", "http", "caching", "rfc9111", "rfc9213", "rfc5861", "freshness", "headers"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Environment :: Console",
|
|
27
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
28
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
dependencies = []
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = ["ruff", "mypy", "pytest", "build", "twine"]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
ccdirectives = "ccdirectives._cli:main"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://lianes8studio.com"
|
|
41
|
+
Repository = "https://github.com/JaviLianes8/ccdirectives"
|
|
42
|
+
Issues = "https://github.com/JaviLianes8/ccdirectives/issues"
|
|
43
|
+
Changelog = "https://github.com/JaviLianes8/ccdirectives/blob/main/CHANGELOG.md"
|
|
44
|
+
LinkedIn = "https://www.linkedin.com/in/jlianes/"
|
|
45
|
+
Funding = "https://buymeacoffee.com/jlianesglrs"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/ccdirectives"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
# Third-party reference files are fetched locally and never shipped; only their manifest is.
|
|
52
|
+
exclude = [
|
|
53
|
+
".github",
|
|
54
|
+
"IMPROVEMENTS-round*.md",
|
|
55
|
+
"tests/upstream/*.txt",
|
|
56
|
+
"tests/upstream/*.csv",
|
|
57
|
+
"tests/upstream/sf",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 100
|
|
62
|
+
target-version = "py310"
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
python_version = "3.10"
|
|
69
|
+
strict = true
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
testpaths = ["tests"]
|
|
73
|
+
pythonpath = ["src"]
|
|
74
|
+
addopts = "-q"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Local quality gate. Run before every commit. No CI runs in the cloud.
|
|
2
|
+
# $ErrorActionPreference does NOT stop on a native command's non-zero exit, so each tool's
|
|
3
|
+
# exit code is checked explicitly: a red gate must fail, never pass silently.
|
|
4
|
+
$ErrorActionPreference = "Stop"
|
|
5
|
+
|
|
6
|
+
function Invoke-Step($label, [scriptblock]$cmd) {
|
|
7
|
+
Write-Host "== $label =="
|
|
8
|
+
& $cmd
|
|
9
|
+
if ($LASTEXITCODE -ne 0) { Write-Host "FAILED: $label (exit $LASTEXITCODE)"; exit $LASTEXITCODE }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
Invoke-Step "ruff format" { ruff format --check . }
|
|
13
|
+
Invoke-Step "ruff lint" { ruff check . }
|
|
14
|
+
Invoke-Step "mypy" { mypy --strict src tests }
|
|
15
|
+
Invoke-Step "pytest" { pytest }
|
|
16
|
+
Write-Host "gate green"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Fetch the third-party test files listed in tests/upstream/MANIFEST.json.
|
|
2
|
+
|
|
3
|
+
The files are not part of the repository. Their tests skip until this script has fetched
|
|
4
|
+
them; each download is checked against the size and sha256 in the manifest.
|
|
5
|
+
|
|
6
|
+
python scripts/fetch_upstream.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
FOLDER = Path(__file__).resolve().parents[1] / "tests" / "upstream"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> int:
|
|
21
|
+
manifest = json.loads((FOLDER / "MANIFEST.json").read_text(encoding="utf-8"))
|
|
22
|
+
failed = 0
|
|
23
|
+
for name, entry in manifest.items():
|
|
24
|
+
with urllib.request.urlopen(entry["url"], timeout=60) as response:
|
|
25
|
+
body = response.read()
|
|
26
|
+
digest = hashlib.sha256(body).hexdigest()
|
|
27
|
+
if len(body) != entry["size"] or digest != entry["sha256"]:
|
|
28
|
+
print(f"{name}: got {len(body)} bytes, sha256 {digest}; manifest differs")
|
|
29
|
+
failed += 1
|
|
30
|
+
continue
|
|
31
|
+
target = FOLDER / name
|
|
32
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
target.write_bytes(body)
|
|
34
|
+
print(f"{name}: ok")
|
|
35
|
+
return 1 if failed else 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
sys.exit(main())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Build and upload to PyPI. Run scripts/check.ps1 first.
|
|
2
|
+
# The name must not already exist on PyPI; the token is read from a secret store, never git.
|
|
3
|
+
$ErrorActionPreference = "Stop"
|
|
4
|
+
|
|
5
|
+
# Works on both Windows PowerShell 5.1 and pwsh 7 (generic catch, no PS7-only types).
|
|
6
|
+
$taken = $true
|
|
7
|
+
try {
|
|
8
|
+
Invoke-WebRequest -UseBasicParsing "https://pypi.org/pypi/ccdirectives/json" | Out-Null
|
|
9
|
+
} catch {
|
|
10
|
+
if ($_.Exception.Response.StatusCode.value__ -eq 404) { $taken = $false } else { throw }
|
|
11
|
+
}
|
|
12
|
+
if ($taken) { throw "The name 'ccdirectives' already exists on PyPI. Choose another before publishing." }
|
|
13
|
+
|
|
14
|
+
$token = $env:PYPI_TOKEN
|
|
15
|
+
if (-not $token -and (Test-Path "C:\secrets\pypi-token.txt")) {
|
|
16
|
+
$token = (Get-Content "C:\secrets\pypi-token.txt" -Raw).Trim()
|
|
17
|
+
}
|
|
18
|
+
if (-not $token) {
|
|
19
|
+
throw "No PyPI token. Set `$env:PYPI_TOKEN or save it to C:\secrets\pypi-token.txt (never commit it)."
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue
|
|
23
|
+
python -m build
|
|
24
|
+
python -m twine check dist/*
|
|
25
|
+
|
|
26
|
+
$env:TWINE_USERNAME = "__token__"
|
|
27
|
+
$env:TWINE_PASSWORD = $token
|
|
28
|
+
try { python -m twine upload dist/* } finally { Remove-Item Env:\TWINE_PASSWORD -ErrorAction SilentlyContinue }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Parse and build the HTTP Cache-Control header as a typed, zero-dependency structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._build import build
|
|
6
|
+
from ._chars import DELTA_MAX
|
|
7
|
+
from ._errors import CacheControlError
|
|
8
|
+
from ._fresh import Freshness, assess, current_age, freshness_lifetime, may_store
|
|
9
|
+
from ._model import CacheControl, DirectiveValue
|
|
10
|
+
from ._parse import parse
|
|
11
|
+
from ._registry import REGISTERED_DIRECTIVES, REQUEST_DIRECTIVES, RESPONSE_DIRECTIVES
|
|
12
|
+
from ._targeted import build_targeted, parse_targeted, select
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DELTA_MAX",
|
|
18
|
+
"REGISTERED_DIRECTIVES",
|
|
19
|
+
"REQUEST_DIRECTIVES",
|
|
20
|
+
"RESPONSE_DIRECTIVES",
|
|
21
|
+
"CacheControl",
|
|
22
|
+
"CacheControlError",
|
|
23
|
+
"DirectiveValue",
|
|
24
|
+
"Freshness",
|
|
25
|
+
"assess",
|
|
26
|
+
"build",
|
|
27
|
+
"build_targeted",
|
|
28
|
+
"current_age",
|
|
29
|
+
"freshness_lifetime",
|
|
30
|
+
"may_store",
|
|
31
|
+
"parse",
|
|
32
|
+
"parse_targeted",
|
|
33
|
+
"select",
|
|
34
|
+
]
|