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 +24 -0
- setcookie/__main__.py +9 -0
- setcookie/_build.py +171 -0
- setcookie/_chars.py +65 -0
- setcookie/_cli.py +91 -0
- setcookie/_dates.py +121 -0
- setcookie/_errors.py +7 -0
- setcookie/_match.py +85 -0
- setcookie/_model.py +84 -0
- setcookie/_parse.py +103 -0
- setcookie/_rules.py +34 -0
- setcookie/_store.py +97 -0
- setcookie/_strict.py +101 -0
- setcookie/py.typed +0 -0
- setcookie-0.2.0.dist-info/METADATA +215 -0
- setcookie-0.2.0.dist-info/RECORD +19 -0
- setcookie-0.2.0.dist-info/WHEEL +4 -0
- setcookie-0.2.0.dist-info/entry_points.txt +2 -0
- setcookie-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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
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,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.
|