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.
- setcookie-0.2.0/.gitattributes +1 -0
- setcookie-0.2.0/.gitignore +16 -0
- setcookie-0.2.0/CHANGELOG.md +49 -0
- setcookie-0.2.0/CONTRIBUTING.md +19 -0
- setcookie-0.2.0/LICENSE +21 -0
- setcookie-0.2.0/PKG-INFO +215 -0
- setcookie-0.2.0/README.md +177 -0
- setcookie-0.2.0/pyproject.toml +74 -0
- setcookie-0.2.0/requirements-dev.txt +3 -0
- setcookie-0.2.0/scripts/check.ps1 +17 -0
- setcookie-0.2.0/scripts/fetch_upstream.py +39 -0
- setcookie-0.2.0/scripts/release.ps1 +28 -0
- setcookie-0.2.0/src/setcookie/__init__.py +24 -0
- setcookie-0.2.0/src/setcookie/__main__.py +9 -0
- setcookie-0.2.0/src/setcookie/_build.py +171 -0
- setcookie-0.2.0/src/setcookie/_chars.py +65 -0
- setcookie-0.2.0/src/setcookie/_cli.py +91 -0
- setcookie-0.2.0/src/setcookie/_dates.py +121 -0
- setcookie-0.2.0/src/setcookie/_errors.py +7 -0
- setcookie-0.2.0/src/setcookie/_match.py +85 -0
- setcookie-0.2.0/src/setcookie/_model.py +84 -0
- setcookie-0.2.0/src/setcookie/_parse.py +103 -0
- setcookie-0.2.0/src/setcookie/_rules.py +34 -0
- setcookie-0.2.0/src/setcookie/_store.py +97 -0
- setcookie-0.2.0/src/setcookie/_strict.py +101 -0
- setcookie-0.2.0/src/setcookie/py.typed +0 -0
- setcookie-0.2.0/tests/conftest.py +53 -0
- setcookie-0.2.0/tests/test_build.py +157 -0
- setcookie-0.2.0/tests/test_cli.py +79 -0
- setcookie-0.2.0/tests/test_contract.py +36 -0
- setcookie-0.2.0/tests/test_dates.py +95 -0
- setcookie-0.2.0/tests/test_docs.py +73 -0
- setcookie-0.2.0/tests/test_e2e_wheel.py +115 -0
- setcookie-0.2.0/tests/test_parse.py +156 -0
- setcookie-0.2.0/tests/test_review_fixes.py +71 -0
- setcookie-0.2.0/tests/test_roundtrip.py +91 -0
- setcookie-0.2.0/tests/test_sources.py +113 -0
- setcookie-0.2.0/tests/test_store.py +173 -0
- setcookie-0.2.0/tests/test_strict.py +100 -0
- setcookie-0.2.0/tests/test_wpt.py +87 -0
- setcookie-0.2.0/tests/upstream/MANIFEST.json +92 -0
- 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.
|
setcookie-0.2.0/LICENSE
ADDED
|
@@ -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.
|
setcookie-0.2.0/PKG-INFO
ADDED
|
@@ -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,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 }
|