"""Matchers for the `text sequence type`_ (:py:class:`str`).
.. _text sequence type: https://docs.python.org/3/library/stdtypes.html#text-sequence-type-str
"""
import json
import re
import typing as tp
from collections.abc import Mapping, Sequence
from urllib.parse import parse_qs, urlparse
from joythief.core import Matcher, MaybeMatcher
from joythief.objects import InstanceOf
[docs]
class JsonString(Matcher[str]):
"""Matches any :py:class:`str` instance representing JSON.
:param expected: What the result of parsing the JSON should be.
If omitted, any valid JSON string is matched.
"""
__ANYTHING = object()
_expected: tp.Any
def __init__(self, expected: tp.Any = __ANYTHING):
super().__init__()
self._expected = expected
def compare(self, other: tp.Any) -> bool:
if not isinstance(other, str):
return tp.cast(bool, NotImplemented)
try:
parsed = json.loads(other)
except json.decoder.JSONDecodeError:
return False
return self._expected is self.__ANYTHING or parsed == self._expected
def represent(self) -> str:
if self._expected is self.__ANYTHING:
return super().represent()
return f"JsonString({self._expected!r})"
[docs]
class StringMatching(Matcher[str]):
"""Matches any :py:class:`str` instance matching a regular expression.
:param pattern: Regex pattern to match, as a string or compiled pattern.
:param flags: Any `flags`_ to compile a :py:class:`str` pattern with
:raises ValueError: if flags are provided with a pre-compiled pattern.
.. _flags: https://docs.python.org/3/library/re.html#flags
"""
_pattern: re.Pattern[str]
@tp.overload
def __init__(self, pattern: re.Pattern[str]): ...
@tp.overload
def __init__(self, pattern: str, *, flags: int = 0): ...
def __init__(
self,
pattern: tp.Union[str, re.Pattern[str]],
*,
flags: int = 0,
):
super().__init__()
self._pattern = re.compile(pattern, flags=flags)
def compare(self, other: tp.Any) -> bool:
if not isinstance(other, str):
return tp.cast(bool, NotImplemented)
return self._pattern.match(other) is not None
def represent(self) -> str:
return f"StringMatching({self._pattern!r})"
[docs]
class UrlString(Matcher[str]):
"""Matches any :py:class:`str` instance representing a URL.
The string is parsed with :py:func:`~urllib.parse.urlparse` and
compared attribute-by-attribute.
Any attributes not provided are ignored.
:param scheme: the scheme (e.g. ``"https"``)
:param hostname: the hostname (e.g. ``"example.com"``)
:param path: the path (e.g. ``"/some/path"``)
:param query: the result of parsing the query string with
:py:func:`~urllib.parse.parse_qs`
:raises TypeError: if no arguments are provided.
"""
_hostname: tp.Optional[MaybeMatcher[str]]
_path: tp.Optional[MaybeMatcher[str]]
_query: tp.Optional[Mapping[str, Sequence[str]]]
_scheme: tp.Optional[MaybeMatcher[str]]
def __init__(
self,
*,
scheme: tp.Optional[MaybeMatcher[str]] = None,
hostname: tp.Optional[MaybeMatcher[str]] = None,
path: tp.Optional[MaybeMatcher[str]] = None,
query: tp.Optional[Mapping[str, Sequence[str]]] = None,
):
super().__init__()
self._hostname = hostname
self._path = path
self._query = query
self._scheme = scheme
if all(
getattr(self, attr) is None
for attr in ["_hostname", "_path", "_query", "_scheme"]
):
raise TypeError("A UrlString with no arguments matches any string")
def compare(self, other: tp.Any) -> bool:
if not isinstance(other, str):
return tp.cast(bool, NotImplemented)
parsed = urlparse(other)
for attribute in ["hostname", "path", "scheme"]:
if (expected := getattr(self, f"_{attribute}")) is not None and getattr(
parsed, attribute
) != expected:
return False
if (expected := self._query) is not None and parse_qs(
parsed.query, keep_blank_values=True
) != expected:
return False
return True
def represent(self) -> str:
parameters = [
f"{name}={value!r}"
for name in ["scheme", "hostname", "path", "query"]
if (value := getattr(self, f"_{name}")) is not None
]
return f"UrlString({', '.join(parameters)})"