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.
Files changed (45) hide show
  1. ccdirectives-0.2.0/.gitattributes +1 -0
  2. ccdirectives-0.2.0/.gitignore +16 -0
  3. ccdirectives-0.2.0/CHANGELOG.md +42 -0
  4. ccdirectives-0.2.0/CONTRIBUTING.md +19 -0
  5. ccdirectives-0.2.0/LICENSE +21 -0
  6. ccdirectives-0.2.0/PKG-INFO +198 -0
  7. ccdirectives-0.2.0/README.md +161 -0
  8. ccdirectives-0.2.0/pyproject.toml +74 -0
  9. ccdirectives-0.2.0/requirements-dev.txt +3 -0
  10. ccdirectives-0.2.0/scripts/check.ps1 +16 -0
  11. ccdirectives-0.2.0/scripts/fetch_upstream.py +39 -0
  12. ccdirectives-0.2.0/scripts/release.ps1 +28 -0
  13. ccdirectives-0.2.0/src/ccdirectives/__init__.py +34 -0
  14. ccdirectives-0.2.0/src/ccdirectives/__main__.py +9 -0
  15. ccdirectives-0.2.0/src/ccdirectives/_build.py +63 -0
  16. ccdirectives-0.2.0/src/ccdirectives/_chars.py +66 -0
  17. ccdirectives-0.2.0/src/ccdirectives/_cli.py +108 -0
  18. ccdirectives-0.2.0/src/ccdirectives/_errors.py +16 -0
  19. ccdirectives-0.2.0/src/ccdirectives/_fresh.py +161 -0
  20. ccdirectives-0.2.0/src/ccdirectives/_head.py +57 -0
  21. ccdirectives-0.2.0/src/ccdirectives/_model.py +175 -0
  22. ccdirectives-0.2.0/src/ccdirectives/_parse.py +99 -0
  23. ccdirectives-0.2.0/src/ccdirectives/_registry.py +64 -0
  24. ccdirectives-0.2.0/src/ccdirectives/_scan.py +118 -0
  25. ccdirectives-0.2.0/src/ccdirectives/_sf.py +197 -0
  26. ccdirectives-0.2.0/src/ccdirectives/_sfdict.py +111 -0
  27. ccdirectives-0.2.0/src/ccdirectives/_targeted.py +131 -0
  28. ccdirectives-0.2.0/src/ccdirectives/py.typed +0 -0
  29. ccdirectives-0.2.0/tests/conftest.py +60 -0
  30. ccdirectives-0.2.0/tests/test_build.py +83 -0
  31. ccdirectives-0.2.0/tests/test_cli.py +89 -0
  32. ccdirectives-0.2.0/tests/test_contract.py +44 -0
  33. ccdirectives-0.2.0/tests/test_docs.py +90 -0
  34. ccdirectives-0.2.0/tests/test_e2e_wheel.py +116 -0
  35. ccdirectives-0.2.0/tests/test_fresh.py +146 -0
  36. ccdirectives-0.2.0/tests/test_model.py +107 -0
  37. ccdirectives-0.2.0/tests/test_parse.py +145 -0
  38. ccdirectives-0.2.0/tests/test_review_fixes.py +122 -0
  39. ccdirectives-0.2.0/tests/test_roundtrip.py +91 -0
  40. ccdirectives-0.2.0/tests/test_sf_corpus.py +118 -0
  41. ccdirectives-0.2.0/tests/test_sources.py +167 -0
  42. ccdirectives-0.2.0/tests/test_strict.py +75 -0
  43. ccdirectives-0.2.0/tests/test_targeted.py +141 -0
  44. ccdirectives-0.2.0/tests/test_typing.py +98 -0
  45. 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,3 @@
1
+ # Development dependencies: the package in editable mode plus its dev extras
2
+ # (ruff, mypy, pytest, build, twine). Install with: pip install -r requirements-dev.txt
3
+ -e .[dev]
@@ -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
+ ]
@@ -0,0 +1,9 @@
1
+ """Allow ``python -m ccdirectives``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())