bas-http 1.0.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.
bas/cookies.py ADDED
@@ -0,0 +1,643 @@
1
+ """
2
+ RFC 6265 compliant CookieJar.
3
+
4
+ This module fixes the major cookie issues in curlcffi:
5
+ 1. Cookies lost during redirects (#55)
6
+ 2. Cookies not accumulating across session requests (#582)
7
+ 3. Need for manual cookie header management
8
+ 4. Improper domain/path matching
9
+
10
+ Key design decisions:
11
+ - Pure Python cookie parsing (no dependency on curl's internal cookie engine)
12
+ - Per-domain storage with RFC 6265 domain matching
13
+ - Thread-safe with threading.Lock
14
+ - Cookie expiration handled correctly
15
+ - Secure, HttpOnly, SameSite attribute support
16
+ - Netscape file format import/export for compatibility
17
+ """
18
+
19
+ import json
20
+ import re
21
+ import threading
22
+ import time
23
+ from datetime import datetime, timezone
24
+ from http.cookiejar import CookieJar as _stdlib_jar
25
+ from typing import Optional
26
+ from urllib.parse import urlparse, urlunparse, quote, unquote
27
+
28
+
29
+ class Cookie:
30
+ """
31
+ Represents a single HTTP cookie.
32
+
33
+ Attributes mirror the key properties in RFC 6265 Section 5.4.
34
+ """
35
+
36
+ __slots__ = (
37
+ "name", "value", "domain", "path", "expires",
38
+ "max_age", "secure", "http_only", "same_site",
39
+ "creation_time", "port", "port_specified",
40
+ "domain_specified", "domain_initial_dot",
41
+ )
42
+
43
+ def __init__(
44
+ self,
45
+ name: str,
46
+ value: str,
47
+ domain: str = "",
48
+ path: str = "/",
49
+ expires: Optional[float] = None,
50
+ max_age: Optional[int] = None,
51
+ secure: bool = False,
52
+ http_only: bool = False,
53
+ same_site: Optional[str] = None,
54
+ port: Optional[str] = None,
55
+ port_specified: bool = False,
56
+ domain_specified: bool = False,
57
+ domain_initial_dot: bool = False,
58
+ ):
59
+ self.name = name
60
+ self.value = value
61
+ self.domain_initial_dot = domain.startswith(".") or domain_initial_dot
62
+ self.domain = domain.lower().strip(".")
63
+ self.path = path or "/"
64
+ self.expires = expires
65
+ self.max_age = max_age
66
+ self.secure = secure
67
+ self.http_only = http_only
68
+ self.same_site = same_site
69
+ self.creation_time = time.time()
70
+ self.port = port
71
+ self.port_specified = port_specified
72
+ self.domain_specified = domain_specified
73
+
74
+ @property
75
+ def effective_expires(self) -> Optional[float]:
76
+ """Compute actual expiry time considering max_age vs expires."""
77
+ if self.max_age is not None:
78
+ return self.creation_time + self.max_age
79
+ return self.expires
80
+
81
+ @property
82
+ def is_expired(self) -> bool:
83
+ """Check if the cookie has expired."""
84
+ exp = self.effective_expires
85
+ if exp is None:
86
+ return False
87
+ return time.time() > exp
88
+
89
+ def matches_domain(self, request_domain: str) -> bool:
90
+ """
91
+ RFC 6265 Section 5.1.3 — Domain Matching.
92
+
93
+ A request-domain D matches a cookie-domain C if:
94
+ - The string is identical to C, OR
95
+ - The string is a rightmost match of C (suffix match)
96
+ AND C's first character is '.', OR
97
+ - The string is a rightmost match of C
98
+ AND C does NOT start with '.'
99
+ AND the request-domain is an IP address.
100
+ """
101
+ rd = request_domain.lower()
102
+ cd = self.domain
103
+
104
+ if not cd:
105
+ return False
106
+
107
+ # Exact match
108
+ if rd == cd:
109
+ return True
110
+
111
+ # If cookie domain starts with '.', it must be a suffix match
112
+ if self.domain_initial_dot:
113
+ return rd.endswith(cd) and rd[-len(cd) - 1] == "."
114
+
115
+ # If cookie domain doesn't start with '.', it must be an exact or
116
+ # suffix match where the request domain ends with the cookie domain
117
+ # and the character before is a '.' (to prevent "evil.com" matching "e.com")
118
+ if rd.endswith(cd):
119
+ if len(rd) == len(cd):
120
+ return True # exact match
121
+ return rd[-(len(cd) + 1)] == "."
122
+
123
+ return False
124
+
125
+ def matches_path(self, request_path: str) -> bool:
126
+ """
127
+ RFC 6265 Section 5.1.4 — Path Matching.
128
+
129
+ A request-path P matches cookie-path C if:
130
+ - The string is identical to C, OR
131
+ - The string starts with C and the last character of C is '/', OR
132
+ - The string starts with C, the next character in the string
133
+ is either '/' or end-of-string.
134
+ """
135
+ cp = self.path
136
+ rp = request_path
137
+
138
+ if rp == cp:
139
+ return True
140
+
141
+ if cp == "/":
142
+ return True # "/" matches everything
143
+
144
+ if rp.startswith(cp):
145
+ if len(rp) == len(cp):
146
+ return True # exact match
147
+ if cp.endswith("/"):
148
+ return True
149
+ return rp[len(cp)] in ("/", "?", "#", "")
150
+
151
+ return False
152
+
153
+ def to_header(self) -> str:
154
+ """Serialize cookie as a Cookie header value."""
155
+ return f"{self.name}={self.value}"
156
+
157
+ def to_set_cookie_header(self) -> str:
158
+ """Serialize as a Set-Cookie header."""
159
+ parts = [f"{self.name}={self.value}"]
160
+ if self.domain:
161
+ parts.append(f"Domain={self.domain}")
162
+ if self.path:
163
+ parts.append(f"Path={self.path}")
164
+ if self.expires is not None:
165
+ parts.append(f"Expires={_format_cookie_time(self.expires)}")
166
+ if self.max_age is not None:
167
+ parts.append(f"Max-Age={self.max_age}")
168
+ if self.secure:
169
+ parts.append("Secure")
170
+ if self.http_only:
171
+ parts.append("HttpOnly")
172
+ if self.same_site:
173
+ parts.append(f"SameSite={self.same_site}")
174
+ return "; ".join(parts)
175
+
176
+ def to_dict(self) -> dict:
177
+ """Serialize cookie to a JSON-safe dict."""
178
+ return {
179
+ "name": self.name,
180
+ "value": self.value,
181
+ "domain": self.domain,
182
+ "path": self.path,
183
+ "expires": self.expires,
184
+ "max_age": self.max_age,
185
+ "secure": self.secure,
186
+ "http_only": self.http_only,
187
+ "same_site": self.same_site,
188
+ "creation_time": self.creation_time,
189
+ "port": self.port,
190
+ "port_specified": self.port_specified,
191
+ "domain_specified": self.domain_specified,
192
+ "domain_initial_dot": self.domain_initial_dot,
193
+ }
194
+
195
+ @classmethod
196
+ def from_dict(cls, data: dict) -> "Cookie":
197
+ """Deserialize a cookie from a dict."""
198
+ # creation_time is set in __init__, extract it separately
199
+ creation_time = data.pop("creation_time", None)
200
+ c = cls(**data)
201
+ if creation_time is not None:
202
+ c.creation_time = creation_time
203
+ return c
204
+
205
+ def __repr__(self) -> str:
206
+ return (
207
+ f"Cookie(name={self.name!r}, value={self.value!r}, "
208
+ f"domain={self.domain!r}, path={self.path!r}, "
209
+ f"secure={self.secure}, http_only={self.http_only})"
210
+ )
211
+
212
+ def __eq__(self, other) -> bool:
213
+ if not isinstance(other, Cookie):
214
+ return NotImplemented
215
+ return (
216
+ self.name == other.name
217
+ and self.domain == other.domain
218
+ and self.path == other.path
219
+ )
220
+
221
+ def __hash__(self) -> int:
222
+ return hash((self.name, self.domain, self.path))
223
+
224
+
225
+ class CookieJar:
226
+ """
227
+ Thread-safe, RFC 6265 compliant cookie jar.
228
+
229
+ This is the heart of bas's cookie handling. Unlike curlcffi's
230
+ approach (which relies on libcurl's internal cookie engine and loses
231
+ cookies during redirects), bas manages cookies entirely in Python.
232
+
233
+ This means:
234
+ - Cookies accumulate correctly across ALL requests in a session
235
+ - Cookies are inherited during redirects (even cross-domain)
236
+ - No manual cookie header construction needed
237
+ - Full control over cookie serialization and persistence
238
+ """
239
+
240
+ def __init__(self):
241
+ self._cookies: list[Cookie] = []
242
+ self._lock = threading.Lock()
243
+
244
+ def __len__(self) -> int:
245
+ with self._lock:
246
+ return len(self._cookies)
247
+
248
+ def __iter__(self):
249
+ with self._lock:
250
+ return iter(list(self._cookies))
251
+
252
+ def __contains__(self, cookie: Cookie) -> bool:
253
+ with self._lock:
254
+ return cookie in self._cookies
255
+
256
+ def clear(self) -> None:
257
+ """Remove all cookies."""
258
+ with self._lock:
259
+ self._cookies.clear()
260
+
261
+ def add(self, cookie: Cookie) -> None:
262
+ """
263
+ Add a cookie to the jar, replacing any existing cookie
264
+ with the same (name, domain, path).
265
+ """
266
+ with self._lock:
267
+ # Remove existing cookie with same key
268
+ self._cookies = [
269
+ c for c in self._cookies
270
+ if not (c.name == cookie.name and c.domain == cookie.domain and c.path == cookie.path)
271
+ ]
272
+ if not cookie.is_expired:
273
+ self._cookies.append(cookie)
274
+
275
+ def remove(self, cookie: Cookie) -> None:
276
+ """Remove a specific cookie."""
277
+ with self._lock:
278
+ self._cookies = [
279
+ c for c in self._cookies
280
+ if not (c.name == cookie.name and c.domain == cookie.domain and c.path == cookie.path)
281
+ ]
282
+
283
+ def update(self, other: "CookieJar") -> None:
284
+ """Merge all cookies from another jar into this one."""
285
+ with self._lock:
286
+ for cookie in other:
287
+ self.add(cookie)
288
+
289
+ def get(
290
+ self,
291
+ name: str,
292
+ domain: Optional[str] = None,
293
+ path: Optional[str] = None,
294
+ ) -> Optional[Cookie]:
295
+ """Get a specific cookie by name, optionally filtered by domain/path."""
296
+ with self._lock:
297
+ for cookie in reversed(self._cookies): # most recent first
298
+ if cookie.name == name:
299
+ if domain and not cookie.matches_domain(domain):
300
+ continue
301
+ if path and not cookie.matches_path(path):
302
+ continue
303
+ if not cookie.is_expired:
304
+ return cookie
305
+ return None
306
+
307
+ def get_all(
308
+ self,
309
+ name: Optional[str] = None,
310
+ domain: Optional[str] = None,
311
+ path: Optional[str] = None,
312
+ ) -> list[Cookie]:
313
+ """Get all matching cookies."""
314
+ with self._lock:
315
+ result = []
316
+ for cookie in self._cookies:
317
+ if cookie.is_expired:
318
+ continue
319
+ if name and cookie.name != name:
320
+ continue
321
+ if domain and not cookie.matches_domain(domain):
322
+ continue
323
+ if path and not cookie.matches_path(path):
324
+ continue
325
+ result.append(cookie)
326
+ return result
327
+
328
+ def match(self, url: str) -> list[Cookie]:
329
+ """
330
+ Get all cookies matching a given URL.
331
+
332
+ This is the primary method for determining which cookies to
333
+ send with a request. It performs full RFC 6265 matching.
334
+ """
335
+ parsed = urlparse(url)
336
+ domain = parsed.hostname or ""
337
+ path = parsed.path or "/"
338
+ is_secure = parsed.scheme == "https"
339
+
340
+ with self._lock:
341
+ result = []
342
+ expired_names = []
343
+
344
+ for cookie in self._cookies:
345
+ if cookie.is_expired:
346
+ expired_names.append(cookie)
347
+ continue
348
+
349
+ if not cookie.matches_domain(domain):
350
+ continue
351
+ if not cookie.matches_path(path):
352
+ continue
353
+ if cookie.secure and not is_secure:
354
+ continue
355
+ result.append(cookie)
356
+
357
+ # Clean up expired cookies
358
+ for c in expired_names:
359
+ try:
360
+ self._cookies.remove(c)
361
+ except ValueError:
362
+ pass
363
+
364
+ return result
365
+
366
+ def parse_set_cookie(
367
+ self,
368
+ set_cookie_header: str,
369
+ request_url: str,
370
+ ) -> Optional[Cookie]:
371
+ """
372
+ Parse a Set-Cookie header and add the cookie to the jar.
373
+
374
+ This is where the magic happens — we parse the Set-Cookie header
375
+ ourselves instead of relying on curl's broken cookie engine.
376
+
377
+ Args:
378
+ set_cookie_header: The raw Set-Cookie header value
379
+ request_url: The URL that set this cookie (used for default domain/path)
380
+
381
+ Returns:
382
+ The parsed Cookie, or None if the cookie should be deleted
383
+ """
384
+ if not set_cookie_header:
385
+ return None
386
+
387
+ parsed_request = urlparse(request_url)
388
+ default_domain = parsed_request.hostname or ""
389
+ default_path = _guess_cookie_path(parsed_request.path or "/")
390
+
391
+ # Parse name=value and attributes
392
+ parts = [p.strip() for p in set_cookie_header.split(";")]
393
+ if not parts:
394
+ return None
395
+
396
+ # First part is name=value
397
+ name_value = parts[0]
398
+ eq_idx = name_value.find("=")
399
+ if eq_idx == -1:
400
+ name = unquote(name_value.strip())
401
+ value = ""
402
+ else:
403
+ name = unquote(name_value[:eq_idx].strip())
404
+ value = unquote(name_value[eq_idx + 1:].strip())
405
+
406
+ if not name:
407
+ return None
408
+
409
+ # Parse attributes
410
+ cookie = Cookie(
411
+ name=name,
412
+ value=value,
413
+ domain=default_domain,
414
+ path=default_path,
415
+ )
416
+
417
+ for part in parts[1:]:
418
+ part = part.strip()
419
+ eq_idx = part.find("=")
420
+
421
+ if eq_idx == -1:
422
+ attr_name = part.lower()
423
+ attr_value = ""
424
+ else:
425
+ attr_name = part[:eq_idx].strip().lower()
426
+ attr_value = part[eq_idx + 1:].strip()
427
+
428
+ if attr_name == "domain":
429
+ d = attr_value.lower().strip(".")
430
+ if d:
431
+ cookie.domain = d
432
+ cookie.domain_specified = True
433
+ cookie.domain_initial_dot = attr_value.startswith(".")
434
+ elif attr_name == "path":
435
+ if attr_value and attr_value.startswith("/"):
436
+ cookie.path = attr_value
437
+ else:
438
+ cookie.path = default_path
439
+ elif attr_name == "expires":
440
+ try:
441
+ cookie.expires = _parse_cookie_time(attr_value)
442
+ except (ValueError, TypeError):
443
+ pass
444
+ elif attr_name == "max-age":
445
+ try:
446
+ cookie.max_age = int(attr_value)
447
+ except (ValueError, TypeError):
448
+ pass
449
+ elif attr_name == "secure":
450
+ cookie.secure = True
451
+ elif attr_name == "httponly":
452
+ cookie.http_only = True
453
+ elif attr_name == "samesite":
454
+ cookie.same_site = attr_value.capitalize()
455
+ elif attr_name == "port":
456
+ cookie.port = attr_value
457
+ cookie.port_specified = bool(attr_value)
458
+
459
+ # Add to jar
460
+ self.add(cookie)
461
+ return cookie
462
+
463
+ def parse_set_cookie_headers(
464
+ self,
465
+ headers: dict[str, str],
466
+ request_url: str,
467
+ ) -> list[Cookie]:
468
+ """
469
+ Parse ALL Set-Cookie headers from a response.
470
+
471
+ Handles both single-value and multi-value Set-Cookie headers.
472
+ """
473
+ cookies = []
474
+
475
+ # Handle both dict and list-of-tuples header formats
476
+ set_cookie_values = []
477
+ for key, value in headers.items():
478
+ if key.lower() == "set-cookie":
479
+ if isinstance(value, list):
480
+ set_cookie_values.extend(value)
481
+ else:
482
+ set_cookie_values.append(value)
483
+
484
+ for sc in set_cookie_values:
485
+ cookie = self.parse_set_cookie(sc, request_url)
486
+ if cookie:
487
+ cookies.append(cookie)
488
+
489
+ return cookies
490
+
491
+ def to_header(self, url: str) -> str:
492
+ """
493
+ Build a Cookie header value for the given URL.
494
+
495
+ This is what gets sent in the HTTP request. It returns ALL matching
496
+ cookies in the correct format, eliminating the need for users to
497
+ manually construct cookie headers.
498
+ """
499
+ cookies = self.match(url)
500
+ if not cookies:
501
+ return ""
502
+ return "; ".join(f"{c.name}={c.value}" for c in cookies)
503
+
504
+ def to_header_dict(self, url: str) -> dict[str, str]:
505
+ """Return a dict suitable for merging into request headers."""
506
+ header = self.to_header(url)
507
+ if header:
508
+ return {"Cookie": header}
509
+ return {}
510
+
511
+ def save_json(self, filepath: str) -> None:
512
+ """Save cookies to a JSON file."""
513
+ with self._lock:
514
+ data = [c.to_dict() for c in self._cookies if not c.is_expired]
515
+ with open(filepath, "w") as f:
516
+ json.dump(data, f, indent=2)
517
+
518
+ def load_json(self, filepath: str) -> None:
519
+ """Load cookies from a JSON file."""
520
+ try:
521
+ with open(filepath, "r") as f:
522
+ data = json.load(f)
523
+ with self._lock:
524
+ self._cookies.clear()
525
+ for item in data:
526
+ c = Cookie.from_dict(item)
527
+ if not c.is_expired:
528
+ self._cookies.append(c)
529
+ except (FileNotFoundError, json.JSONDecodeError):
530
+ pass
531
+
532
+ def save_netscape(self, filepath: str) -> None:
533
+ """Save cookies in Netscape/Mozilla cookie file format."""
534
+ with self._lock:
535
+ lines = ["# Netscape HTTP Cookie File\n"]
536
+ for c in self._cookies:
537
+ if c.is_expired:
538
+ continue
539
+ domain = ("." + c.domain) if not c.domain_initial_dot and not c.domain.startswith(".") else c.domain
540
+ flag = "TRUE" if c.domain_initial_dot else "FALSE"
541
+ secure = "TRUE" if c.secure else "FALSE"
542
+ expires = str(int(c.expires)) if c.expires else "0"
543
+ lines.append(
544
+ f"{domain}\t{flag}\t{c.path}\t{secure}\t{expires}\t{c.name}\t{c.value}"
545
+ )
546
+ with open(filepath, "w") as f:
547
+ f.write("\n".join(lines) + "\n")
548
+
549
+ def load_netscape(self, filepath: str) -> None:
550
+ """Load cookies from a Netscape/Mozilla cookie file."""
551
+ try:
552
+ with open(filepath, "r") as f:
553
+ for line in f:
554
+ line = line.strip()
555
+ if not line or line.startswith("#"):
556
+ continue
557
+ parts = line.split("\t")
558
+ if len(parts) < 7:
559
+ continue
560
+ domain, _, path, secure, expires, name, value = parts[:7]
561
+ initial_dot = domain.startswith(".")
562
+ cookie = Cookie(
563
+ name=name,
564
+ value=value,
565
+ domain=domain.strip("."),
566
+ path=path,
567
+ expires=float(expires) if expires != "0" else None,
568
+ secure=secure.upper() == "TRUE",
569
+ domain_initial_dot=initial_dot,
570
+ domain_specified=True,
571
+ )
572
+ self.add(cookie)
573
+ except FileNotFoundError:
574
+ pass
575
+
576
+ def get_dict(self, url: str) -> dict[str, str]:
577
+ """Return cookies as a simple {name: value} dict for the given URL."""
578
+ return {c.name: c.value for c in self.match(url)}
579
+
580
+
581
+ # ── Helper functions ─────────────────────────────────────────────────────────
582
+
583
+ def _format_cookie_time(timestamp: float) -> str:
584
+ """Format a timestamp as an HTTP date string for Set-Cookie."""
585
+ dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
586
+ return dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
587
+
588
+
589
+ _COOKIE_MONTHS = {
590
+ "jan": 1, "feb": 2, "mar": 3, "apr": 4,
591
+ "may": 5, "jun": 6, "jul": 7, "aug": 8,
592
+ "sep": 9, "oct": 10, "nov": 11, "dec": 12,
593
+ }
594
+
595
+
596
+ def _parse_cookie_time(time_str: str) -> Optional[float]:
597
+ """
598
+ Parse various date formats found in Set-Cookie Expires headers.
599
+
600
+ Supports:
601
+ - RFC 1123: "Thu, 01 Dec 2025 16:00:00 GMT"
602
+ - RFC 1036: "Thursday, 01-Dec-25 16:00:00 GMT"
603
+ - Asctime: "Dec 1 16:00:00 2025 GMT"
604
+ - Simple: "Thu, 01 Dec 2025 16:00:00"
605
+ """
606
+ time_str = time_str.strip()
607
+
608
+ # Try common formats
609
+ formats = [
610
+ "%a, %d %b %Y %H:%M:%S %Z",
611
+ "%a, %d-%b-%Y %H:%M:%S %Z",
612
+ "%a, %d %b %Y %H:%M:%S",
613
+ "%A, %d-%b-%y %H:%M:%S %Z",
614
+ "%a %b %d %H:%M:%S %Y",
615
+ "%a %b %d %H:%M:%S %Y",
616
+ ]
617
+
618
+ for fmt in formats:
619
+ try:
620
+ dt = datetime.strptime(time_str, fmt)
621
+ return dt.timestamp()
622
+ except ValueError:
623
+ continue
624
+
625
+ return None
626
+
627
+
628
+ def _guess_cookie_path(request_path: str) -> str:
629
+ """
630
+ Guess a sensible default cookie path from the request path.
631
+
632
+ RFC 6265 Section 5.1.4 says the default path should be the
633
+ "longest directory path" — i.e., everything up to the last '/'.
634
+ """
635
+ if not request_path or not request_path.startswith("/"):
636
+ return "/"
637
+
638
+ # Find the last '/' that isn't the first character
639
+ last_slash = request_path.rfind("/")
640
+ if last_slash <= 0:
641
+ return "/"
642
+
643
+ return request_path[:last_slash]