ccdirectives 0.2.0__py3-none-any.whl

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.
@@ -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())
ccdirectives/_build.py ADDED
@@ -0,0 +1,63 @@
1
+ """Build a Cache-Control field value from a CacheControl structure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._chars import DELTA_MAX, delta_seconds, field_names, is_token
6
+ from ._errors import CacheControlError
7
+ from ._model import CacheControl, DirectiveValue
8
+ from ._registry import DELTA_DIRECTIVES, FIELD_DIRECTIVES, argument_rule
9
+
10
+
11
+ def build(cache_control: CacheControl) -> str:
12
+ """Render a CacheControl as a field value, refusing anything the grammar cannot carry.
13
+
14
+ Bare flags are written alone and ``False`` is left out. delta-seconds go out in token
15
+ form and ``no-cache`` and ``private`` field lists in quoted-string form, as RFC 9111
16
+ section 5.2 asks of senders; other arguments are quoted only when they are not tokens.
17
+ Names that are not tokens, negative numbers, non-numeric or missing delta-seconds,
18
+ arguments on registered directives that take none, control characters and non-ASCII
19
+ text raise ``CacheControlError``, so the output always passes ``parse(strict=True)``.
20
+ """
21
+ parts: list[str] = []
22
+ for name, value in cache_control.directives.items():
23
+ if value is False:
24
+ continue
25
+ if not is_token(name):
26
+ raise CacheControlError(f"directive name {name!r} is not a token")
27
+ rule = argument_rule(name, None)
28
+ if value is True:
29
+ if rule == "delta":
30
+ raise CacheControlError(f"{name!r} needs delta-seconds")
31
+ parts.append(name)
32
+ elif rule in ("none", "ignored"):
33
+ raise CacheControlError(f"{name!r} takes no argument")
34
+ else:
35
+ parts.append(f"{name}={_argument(name, value)}")
36
+ return ", ".join(parts)
37
+
38
+
39
+ def _argument(name: str, value: DirectiveValue) -> str:
40
+ if isinstance(value, int):
41
+ if value < 0:
42
+ raise CacheControlError(f"{name!r} cannot be negative")
43
+ # RFC 9111 section 1.2.2: anything past 2^31 means the same, so write the cap.
44
+ return str(min(value, DELTA_MAX) if name in DELTA_DIRECTIVES else value)
45
+ if name in DELTA_DIRECTIVES:
46
+ seconds = delta_seconds(value)
47
+ if seconds is None:
48
+ raise CacheControlError(f"{name!r} needs delta-seconds, not {value!r}")
49
+ return str(seconds)
50
+ for char in value:
51
+ code = ord(char)
52
+ if code > 0x7E or (code < 0x20 and char != "\t") or code == 0x7F:
53
+ raise CacheControlError(f"{name!r} argument has a character a header cannot carry")
54
+ if name in FIELD_DIRECTIVES:
55
+ if field_names(value) is None:
56
+ raise CacheControlError(f"{name!r} argument is not a list of field names")
57
+ return _quote(value)
58
+ return value if is_token(value) else _quote(value)
59
+
60
+
61
+ def _quote(value: str) -> str:
62
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
63
+ return f'"{escaped}"'
ccdirectives/_chars.py ADDED
@@ -0,0 +1,66 @@
1
+ """Character classes and value rules shared by the parser and the builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # RFC 9110 section 5.6.2: tchar is any VCHAR except the delimiters.
6
+ TCHAR = frozenset("!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
7
+ DIGITS = frozenset("0123456789")
8
+ OWS = " \t"
9
+
10
+ # RFC 9111 section 1.2.2: an overflowing delta-seconds is read as 2^31, meaning "forever".
11
+ DELTA_MAX = 2147483648
12
+
13
+
14
+ def is_token(text: str) -> bool:
15
+ """True when ``text`` is a non-empty RFC 9110 token."""
16
+ return bool(text) and all(char in TCHAR for char in text)
17
+
18
+
19
+ def ascii_lower(text: str) -> str:
20
+ # str.lower() folds non-ASCII letters too (the Kelvin sign becomes "k"), which would let a
21
+ # look-alike name match a registered directive.
22
+ return text.translate(_LOWER)
23
+
24
+
25
+ _LOWER = {code: code + 32 for code in range(ord("A"), ord("Z") + 1)}
26
+
27
+
28
+ def delta_seconds(text: str) -> int | None:
29
+ """Read ``1*DIGIT`` as an int capped at 2^31, or None when it is not delta-seconds."""
30
+ if not text or any(char not in DIGITS for char in text):
31
+ return None
32
+ # Compare by length first so a huge string is never converted in full.
33
+ stripped = text.lstrip("0") or "0"
34
+ if len(stripped) > len(str(DELTA_MAX)):
35
+ return DELTA_MAX
36
+ return min(int(stripped), DELTA_MAX)
37
+
38
+
39
+ def is_qdtext(char: str) -> bool:
40
+ """True for a character allowed unescaped inside an RFC 9110 quoted-string."""
41
+ code = ord(char)
42
+ return (
43
+ code in (0x09, 0x20, 0x21)
44
+ or 0x23 <= code <= 0x5B
45
+ or 0x5D <= code <= 0x7E
46
+ or (0x80 <= code <= 0xFF)
47
+ )
48
+
49
+
50
+ def is_quotable(char: str) -> bool:
51
+ """True for a character that may follow a backslash in a quoted-pair."""
52
+ code = ord(char)
53
+ return code in (0x09, 0x20) or 0x21 <= code <= 0x7E or 0x80 <= code <= 0xFF
54
+
55
+
56
+ def field_names(text: str) -> tuple[str, ...] | None:
57
+ """Split a ``#field-name`` list, lower-cased, or None when an element is not a token."""
58
+ names: list[str] = []
59
+ for element in text.split(","):
60
+ name = element.strip(OWS)
61
+ if not name:
62
+ continue
63
+ if not is_token(name):
64
+ return None
65
+ names.append(ascii_lower(name))
66
+ return tuple(names)
ccdirectives/_cli.py ADDED
@@ -0,0 +1,108 @@
1
+ """Command line: show the cache policy in a header value or a response head as JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import time
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+
12
+ from ._errors import CacheControlError
13
+ from ._fresh import assess
14
+ from ._head import age_of, field, head_fields, http_date
15
+ from ._model import CacheControl
16
+ from ._parse import parse
17
+ from ._targeted import select
18
+
19
+ EXIT_INVALID = 1
20
+
21
+
22
+ @dataclass
23
+ class _Clock:
24
+ age: int
25
+ shared: bool
26
+ expires: float | None = None
27
+ date: float | None = None
28
+
29
+
30
+ def _report(name: str, policy: CacheControl, clock: _Clock) -> dict[str, object]:
31
+ verdict = assess(
32
+ policy, age=clock.age, shared=clock.shared, expires=clock.expires, date=clock.date
33
+ )
34
+ return {
35
+ "field": name,
36
+ "directives": policy.directives,
37
+ "duplicates": sorted(policy.duplicates),
38
+ "extensions": list(policy.extensions()),
39
+ "lifetime": verdict.lifetime,
40
+ "age": verdict.age,
41
+ "fresh": verdict.fresh,
42
+ "reusable": verdict.reusable,
43
+ "stale_while_revalidate": verdict.stale_while_revalidate,
44
+ "stale_if_error": verdict.stale_if_error,
45
+ }
46
+
47
+
48
+ def _from_head(
49
+ fields: list[tuple[str, str]], args: argparse.Namespace, clock: _Clock
50
+ ) -> tuple[str, CacheControl]:
51
+ targets = args.target or (["CDN-Cache-Control"] if args.shared else [])
52
+ name, policy = select(fields, targets)
53
+ if args.strict and name == "Cache-Control":
54
+ values = [v for n, v in fields if n.lower() == "cache-control"]
55
+ policy = parse(values, strict=True, context="response")
56
+ if args.age is None:
57
+ clock.age = age_of(fields)
58
+ expires = field(fields, "Expires")
59
+ if name == "Cache-Control" and expires is not None:
60
+ # RFC 9111 section 5.3: an Expires that does not parse means already expired.
61
+ date = http_date(field(fields, "Date"))
62
+ received = date if date is not None else time.time()
63
+ stamp = http_date(expires)
64
+ clock.expires = stamp if stamp is not None else received - 1
65
+ clock.date = received
66
+ return name, policy
67
+
68
+
69
+ def main(argv: Sequence[str] | None = None) -> int:
70
+ """Run the command line; returns the process exit code."""
71
+ parser = argparse.ArgumentParser(
72
+ prog="ccdirectives",
73
+ description="Show the cache policy of a Cache-Control value, or of a response head "
74
+ "read from standard input (for example the output of curl -sI). Exit status 1 "
75
+ "means an invalid value in --strict mode, 2 a usage error.",
76
+ )
77
+ parser.add_argument("value", nargs="?", help="a Cache-Control field value")
78
+ parser.add_argument("--strict", action="store_true", help="reject values outside the RFC")
79
+ parser.add_argument("--shared", action="store_true", help="judge as a shared cache (a CDN)")
80
+ parser.add_argument("--age", type=int, help="age in seconds (default: the Age field, or 0)")
81
+ parser.add_argument(
82
+ "--target",
83
+ action="append",
84
+ help="targeted field to prefer, in order (default CDN-Cache-Control with --shared)",
85
+ )
86
+ args = parser.parse_args(argv)
87
+ if args.age is not None and args.age < 0:
88
+ parser.error("--age cannot be negative")
89
+ clock = _Clock(args.age or 0, args.shared)
90
+ try:
91
+ if args.value is not None:
92
+ value = args.value
93
+ if value.lower().startswith("cache-control:"):
94
+ value = value.partition(":")[2]
95
+ name, policy = "Cache-Control", parse(value.strip(), strict=args.strict)
96
+ else:
97
+ # ISO-8859-1 maps every octet, so no input byte can stop the program.
98
+ text = sys.stdin.buffer.read().decode("iso-8859-1")
99
+ fields = head_fields(text)
100
+ if fields is None:
101
+ name, policy = "Cache-Control", parse(text.strip(), strict=args.strict)
102
+ else:
103
+ name, policy = _from_head(fields, args, clock)
104
+ except CacheControlError as error:
105
+ print(f"ccdirectives: {error}", file=sys.stderr)
106
+ return EXIT_INVALID
107
+ print(json.dumps(_report(name, policy, clock), indent=2))
108
+ return 0
@@ -0,0 +1,16 @@
1
+ """The exception raised for input that breaks the rules in strict mode or on build."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CacheControlError(ValueError):
7
+ """A header value or a directive set that breaks the RFC rules.
8
+
9
+ ``offset`` is the index in the header value where the problem starts, or None when the
10
+ error is about a structure rather than a position in text.
11
+ """
12
+
13
+ def __init__(self, message: str, offset: int | None = None) -> None:
14
+ super().__init__(message if offset is None else f"{message} at offset {offset}")
15
+ self.reason = message
16
+ self.offset = offset
ccdirectives/_fresh.py ADDED
@@ -0,0 +1,161 @@
1
+ """Freshness, age and reuse decisions for a stored response (RFC 9111 section 4.2, RFC 5861)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+
8
+ from ._chars import field_names
9
+ from ._model import CacheControl
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Freshness:
14
+ """What a cache may do with a stored response at a given age.
15
+
16
+ ``lifetime`` is None when the response carries no explicit expiration; no heuristic is
17
+ applied, so such a response counts as stale. ``reusable`` means it may be sent without
18
+ contacting the origin; ``stale_while_revalidate`` that it may be sent while a background
19
+ revalidation runs; ``stale_if_error`` that it may be sent if the origin fails.
20
+ """
21
+
22
+ lifetime: int | None
23
+ age: int
24
+ fresh: bool
25
+ reusable: bool
26
+ stale_while_revalidate: bool
27
+ stale_if_error: bool
28
+
29
+ @property
30
+ def staleness(self) -> int:
31
+ """Seconds past the end of the lifetime, 0 while fresh."""
32
+ return max(0, self.age - (self.lifetime or 0))
33
+
34
+
35
+ def freshness_lifetime(
36
+ response: CacheControl,
37
+ *,
38
+ shared: bool = False,
39
+ expires: float | None = None,
40
+ date: float | None = None,
41
+ ) -> int | None:
42
+ """The freshness lifetime in seconds by the first matching rule of RFC 9111 4.2.1.
43
+
44
+ ``s-maxage`` counts for a shared cache, then ``max-age``, then ``expires - date``
45
+ (both in seconds since the epoch; pass the time of receipt as ``date`` when the
46
+ response has no Date). A directive that is present but not delta-seconds gives 0, as
47
+ section 4.2.1 encourages treating invalid freshness information as stale.
48
+ """
49
+ for name in ("s-maxage", "max-age") if shared else ("max-age",):
50
+ if response.has(name):
51
+ return response.seconds(name) or 0
52
+ if expires is None:
53
+ return None
54
+ if date is None:
55
+ raise ValueError("pass date (or the time the response was received) with expires")
56
+ return max(0, math.floor(expires - date))
57
+
58
+
59
+ def current_age(
60
+ *,
61
+ date_value: float,
62
+ request_time: float,
63
+ response_time: float,
64
+ now: float,
65
+ age_value: int = 0,
66
+ ) -> int:
67
+ """The current age in whole seconds, rounded up (RFC 9111 section 4.2.3)."""
68
+ if age_value < 0:
69
+ raise ValueError("age_value cannot be negative")
70
+ apparent_age = max(0.0, response_time - date_value)
71
+ corrected_age_value = age_value + (response_time - request_time)
72
+ corrected_initial_age = max(apparent_age, corrected_age_value)
73
+ return max(0, math.ceil(corrected_initial_age + (now - response_time)))
74
+
75
+
76
+ def _stale_forbidden(response: CacheControl, shared: bool) -> bool:
77
+ # RFC 9111 section 4.2.4 lists what forbids serving a stale response.
78
+ if response.no_cache or response.must_revalidate:
79
+ return True
80
+ return shared and (response.proxy_revalidate or response.has("s-maxage"))
81
+
82
+
83
+ def assess(
84
+ response: CacheControl,
85
+ *,
86
+ age: int,
87
+ shared: bool = False,
88
+ request: CacheControl | None = None,
89
+ expires: float | None = None,
90
+ date: float | None = None,
91
+ ) -> Freshness:
92
+ """Decide whether a stored response of ``age`` seconds may be reused.
93
+
94
+ The response directives set the lifetime and what forbids stale use (``no-cache``,
95
+ ``must-revalidate``, and for shared caches ``proxy-revalidate`` and ``s-maxage``);
96
+ a ``no-cache`` response is never reusable without validation, qualified or not. The
97
+ optional request directives narrow reuse: ``no-cache``, ``max-age``, ``min-fresh``,
98
+ and ``max-stale`` to accept a stale response. ``stale-if-error`` in the request takes
99
+ precedence over the response's for that request (RFC 5861 section 4).
100
+ """
101
+ if isinstance(age, bool) or not isinstance(age, int):
102
+ raise TypeError("age is a whole number of seconds")
103
+ if age < 0:
104
+ raise ValueError("age cannot be negative")
105
+ asked = request if request is not None else CacheControl()
106
+ lifetime = freshness_lifetime(response, shared=shared, expires=expires, date=date)
107
+ fresh = lifetime is not None and lifetime > age
108
+ staleness = max(0, age - (lifetime or 0))
109
+ forbidden = _stale_forbidden(response, shared)
110
+ reusable = not response.no_cache and not asked.no_cache
111
+ if asked.has("max-age"):
112
+ reusable = reusable and age <= (asked.max_age or 0)
113
+ if fresh:
114
+ if asked.has("min-fresh"):
115
+ reusable = reusable and (lifetime or 0) - age >= (asked.min_fresh or 0)
116
+ else:
117
+ allowance = asked.max_stale
118
+ reusable = reusable and not forbidden and allowance is not None and staleness <= allowance
119
+ revalidate_window = response.stale_while_revalidate
120
+ error_window = asked.stale_if_error if asked.has("stale-if-error") else response.stale_if_error
121
+ stale_ok = not fresh and not forbidden
122
+ return Freshness(
123
+ lifetime=lifetime,
124
+ age=age,
125
+ fresh=fresh,
126
+ reusable=reusable,
127
+ stale_while_revalidate=stale_ok
128
+ and not asked.no_cache
129
+ and revalidate_window is not None
130
+ and staleness <= revalidate_window,
131
+ stale_if_error=stale_ok and error_window is not None and staleness <= error_window,
132
+ )
133
+
134
+
135
+ def may_store(
136
+ response: CacheControl,
137
+ *,
138
+ shared: bool = False,
139
+ authorized: bool = False,
140
+ understood_status: bool = False,
141
+ ) -> bool:
142
+ """Whether the response directives let a cache store the response (RFC 9111 3 and 3.5).
143
+
144
+ ``authorized`` is True when the request carried Authorization; ``understood_status``
145
+ when the cache implements the caching rules of the response's status code, which is
146
+ what ``must-understand`` asks for and what lets it override ``no-store``. A qualified
147
+ ``private`` still allows a shared cache to store the response without those fields.
148
+ Only directives are judged: method, status and freshness rules stay with the caller.
149
+ """
150
+ if response.must_understand:
151
+ if not understood_status:
152
+ return False
153
+ elif response.no_store:
154
+ return False
155
+ qualifier = response.get("private")
156
+ unqualified = not isinstance(qualifier, str) or field_names(qualifier) is None
157
+ if shared and response.private and unqualified:
158
+ return False
159
+ if shared and authorized:
160
+ return response.must_revalidate or response.public or response.has("s-maxage")
161
+ return True
ccdirectives/_head.py ADDED
@@ -0,0 +1,57 @@
1
+ """Read the fields of a response head, as printed by ``curl -sI``, for the command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import email.utils
6
+
7
+ from ._chars import ascii_lower, delta_seconds
8
+
9
+
10
+ def head_fields(text: str) -> list[tuple[str, str]] | None:
11
+ """The fields of the last response head in a dump, or None when it holds no head.
12
+
13
+ A redirect chain prints one head per response; the last is the one a cache stores.
14
+ Continuation lines (obs-fold) join the previous field with a space, as RFC 9112
15
+ section 5.2 allows a recipient to do.
16
+ """
17
+ lines = text.split("\n")
18
+ starts = [i for i, line in enumerate(lines) if line.startswith("HTTP/")]
19
+ if not starts:
20
+ return None
21
+ fields: list[tuple[str, str]] = []
22
+ for raw in lines[starts[-1] + 1 :]:
23
+ line = raw.rstrip("\r")
24
+ if not line:
25
+ break
26
+ if line[0] in " \t":
27
+ if fields:
28
+ name, value = fields[-1]
29
+ fields[-1] = (name, (value + " " + line.strip(" \t")).strip())
30
+ continue
31
+ name, colon, value = line.partition(":")
32
+ if colon and name and name == name.strip():
33
+ fields.append((name, value.strip(" \t")))
34
+ return fields
35
+
36
+
37
+ def field(fields: list[tuple[str, str]], name: str) -> str | None:
38
+ """The first value of ``name``, compared case-insensitively."""
39
+ wanted = ascii_lower(name)
40
+ return next((value for key, value in fields if ascii_lower(key) == wanted), None)
41
+
42
+
43
+ def age_of(fields: list[tuple[str, str]]) -> int:
44
+ """The Age field as delta-seconds, 0 when absent or invalid (RFC 9111 section 5.1)."""
45
+ value = field(fields, "Age")
46
+ return (delta_seconds(value.strip()) or 0) if value is not None else 0
47
+
48
+
49
+ def http_date(value: str | None) -> float | None:
50
+ """Seconds since the epoch for an HTTP-date, or None when it does not parse."""
51
+ if value is None:
52
+ return None
53
+ try:
54
+ parsed = email.utils.parsedate_to_datetime(value)
55
+ except (TypeError, ValueError, IndexError):
56
+ return None
57
+ return parsed.timestamp() if parsed.tzinfo is not None else None