def test_any_mapping_requires_items(self): class TestObject(list): def items(self): yield from self # pragma: no cover assert TestObject() == AnyMapping() assert dict() == AnyMapping()
def with_headers(self, headers=...): """Specify the request must have headers. This method will remove 'Host' from the set of headers as some libraries add it before sending, and it's mostly noise. :param headers: A mappable of headers or matcher to match exactly """ if headers is ...: self.headers = AnyMapping.of_size(at_least=1) elif isinstance(headers, Matcher): self.headers = headers elif headers is not None: self.headers = AnyMapping.containing(headers).only()
def _set_query(self, query, exact_match=True): if query is not self.APPLY_DEFAULT: query = MultiValueQuery.normalise(query) if query and not isinstance(query, Matcher): # MultiValueQuery is guaranteed to return something we can # provide to AnyMapping for comparison query = AnyMapping.containing(query) if exact_match: query = query.only() self.parts["query"] = query
class AnyURLCore(Matcher): """Matches any URL.""" APPLY_DEFAULT = object() STRING_OR_NONE = NamedMatcher("<AnyStringOrNone>", AnyOf([None, AnyString()])) MAP_OR_NONE = NamedMatcher("<AnyMappingOrNone>", AnyOf([None, AnyMapping()])) DEFAULTS = { "scheme": STRING_OR_NONE, "host": STRING_OR_NONE, "path": STRING_OR_NONE, "params": STRING_OR_NONE, "query": MAP_OR_NONE, "fragment": STRING_OR_NONE, } # pylint: disable=too-many-arguments # I can't see a way around it. We could use kwargs, but then auto complete # would be hard def __init__( self, base_url=None, scheme=APPLY_DEFAULT, host=APPLY_DEFAULT, path=APPLY_DEFAULT, params=APPLY_DEFAULT, query=APPLY_DEFAULT, fragment=APPLY_DEFAULT, ): """Initialize a new URL matcher. :param base_url: URL (with scheme) to base the matcher on :param scheme: Scheme to match (e.g. http) :param host: Hostname to match :param path: URL path to match :param params: URL path params to match :param query: Query to match (string, dict or matcher) :param fragment: Anchor fragment to match (e.g. "name" for "#name") """ self.parts = { # https://tools.ietf.org/html/rfc7230#section-2.7.3 # scheme and host are case-insensitive "scheme": self._lower_if_string(scheme), "host": self._lower_if_string(host), # `path`, `query` and `fragment` are case-sensitive "path": self._get_path_matcher(path, scheme, host), "params": params, "fragment": fragment, } self._set_query(query) if base_url: self._set_base_url(base_url) else: # Apply default matchers for everything not provided self._apply_defaults(self.parts, self.DEFAULTS) super().__init__("dummy", self.assert_equal_to) def __str__(self): contraints = { key: value for key, value in self.parts.items() if value is not self.DEFAULTS[key] } if not contraints: return "* any URL *" return f"* any URL matching {contraints} *" @classmethod def parse_url(cls, url_string): """Parse a URL into a dict for comparison. Parses the given URL allowing you to see how AnyURL will understand it. This can be useful when debugging why a particular URL does or does not match. :param url_string: URL to parse :raise ValueError: If scheme is mandatory and not provided :return: A normalised string of comparison values """ url = urlparse(url_string) if not url.scheme and not url.netloc: # Without a scheme `urlparse()` assumes that the hostname is part # of the path, so we have to try and guess what it really was host, path = cls._guess_hostname_and_path(url.path) url = url._replace(netloc=host, path=path) return { "scheme": url.scheme.lower() if url.scheme else None, "host": url.netloc.lower() if url.netloc else None, "path": url.path or None, "params": url.params or None, "query": MultiValueQuery.normalise(url.query), "fragment": url.fragment or None, } @staticmethod def _get_path_matcher(path, scheme, host): # If we are anything other than a plain string or None, use it directly if path is not None and not isinstance(path, str): return path # If we are matching paths alone, just return whatever we were given # so we match exactly. This lets the user distinguish between /path # and path which may be important if scheme is None and host is None: return path # If we got None, we need to allow ourselves to match either slash, '' # or None if path in (None, "/", ""): return NamedMatcher("<Path '/'>", AnyOf([None, AnyStringMatching(r"^/?$")])) # Otherwise construct a matcher which doesn't care about leading # slashes return NamedMatcher( f"'<Path '{path}'>", AnyStringMatching(f"^/?{re.escape(path)}$") ) def _set_query(self, query, exact_match=True): if query is not self.APPLY_DEFAULT: query = MultiValueQuery.normalise(query) if query and not isinstance(query, Matcher): # MultiValueQuery is guaranteed to return something we can # provide to AnyMapping for comparison query = AnyMapping.containing(query) if exact_match: query = query.only() self.parts["query"] = query def _set_base_url(self, base_url): # If we have a base URL, we'll take everything from there if it # wasn't explicitly provided in the constructor overlay = self.parse_url(base_url) path_matcher = self._get_path_matcher( overlay["path"], overlay["scheme"], overlay["host"] ) overlay["path"] = path_matcher self._apply_defaults(self.parts, overlay) @staticmethod def _lower_if_string(value): if isinstance(value, str): return value.lower() return value @staticmethod def _apply_defaults(values, defaults, default_key=APPLY_DEFAULT): for key, default_value in defaults.items(): if values[key] is default_key: values[key] = default_value @classmethod def _is_hostname(cls, host): if not host: return False if "." in host: return True return host.lower() == "localhost" @classmethod def _guess_hostname_and_path(cls, path): if "/" in path: head, tail = path.split("/", 1) if cls._is_hostname(head): return head, f"/{tail}" elif cls._is_hostname(path): return path, None return None, path def assert_equal_to(self, other): """Assert that the URL object is equal to another object. :raise AssertionError: If no match is found with details of why :return: True if equal """ if not isinstance(other, str): raise AssertionError("Other URL is not a string") comparison = self.parse_url(other) for key, self_value in self.parts.items(): other_value = comparison.get(key) if self_value != other_value: raise AssertionError(f"Other '{key}' {other_value} != {self_value}") return True
class TestAnyURL: def test_base_case(self): matcher = AnyURLCore() assert "" == matcher assert 3 != matcher BASE_URL = "http://www.example.com/path;params?a=1&b=2#fragment" # URLs where the specified part is different from BASE_URL PART_MODIFIED_URLS = { # We must use a URL scheme with `params` for this test "scheme": "ftp://www.example.com/path;params?a=1&b=2#fragment", "host": "http://MODIFIED/path;params?a=1&b=2#fragment", "path": "http://www.example.com/MODIFIED;params?a=1&b=2#fragment", "params": "http://www.example.com/path;MODIFIED?a=1&b=2#fragment", "query": "http://www.example.com/path;params?MODIFIED=1#fragment", "fragment": "http://www.example.com/path;params?a=1&b=2#MODIFIED", } @pytest.mark.parametrize("part,url_with_part_changed", tuple(PART_MODIFIED_URLS.items())) def test_you_can_make_one_part_wild_with_a_base_url( self, part, url_with_part_changed): # Create a matcher with the specified part wild i.e. `scheme=Any()` matcher = AnyURLCore(self.BASE_URL, **{part: Any()}) # Check it matches the original URL and the URL with that part changed assert self.BASE_URL == matcher assert url_with_part_changed == matcher @pytest.mark.parametrize( "base_url, url", ( ("http://example.com", "http://example.com"), ("http://example.com", "http://example.com/"), ("http://example.com/", "http://example.com"), ("http://example.com/", "http://example.com/"), ), ) def test_base_url_matches_with_or_without_path(self, base_url, url): matcher = AnyURLCore(base_url=base_url) assert matcher == url @pytest.mark.parametrize("part", ["scheme", "host", "path", "query", "fragment"]) def test_a_wild_part_does_not_just_match_everything(self, part): # Create a matcher with the specified part wild i.e. `scheme=Any()` matcher = AnyURLCore(self.BASE_URL, **{part: Any()}) for modified_part, modified_url in self.PART_MODIFIED_URLS.items(): # We expect to match the part where the modified part is the part # we have made wild so skip if modified_part == part: continue assert modified_url != matcher @pytest.mark.parametrize( "part,url_with_part_missing", ( # URLs where the specified part is missing from BASE_URL ("scheme", "www.example.com/path;params?a=1&b=2#fragment"), ("host", "http:///path;params?a=1&b=2#fragment"), ("path", "http://www.example.com;params?a=1&b=2#fragment"), ("params", "http://www.example.com/path?a=1&b=2#fragment"), ("query", "http://www.example.com/path;params#fragment"), ("fragment", "http://www.example.com/path;params?a=1&b=2"), ), ) def test_you_can_override_default_with_params(self, part, url_with_part_missing): # Create a matcher with the specified part set to None # i.e. `scheme=None` matcher = AnyURLCore(**{part: None}) # Check we match the URL with the part missing assert url_with_part_missing == matcher # ... but not the URL with it present assert self.BASE_URL != matcher def test_case_sensitivity_for_other(self): matcher = AnyURLCore(self.BASE_URL) # https://tools.ietf.org/html/rfc7230#section-2.7.3 # scheme and host are case-insensitive assert matcher == "HTTP://www.example.com/path;params?a=1&b=2#fragment" assert matcher == "http://WWW.EXAMPLE.COM/path;params?a=1&b=2#fragment" # ... path, query string and fragment are case-sensitive assert matcher != "http://www.example.com/PATH;params?a=1&b=2#fragment" assert matcher != "http://www.example.com/path;PARAMS?a=1&b=2#fragment" assert matcher != "http://www.example.com/path;params?A=1&B=2#fragment" assert matcher != "http://www.example.com/path;params?a=1&b=2#FRAGMENT" @pytest.mark.parametrize( "matcher", ( AnyURLCore(BASE_URL.upper()), AnyURLCore(BASE_URL.upper(), scheme="HTTP"), AnyURLCore(BASE_URL.upper(), host="WWW.EXAMPLE.COM"), ), ) def test_case_sensitivity_for_self(self, matcher): # https://tools.ietf.org/html/rfc7230#section-2.7.3 # scheme and host are case-insensitive assert matcher == "http://WWW.EXAMPLE.COM/PATH;PARAMS?A=1&B=2#FRAGMENT" assert matcher == "HTTP://www.example.com/PATH;PARAMS?A=1&B=2#FRAGMENT" # ... path, query string and fragment are case-sensitive assert matcher != "HTTP://WWW.EXAMPLE.COM/path;PARAMS?A=1&B=2#FRAGMENT" assert matcher != "HTTP://WWW.EXAMPLE.COM/PATH;params?A=1&B=2#FRAGMENT" assert matcher != "HTTP://WWW.EXAMPLE.COM/PATH;PARAMS?a=1&b=2#FRAGMENT" assert matcher != "HTTP://WWW.EXAMPLE.COM/PATH;PARAMS?A=1&B=2#fragment" @pytest.mark.parametrize( "part,value", ( ("scheme", "http"), ("host", "www.example.com"), ("path", "/path"), ("query", "a=1&b=2"), ("fragment", "fragment"), ), ) def test_generic_matching(self, part, value): matcher = AnyURLCore(**{part: value}) for comparison_part, url in self.PART_MODIFIED_URLS.items(): if comparison_part == part: # The URLs are different here and this is the part we specified # so we should spot the difference assert url != matcher else: # These are different too, but these should all match assert url == matcher @pytest.mark.parametrize( "_,query", ( ("plain string", "a=1&b=2"), ("dict", { "a": "1", "b": "2" }), ("any mapping", AnyMapping.containing({ "a": "1", "b": "2" }).only()), ("any dict", Any.dict.containing({ "a": "1", "b": "2" }).only()), ), ) def test_specifying_query_string(self, query, _): matcher = AnyURLCore(query=query) assert matcher == "http://example.com?b=2&a=1" assert matcher != "http://example.com?b=2" assert matcher != "http://example.com?b=2&a=1&c=3" assert matcher != "http://example.com?b=2&a=1&a=1" def test_multi_query_params(self): url = "http://example.com?a=1&a=1&a=2" assert url != AnyURLCore(query={"a": "1"}) assert url != AnyURLCore(query=Any.dict.containing({"a": "1"})) assert url == AnyURLCore(query=Any.mapping.containing({"a": "1"})) assert url == AnyURLCore( query=Any.mapping.containing([("a", "1"), ("a", "2"), ("a", "1")]).only()) assert url != AnyURLCore(query=Any.mapping.containing([( "a", "1"), ("a", "2"), ("a", "1"), ("b", 5)]).only()) def test_stringification_changes_when_contents_change(self): matcher = AnyURLCore(scheme="foo") assert "foo" in repr(matcher) assert "foo" in str(matcher) matcher.parts["scheme"] = "boo" assert "boo" in repr(matcher) assert "boo" in str(matcher) def test_it_raises_with_assert_on_comparison_enabled(self): # Normally you'd turn this on for the whole class, but it has totally # non-local effects and explodes the tests matcher = AnyURLCore(scheme="missing") matcher.assert_on_comparison = True with pytest.raises(AssertionError): _ = "abc" == matcher @pytest.mark.parametrize("other", (None, 123, True)) def test_it_refuses_to_compare_to_non_strings(self, other): assert AnyURLCore() != other def test_stringification_default(self): assert str(AnyURLCore()) == "* any URL *"
class AnyURL(AnyURLCore): """A URL matcher with a fluent style interface.""" # pylint: disable=function-redefined PRESENT_DEFAULT = { "scheme": AnyString(), "host": AnyString(), "path": AnyString(), "params": AnyString(), "query": AnyMapping(), "fragment": AnyString(), } def _apply_field_default(self, field, value): if value is not AnyURLCore.APPLY_DEFAULT: return value return self.PRESENT_DEFAULT[field] @staticmethod def with_scheme(scheme=AnyURLCore.APPLY_DEFAULT): """Confuse pylint so it doesn't complain about fluent-endpoints.""" @staticmethod def with_host(host=AnyURLCore.APPLY_DEFAULT): """Confuse pylint so it doesn't complain about fluent-endpoints.""" @staticmethod def with_path(path=AnyURLCore.APPLY_DEFAULT): """Confuse pylint so it doesn't complain about fluent-endpoints.""" @staticmethod def with_params(params=AnyURLCore.APPLY_DEFAULT): """Confuse pylint so it doesn't complain about fluent-endpoints.""" @staticmethod def containing_query(query=AnyURLCore.APPLY_DEFAULT): """Confuse pylint so it doesn't complain about fluent-endpoints.""" @staticmethod def with_query(query=AnyURLCore.APPLY_DEFAULT): """Confuse pylint so it doesn't complain about fluent-endpoints.""" @staticmethod def with_fragment(fragment=AnyURLCore.APPLY_DEFAULT): """Confuse pylint so it doesn't complain about fluent-endpoints.""" @classmethod def matching(cls, base_url): """Create a URL matcher based on the given URL. :param base_url: URL to base the matcher on :return: An instance of AnyURLFluent """ return AnyURL(base_url) @fluent_entrypoint def with_scheme(self, scheme=AnyURLCore.APPLY_DEFAULT): """Specify that this URL must have a scheme or None. If you pass None this will ensure the URL has no scheme. :param scheme: None, string or matcher for the scheme """ self.parts["scheme"] = self._apply_field_default("scheme", scheme) @fluent_entrypoint def with_host(self, host=AnyURLCore.APPLY_DEFAULT): """Specify that this URL must have a host or None. If you pass None this will ensure the URL has no host. :param host: None, string or matcher for the host """ self.parts["host"] = self._apply_field_default("host", host) @fluent_entrypoint def with_path(self, path=AnyURLCore.APPLY_DEFAULT): """Specify that this URL must have a path or None. If you pass None this will ensure the URL has no path. :param path: None, string or matcher for the path """ if path is AnyURLCore.APPLY_DEFAULT: self.parts["path"] = AnyString() else: self.parts["path"] = self._get_path_matcher( path, self.parts["scheme"], self.parts["host"]) @fluent_entrypoint def with_params(self, params=AnyURLCore.APPLY_DEFAULT): """Specify that this URL must have a params or None. If you pass None this will ensure the URL has no params. :param params: None, string or matcher for the params """ self.parts["params"] = self._apply_field_default("params", params) @fluent_entrypoint def containing_query(self, query): """Specify that the query must have at least the items specified. :param query: A mappable to check """ self._set_query(query, exact_match=False) @fluent_entrypoint def with_query(self, query=AnyURLCore.APPLY_DEFAULT): """Specify that this URL must have a query or None. If you pass None this will ensure the URL has no query. :param query: None, mapping or matcher for the query """ query = self._apply_field_default("query", query) self._set_query(query) @fluent_entrypoint def with_fragment(self, fragment=AnyURLCore.APPLY_DEFAULT): """Specify that this URL must have a fragment or None. If you pass None this will ensure the URL has no fragment. :param fragment: None, string or matcher for the fragment """ self.parts["fragment"] = self._apply_field_default( "fragment", fragment)
def test_non_matching_items(self, non_matching): assert non_matching != AnyMapping()
def containing_headers(self, headers): """Specify the request must have at least the headers specified. :param headers: A mappable of headers to match """ self.headers = AnyMapping.containing(headers)
def test_it_stringifies_as_we_specify(self): matcher = NamedMatcher("string", AnyMapping()) assert str(matcher) == "string" assert repr(matcher) == "string"
def test_it_matches_like_its_contents(self): matcher = NamedMatcher("string", AnyMapping()) assert matcher == {} assert matcher != []