setcookie 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.
setcookie/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Typed Set-Cookie codec: parse, validate and build one Set-Cookie line, and store it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._build import build, expire
6
+ from ._dates import parse_date
7
+ from ._errors import CookieError
8
+ from ._model import Cookie, SameSite, StoredCookie
9
+ from ._parse import parse
10
+ from ._store import receive
11
+
12
+ __version__ = "0.2.0"
13
+
14
+ __all__ = [
15
+ "Cookie",
16
+ "CookieError",
17
+ "SameSite",
18
+ "StoredCookie",
19
+ "build",
20
+ "expire",
21
+ "parse",
22
+ "parse_date",
23
+ "receive",
24
+ ]
setcookie/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Run the command line with ``python -m setcookie``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())
setcookie/_build.py ADDED
@@ -0,0 +1,171 @@
1
+ """Build one Set-Cookie line that follows the server grammar of section 4.1.1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from datetime import datetime, timezone
7
+ from typing import NoReturn
8
+
9
+ from ._chars import (
10
+ MAX_ATTRIBUTE_OCTETS,
11
+ MAX_PAIR_OCTETS,
12
+ WSP,
13
+ ascii_lower,
14
+ is_av_text,
15
+ is_cookie_value,
16
+ is_hostname,
17
+ is_token,
18
+ octets,
19
+ )
20
+ from ._dates import format_http_date
21
+ from ._errors import CookieError
22
+ from ._model import SameSite
23
+ from ._rules import prefix_problem, secure_problem
24
+ from ._strict import check_strict
25
+
26
+ _SAME_SITE_VALUES = ("Lax", "Strict", "None")
27
+ _KNOWN = frozenset(
28
+ {"expires", "max-age", "domain", "path", "secure", "httponly", "samesite", "partitioned"}
29
+ )
30
+ _EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
31
+ _BIG = 10**MAX_ATTRIBUTE_OCTETS # more digits than an attribute value may hold
32
+
33
+
34
+ def build(
35
+ name: str,
36
+ value: str,
37
+ *,
38
+ expires: datetime | None = None,
39
+ max_age: int | None = None,
40
+ domain: str | None = None,
41
+ path: str | None = "/",
42
+ secure: bool = False,
43
+ http_only: bool = False,
44
+ same_site: SameSite | None = None,
45
+ partitioned: bool = False,
46
+ extensions: Iterable[tuple[str, str | None]] = (),
47
+ ) -> str:
48
+ """Assemble one Set-Cookie line; raise CookieError for anything unsafe or ignored.
49
+
50
+ Every part is checked on its own before it is joined, so no argument can end the
51
+ line, start another attribute or inject a header. Prefix rules, the Secure
52
+ requirement of SameSite=None and Partitioned, and the size limits are enforced.
53
+ A naive ``expires`` is taken to be UTC, not local time.
54
+ """
55
+ _check_pair(name, value)
56
+ parts = [f"{name}={value}"]
57
+ if expires is not None:
58
+ parts.append(f"Expires={_expires(expires)}")
59
+ if max_age is not None:
60
+ if isinstance(max_age, bool) or not isinstance(max_age, int) or not 0 < max_age < _BIG:
61
+ _fail("max_age must be a positive int below 10**1024; use expire() to delete")
62
+ parts.append(f"Max-Age={max_age}")
63
+ if domain is not None:
64
+ if not isinstance(domain, str) or not is_hostname(domain):
65
+ _fail(f"domain {domain!r} is not a host name (no leading dot, no port)")
66
+ parts.append(f"Domain={domain}")
67
+ if path is not None:
68
+ if not isinstance(path, str) or (path and not path.startswith("/")):
69
+ _fail(f"path {path!r} must be empty or start with '/'")
70
+ if path != path.strip(WSP):
71
+ _fail(f"path {path!r} has leading or trailing whitespace")
72
+ _check_attribute_value("path", path)
73
+ parts.append(f"Path={path}")
74
+ parts.extend(flag for flag, on in (("Secure", secure), ("HttpOnly", http_only)) if on)
75
+ if same_site is not None:
76
+ if same_site not in _SAME_SITE_VALUES:
77
+ _fail(f"same_site must be one of {_SAME_SITE_VALUES}, got {same_site!r}")
78
+ parts.append(f"SameSite={same_site}")
79
+ if partitioned:
80
+ parts.append("Partitioned")
81
+ parts.extend(_extension(item) for item in extensions)
82
+ problem = prefix_problem(
83
+ name, secure=secure, has_domain=domain is not None, path=path
84
+ ) or secure_problem(same_site=same_site, partitioned=partitioned, secure=secure)
85
+ if problem:
86
+ _fail(problem)
87
+ header = "; ".join(parts)
88
+ check_strict(header)
89
+ return header
90
+
91
+
92
+ def expire(
93
+ name: str,
94
+ *,
95
+ domain: str | None = None,
96
+ path: str | None = "/",
97
+ secure: bool = False,
98
+ http_only: bool = False,
99
+ same_site: SameSite | None = None,
100
+ partitioned: bool = False,
101
+ ) -> str:
102
+ """Build a line that deletes the cookie: an empty value and an Expires in 1970.
103
+
104
+ Pass the same name, Domain and Path the cookie was set with; prefixed names still
105
+ need their Secure, Domain and Path rules.
106
+ """
107
+ return build(
108
+ name,
109
+ "",
110
+ expires=_EPOCH,
111
+ domain=domain,
112
+ path=path,
113
+ secure=secure,
114
+ http_only=http_only,
115
+ same_site=same_site,
116
+ partitioned=partitioned,
117
+ )
118
+
119
+
120
+ def _check_pair(name: str, value: str) -> None:
121
+ if not isinstance(name, str) or not is_token(name):
122
+ _fail(f"cookie name {name!r} is not an HTTP token")
123
+ if not isinstance(value, str) or not is_cookie_value(value):
124
+ _fail(f"cookie value {value!r} has characters outside cookie-octet; encode it first")
125
+ if octets(name) + octets(value) > MAX_PAIR_OCTETS:
126
+ _fail("the name and value are longer than 4096 octets together")
127
+
128
+
129
+ def _expires(moment: datetime) -> str:
130
+ if not isinstance(moment, datetime):
131
+ _fail(f"expires must be a datetime, got {type(moment).__name__}")
132
+ try:
133
+ text = format_http_date(moment)
134
+ except OverflowError:
135
+ _fail("expires is outside the range of representable UTC dates")
136
+ if int(text[12:16]) < 1601:
137
+ _fail("expires before the year 1601 is ignored by user agents")
138
+ return text
139
+
140
+
141
+ def _extension(item: tuple[str, str | None]) -> str:
142
+ if not isinstance(item, tuple) or len(item) != 2:
143
+ _fail(f"each extension must be a (name, value) tuple, got {item!r}")
144
+ key, text = item
145
+ if (
146
+ not isinstance(key, str)
147
+ or not key
148
+ or key != key.strip(WSP)
149
+ or "=" in key
150
+ or not is_av_text(key)
151
+ ):
152
+ _fail(f"extension name {key!r} is not a valid attribute name")
153
+ if ascii_lower(key) in _KNOWN:
154
+ _fail(f"extension {key!r} would be read as a standard attribute; use its argument")
155
+ if text is None:
156
+ return key
157
+ if not isinstance(text, str) or text != text.strip(WSP):
158
+ _fail(f"extension value {text!r} must be a str without outer whitespace")
159
+ _check_attribute_value(key, text)
160
+ return f"{key}={text}"
161
+
162
+
163
+ def _check_attribute_value(label: str, text: str) -> None:
164
+ if not is_av_text(text):
165
+ _fail(f"{label} value {text!r} has control characters or ';'")
166
+ if octets(text) > MAX_ATTRIBUTE_OCTETS:
167
+ _fail(f"{label} value is longer than 1024 octets")
168
+
169
+
170
+ def _fail(reason: str) -> NoReturn:
171
+ raise CookieError(reason)
setcookie/_chars.py ADDED
@@ -0,0 +1,65 @@
1
+ """Character classes, limits and small text helpers from the cookie grammar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ WSP = " \t"
6
+ MAX_PAIR_OCTETS = 4096
7
+ MAX_ATTRIBUTE_OCTETS = 1024
8
+ MAX_AGE_LIMIT = 34560000 # 400 days in seconds, the cap of section 5.5
9
+
10
+ _ALNUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
11
+ TCHAR = frozenset("!#$%&'*+-.^_`|~" + _ALNUM)
12
+ # Visible ASCII without DQUOTE, comma, semicolon and backslash.
13
+ COOKIE_OCTET = frozenset(chr(code) for code in range(0x21, 0x7F)) - frozenset('",;\\')
14
+ # Any CHAR except CTLs and semicolon.
15
+ AV_OCTET = frozenset(chr(code) for code in range(0x20, 0x7F)) - frozenset(";")
16
+ _UPPER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
17
+
18
+
19
+ def has_forbidden_ctl(text: str) -> bool:
20
+ """True for %x00-08, %x0A-1F or %x7F: control characters other than HTAB."""
21
+ return any((char < " " and char != "\t") or char == "\x7f" for char in text)
22
+
23
+
24
+ def octets(text: str) -> int:
25
+ """Length in octets once the text is put on the wire as UTF-8."""
26
+ return len(text.encode("utf-8", "surrogatepass"))
27
+
28
+
29
+ def trim(text: str) -> str:
30
+ """Remove leading and trailing WSP (space and horizontal tab) only."""
31
+ return text.strip(WSP)
32
+
33
+
34
+ def ascii_lower(text: str) -> str:
35
+ """Lower-case A to Z only, so no Unicode folding can turn a name into a keyword."""
36
+ return text.translate(_UPPER)
37
+
38
+
39
+ def is_token(text: str) -> bool:
40
+ """True for a non-empty HTTP token (RFC 9110 section 5.6.2)."""
41
+ return bool(text) and all(char in TCHAR for char in text)
42
+
43
+
44
+ def is_cookie_value(text: str) -> bool:
45
+ """True for *cookie-octet, optionally wrapped in one pair of DQUOTEs."""
46
+ if len(text) >= 2 and text[0] == '"' == text[-1]:
47
+ text = text[1:-1]
48
+ return all(char in COOKIE_OCTET for char in text)
49
+
50
+
51
+ def is_av_text(text: str) -> bool:
52
+ """True when every character is an av-octet: any CHAR except CTLs and ';'."""
53
+ return all(char in AV_OCTET for char in text)
54
+
55
+
56
+ def is_hostname(text: str) -> bool:
57
+ """True for an RFC 1034 subdomain as relaxed by RFC 1123 (labels may start with a digit)."""
58
+ if not text or len(text) > 253 or not text.isascii():
59
+ return False
60
+ for label in text.split("."):
61
+ if not 0 < len(label) <= 63 or label[0] == "-" or label[-1] == "-":
62
+ return False
63
+ if not all(char.isalnum() or char == "-" for char in label):
64
+ return False
65
+ return True
setcookie/_cli.py ADDED
@@ -0,0 +1,91 @@
1
+ """Command line: read Set-Cookie lines and print what they mean as JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import dataclasses
7
+ import json
8
+ import re
9
+ import sys
10
+ from collections.abc import Sequence
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ from ._errors import CookieError
15
+ from ._parse import parse
16
+ from ._store import receive
17
+
18
+ _HEADER = re.compile(r"set-cookie[ \t]*:[ \t]*(.*)", re.IGNORECASE | re.DOTALL)
19
+
20
+
21
+ def _lines(args: Sequence[str], stdin: str) -> list[str]:
22
+ """Explicit lines, or stdin; from a header dump only the Set-Cookie fields are kept."""
23
+ if args:
24
+ return list(args)
25
+ raw = [line.rstrip("\r") for line in stdin.split("\n")]
26
+ present = [line for line in raw if line.strip()]
27
+ headers = [match[1] for line in raw if (match := _HEADER.fullmatch(line))]
28
+ # A dump (curl -sI starts with a status line) yields only its Set-Cookie fields, maybe none.
29
+ if headers or (present and present[0].startswith("HTTP/")):
30
+ return headers
31
+ return present
32
+
33
+
34
+ def _plain(value: object) -> Any:
35
+ if isinstance(value, datetime):
36
+ return value.isoformat()
37
+ if isinstance(value, tuple):
38
+ return [_plain(item) for item in value]
39
+ return value
40
+
41
+
42
+ def _fields(record: Any) -> dict[str, Any]:
43
+ return {field.name: _plain(getattr(record, field.name)) for field in dataclasses.fields(record)}
44
+
45
+
46
+ def _report(line: str, options: argparse.Namespace) -> tuple[dict[str, Any], bool]:
47
+ entry: dict[str, Any] = {"line": line}
48
+ try:
49
+ entry["cookie"] = _fields(parse(line, strict=options.strict))
50
+ if options.url:
51
+ entry["stored"] = _fields(receive(line, options.url, now=options.now))
52
+ except CookieError as error:
53
+ entry["error"] = str(error)
54
+ return entry, False
55
+ return entry, True
56
+
57
+
58
+ def _moment(text: str) -> datetime:
59
+ # fromisoformat only reads a trailing "Z" from Python 3.11 on.
60
+ moment = datetime.fromisoformat(text[:-1] + "+00:00" if text.endswith("Z") else text)
61
+ return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
62
+
63
+
64
+ _moment.__name__ = "ISO time"
65
+
66
+
67
+ def main(argv: Sequence[str] | None = None) -> int:
68
+ """Entry point of the ``setcookie`` command; returns the exit status."""
69
+ parser = argparse.ArgumentParser(
70
+ prog="setcookie",
71
+ description="Parse Set-Cookie lines (arguments, or stdin such as a curl -sI dump).",
72
+ )
73
+ parser.add_argument("lines", nargs="*", help="Set-Cookie values; read stdin when absent")
74
+ parser.add_argument("--strict", action="store_true", help="check the server grammar")
75
+ parser.add_argument("--url", help="show what a user agent stores for this request URL")
76
+ parser.add_argument(
77
+ "--now", type=_moment, default=None, help="ISO time for --url (default: the clock)"
78
+ )
79
+ options = parser.parse_args(argv)
80
+ if options.now is None:
81
+ options.now = datetime.now(timezone.utc)
82
+ lines = _lines(options.lines, "" if options.lines else sys.stdin.read())
83
+ if not lines:
84
+ parser.error("no Set-Cookie line given")
85
+ reports = [_report(line, options) for line in lines]
86
+ json.dump([entry for entry, _ in reports], sys.stdout, indent=2)
87
+ sys.stdout.write("\n")
88
+ failures = [entry["error"] for entry, ok in reports if not ok]
89
+ for reason in failures:
90
+ print(f"setcookie: {reason}", file=sys.stderr)
91
+ return 1 if failures else 0
setcookie/_dates.py ADDED
@@ -0,0 +1,121 @@
1
+ """Cookie dates: the parsing algorithm of section 5.1.1 and IMF-fixdate output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime, timezone
7
+
8
+ _DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
9
+ _MONTHS = (
10
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
11
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
12
+ ) # fmt: skip
13
+ _LOWER_MONTHS = tuple(month.lower() for month in _MONTHS)
14
+
15
+ # The productions of section 5.1.1; [0-9] rather than \d so no non-ASCII digit matches.
16
+ _TIME = re.compile(r"([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})(?:[^0-9].*)?", re.DOTALL)
17
+ _DAY = re.compile(r"([0-9]{1,2})(?:[^0-9].*)?", re.DOTALL)
18
+ _YEAR = re.compile(r"([0-9]{2,4})(?:[^0-9].*)?", re.DOTALL)
19
+ _FIXDATE = re.compile(
20
+ r"(Mon|Tue|Wed|Thu|Fri|Sat|Sun), ([0-9]{2}) "
21
+ r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([0-9]{4}) "
22
+ r"([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT"
23
+ )
24
+
25
+
26
+ def _is_delimiter(char: str) -> bool:
27
+ code = ord(char)
28
+ return (
29
+ code == 0x09
30
+ or 0x20 <= code <= 0x2F
31
+ or 0x3B <= code <= 0x40
32
+ or 0x5B <= code <= 0x60
33
+ or 0x7B <= code <= 0x7E
34
+ )
35
+
36
+
37
+ def _tokens(text: str) -> list[str]:
38
+ tokens: list[str] = []
39
+ current: list[str] = []
40
+ for char in text:
41
+ if _is_delimiter(char):
42
+ if current:
43
+ tokens.append("".join(current))
44
+ current = []
45
+ else:
46
+ current.append(char)
47
+ if current:
48
+ tokens.append("".join(current))
49
+ return tokens
50
+
51
+
52
+ def parse_date(text: str) -> datetime | None:
53
+ """Parse a cookie-date with the algorithm of section 5.1.1; None when it fails.
54
+
55
+ The result is an aware datetime in UTC. Zone names and offsets are not read: the
56
+ algorithm treats every time as UTC.
57
+ """
58
+ time: tuple[int, int, int] | None = None
59
+ day: int | None = None
60
+ month: int | None = None
61
+ year: int | None = None
62
+ for token in _tokens(text):
63
+ if time is None and (match := _TIME.fullmatch(token)):
64
+ time = (int(match[1]), int(match[2]), int(match[3]))
65
+ elif day is None and (match := _DAY.fullmatch(token)):
66
+ day = int(match[1])
67
+ elif month is None and token[:3].isascii() and token[:3].lower() in _LOWER_MONTHS:
68
+ month = _LOWER_MONTHS.index(token[:3].lower()) + 1
69
+ elif year is None and (match := _YEAR.fullmatch(token)):
70
+ year = int(match[1])
71
+ if time is None or day is None or month is None or year is None:
72
+ return None
73
+ if 70 <= year <= 99:
74
+ year += 1900
75
+ elif 0 <= year <= 69:
76
+ year += 2000
77
+ hour, minute, second = time
78
+ if not 1 <= day <= 31 or year < 1601 or hour > 23 or minute > 59 or second > 59:
79
+ return None
80
+ try:
81
+ return datetime(year, month, day, hour, minute, second, tzinfo=timezone.utc)
82
+ except ValueError:
83
+ return None
84
+
85
+
86
+ def parse_fixdate(text: str) -> datetime | None:
87
+ """Parse an exact IMF-fixdate (RFC 9110 section 5.6.7); None for anything else.
88
+
89
+ Only the grammar and the calendar are checked, not that the day name fits the date:
90
+ the draft's own example in section 3.1 pairs "Wed" with 9 June 2026, a Tuesday.
91
+ """
92
+ match = _FIXDATE.fullmatch(text)
93
+ if match is None:
94
+ return None
95
+ try:
96
+ moment = datetime(
97
+ int(match[4]),
98
+ _MONTHS.index(match[3]) + 1,
99
+ int(match[2]),
100
+ int(match[5]),
101
+ int(match[6]),
102
+ int(match[7]),
103
+ tzinfo=timezone.utc,
104
+ )
105
+ except ValueError:
106
+ return None
107
+ return moment
108
+
109
+
110
+ def format_http_date(value: datetime) -> str:
111
+ """Render a datetime as an IMF-fixdate in GMT, regardless of locale.
112
+
113
+ A naive datetime is taken to be UTC.
114
+ """
115
+ moment = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
116
+ moment = moment.astimezone(timezone.utc)
117
+ return (
118
+ f"{_DAYS[moment.weekday()]}, "
119
+ f"{moment.day:02d} {_MONTHS[moment.month - 1]} {moment.year:04d} "
120
+ f"{moment.hour:02d}:{moment.minute:02d}:{moment.second:02d} GMT"
121
+ )
setcookie/_errors.py ADDED
@@ -0,0 +1,7 @@
1
+ """Errors raised by the codec."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CookieError(ValueError):
7
+ """A Set-Cookie line a user agent ignores, breaks the grammar, or cannot be built safely."""
setcookie/_match.py ADDED
@@ -0,0 +1,85 @@
1
+ """Request URLs, default-path, domain-match and path-match (sections 5.1.2 to 5.1.4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ from typing import TYPE_CHECKING, NamedTuple
7
+ from urllib.parse import urlsplit
8
+
9
+ from ._errors import CookieError
10
+
11
+ if TYPE_CHECKING:
12
+ from ._model import StoredCookie
13
+
14
+ _SECURE_SCHEMES = frozenset({"https", "wss"})
15
+
16
+
17
+ class Request(NamedTuple):
18
+ host: str
19
+ path: str
20
+ secure: bool
21
+
22
+
23
+ def request_of(url: str) -> Request:
24
+ """Split a request URL into its canonical host, path and whether it is secure.
25
+
26
+ Non-ASCII hosts go through the standard library's IDNA 2003 codec, which maps a few
27
+ characters (such as sharp s) differently from the IDNA 2008 rules of section 5.1.2.
28
+ """
29
+ try:
30
+ parts = urlsplit(url)
31
+ host = parts.hostname
32
+ except ValueError as error:
33
+ raise CookieError(f"the request URL is malformed: {url!r}") from error
34
+ if not host:
35
+ raise CookieError(f"the request URL has no host: {url!r}")
36
+ try:
37
+ canonical = host.rstrip(".").encode("idna").decode("ascii").lower()
38
+ # A label that claims to be an A-label must decode (section 5.1.2 step 4).
39
+ canonical.encode("ascii").decode("idna")
40
+ except UnicodeError:
41
+ raise CookieError(f"the request host cannot be canonicalized: {host!r}") from None
42
+ return Request(canonical, parts.path, parts.scheme.lower() in _SECURE_SCHEMES)
43
+
44
+
45
+ def is_ip_address(host: str) -> bool:
46
+ try:
47
+ ipaddress.ip_address(host)
48
+ except ValueError:
49
+ return False
50
+ return True
51
+
52
+
53
+ def domain_match(host: str, domain: str) -> bool:
54
+ """Section 5.1.3: identical, or a host name ending in '.' + domain."""
55
+ if host == domain:
56
+ return True
57
+ return host.endswith("." + domain) and not is_ip_address(host)
58
+
59
+
60
+ def default_path(uri_path: str) -> str:
61
+ """Section 5.1.4: the directory of the request path, '/' when there is none."""
62
+ if not uri_path.startswith("/") or uri_path.count("/") <= 1:
63
+ return "/"
64
+ return uri_path[: uri_path.rindex("/")]
65
+
66
+
67
+ def path_match(request_path: str, cookie_path: str) -> bool:
68
+ """Section 5.1.4: identical, or a prefix that ends at a '/' boundary."""
69
+ if request_path == cookie_path:
70
+ return True
71
+ if not request_path.startswith(cookie_path):
72
+ return False
73
+ return cookie_path.endswith("/") or request_path[len(cookie_path)] == "/"
74
+
75
+
76
+ def request_matches(cookie: StoredCookie, url: str) -> bool:
77
+ request = request_of(url)
78
+ if cookie.host_only:
79
+ if request.host != cookie.domain:
80
+ return False
81
+ elif not domain_match(request.host, cookie.domain):
82
+ return False
83
+ if not path_match(request.path or "/", cookie.path):
84
+ return False
85
+ return request.secure or not cookie.secure
setcookie/_model.py ADDED
@@ -0,0 +1,84 @@
1
+ """Typed representations of a Set-Cookie line and of a cookie a user agent stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Literal
8
+
9
+ SameSite = Literal["Lax", "Strict", "None"]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Cookie:
14
+ """One Set-Cookie line decomposed into its name, value and attributes.
15
+
16
+ When an attribute repeats, the last one wins as in a user agent: an unreadable Expires
17
+ or Max-Age is skipped, while an empty Domain or an unknown SameSite value resets the
18
+ earlier one. ``path`` is ``""`` when a Path attribute was given but was empty or
19
+ relative, which tells the user agent to use the default path. ``extensions`` keeps
20
+ unrecognised attributes in order as ``(name, value)``, with ``None`` for an attribute
21
+ that had no ``=``.
22
+ """
23
+
24
+ name: str
25
+ value: str
26
+ expires: datetime | None = None
27
+ max_age: int | None = None
28
+ domain: str | None = None
29
+ path: str | None = None
30
+ secure: bool = False
31
+ http_only: bool = False
32
+ same_site: SameSite | None = None
33
+ partitioned: bool = False
34
+ extensions: tuple[tuple[str, str | None], ...] = ()
35
+
36
+ def header(self) -> str:
37
+ """Render this cookie with `build`; raise CookieError when it breaks the grammar."""
38
+ from ._build import build
39
+
40
+ return build(
41
+ self.name,
42
+ self.value,
43
+ expires=self.expires,
44
+ max_age=self.max_age,
45
+ domain=self.domain,
46
+ path=self.path,
47
+ secure=self.secure,
48
+ http_only=self.http_only,
49
+ same_site=self.same_site,
50
+ partitioned=self.partitioned,
51
+ extensions=self.extensions,
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class StoredCookie:
57
+ """A cookie as the storage model of section 5.7 records it.
58
+
59
+ ``domain`` is the cookie's domain after matching against the request host;
60
+ ``host_only`` is true when no Domain attribute widened it. ``expiry`` is ``None`` for
61
+ a session cookie (``persistent`` false).
62
+ """
63
+
64
+ name: str
65
+ value: str
66
+ domain: str
67
+ path: str
68
+ host_only: bool
69
+ secure: bool
70
+ http_only: bool
71
+ same_site: SameSite | None
72
+ partitioned: bool
73
+ persistent: bool
74
+ expiry: datetime | None
75
+
76
+ def is_expired(self, now: datetime) -> bool:
77
+ """True when the expiry time has been reached at ``now`` (an aware datetime)."""
78
+ return self.expiry is not None and self.expiry <= now
79
+
80
+ def matches(self, url: str) -> bool:
81
+ """True when a request to ``url`` would carry this cookie (domain, path, secure)."""
82
+ from ._match import request_matches
83
+
84
+ return request_matches(self, url)
setcookie/_parse.py ADDED
@@ -0,0 +1,103 @@
1
+ """Parse one Set-Cookie line with the user agent algorithm of section 5.6."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from ._chars import (
8
+ MAX_ATTRIBUTE_OCTETS,
9
+ MAX_PAIR_OCTETS,
10
+ ascii_lower,
11
+ has_forbidden_ctl,
12
+ octets,
13
+ trim,
14
+ )
15
+ from ._dates import parse_date
16
+ from ._errors import CookieError
17
+ from ._model import Cookie, SameSite
18
+ from ._strict import check_strict
19
+
20
+ _SAME_SITE: dict[str, SameSite] = {"lax": "Lax", "strict": "Strict", "none": "None"}
21
+
22
+
23
+ def parse(line: str, *, strict: bool = False) -> Cookie:
24
+ """Decode one Set-Cookie line the way a user agent reads it.
25
+
26
+ Raise CookieError when a user agent ignores the whole line: a control character
27
+ other than HTAB, an empty name with an empty value, or a name and value longer than
28
+ 4096 octets together. Unknown or malformed attributes are skipped, never rejected.
29
+ With ``strict=True`` the line must also follow the server grammar of section 4.1.1,
30
+ and CookieError names the first rule it breaks.
31
+ """
32
+ if not isinstance(line, str):
33
+ raise TypeError(f"expected str, got {type(line).__name__}")
34
+ if strict:
35
+ check_strict(line)
36
+ if has_forbidden_ctl(line):
37
+ raise CookieError(f"control character in the line: {line!r}")
38
+ pair, separator, attributes = line.partition(";")
39
+ if "=" in pair:
40
+ raw_name, _, raw_value = pair.partition("=")
41
+ else:
42
+ raw_name, raw_value = "", pair
43
+ name, value = trim(raw_name), trim(raw_value)
44
+ if octets(name) + octets(value) > MAX_PAIR_OCTETS:
45
+ raise CookieError("the name and value are longer than 4096 octets together")
46
+ if not name and not value:
47
+ raise CookieError(f"empty name and empty value: {line!r}")
48
+ return _read_attributes(name, value, attributes.split(";") if separator else [])
49
+
50
+
51
+ def _read_attributes(name: str, value: str, avs: list[str]) -> Cookie:
52
+ expires: datetime | None = None
53
+ max_age: int | None = None
54
+ domain: str | None = None
55
+ path: str | None = None
56
+ secure = http_only = partitioned = False
57
+ same_site: SameSite | None = None
58
+ extensions: list[tuple[str, str | None]] = []
59
+ for av in avs:
60
+ raw_key, equals, raw = av.partition("=")
61
+ key, raw = trim(raw_key), trim(raw)
62
+ if not key or octets(raw) > MAX_ATTRIBUTE_OCTETS:
63
+ continue
64
+ keyword = ascii_lower(key)
65
+ if keyword == "expires":
66
+ expires = parse_date(raw) or expires
67
+ elif keyword == "max-age":
68
+ max_age = _delta_seconds(raw, max_age)
69
+ elif keyword == "domain":
70
+ domain = ascii_lower(raw[1:] if raw.startswith(".") else raw) or None
71
+ elif keyword == "path":
72
+ path = raw if raw.startswith("/") else ""
73
+ elif keyword == "secure":
74
+ secure = True
75
+ elif keyword == "httponly":
76
+ http_only = True
77
+ elif keyword == "partitioned":
78
+ partitioned = True
79
+ elif keyword == "samesite":
80
+ same_site = _SAME_SITE.get(ascii_lower(raw))
81
+ else:
82
+ extensions.append((key, raw if equals else None))
83
+ return Cookie(
84
+ name=name,
85
+ value=value,
86
+ expires=expires,
87
+ max_age=max_age,
88
+ domain=domain,
89
+ path=path,
90
+ secure=secure,
91
+ http_only=http_only,
92
+ same_site=same_site,
93
+ partitioned=partitioned,
94
+ extensions=tuple(extensions),
95
+ )
96
+
97
+
98
+ def _delta_seconds(raw: str, previous: int | None) -> int | None:
99
+ """Section 5.6.2: DIGITs with an optional leading '-'; anything else keeps ``previous``."""
100
+ digits = raw[1:] if raw.startswith("-") else raw
101
+ if not digits or not all("0" <= char <= "9" for char in digits):
102
+ return previous
103
+ return int(raw)
setcookie/_rules.py ADDED
@@ -0,0 +1,34 @@
1
+ """Rules shared by the builder, strict mode and the storage model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._chars import ascii_lower
6
+
7
+
8
+ def prefix_problem(name: str, *, secure: bool, has_domain: bool, path: str | None) -> str | None:
9
+ """Explain how a name prefix rule (section 5.7 steps 20 and 21) is broken, or None.
10
+
11
+ Prefixes are matched case-insensitively, as user agents do (section 5.4).
12
+ """
13
+ folded = ascii_lower(name)
14
+ if folded.startswith("__secure-") and not secure:
15
+ return "a __Secure- cookie needs the Secure attribute"
16
+ if folded.startswith("__host-"):
17
+ if not secure:
18
+ return "a __Host- cookie needs the Secure attribute"
19
+ if has_domain:
20
+ return "a __Host- cookie must not have a Domain attribute"
21
+ if path != "/":
22
+ return "a __Host- cookie needs Path=/"
23
+ return None
24
+
25
+
26
+ def secure_problem(*, same_site: str | None, partitioned: bool, secure: bool) -> str | None:
27
+ """Explain why SameSite=None or Partitioned is refused without Secure, or None."""
28
+ if secure:
29
+ return None
30
+ if same_site == "None":
31
+ return "SameSite=None needs the Secure attribute"
32
+ if partitioned:
33
+ return "Partitioned needs the Secure attribute"
34
+ return None
setcookie/_store.py ADDED
@@ -0,0 +1,97 @@
1
+ """The storage model of section 5.7 for one received cookie."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ from ._chars import MAX_AGE_LIMIT, ascii_lower
9
+ from ._errors import CookieError
10
+ from ._match import default_path, domain_match, request_of
11
+ from ._model import Cookie, StoredCookie
12
+ from ._parse import parse
13
+ from ._rules import prefix_problem, secure_problem
14
+
15
+ _EARLIEST = datetime.min.replace(tzinfo=timezone.utc)
16
+ _LIMIT = timedelta(seconds=MAX_AGE_LIMIT)
17
+
18
+
19
+ def receive(
20
+ line: str,
21
+ url: str,
22
+ *,
23
+ now: datetime,
24
+ cross_site: bool = False,
25
+ is_public_suffix: Callable[[str], bool] | None = None,
26
+ ) -> StoredCookie:
27
+ """Run the storage model for a Set-Cookie line received from ``url`` at ``now``.
28
+
29
+ Return the cookie a user agent would store, or raise CookieError naming the rule that
30
+ makes it ignore the line. ``now`` must be an aware datetime. ``cross_site`` marks a
31
+ cross-site request that is not a top-level navigation, where only SameSite=None
32
+ cookies may be set. ``is_public_suffix`` enables the public suffix check of step 9.
33
+ Rules that need the existing cookie store (steps 16, 23 and 24) are left to the caller.
34
+ """
35
+ if now.tzinfo is None:
36
+ raise ValueError("now must be an aware datetime")
37
+ cookie = parse(line)
38
+ request = request_of(url)
39
+ domain, host_only = _domain(cookie, request.host, is_public_suffix)
40
+ path = cookie.path or default_path(request.path)
41
+ if cookie.secure and not request.secure:
42
+ raise CookieError("a Secure cookie cannot be set from an insecure URL")
43
+ if cross_site and cookie.same_site != "None":
44
+ raise CookieError("a cross-site request can only set a SameSite=None cookie")
45
+ problem = secure_problem(
46
+ same_site=cookie.same_site, partitioned=cookie.partitioned, secure=cookie.secure
47
+ ) or prefix_problem(
48
+ cookie.name,
49
+ secure=cookie.secure,
50
+ has_domain=not host_only,
51
+ path=path if cookie.path is not None else None,
52
+ )
53
+ if problem:
54
+ raise CookieError(problem)
55
+ if not cookie.name and ascii_lower(cookie.value).startswith(("__secure-", "__host-")):
56
+ raise CookieError("a nameless cookie cannot carry a prefixed value")
57
+ expiry = _expiry(cookie, now)
58
+ return StoredCookie(
59
+ name=cookie.name,
60
+ value=cookie.value,
61
+ domain=domain,
62
+ path=path,
63
+ host_only=host_only,
64
+ secure=cookie.secure,
65
+ http_only=cookie.http_only,
66
+ same_site=cookie.same_site,
67
+ partitioned=cookie.partitioned,
68
+ persistent=expiry is not None,
69
+ expiry=expiry,
70
+ )
71
+
72
+
73
+ def _domain(
74
+ cookie: Cookie, host: str, is_public_suffix: Callable[[str], bool] | None
75
+ ) -> tuple[str, bool]:
76
+ attribute = cookie.domain or ""
77
+ if not attribute.isascii():
78
+ raise CookieError(f"the Domain attribute {attribute!r} is not ASCII")
79
+ if attribute and is_public_suffix is not None and is_public_suffix(attribute):
80
+ if attribute != host:
81
+ raise CookieError(f"the Domain attribute {attribute!r} is a public suffix")
82
+ attribute = ""
83
+ if not attribute:
84
+ return host, True
85
+ if not domain_match(host, attribute):
86
+ raise CookieError(f"the host {host!r} does not domain-match {attribute!r}")
87
+ return attribute, False
88
+
89
+
90
+ def _expiry(cookie: Cookie, now: datetime) -> datetime | None:
91
+ if cookie.max_age is not None:
92
+ if cookie.max_age <= 0:
93
+ return _EARLIEST
94
+ return now + timedelta(seconds=min(cookie.max_age, MAX_AGE_LIMIT))
95
+ if cookie.expires is not None:
96
+ return min(cookie.expires, now + _LIMIT)
97
+ return None
setcookie/_strict.py ADDED
@@ -0,0 +1,101 @@
1
+ """Strict mode: check a Set-Cookie line against the server grammar of section 4.1.1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import NoReturn
7
+
8
+ from ._chars import (
9
+ MAX_ATTRIBUTE_OCTETS,
10
+ MAX_PAIR_OCTETS,
11
+ WSP,
12
+ ascii_lower,
13
+ is_av_text,
14
+ is_cookie_value,
15
+ is_hostname,
16
+ is_token,
17
+ octets,
18
+ )
19
+ from ._dates import parse_fixdate
20
+ from ._errors import CookieError
21
+ from ._rules import prefix_problem, secure_problem
22
+
23
+ _FLAGS = frozenset({"secure", "httponly", "partitioned"})
24
+ _VALUED = frozenset({"expires", "max-age", "domain", "path", "samesite"})
25
+ _MAX_AGE = re.compile(r"[1-9][0-9]*")
26
+ _SAME_SITE = frozenset({"strict", "lax", "none"})
27
+
28
+
29
+ def check_strict(line: str) -> None:
30
+ """Raise CookieError naming the first rule of the server grammar the line breaks."""
31
+ if line != line.rstrip(WSP):
32
+ _fail("trailing whitespace")
33
+ pair, *avs = line.split(";")
34
+ raw_name, equals, raw_value = pair.partition("=")
35
+ if not equals:
36
+ _fail("the cookie-pair has no '='")
37
+ name, value = raw_name.strip(WSP), raw_value.strip(WSP)
38
+ if not is_token(name):
39
+ _fail(f"the cookie-name {name!r} is not a token")
40
+ if not is_cookie_value(value):
41
+ _fail(f"the cookie-value {value!r} has characters outside cookie-octet")
42
+ if octets(name) + octets(value) > MAX_PAIR_OCTETS:
43
+ _fail("the name and value are longer than 4096 octets together")
44
+ seen: dict[str, str] = {}
45
+ for av in avs:
46
+ keyword, text = _attribute(av.strip(WSP))
47
+ if keyword in seen:
48
+ _fail(f"the {keyword} attribute appears twice")
49
+ seen[keyword] = text
50
+ _check_semantics(name, seen)
51
+
52
+
53
+ def _attribute(av: str) -> tuple[str, str]:
54
+ """Validate one cookie-av; return its lower-cased name and its value."""
55
+ if not av:
56
+ _fail("empty attribute")
57
+ raw_key, equals, raw = av.partition("=")
58
+ keyword, text = ascii_lower(raw_key.strip(WSP)), raw.strip(WSP)
59
+ if octets(text) > MAX_ATTRIBUTE_OCTETS:
60
+ _fail(f"the {raw_key.strip(WSP)} value is longer than 1024 octets")
61
+ if keyword in _FLAGS:
62
+ if equals:
63
+ _fail(f"the {keyword} attribute takes no value")
64
+ elif keyword in _VALUED:
65
+ if not equals:
66
+ _fail(f"the {keyword} attribute needs a value")
67
+ _check_value(keyword, text)
68
+ elif not is_av_text(av):
69
+ _fail(f"the attribute {av!r} has characters outside av-octet")
70
+ return keyword, text
71
+
72
+
73
+ def _check_value(keyword: str, text: str) -> None:
74
+ if keyword == "expires" and parse_fixdate(text) is None:
75
+ _fail(f"Expires={text!r} is not an IMF-fixdate")
76
+ if keyword == "max-age" and not _MAX_AGE.fullmatch(text):
77
+ _fail(f"Max-Age={text!r} is not a non-zero-digit followed by DIGITs")
78
+ if keyword == "domain" and not is_hostname(text):
79
+ _fail(f"Domain={text!r} is not a host name")
80
+ if keyword == "path" and not is_av_text(text):
81
+ _fail(f"Path={text!r} has characters outside av-octet")
82
+ if keyword == "samesite" and ascii_lower(text) not in _SAME_SITE:
83
+ _fail(f"SameSite={text!r} is not Strict, Lax or None")
84
+
85
+
86
+ def _check_semantics(name: str, attributes: dict[str, str]) -> None:
87
+ secure = "secure" in attributes
88
+ same_site = attributes.get("samesite")
89
+ problem = prefix_problem(
90
+ name, secure=secure, has_domain="domain" in attributes, path=attributes.get("path")
91
+ ) or secure_problem(
92
+ same_site="None" if same_site is not None and ascii_lower(same_site) == "none" else None,
93
+ partitioned="partitioned" in attributes,
94
+ secure=secure,
95
+ )
96
+ if problem:
97
+ _fail(problem)
98
+
99
+
100
+ def _fail(reason: str) -> NoReturn:
101
+ raise CookieError(f"strict: {reason}")
setcookie/py.typed ADDED
File without changes
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.5
2
+ Name: setcookie
3
+ Version: 0.2.0
4
+ Summary: Parse, validate and safely build HTTP Set-Cookie headers, typed and zero-dependency, per RFC 6265bis
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/setcookie
7
+ Project-URL: Issues, https://github.com/JaviLianes8/setcookie/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/setcookie/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: chips,cookie,headers,http,partitioned,rfc6265,rfc6265bis,samesite,set-cookie
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 :: Internet :: WWW/HTTP :: Session
28
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
29
+ Classifier: Typing :: Typed
30
+ Requires-Python: >=3.10
31
+ Provides-Extra: dev
32
+ Requires-Dist: build; extra == 'dev'
33
+ Requires-Dist: mypy; extra == 'dev'
34
+ Requires-Dist: pytest; extra == 'dev'
35
+ Requires-Dist: ruff; extra == 'dev'
36
+ Requires-Dist: twine; extra == 'dev'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # setcookie
40
+
41
+ Typed Set-Cookie codec for Python: parse one Set-Cookie line the way browsers do, check
42
+ it against the server grammar, build lines that cannot inject headers or break prefix
43
+ rules, and see what a user agent would store. Zero dependencies.
44
+
45
+ It follows the current cookie specification, draft-ietf-httpbis-rfc6265bis (revision 22,
46
+ the successor of RFC 6265), plus the Partitioned attribute, and is checked against the
47
+ web-platform-tests cookie parser cases.
48
+
49
+ ## Install
50
+
51
+ ```
52
+ pip install setcookie
53
+ ```
54
+
55
+ ## Build a header
56
+
57
+ ```python
58
+ from setcookie import build, expire
59
+
60
+ print(build("sid", "a1b2c3", max_age=3600, secure=True, http_only=True, same_site="Lax"))
61
+ # sid=a1b2c3; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=Lax
62
+ print(build("__Host-session", "x9", secure=True, partitioned=True, same_site="None"))
63
+ # __Host-session=x9; Path=/; Secure; SameSite=None; Partitioned
64
+ print(expire("sid"))
65
+ # sid=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/
66
+ ```
67
+
68
+ Every argument is checked on its own before it is joined, and anything a user agent
69
+ would drop or misread raises `CookieError` (a `ValueError`):
70
+
71
+ ```python
72
+ from setcookie import CookieError, build
73
+
74
+ for name, value, options in [
75
+ ("sid", "a\r\nSet-Cookie: admin=1", {}),
76
+ ("sid", "abc", {"domain": "example.com; Secure"}),
77
+ ("__Host-sid", "abc", {"secure": True, "domain": "example.com"}),
78
+ ("sid", "abc", {"same_site": "None"}),
79
+ ]:
80
+ try:
81
+ build(name, value, **options)
82
+ except CookieError as error:
83
+ print(error)
84
+ # cookie value 'a\r\nSet-Cookie: admin=1' has characters outside cookie-octet; encode it first
85
+ # domain 'example.com; Secure' is not a host name (no leading dot, no port)
86
+ # a __Host- cookie must not have a Domain attribute
87
+ # SameSite=None needs the Secure attribute
88
+ ```
89
+
90
+ Names must be HTTP tokens and values cookie-octets (encode arbitrary data, for example
91
+ with Base64). `max_age` must be a positive int: the grammar has no `Max-Age=0`, so use
92
+ `expire` to delete a cookie. `expires` is written as an IMF-fixdate in GMT whatever the
93
+ locale or time zone of the datetime; a naive datetime is taken to be UTC, not local time.
94
+
95
+ ## Parse a header
96
+
97
+ `parse` runs the user agent algorithm: whitespace is trimmed, the last attribute of each
98
+ kind wins, malformed attributes are skipped, dates are read with the cookie date
99
+ algorithm, and unknown attributes are kept.
100
+
101
+ ```python
102
+ from setcookie import parse
103
+
104
+ cookie = parse(
105
+ "sid=abc; expires=Fri Jan 01 2038 00:00:00 GMT; Domain=.Example.com; "
106
+ "Max-Age=+5; samesite=lax; Priority=High"
107
+ )
108
+ print(cookie.expires, cookie.domain, cookie.max_age, cookie.same_site)
109
+ # 2038-01-01 00:00:00+00:00 example.com None Lax
110
+ print(cookie.extensions)
111
+ # (('Priority', 'High'),)
112
+ print(parse("nameless").name == "", parse("nameless").value)
113
+ # True nameless
114
+ ```
115
+
116
+ A line a user agent ignores entirely (a control character, an empty name and value, a
117
+ name and value over 4096 octets) raises `CookieError`. With `strict=True` the line must
118
+ also follow the server grammar, and the error names the rule:
119
+
120
+ ```python
121
+ from setcookie import CookieError, parse
122
+
123
+ for line in ["sid=a b", "sid=abc; Max-Age=0", "__Secure-id=1", "sid=abc; Path=/; path=/x"]:
124
+ try:
125
+ parse(line, strict=True)
126
+ except CookieError as error:
127
+ print(error)
128
+ # strict: the cookie-value 'a b' has characters outside cookie-octet
129
+ # strict: Max-Age='0' is not a non-zero-digit followed by DIGITs
130
+ # strict: a __Secure- cookie needs the Secure attribute
131
+ # strict: the path attribute appears twice
132
+ ```
133
+
134
+ ## What a browser stores
135
+
136
+ `receive` runs the storage model for one line received from a URL. It returns a
137
+ `StoredCookie` with the resolved domain, path and expiry, or raises `CookieError` with
138
+ the rule that makes a user agent ignore the line.
139
+
140
+ ```python
141
+ from datetime import datetime, timezone
142
+
143
+ from setcookie import CookieError, receive
144
+
145
+ now = datetime(2026, 10, 13, tzinfo=timezone.utc)
146
+ stored = receive(
147
+ "sid=1; Domain=example.com; Max-Age=999999999", "https://www.example.com/app/login", now=now
148
+ )
149
+ print(stored.domain, stored.host_only, stored.path, stored.expiry)
150
+ # example.com False /app 2027-11-17 00:00:00+00:00
151
+ print(
152
+ stored.matches("https://shop.example.com/app/cart"), stored.matches("https://example.org/app")
153
+ )
154
+ # True False
155
+ try:
156
+ receive("sid=1; Secure", "http://www.example.com/", now=now)
157
+ except CookieError as error:
158
+ print(error)
159
+ # a Secure cookie cannot be set from an insecure URL
160
+ ```
161
+
162
+ Expiry follows Max-Age over Expires and is capped at 400 days. Pass
163
+ `is_public_suffix` to reject domains such as `co.uk`, and `cross_site=True` for a
164
+ cross-site request that is not a top-level navigation. Rules that need the rest of the
165
+ cookie store (overwriting, eviction, secure cookie shadowing) are left to the caller.
166
+ Non-ASCII hosts are converted with the standard library's IDNA 2003 codec, which differs
167
+ from IDNA 2008 for a few characters.
168
+
169
+ ## Command line
170
+
171
+ ```
172
+ $ curl -sI https://example.com | setcookie --strict
173
+ $ setcookie --url https://www.example.com/app "sid=1; Domain=example.com"
174
+ ```
175
+
176
+ `setcookie` reads lines from its arguments or from stdin (from a header dump, which starts
177
+ with an `HTTP/` status line, only the Set-Cookie fields are used) and prints JSON.
178
+ `--strict` checks the server grammar, `--url` adds what a user agent would store, `--now`
179
+ fixes the clock. The exit status is 1 when any line is refused. `python -m setcookie`
180
+ works too.
181
+
182
+ ## API
183
+
184
+ | Name | What it does |
185
+ | --- | --- |
186
+ | `build(name, value, *, expires, max_age, domain, path="/", secure, http_only, same_site, partitioned, extensions)` | One safe Set-Cookie line |
187
+ | `expire(name, *, domain, path="/", secure, ...)` | A line that deletes the cookie |
188
+ | `parse(line, *, strict=False)` | A frozen `Cookie`, read the way user agents read it |
189
+ | `receive(line, url, *, now, cross_site=False, is_public_suffix=None)` | The `StoredCookie` a user agent keeps |
190
+ | `parse_date(text)` | The cookie date algorithm, an aware UTC datetime or `None` |
191
+ | `Cookie` | Name, value, attributes, `extensions`, and `header()` to render it again |
192
+ | `StoredCookie` | Domain, path, flags, expiry, `is_expired(now)` and `matches(url)` |
193
+ | `SameSite` | `Literal["Lax", "Strict", "None"]` |
194
+ | `CookieError` | The `ValueError` raised for ignored, ungrammatical or unsafe lines |
195
+
196
+ ## Why
197
+
198
+ `http.cookies.SimpleCookie` was written for the old Cookie header, not for one
199
+ Set-Cookie line: it keeps `Expires` as a string, accepts any `SameSite` value, reads an
200
+ unknown attribute such as `Priority=High` as a second cookie, silently drops the whole
201
+ line when it meets a flag it does not know (`Foo`, and `Partitioned` before Python 3.14),
202
+ drops nameless cookies, and
203
+ writes a control character in a value as an octal escape that browsers do not decode.
204
+ `setcookie` follows the current draft step by step, refuses to build what browsers would
205
+ drop, and gives typed results.
206
+
207
+ ## License
208
+
209
+ MIT. See [LICENSE](LICENSE).
210
+
211
+ ## Links
212
+
213
+ - Website: https://lianes8studio.com
214
+ - GitHub: https://github.com/JaviLianes8
215
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,19 @@
1
+ setcookie/__init__.py,sha256=eJuOsy4hZUHuOgh_fprcsy1k5gjVLElzDVG1U1hLrEQ,511
2
+ setcookie/__main__.py,sha256=opkfF8RTjeDb9qj2vActbwsjJC-tHJc3ZOyH62beCHg,147
3
+ setcookie/_build.py,sha256=0AgmjsPNcE5FRCXX3EvuWvYPluvLYlNQi4AvwR_vAxo,6044
4
+ setcookie/_chars.py,sha256=H3xyr8msUgQSLObbonToZiRP9mU-UVPtGBgG0v-3rAo,2423
5
+ setcookie/_cli.py,sha256=x3Gjto1XjiAKHZT-pCXfGcknJgMXJB1lMryKR6WHsuc,3411
6
+ setcookie/_dates.py,sha256=IEpyN5WEjrl8tXIpqki8T3pkxS8GUKepzS1ynnnxUP8,4053
7
+ setcookie/_errors.py,sha256=6PgsqhCuRfbK8BKYmsg5HJnYxAs1JY3LRfMp3HvXEe8,200
8
+ setcookie/_match.py,sha256=RKhAC4AP3Ggadej8TM45Eo6SCJGQ5YdguprEGCFc9tk,2786
9
+ setcookie/_model.py,sha256=_IriNlW0izfnVc4PW2iyKlnCsR16A1Ch6ALUI_J1l7E,2710
10
+ setcookie/_parse.py,sha256=Zywf29EFFruFJQ7QuoSmb8Ozgpx0ZRnvr5RlBHBwXRU,3757
11
+ setcookie/_rules.py,sha256=wW8STksRRy5wcKVdSMXBoRGMaXgbRp_DRYtIy6Fb6Nw,1267
12
+ setcookie/_store.py,sha256=oBRQiStcpRukies3pklhXMMLihc-Ar5knS7kLplfX-w,3712
13
+ setcookie/_strict.py,sha256=XjcMgJITl-MmzDsIFvC6Gba0SbM9FcRWp_JWR8a3i4c,3708
14
+ setcookie/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ setcookie-0.2.0.dist-info/METADATA,sha256=mBxmwo3ZGAhmF0rMRZ2M9HJBCGdX3mYw4PXmpsXnujk,8779
16
+ setcookie-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
17
+ setcookie-0.2.0.dist-info/entry_points.txt,sha256=vqrbPY9EfyH_hPSFyiAowTRVbCzFlDdDBLHnJy-ih58,50
18
+ setcookie-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
19
+ setcookie-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ setcookie = setcookie._cli:main
@@ -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.