Coverage for human_requests/abstraction/cookies.py: 82%

73 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-13 21:41 +0000

1from dataclasses import dataclass, field 

2from datetime import datetime 

3from typing import Any, Iterable, Iterator, Literal, Mapping 

4from urllib.parse import urlsplit 

5 

6from playwright.async_api import StorageStateCookie 

7 

8 

9@dataclass 

10class Cookie: 

11 """ 

12 A dataclass containing the information about a cookie. 

13 

14 Please, see the MDN Web Docs for the full documentation: 

15 https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie 

16 """ 

17 

18 name: str 

19 """This is the name of the cookie 

20 that will be used to identify the cookie in the Cookie header.""" 

21 

22 value: str 

23 """This is the value that will be sent with the Cookie header.""" 

24 

25 path: str = "/" 

26 """This is the path from which the cookie will be readable.""" 

27 

28 domain: str = "" 

29 """This is the domain from which the cookie will be readable.""" 

30 

31 expires: int = 0 

32 """This is the date when the cookie will be deleted. Coded in Unix timestamp.""" 

33 

34 max_age: int = 0 

35 """This is the maximum age of the cookie in seconds.""" 

36 

37 same_site: Literal["Lax", "Strict", "None"] = "Lax" 

38 """This is the policy that determines whether the cookie will be sent with requests.""" 

39 

40 secure: bool = False 

41 """This is whether the cookie will be sent over a secure connection.""" 

42 

43 http_only: bool = False 

44 """This is whether the cookie will be accessible to JavaScript.""" 

45 

46 def expires_as_datetime(self) -> datetime: 

47 """This is the same as the `expires` property but as a datetime object.""" 

48 return datetime.fromtimestamp(self.expires) 

49 

50 def max_age_as_datetime(self) -> datetime: 

51 """This is the same as the `max_age` property but as a datetime object.""" 

52 return datetime.fromtimestamp(self.max_age) 

53 

54 def to_playwright_like_dict(self) -> StorageStateCookie: 

55 """Return a dictionary compatible with Playwright StorageState cookies.""" 

56 return { 

57 "name": self.name, 

58 "value": self.value, 

59 "domain": self.domain or "", 

60 "path": self.path or "/", 

61 "expires": float(self.expires or 0), 

62 "httpOnly": bool(self.http_only or False), 

63 "secure": bool(self.secure or False), 

64 "sameSite": self.same_site, 

65 } 

66 

67 @staticmethod 

68 def from_playwright_like_dict(data: Mapping[str, Any]) -> "Cookie": 

69 """Accept any mapping (dict or Playwright's StorageStateCookie).""" 

70 return Cookie( 

71 name=str(data["name"]), 

72 value=str(data["value"]), 

73 domain=str(data.get("domain") or ""), 

74 path=str(data.get("path") or "/"), 

75 expires=int(data.get("expires") or 0), 

76 secure=bool(data.get("secure")), 

77 http_only=bool(data.get("httpOnly")), 

78 ) 

79 

80 

81@dataclass 

82class CookieManager: 

83 """Convenient jar-style wrapper + Playwright conversion.""" 

84 

85 storage: list[Cookie] = field(default_factory=list) 

86 

87 # ────── dunder helpers ────── 

88 def __iter__(self) -> Iterator[Cookie]: 

89 return iter(self.storage) 

90 

91 def __len__(self) -> int: 

92 return len(self.storage) 

93 

94 def __bool__(self) -> bool: 

95 return bool(self.storage) 

96 

97 # ────── CRUD ────── 

98 def get(self, name: str, domain: str | None = None, path: str | None = None) -> Cookie | None: 

99 """Get a cookie by name, domain, and path.""" 

100 return next( 

101 ( 

102 c 

103 for c in self.storage 

104 if c.name == name 

105 and (domain is None or c.domain == domain) 

106 and (path is None or c.path == path) 

107 ), 

108 None, 

109 ) 

110 

111 def get_for_domain(self, url_or_domain: str) -> list[Cookie]: 

112 """Get all cookies available for a domain/URL.""" 

113 host = urlsplit(url_or_domain).hostname or url_or_domain.split(":")[0] 

114 if not host: 

115 return [] 

116 

117 def _match(cookie_domain: str, h: str) -> bool: 

118 return h == cookie_domain or h.endswith("." + cookie_domain) 

119 

120 return [c for c in self.storage if _match(c.domain, host)] 

121 

122 def add(self, cookie: Cookie | Iterable[Cookie]) -> None: 

123 """Add a cookie or cookies.""" 

124 

125 def _add_one(c: Cookie) -> None: 

126 key = (c.domain, c.path, c.name) 

127 for i, old in enumerate(self.storage): 

128 if (old.domain, old.path, old.name) == key: 

129 self.storage[i] = c 

130 break 

131 else: 

132 self.storage.append(c) 

133 

134 if isinstance(cookie, Iterable) and not isinstance(cookie, Cookie): 

135 for c in cookie: 

136 _add_one(c) 

137 else: 

138 _add_one(cookie) 

139 

140 def delete( 

141 self, name: str, domain: str | None = None, path: str | None = None 

142 ) -> Cookie | None: 

143 """Delete a cookie by name, domain, and path.""" 

144 for i, c in enumerate(self.storage): 

145 if ( 

146 c.name == name 

147 and (domain is None or c.domain == domain) 

148 and (path is None or c.path == path) 

149 ): 

150 return self.storage.pop(i) 

151 return None 

152 

153 # ────── Playwright helpers ────── 

154 def to_playwright(self) -> list[StorageStateCookie]: 

155 """Serialize all cookies into a format understood by Playwright.""" 

156 return [c.to_playwright_like_dict() for c in self.storage] 

157 

158 def add_from_playwright(self, raw_cookies: Iterable[Mapping[str, Any]]) -> None: 

159 """Inverse operation — add a list of Playwright cookies/mappings to the jar.""" 

160 self.add(Cookie.from_playwright_like_dict(rc) for rc in raw_cookies)