You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

704 lines
22 KiB

4 years ago
  1. """Various helper functions"""
  2. import asyncio
  3. import base64
  4. import binascii
  5. import cgi
  6. import functools
  7. import inspect
  8. import netrc
  9. import os
  10. import platform
  11. import re
  12. import sys
  13. import time
  14. import warnings
  15. import weakref
  16. from collections import namedtuple
  17. from contextlib import suppress
  18. from math import ceil
  19. from pathlib import Path
  20. from types import TracebackType
  21. from typing import ( # noqa
  22. Any,
  23. Callable,
  24. Dict,
  25. Iterable,
  26. Iterator,
  27. List,
  28. Mapping,
  29. Optional,
  30. Pattern,
  31. Set,
  32. Tuple,
  33. Type,
  34. TypeVar,
  35. Union,
  36. cast,
  37. )
  38. from urllib.parse import quote
  39. from urllib.request import getproxies
  40. import async_timeout
  41. import attr
  42. from multidict import MultiDict, MultiDictProxy
  43. from yarl import URL
  44. from . import hdrs
  45. from .log import client_logger, internal_logger
  46. from .typedefs import PathLike # noqa
  47. __all__ = ('BasicAuth', 'ChainMapProxy')
  48. PY_36 = sys.version_info >= (3, 6)
  49. PY_37 = sys.version_info >= (3, 7)
  50. if not PY_37:
  51. import idna_ssl
  52. idna_ssl.patch_match_hostname()
  53. try:
  54. from typing import ContextManager
  55. except ImportError:
  56. from typing_extensions import ContextManager
  57. def all_tasks(
  58. loop: Optional[asyncio.AbstractEventLoop] = None
  59. ) -> Set['asyncio.Task[Any]']:
  60. tasks = list(asyncio.Task.all_tasks(loop)) # type: ignore
  61. return {t for t in tasks if not t.done()}
  62. if PY_37:
  63. all_tasks = getattr(asyncio, 'all_tasks') # noqa
  64. _T = TypeVar('_T')
  65. sentinel = object() # type: Any
  66. NO_EXTENSIONS = bool(os.environ.get('AIOHTTP_NO_EXTENSIONS')) # type: bool
  67. # N.B. sys.flags.dev_mode is available on Python 3.7+, use getattr
  68. # for compatibility with older versions
  69. DEBUG = (getattr(sys.flags, 'dev_mode', False) or
  70. (not sys.flags.ignore_environment and
  71. bool(os.environ.get('PYTHONASYNCIODEBUG')))) # type: bool
  72. CHAR = set(chr(i) for i in range(0, 128))
  73. CTL = set(chr(i) for i in range(0, 32)) | {chr(127), }
  74. SEPARATORS = {'(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']',
  75. '?', '=', '{', '}', ' ', chr(9)}
  76. TOKEN = CHAR ^ CTL ^ SEPARATORS
  77. coroutines = asyncio.coroutines
  78. old_debug = coroutines._DEBUG # type: ignore
  79. # prevent "coroutine noop was never awaited" warning.
  80. coroutines._DEBUG = False # type: ignore
  81. @asyncio.coroutine
  82. def noop(*args, **kwargs): # type: ignore
  83. return # type: ignore
  84. async def noop2(*args: Any, **kwargs: Any) -> None:
  85. return
  86. coroutines._DEBUG = old_debug # type: ignore
  87. class BasicAuth(namedtuple('BasicAuth', ['login', 'password', 'encoding'])):
  88. """Http basic authentication helper."""
  89. def __new__(cls, login: str,
  90. password: str='',
  91. encoding: str='latin1') -> 'BasicAuth':
  92. if login is None:
  93. raise ValueError('None is not allowed as login value')
  94. if password is None:
  95. raise ValueError('None is not allowed as password value')
  96. if ':' in login:
  97. raise ValueError(
  98. 'A ":" is not allowed in login (RFC 1945#section-11.1)')
  99. return super().__new__(cls, login, password, encoding)
  100. @classmethod
  101. def decode(cls, auth_header: str, encoding: str='latin1') -> 'BasicAuth':
  102. """Create a BasicAuth object from an Authorization HTTP header."""
  103. try:
  104. auth_type, encoded_credentials = auth_header.split(' ', 1)
  105. except ValueError:
  106. raise ValueError('Could not parse authorization header.')
  107. if auth_type.lower() != 'basic':
  108. raise ValueError('Unknown authorization method %s' % auth_type)
  109. try:
  110. decoded = base64.b64decode(
  111. encoded_credentials.encode('ascii'), validate=True
  112. ).decode(encoding)
  113. except binascii.Error:
  114. raise ValueError('Invalid base64 encoding.')
  115. try:
  116. # RFC 2617 HTTP Authentication
  117. # https://www.ietf.org/rfc/rfc2617.txt
  118. # the colon must be present, but the username and password may be
  119. # otherwise blank.
  120. username, password = decoded.split(':', 1)
  121. except ValueError:
  122. raise ValueError('Invalid credentials.')
  123. return cls(username, password, encoding=encoding)
  124. @classmethod
  125. def from_url(cls, url: URL,
  126. *, encoding: str='latin1') -> Optional['BasicAuth']:
  127. """Create BasicAuth from url."""
  128. if not isinstance(url, URL):
  129. raise TypeError("url should be yarl.URL instance")
  130. if url.user is None:
  131. return None
  132. return cls(url.user, url.password or '', encoding=encoding)
  133. def encode(self) -> str:
  134. """Encode credentials."""
  135. creds = ('%s:%s' % (self.login, self.password)).encode(self.encoding)
  136. return 'Basic %s' % base64.b64encode(creds).decode(self.encoding)
  137. def strip_auth_from_url(url: URL) -> Tuple[URL, Optional[BasicAuth]]:
  138. auth = BasicAuth.from_url(url)
  139. if auth is None:
  140. return url, None
  141. else:
  142. return url.with_user(None), auth
  143. def netrc_from_env() -> Optional[netrc.netrc]:
  144. """Attempt to load the netrc file from the path specified by the env-var
  145. NETRC or in the default location in the user's home directory.
  146. Returns None if it couldn't be found or fails to parse.
  147. """
  148. netrc_env = os.environ.get('NETRC')
  149. if netrc_env is not None:
  150. netrc_path = Path(netrc_env)
  151. else:
  152. try:
  153. home_dir = Path.home()
  154. except RuntimeError as e: # pragma: no cover
  155. # if pathlib can't resolve home, it may raise a RuntimeError
  156. client_logger.debug('Could not resolve home directory when '
  157. 'trying to look for .netrc file: %s', e)
  158. return None
  159. netrc_path = home_dir / (
  160. '_netrc' if platform.system() == 'Windows' else '.netrc')
  161. try:
  162. return netrc.netrc(str(netrc_path))
  163. except netrc.NetrcParseError as e:
  164. client_logger.warning('Could not parse .netrc file: %s', e)
  165. except OSError as e:
  166. # we couldn't read the file (doesn't exist, permissions, etc.)
  167. if netrc_env or netrc_path.is_file():
  168. # only warn if the environment wanted us to load it,
  169. # or it appears like the default file does actually exist
  170. client_logger.warning('Could not read .netrc file: %s', e)
  171. return None
  172. @attr.s(frozen=True, slots=True)
  173. class ProxyInfo:
  174. proxy = attr.ib(type=URL)
  175. proxy_auth = attr.ib(type=Optional[BasicAuth])
  176. def proxies_from_env() -> Dict[str, ProxyInfo]:
  177. proxy_urls = {k: URL(v) for k, v in getproxies().items()
  178. if k in ('http', 'https')}
  179. netrc_obj = netrc_from_env()
  180. stripped = {k: strip_auth_from_url(v) for k, v in proxy_urls.items()}
  181. ret = {}
  182. for proto, val in stripped.items():
  183. proxy, auth = val
  184. if proxy.scheme == 'https':
  185. client_logger.warning(
  186. "HTTPS proxies %s are not supported, ignoring", proxy)
  187. continue
  188. if netrc_obj and auth is None:
  189. auth_from_netrc = None
  190. if proxy.host is not None:
  191. auth_from_netrc = netrc_obj.authenticators(proxy.host)
  192. if auth_from_netrc is not None:
  193. # auth_from_netrc is a (`user`, `account`, `password`) tuple,
  194. # `user` and `account` both can be username,
  195. # if `user` is None, use `account`
  196. *logins, password = auth_from_netrc
  197. login = logins[0] if logins[0] else logins[-1]
  198. auth = BasicAuth(cast(str, login), cast(str, password))
  199. ret[proto] = ProxyInfo(proxy, auth)
  200. return ret
  201. def current_task(loop: Optional[asyncio.AbstractEventLoop]=None) -> asyncio.Task: # type: ignore # noqa # Return type is intentionally Generic here
  202. if PY_37:
  203. return asyncio.current_task(loop=loop) # type: ignore
  204. else:
  205. return asyncio.Task.current_task(loop=loop) # type: ignore
  206. def get_running_loop(
  207. loop: Optional[asyncio.AbstractEventLoop]=None
  208. ) -> asyncio.AbstractEventLoop:
  209. if loop is None:
  210. loop = asyncio.get_event_loop()
  211. if not loop.is_running():
  212. warnings.warn("The object should be created from async function",
  213. DeprecationWarning, stacklevel=3)
  214. if loop.get_debug():
  215. internal_logger.warning(
  216. "The object should be created from async function",
  217. stack_info=True)
  218. return loop
  219. def isasyncgenfunction(obj: Any) -> bool:
  220. func = getattr(inspect, 'isasyncgenfunction', None)
  221. if func is not None:
  222. return func(obj)
  223. else:
  224. return False
  225. @attr.s(frozen=True, slots=True)
  226. class MimeType:
  227. type = attr.ib(type=str)
  228. subtype = attr.ib(type=str)
  229. suffix = attr.ib(type=str)
  230. parameters = attr.ib(type=MultiDictProxy) # type: MultiDictProxy[str]
  231. @functools.lru_cache(maxsize=56)
  232. def parse_mimetype(mimetype: str) -> MimeType:
  233. """Parses a MIME type into its components.
  234. mimetype is a MIME type string.
  235. Returns a MimeType object.
  236. Example:
  237. >>> parse_mimetype('text/html; charset=utf-8')
  238. MimeType(type='text', subtype='html', suffix='',
  239. parameters={'charset': 'utf-8'})
  240. """
  241. if not mimetype:
  242. return MimeType(type='', subtype='', suffix='',
  243. parameters=MultiDictProxy(MultiDict()))
  244. parts = mimetype.split(';')
  245. params = MultiDict() # type: MultiDict[str]
  246. for item in parts[1:]:
  247. if not item:
  248. continue
  249. key, value = cast(Tuple[str, str],
  250. item.split('=', 1) if '=' in item else (item, ''))
  251. params.add(key.lower().strip(), value.strip(' "'))
  252. fulltype = parts[0].strip().lower()
  253. if fulltype == '*':
  254. fulltype = '*/*'
  255. mtype, stype = (cast(Tuple[str, str], fulltype.split('/', 1))
  256. if '/' in fulltype else (fulltype, ''))
  257. stype, suffix = (cast(Tuple[str, str], stype.split('+', 1))
  258. if '+' in stype else (stype, ''))
  259. return MimeType(type=mtype, subtype=stype, suffix=suffix,
  260. parameters=MultiDictProxy(params))
  261. def guess_filename(obj: Any, default: Optional[str]=None) -> Optional[str]:
  262. name = getattr(obj, 'name', None)
  263. if name and isinstance(name, str) and name[0] != '<' and name[-1] != '>':
  264. return Path(name).name
  265. return default
  266. def content_disposition_header(disptype: str,
  267. quote_fields: bool=True,
  268. **params: str) -> str:
  269. """Sets ``Content-Disposition`` header.
  270. disptype is a disposition type: inline, attachment, form-data.
  271. Should be valid extension token (see RFC 2183)
  272. params is a dict with disposition params.
  273. """
  274. if not disptype or not (TOKEN > set(disptype)):
  275. raise ValueError('bad content disposition type {!r}'
  276. ''.format(disptype))
  277. value = disptype
  278. if params:
  279. lparams = []
  280. for key, val in params.items():
  281. if not key or not (TOKEN > set(key)):
  282. raise ValueError('bad content disposition parameter'
  283. ' {!r}={!r}'.format(key, val))
  284. qval = quote(val, '') if quote_fields else val
  285. lparams.append((key, '"%s"' % qval))
  286. if key == 'filename':
  287. lparams.append(('filename*', "utf-8''" + qval))
  288. sparams = '; '.join('='.join(pair) for pair in lparams)
  289. value = '; '.join((value, sparams))
  290. return value
  291. class reify:
  292. """Use as a class method decorator. It operates almost exactly like
  293. the Python `@property` decorator, but it puts the result of the
  294. method it decorates into the instance dict after the first call,
  295. effectively replacing the function it decorates with an instance
  296. variable. It is, in Python parlance, a data descriptor.
  297. """
  298. def __init__(self, wrapped: Callable[..., Any]) -> None:
  299. self.wrapped = wrapped
  300. self.__doc__ = wrapped.__doc__
  301. self.name = wrapped.__name__
  302. def __get__(self, inst: Any, owner: Any) -> Any:
  303. try:
  304. try:
  305. return inst._cache[self.name]
  306. except KeyError:
  307. val = self.wrapped(inst)
  308. inst._cache[self.name] = val
  309. return val
  310. except AttributeError:
  311. if inst is None:
  312. return self
  313. raise
  314. def __set__(self, inst: Any, value: Any) -> None:
  315. raise AttributeError("reified property is read-only")
  316. reify_py = reify
  317. try:
  318. from ._helpers import reify as reify_c
  319. if not NO_EXTENSIONS:
  320. reify = reify_c # type: ignore
  321. except ImportError:
  322. pass
  323. _ipv4_pattern = (r'^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}'
  324. r'(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$')
  325. _ipv6_pattern = (
  326. r'^(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}'
  327. r'(?:[0-9]{1,3}\.){3}[0-9]{1,3}$)(([0-9A-F]{1,4}:){0,5}|:)'
  328. r'((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})'
  329. r'(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}'
  330. r'(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])|(?:[A-F0-9]{1,4}:){7}'
  331. r'[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}$)'
  332. r'(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}'
  333. r':|:(:[A-F0-9]{1,4}){7})$')
  334. _ipv4_regex = re.compile(_ipv4_pattern)
  335. _ipv6_regex = re.compile(_ipv6_pattern, flags=re.IGNORECASE)
  336. _ipv4_regexb = re.compile(_ipv4_pattern.encode('ascii'))
  337. _ipv6_regexb = re.compile(_ipv6_pattern.encode('ascii'), flags=re.IGNORECASE)
  338. def _is_ip_address(
  339. regex: Pattern[str], regexb: Pattern[bytes],
  340. host: Optional[Union[str, bytes]])-> bool:
  341. if host is None:
  342. return False
  343. if isinstance(host, str):
  344. return bool(regex.match(host))
  345. elif isinstance(host, (bytes, bytearray, memoryview)):
  346. return bool(regexb.match(host))
  347. else:
  348. raise TypeError("{} [{}] is not a str or bytes"
  349. .format(host, type(host)))
  350. is_ipv4_address = functools.partial(_is_ip_address, _ipv4_regex, _ipv4_regexb)
  351. is_ipv6_address = functools.partial(_is_ip_address, _ipv6_regex, _ipv6_regexb)
  352. def is_ip_address(
  353. host: Optional[Union[str, bytes, bytearray, memoryview]]) -> bool:
  354. return is_ipv4_address(host) or is_ipv6_address(host)
  355. _cached_current_datetime = None
  356. _cached_formatted_datetime = None
  357. def rfc822_formatted_time() -> str:
  358. global _cached_current_datetime
  359. global _cached_formatted_datetime
  360. now = int(time.time())
  361. if now != _cached_current_datetime:
  362. # Weekday and month names for HTTP date/time formatting;
  363. # always English!
  364. # Tuples are constants stored in codeobject!
  365. _weekdayname = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
  366. _monthname = ("", # Dummy so we can use 1-based month numbers
  367. "Jan", "Feb", "Mar", "Apr", "May", "Jun",
  368. "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
  369. year, month, day, hh, mm, ss, wd, y, z = time.gmtime(now) # type: ignore # noqa
  370. _cached_formatted_datetime = "%s, %02d %3s %4d %02d:%02d:%02d GMT" % (
  371. _weekdayname[wd], day, _monthname[month], year, hh, mm, ss
  372. )
  373. _cached_current_datetime = now
  374. return _cached_formatted_datetime # type: ignore
  375. def _weakref_handle(info): # type: ignore
  376. ref, name = info
  377. ob = ref()
  378. if ob is not None:
  379. with suppress(Exception):
  380. getattr(ob, name)()
  381. def weakref_handle(ob, name, timeout, loop, ceil_timeout=True): # type: ignore
  382. if timeout is not None and timeout > 0:
  383. when = loop.time() + timeout
  384. if ceil_timeout:
  385. when = ceil(when)
  386. return loop.call_at(when, _weakref_handle, (weakref.ref(ob), name))
  387. def call_later(cb, timeout, loop): # type: ignore
  388. if timeout is not None and timeout > 0:
  389. when = ceil(loop.time() + timeout)
  390. return loop.call_at(when, cb)
  391. class TimeoutHandle:
  392. """ Timeout handle """
  393. def __init__(self,
  394. loop: asyncio.AbstractEventLoop,
  395. timeout: Optional[float]) -> None:
  396. self._timeout = timeout
  397. self._loop = loop
  398. self._callbacks = [] # type: List[Tuple[Callable[..., None], Tuple[Any, ...], Dict[str, Any]]] # noqa
  399. def register(self, callback: Callable[..., None],
  400. *args: Any, **kwargs: Any) -> None:
  401. self._callbacks.append((callback, args, kwargs))
  402. def close(self) -> None:
  403. self._callbacks.clear()
  404. def start(self) -> Optional[asyncio.Handle]:
  405. if self._timeout is not None and self._timeout > 0:
  406. at = ceil(self._loop.time() + self._timeout)
  407. return self._loop.call_at(at, self.__call__)
  408. else:
  409. return None
  410. def timer(self) -> 'BaseTimerContext':
  411. if self._timeout is not None and self._timeout > 0:
  412. timer = TimerContext(self._loop)
  413. self.register(timer.timeout)
  414. return timer
  415. else:
  416. return TimerNoop()
  417. def __call__(self) -> None:
  418. for cb, args, kwargs in self._callbacks:
  419. with suppress(Exception):
  420. cb(*args, **kwargs)
  421. self._callbacks.clear()
  422. class BaseTimerContext(ContextManager['BaseTimerContext']):
  423. pass
  424. class TimerNoop(BaseTimerContext):
  425. def __enter__(self) -> BaseTimerContext:
  426. return self
  427. def __exit__(self, exc_type: Optional[Type[BaseException]],
  428. exc_val: Optional[BaseException],
  429. exc_tb: Optional[TracebackType]) -> Optional[bool]:
  430. return False
  431. class TimerContext(BaseTimerContext):
  432. """ Low resolution timeout context manager """
  433. def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
  434. self._loop = loop
  435. self._tasks = [] # type: List[asyncio.Task[Any]]
  436. self._cancelled = False
  437. def __enter__(self) -> BaseTimerContext:
  438. task = current_task(loop=self._loop)
  439. if task is None:
  440. raise RuntimeError('Timeout context manager should be used '
  441. 'inside a task')
  442. if self._cancelled:
  443. task.cancel()
  444. raise asyncio.TimeoutError from None
  445. self._tasks.append(task)
  446. return self
  447. def __exit__(self, exc_type: Optional[Type[BaseException]],
  448. exc_val: Optional[BaseException],
  449. exc_tb: Optional[TracebackType]) -> Optional[bool]:
  450. if self._tasks:
  451. self._tasks.pop()
  452. if exc_type is asyncio.CancelledError and self._cancelled:
  453. raise asyncio.TimeoutError from None
  454. return None
  455. def timeout(self) -> None:
  456. if not self._cancelled:
  457. for task in set(self._tasks):
  458. task.cancel()
  459. self._cancelled = True
  460. class CeilTimeout(async_timeout.timeout):
  461. def __enter__(self) -> async_timeout.timeout:
  462. if self._timeout is not None:
  463. self._task = current_task(loop=self._loop)
  464. if self._task is None:
  465. raise RuntimeError(
  466. 'Timeout context manager should be used inside a task')
  467. self._cancel_handler = self._loop.call_at(
  468. ceil(self._loop.time() + self._timeout), self._cancel_task)
  469. return self
  470. class HeadersMixin:
  471. ATTRS = frozenset([
  472. '_content_type', '_content_dict', '_stored_content_type'])
  473. _content_type = None # type: Optional[str]
  474. _content_dict = None # type: Optional[Dict[str, str]]
  475. _stored_content_type = sentinel
  476. def _parse_content_type(self, raw: str) -> None:
  477. self._stored_content_type = raw
  478. if raw is None:
  479. # default value according to RFC 2616
  480. self._content_type = 'application/octet-stream'
  481. self._content_dict = {}
  482. else:
  483. self._content_type, self._content_dict = cgi.parse_header(raw)
  484. @property
  485. def content_type(self) -> str:
  486. """The value of content part for Content-Type HTTP header."""
  487. raw = self._headers.get(hdrs.CONTENT_TYPE) # type: ignore
  488. if self._stored_content_type != raw:
  489. self._parse_content_type(raw)
  490. return self._content_type # type: ignore
  491. @property
  492. def charset(self) -> Optional[str]:
  493. """The value of charset part for Content-Type HTTP header."""
  494. raw = self._headers.get(hdrs.CONTENT_TYPE) # type: ignore
  495. if self._stored_content_type != raw:
  496. self._parse_content_type(raw)
  497. return self._content_dict.get('charset') # type: ignore
  498. @property
  499. def content_length(self) -> Optional[int]:
  500. """The value of Content-Length HTTP header."""
  501. content_length = self._headers.get(hdrs.CONTENT_LENGTH) # type: ignore
  502. if content_length is not None:
  503. return int(content_length)
  504. else:
  505. return None
  506. def set_result(fut: 'asyncio.Future[_T]', result: _T) -> None:
  507. if not fut.done():
  508. fut.set_result(result)
  509. def set_exception(fut: 'asyncio.Future[_T]', exc: BaseException) -> None:
  510. if not fut.done():
  511. fut.set_exception(exc)
  512. class ChainMapProxy(Mapping[str, Any]):
  513. __slots__ = ('_maps',)
  514. def __init__(self, maps: Iterable[Mapping[str, Any]]) -> None:
  515. self._maps = tuple(maps)
  516. def __init_subclass__(cls) -> None:
  517. raise TypeError("Inheritance class {} from ChainMapProxy "
  518. "is forbidden".format(cls.__name__))
  519. def __getitem__(self, key: str) -> Any:
  520. for mapping in self._maps:
  521. try:
  522. return mapping[key]
  523. except KeyError:
  524. pass
  525. raise KeyError(key)
  526. def get(self, key: str, default: Any=None) -> Any:
  527. return self[key] if key in self else default
  528. def __len__(self) -> int:
  529. # reuses stored hash values if possible
  530. return len(set().union(*self._maps)) # type: ignore
  531. def __iter__(self) -> Iterator[str]:
  532. d = {} # type: Dict[str, Any]
  533. for mapping in reversed(self._maps):
  534. # reuses stored hash values if possible
  535. d.update(mapping)
  536. return iter(d)
  537. def __contains__(self, key: object) -> bool:
  538. return any(key in m for m in self._maps)
  539. def __bool__(self) -> bool:
  540. return any(self._maps)
  541. def __repr__(self) -> str:
  542. content = ", ".join(map(repr, self._maps))
  543. return 'ChainMapProxy({})'.format(content)