setcookie 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 (42) hide show
  1. setcookie-0.2.0/.gitattributes +1 -0
  2. setcookie-0.2.0/.gitignore +16 -0
  3. setcookie-0.2.0/CHANGELOG.md +49 -0
  4. setcookie-0.2.0/CONTRIBUTING.md +19 -0
  5. setcookie-0.2.0/LICENSE +21 -0
  6. setcookie-0.2.0/PKG-INFO +215 -0
  7. setcookie-0.2.0/README.md +177 -0
  8. setcookie-0.2.0/pyproject.toml +74 -0
  9. setcookie-0.2.0/requirements-dev.txt +3 -0
  10. setcookie-0.2.0/scripts/check.ps1 +17 -0
  11. setcookie-0.2.0/scripts/fetch_upstream.py +39 -0
  12. setcookie-0.2.0/scripts/release.ps1 +28 -0
  13. setcookie-0.2.0/src/setcookie/__init__.py +24 -0
  14. setcookie-0.2.0/src/setcookie/__main__.py +9 -0
  15. setcookie-0.2.0/src/setcookie/_build.py +171 -0
  16. setcookie-0.2.0/src/setcookie/_chars.py +65 -0
  17. setcookie-0.2.0/src/setcookie/_cli.py +91 -0
  18. setcookie-0.2.0/src/setcookie/_dates.py +121 -0
  19. setcookie-0.2.0/src/setcookie/_errors.py +7 -0
  20. setcookie-0.2.0/src/setcookie/_match.py +85 -0
  21. setcookie-0.2.0/src/setcookie/_model.py +84 -0
  22. setcookie-0.2.0/src/setcookie/_parse.py +103 -0
  23. setcookie-0.2.0/src/setcookie/_rules.py +34 -0
  24. setcookie-0.2.0/src/setcookie/_store.py +97 -0
  25. setcookie-0.2.0/src/setcookie/_strict.py +101 -0
  26. setcookie-0.2.0/src/setcookie/py.typed +0 -0
  27. setcookie-0.2.0/tests/conftest.py +53 -0
  28. setcookie-0.2.0/tests/test_build.py +157 -0
  29. setcookie-0.2.0/tests/test_cli.py +79 -0
  30. setcookie-0.2.0/tests/test_contract.py +36 -0
  31. setcookie-0.2.0/tests/test_dates.py +95 -0
  32. setcookie-0.2.0/tests/test_docs.py +73 -0
  33. setcookie-0.2.0/tests/test_e2e_wheel.py +115 -0
  34. setcookie-0.2.0/tests/test_parse.py +156 -0
  35. setcookie-0.2.0/tests/test_review_fixes.py +71 -0
  36. setcookie-0.2.0/tests/test_roundtrip.py +91 -0
  37. setcookie-0.2.0/tests/test_sources.py +113 -0
  38. setcookie-0.2.0/tests/test_store.py +173 -0
  39. setcookie-0.2.0/tests/test_strict.py +100 -0
  40. setcookie-0.2.0/tests/test_wpt.py +87 -0
  41. setcookie-0.2.0/tests/upstream/MANIFEST.json +92 -0
  42. setcookie-0.2.0/tests/wpt_cases.py +157 -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,49 @@
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
+ ### Changed
12
+
13
+ - `parse` follows the user agent algorithm of draft-ietf-httpbis-rfc6265bis-22: a pair
14
+ without `=` is a nameless cookie instead of an error, only spaces and tabs are trimmed,
15
+ a line with a control character other than tab, an empty name and value, or a name and
16
+ value over 4096 octets raises `CookieError`, attribute values over 1024 octets are
17
+ skipped, and the last valid attribute of each kind wins.
18
+ - Expires is read with the cookie date algorithm (any token order, two digit years,
19
+ zones ignored, nothing before 1601). Max-Age accepts only digits with an optional
20
+ leading minus. Domain loses its leading dot and is lower-cased. An empty or relative
21
+ Path reads as `""`, meaning the default path. An unknown SameSite value resets it.
22
+ - `build` checks every part before joining it and raises `CookieError` (still a
23
+ `ValueError`) for names that are not tokens, values outside cookie-octet, domains that
24
+ are not host names, relative paths, control characters or `;` in attribute values,
25
+ a `max_age` below 1, Expires before 1601, size limits, broken `__Secure-` and `__Host-`
26
+ prefix rules, and SameSite=None or Partitioned without Secure.
27
+
28
+ ### Added
29
+
30
+ - `parse(line, strict=True)` checks the server grammar and names the broken rule.
31
+ - `Cookie.partitioned`, `Cookie.extensions` for unknown attributes, and `Cookie.header()`.
32
+ - `build(partitioned=..., extensions=...)` and `expire()` for deleting a cookie.
33
+ - `receive()` runs the storage model for one line and returns a `StoredCookie` with the
34
+ resolved domain, path and expiry (Max-Age over Expires, capped at 400 days), with
35
+ `is_expired()` and `matches(url)`.
36
+ - `parse_date()`, the cookie date algorithm.
37
+ - The `setcookie` command and `python -m setcookie`: JSON output, `--strict`, `--url`.
38
+
39
+ ## [0.1.0]
40
+
41
+ - First release.
42
+ - `build` assembles one Set-Cookie line from a name, value and the RFC 6265 attributes
43
+ (Expires, Max-Age, Domain, Path, Secure, HttpOnly, SameSite), formatting Expires as a
44
+ locale-independent RFC 7231 GMT date.
45
+ - `parse` decodes one Set-Cookie line into a frozen, typed `Cookie`, reading attribute
46
+ names case-insensitively and tolerating unknown or malformed attributes.
47
+ - `SameSite` is modelled as a `Literal`, closing the gap left by
48
+ `http.cookies.SimpleCookie`, which mishandles the Expires comma and treats SameSite as
49
+ a free-form value.
@@ -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/setcookie/__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,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,177 @@
1
+ # setcookie
2
+
3
+ Typed Set-Cookie codec for Python: parse one Set-Cookie line the way browsers do, check
4
+ it against the server grammar, build lines that cannot inject headers or break prefix
5
+ rules, and see what a user agent would store. Zero dependencies.
6
+
7
+ It follows the current cookie specification, draft-ietf-httpbis-rfc6265bis (revision 22,
8
+ the successor of RFC 6265), plus the Partitioned attribute, and is checked against the
9
+ web-platform-tests cookie parser cases.
10
+
11
+ ## Install
12
+
13
+ ```
14
+ pip install setcookie
15
+ ```
16
+
17
+ ## Build a header
18
+
19
+ ```python
20
+ from setcookie import build, expire
21
+
22
+ print(build("sid", "a1b2c3", max_age=3600, secure=True, http_only=True, same_site="Lax"))
23
+ # sid=a1b2c3; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=Lax
24
+ print(build("__Host-session", "x9", secure=True, partitioned=True, same_site="None"))
25
+ # __Host-session=x9; Path=/; Secure; SameSite=None; Partitioned
26
+ print(expire("sid"))
27
+ # sid=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/
28
+ ```
29
+
30
+ Every argument is checked on its own before it is joined, and anything a user agent
31
+ would drop or misread raises `CookieError` (a `ValueError`):
32
+
33
+ ```python
34
+ from setcookie import CookieError, build
35
+
36
+ for name, value, options in [
37
+ ("sid", "a\r\nSet-Cookie: admin=1", {}),
38
+ ("sid", "abc", {"domain": "example.com; Secure"}),
39
+ ("__Host-sid", "abc", {"secure": True, "domain": "example.com"}),
40
+ ("sid", "abc", {"same_site": "None"}),
41
+ ]:
42
+ try:
43
+ build(name, value, **options)
44
+ except CookieError as error:
45
+ print(error)
46
+ # cookie value 'a\r\nSet-Cookie: admin=1' has characters outside cookie-octet; encode it first
47
+ # domain 'example.com; Secure' is not a host name (no leading dot, no port)
48
+ # a __Host- cookie must not have a Domain attribute
49
+ # SameSite=None needs the Secure attribute
50
+ ```
51
+
52
+ Names must be HTTP tokens and values cookie-octets (encode arbitrary data, for example
53
+ with Base64). `max_age` must be a positive int: the grammar has no `Max-Age=0`, so use
54
+ `expire` to delete a cookie. `expires` is written as an IMF-fixdate in GMT whatever the
55
+ locale or time zone of the datetime; a naive datetime is taken to be UTC, not local time.
56
+
57
+ ## Parse a header
58
+
59
+ `parse` runs the user agent algorithm: whitespace is trimmed, the last attribute of each
60
+ kind wins, malformed attributes are skipped, dates are read with the cookie date
61
+ algorithm, and unknown attributes are kept.
62
+
63
+ ```python
64
+ from setcookie import parse
65
+
66
+ cookie = parse(
67
+ "sid=abc; expires=Fri Jan 01 2038 00:00:00 GMT; Domain=.Example.com; "
68
+ "Max-Age=+5; samesite=lax; Priority=High"
69
+ )
70
+ print(cookie.expires, cookie.domain, cookie.max_age, cookie.same_site)
71
+ # 2038-01-01 00:00:00+00:00 example.com None Lax
72
+ print(cookie.extensions)
73
+ # (('Priority', 'High'),)
74
+ print(parse("nameless").name == "", parse("nameless").value)
75
+ # True nameless
76
+ ```
77
+
78
+ A line a user agent ignores entirely (a control character, an empty name and value, a
79
+ name and value over 4096 octets) raises `CookieError`. With `strict=True` the line must
80
+ also follow the server grammar, and the error names the rule:
81
+
82
+ ```python
83
+ from setcookie import CookieError, parse
84
+
85
+ for line in ["sid=a b", "sid=abc; Max-Age=0", "__Secure-id=1", "sid=abc; Path=/; path=/x"]:
86
+ try:
87
+ parse(line, strict=True)
88
+ except CookieError as error:
89
+ print(error)
90
+ # strict: the cookie-value 'a b' has characters outside cookie-octet
91
+ # strict: Max-Age='0' is not a non-zero-digit followed by DIGITs
92
+ # strict: a __Secure- cookie needs the Secure attribute
93
+ # strict: the path attribute appears twice
94
+ ```
95
+
96
+ ## What a browser stores
97
+
98
+ `receive` runs the storage model for one line received from a URL. It returns a
99
+ `StoredCookie` with the resolved domain, path and expiry, or raises `CookieError` with
100
+ the rule that makes a user agent ignore the line.
101
+
102
+ ```python
103
+ from datetime import datetime, timezone
104
+
105
+ from setcookie import CookieError, receive
106
+
107
+ now = datetime(2026, 10, 13, tzinfo=timezone.utc)
108
+ stored = receive(
109
+ "sid=1; Domain=example.com; Max-Age=999999999", "https://www.example.com/app/login", now=now
110
+ )
111
+ print(stored.domain, stored.host_only, stored.path, stored.expiry)
112
+ # example.com False /app 2027-11-17 00:00:00+00:00
113
+ print(
114
+ stored.matches("https://shop.example.com/app/cart"), stored.matches("https://example.org/app")
115
+ )
116
+ # True False
117
+ try:
118
+ receive("sid=1; Secure", "http://www.example.com/", now=now)
119
+ except CookieError as error:
120
+ print(error)
121
+ # a Secure cookie cannot be set from an insecure URL
122
+ ```
123
+
124
+ Expiry follows Max-Age over Expires and is capped at 400 days. Pass
125
+ `is_public_suffix` to reject domains such as `co.uk`, and `cross_site=True` for a
126
+ cross-site request that is not a top-level navigation. Rules that need the rest of the
127
+ cookie store (overwriting, eviction, secure cookie shadowing) are left to the caller.
128
+ Non-ASCII hosts are converted with the standard library's IDNA 2003 codec, which differs
129
+ from IDNA 2008 for a few characters.
130
+
131
+ ## Command line
132
+
133
+ ```
134
+ $ curl -sI https://example.com | setcookie --strict
135
+ $ setcookie --url https://www.example.com/app "sid=1; Domain=example.com"
136
+ ```
137
+
138
+ `setcookie` reads lines from its arguments or from stdin (from a header dump, which starts
139
+ with an `HTTP/` status line, only the Set-Cookie fields are used) and prints JSON.
140
+ `--strict` checks the server grammar, `--url` adds what a user agent would store, `--now`
141
+ fixes the clock. The exit status is 1 when any line is refused. `python -m setcookie`
142
+ works too.
143
+
144
+ ## API
145
+
146
+ | Name | What it does |
147
+ | --- | --- |
148
+ | `build(name, value, *, expires, max_age, domain, path="/", secure, http_only, same_site, partitioned, extensions)` | One safe Set-Cookie line |
149
+ | `expire(name, *, domain, path="/", secure, ...)` | A line that deletes the cookie |
150
+ | `parse(line, *, strict=False)` | A frozen `Cookie`, read the way user agents read it |
151
+ | `receive(line, url, *, now, cross_site=False, is_public_suffix=None)` | The `StoredCookie` a user agent keeps |
152
+ | `parse_date(text)` | The cookie date algorithm, an aware UTC datetime or `None` |
153
+ | `Cookie` | Name, value, attributes, `extensions`, and `header()` to render it again |
154
+ | `StoredCookie` | Domain, path, flags, expiry, `is_expired(now)` and `matches(url)` |
155
+ | `SameSite` | `Literal["Lax", "Strict", "None"]` |
156
+ | `CookieError` | The `ValueError` raised for ignored, ungrammatical or unsafe lines |
157
+
158
+ ## Why
159
+
160
+ `http.cookies.SimpleCookie` was written for the old Cookie header, not for one
161
+ Set-Cookie line: it keeps `Expires` as a string, accepts any `SameSite` value, reads an
162
+ unknown attribute such as `Priority=High` as a second cookie, silently drops the whole
163
+ line when it meets a flag it does not know (`Foo`, and `Partitioned` before Python 3.14),
164
+ drops nameless cookies, and
165
+ writes a control character in a value as an octal escape that browsers do not decode.
166
+ `setcookie` follows the current draft step by step, refuses to build what browsers would
167
+ drop, and gives typed results.
168
+
169
+ ## License
170
+
171
+ MIT. See [LICENSE](LICENSE).
172
+
173
+ ## Links
174
+
175
+ - Website: https://lianes8studio.com
176
+ - GitHub: https://github.com/JaviLianes8
177
+ - 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 = "setcookie"
7
+ version = "0.2.0"
8
+ description = "Parse, validate and safely build HTTP Set-Cookie headers, typed and zero-dependency, per RFC 6265bis"
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 = ["cookie", "set-cookie", "http", "headers", "rfc6265", "rfc6265bis", "samesite", "partitioned", "chips"]
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 :: Internet :: WWW/HTTP :: Session",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ "Typing :: Typed",
31
+ ]
32
+ dependencies = []
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
36
+
37
+ [project.scripts]
38
+ setcookie = "setcookie._cli:main"
39
+
40
+ [project.urls]
41
+ Homepage = "https://lianes8studio.com"
42
+ Repository = "https://github.com/JaviLianes8/setcookie"
43
+ Issues = "https://github.com/JaviLianes8/setcookie/issues"
44
+ Changelog = "https://github.com/JaviLianes8/setcookie/blob/main/CHANGELOG.md"
45
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
46
+ Funding = "https://buymeacoffee.com/jlianesglrs"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/setcookie"]
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ # Third-party reference files are fetched locally and never shipped; only their manifest is.
53
+ exclude = [
54
+ ".github",
55
+ "IMPROVEMENTS-round*.md",
56
+ "tests/upstream/*.txt",
57
+ "tests/upstream/wpt",
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,17 @@
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"
17
+ Write-Host "Reference files: python scripts/fetch_upstream.py; wheel test: SETCOOKIE_E2E=1 pytest tests/test_e2e_wheel.py"
@@ -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/setcookie/json" | Out-Null
9
+ } catch {
10
+ if ($_.Exception.Response.StatusCode.value__ -eq 404) { $taken = $false } else { throw }
11
+ }
12
+ if ($taken) { throw "The name 'setcookie' 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 }