codekingpro/portable-devtools
114k
1"""2Persistent, JSON-serialized sessions.3 4"""5import os6import re7 8from http.cookies import SimpleCookie9from http.cookiejar import Cookie10from pathlib import Path11from typing import Any, Dict, List, Optional, Union12 13from requests.auth import AuthBase14from requests.cookies import RequestsCookieJar, remove_cookie_by_name15 16from .context import Environment, LogLevel17from .cookies import HTTPieCookiePolicy18from .cli.dicts import HTTPHeadersDict19from .config import BaseConfigDict, DEFAULT_CONFIG_DIR20from .utils import url_as_host21from .plugins.registry import plugin_manager22 23from .legacy import (24 v3_1_0_session_cookie_format as legacy_cookies,25 v3_2_0_session_header_format as legacy_headers26)27 28 29SESSIONS_DIR_NAME = 'sessions'30DEFAULT_SESSIONS_DIR = DEFAULT_CONFIG_DIR / SESSIONS_DIR_NAME31VALID_SESSION_NAME_PATTERN = re.compile('^[a-zA-Z0-9_.-]+$')32# Request headers starting with these prefixes won't be stored in sessions.33# They are specific to each request.34# <https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Requests>35SESSION_IGNORED_HEADER_PREFIXES = ['Content-', 'If-']36 37# Cookie related options38KEPT_COOKIE_OPTIONS = ['name', 'expires', 'path', 'value', 'domain', 'secure']39DEFAULT_COOKIE_PATH = '/'40 41 42def is_anonymous_session(session_name: str) -> bool:43 return os.path.sep in session_name44 45 46def session_hostname_to_dirname(hostname: str, session_name: str) -> str:47 # host:port => host_port48 hostname = hostname.replace(':', '_')49 return os.path.join(50 SESSIONS_DIR_NAME,51 hostname,52 f'{session_name}.json'53 )54 55 56def strip_port(hostname: str) -> str:57 return hostname.split(':')[0]58 59 60def materialize_cookie(cookie: Cookie) -> Dict[str, Any]:61 materialized_cookie = {62 option: getattr(cookie, option)63 for option in KEPT_COOKIE_OPTIONS64 }65 66 if (67 cookie._rest.get('is_explicit_none')68 and materialized_cookie['domain'] == ''69 ):70 materialized_cookie['domain'] = None71 72 return materialized_cookie73 74 75def materialize_cookies(jar: RequestsCookieJar) -> List[Dict[str, Any]]:76 return [77 materialize_cookie(cookie)78 for cookie in jar79 ]80 81 82def materialize_headers(headers: Dict[str, str]) -> List[Dict[str, Any]]:83 return [84 {85 'name': name,86 'value': value87 }88 for name, value in headers.copy().items()89 ]90 91 92def get_httpie_session(93 env: Environment,94 config_dir: Path,95 session_name: str,96 host: Optional[str],97 url: str,98 *,99 suppress_legacy_warnings: bool = False100) -> 'Session':101 bound_hostname = host or url_as_host(url)102 if not bound_hostname:103 # HACK/FIXME: httpie-unixsocket's URLs have no hostname.104 bound_hostname = 'localhost'105 106 if is_anonymous_session(session_name):107 path = os.path.expanduser(session_name)108 session_id = path109 else:110 path = config_dir / session_hostname_to_dirname(bound_hostname, session_name)111 session_id = session_name112 113 session = Session(114 path,115 env=env,116 session_id=session_id,117 bound_host=strip_port(bound_hostname),118 suppress_legacy_warnings=suppress_legacy_warnings119 )120 session.load()121 return session122 123 124class Session(BaseConfigDict):125 helpurl = 'https://httpie.io/docs#sessions'126 about = 'HTTPie session file'127 128 def __init__(129 self,130 path: Union[str, Path],131 env: Environment,132 bound_host: str,133 session_id: str,134 suppress_legacy_warnings: bool = False,135 ):136 super().__init__(path=Path(path))137 138 # Default values for the session files139 self['headers'] = []140 self['cookies'] = []141 self['auth'] = {142 'type': None,143 'username': None,144 'password': None145 }146 147 # Runtime state of the Session objects.148 self.env = env149 self._headers = HTTPHeadersDict()150 self.cookie_jar = RequestsCookieJar(151 # See also a temporary workaround for a Requests bug in `compat.py`.152 policy=HTTPieCookiePolicy(),153 )154 self.session_id = session_id155 self.bound_host = bound_host156 self.suppress_legacy_warnings = suppress_legacy_warnings157 158 def _add_cookies(self, cookies: List[Dict[str, Any]]) -> None:159 for cookie in cookies:160 domain = cookie.get('domain', '')161 if domain is None:162 # domain = None means explicitly lack of cookie, though163 # requests requires domain to be a string so we'll cast it164 # manually.165 cookie['domain'] = ''166 cookie['rest'] = {'is_explicit_none': True}167 168 self.cookie_jar.set(**cookie)169 170 def pre_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:171 for key, deserializer, importer in [172 ('cookies', legacy_cookies.pre_process, self._add_cookies),173 ('headers', legacy_headers.pre_process, self._headers.update),174 ]:175 values = data.get(key)176 if values:177 normalized_values = deserializer(self, values)178 else:179 normalized_values = []180 181 importer(normalized_values)182 183 return data184 185 def post_process_data(self, data: Dict[str, Any]) -> Dict[str, Any]:186 for key, store, serializer, exporter in [187 ('cookies', self.cookie_jar, materialize_cookies, legacy_cookies.post_process),188 ('headers', self._headers, materialize_headers, legacy_headers.post_process),189 ]:190 original_type = type(data.get(key))191 values = serializer(store)192 193 data[key] = exporter(194 values,195 original_type=original_type196 )197 198 return data199 200 def _compute_new_headers(self, request_headers: HTTPHeadersDict) -> HTTPHeadersDict:201 new_headers = HTTPHeadersDict()202 for name, value in request_headers.copy().items():203 if value is None:204 continue # Ignore explicitly unset headers205 206 original_value = value207 if type(value) is not str:208 value = value.decode()209 210 if name.lower() == 'user-agent' and value.startswith('HTTPie/'):211 continue212 213 if name.lower() == 'cookie':214 for cookie_name, morsel in SimpleCookie(value).items():215 if not morsel['path']:216 morsel['path'] = DEFAULT_COOKIE_PATH217 self.cookie_jar.set(cookie_name, morsel)218 219 request_headers.remove_item(name, original_value)220 continue221 222 for prefix in SESSION_IGNORED_HEADER_PREFIXES:223 if name.lower().startswith(prefix.lower()):224 break225 else:226 new_headers.add(name, value)227 228 return new_headers229 230 def update_headers(self, request_headers: HTTPHeadersDict):231 """232 Update the session headers with the request ones while ignoring233 certain name prefixes.234 235 """236 237 new_headers = self._compute_new_headers(request_headers)238 new_keys = new_headers.copy().keys()239 240 # New headers will take priority over the existing ones, and override241 # them directly instead of extending them.242 for key, value in self._headers.copy().items():243 if key in new_keys:244 continue245 246 new_headers.add(key, value)247 248 self._headers = new_headers249 250 @property251 def headers(self) -> HTTPHeadersDict:252 return self._headers.copy()253 254 @property255 def cookies(self) -> RequestsCookieJar:256 self.cookie_jar.clear_expired_cookies()257 return self.cookie_jar258 259 @cookies.setter260 def cookies(self, jar: RequestsCookieJar):261 self.cookie_jar = jar262 263 def remove_cookies(self, cookies: List[Dict[str, str]]):264 for cookie in cookies:265 remove_cookie_by_name(266 self.cookie_jar,267 cookie['name'],268 domain=cookie.get('domain', None),269 path=cookie.get('path', None)270 )271 272 @property273 def auth(self) -> Optional[AuthBase]:274 auth = self.get('auth', None)275 if not auth or not auth['type']:276 return277 278 plugin = plugin_manager.get_auth_plugin(auth['type'])()279 280 credentials = {'username': None, 'password': None}281 try:282 # New style283 plugin.raw_auth = auth['raw_auth']284 except KeyError:285 # Old style286 credentials = {287 'username': auth['username'],288 'password': auth['password'],289 }290 else:291 if plugin.auth_parse:292 from .cli.argtypes import parse_auth293 parsed = parse_auth(plugin.raw_auth)294 credentials = {295 'username': parsed.key,296 'password': parsed.value,297 }298 299 return plugin.get_auth(**credentials)300 301 @auth.setter302 def auth(self, auth: dict):303 assert {'type', 'raw_auth'} == auth.keys()304 self['auth'] = auth305 306 @property307 def is_anonymous(self):308 return is_anonymous_session(self.session_id)309 310 def warn_legacy_usage(self, warning: str) -> None:311 if self.suppress_legacy_warnings:312 return None313 314 self.env.log_error(315 warning,316 level=LogLevel.WARNING317 )318 319 # We don't want to spam multiple warnings on each usage,320 # so if there is already a warning for the legacy usage321 # we'll skip the next ones.322 self.suppress_legacy_warnings = True323 