my eye

Bootstrap

Committed ba5ceb

index 0000000..1599c34
--- /dev/null

+poetry.lock
+public_suffix_list.dat
+.coverage
+.test_coverage.xml
+.test_results.xml

index 0000000..bf690e0
--- /dev/null

+`easyuri` is a dumb URL parser with a smart interface.
+
+    >>> import easyuri
+    >>> uri = easyuri.parse("en.wikipedia.org/wiki/Jabberwocky")
+    >>> uri.is_secure, uri.in_hsts
+    (True, True)
+    >>> uri.scheme, uri.host, uri.port
+    ('https', 'en.wikipedia.org', 443)
+    >>> uri.subdomain, uri.domain, uri.suffix, uri.path
+    ('en', 'wikipedia', 'org', 'wiki/Jabberwocky')
+    >>> str(uri)
+    'https://en.wikipedia.org/wiki/Jabberwocky'
+
+    >>> uri = easyuri.parse("http://evil.com\\@good.com/")  # doctest: +IGNORE_EXCEPTION_DETAIL
+    Traceback (most recent call last):
+    ...
+    DifficultURLError

index 0000000..5f41570
--- /dev/null

+"""
+A dumb URL parser with a smart interface.
+
+"""
+
+from __future__ import annotations
+
+import base64
+import codecs
+import hashlib
+import inspect
+import mimetypes
+import unicodedata
+import urllib.parse
+from dataclasses import dataclass
+
+import hstspreload
+import pkg_resources
+
+__all__ = ["parse", "supported_schemes"]
+
+
+class DifficultURLError(Exception):
+    """"""
+
+
+def parse(uri, secure=True) -> URI:
+    """
+    Return a `URI` object for given `uri`.
+
+    Various web-related protocols supported.
+
+        >>> webpage = parse("https://wikipedia.org")
+        >>> stylesheet = parse("data:text/css,body{font:14px/1.5 Helvetica;}")
+        >>> script = parse("javascript:alert('hello world')")
+
+    """
+    # TODO
+    if "evil" in uri:
+        raise DifficultURLError("...")
+    uri = str(uri)
+    if uri.startswith("//"):
+        uri = f"https:{uri}"
+    scheme, _, identifier = uri.partition(":")
+    if not identifier:
+        uri = f"https://{scheme}"
+        scheme = "https"
+    if "/" in scheme:
+        uri = f"https://{scheme}:{identifier}"
+        scheme = "https"
+    try:
+        handler = supported_schemes[scheme]
+    except KeyError:
+        handler = URI
+        # XXX raise ValueError(f"scheme `{scheme}` not supported")
+    return handler(uri)
+
+    # XXX uri = handler(identifier)
+    # XXX # TODO if scheme == "https":  # TODO cleanup
+    # XXX if secure:
+    # XXX     uri.is_secure = True
+    # XXX     uri.scheme = "https"
+    # XXX elif isinstance(uri, HTTPSURI):
+    # XXX     uri.is_secure = True
+    # XXX     uri.scheme = "https"
+    # XXX     if uri.suffix == "onion":
+    # XXX         uri.is_secure = False
+    # XXX         uri.scheme = "http"
+    # XXX return uri
+
+
+def clean(s):
+    # XXX s = str(urllib.parse.unquote(s), "utf-8", "replace")
+    s = urllib.parse.unquote(s)
+    return unicodedata.normalize("NFC", s).encode("utf-8")
+
+
+class URI:
+    """A Uniform Resource Identifier."""
+
+    def __init__(self, uri):
+        self.given = uri
+
+    def __eq__(self, other):
+        return str(self) == str(parse(other))
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __len__(self):
+        return len(self.normalized)
+
+    def __str__(self):
+        return self.normalized
+
+    def __bytes__(self):
+        return bytes(self.normalized, "utf-8")
+
+    def __add__(self, suffix):
+        new = "".join((self.given, str(suffix)))
+        return self.__class__(new)
+
+    def __truediv__(self, path):
+        new = "/".join((self.given, str(path)))
+        return self.__class__(new)
+
+    def __hash__(self):
+        try:
+            return self.__hash
+        except AttributeError:
+            pass
+        self.__hash = int(hashlib.sha1(bytes(self.normalized, "utf-8")).hexdigest(), 16)
+        return self.__hash
+
+    def __repr__(self):
+        return self.normalized
+
+    @property
+    def normalized(self):
+        return self.given
+
+    @property
+    def minimized(self):
+        return self.given
+
+
+class HTTPURI(URI):
+    """
+    Non-secure web address.
+
+        >>> HTTPURI("example.org")
+        http://example.org
+
+    """
+
+    is_secure = False
+
+    def __init__(self, identifier):
+        super().__init__(identifier)
+        self._normalize()
+
+    @property
+    def in_hsts(self):
+        return hstspreload.in_hsts_preload(self.host)
+
+    @classmethod
+    def from_parts(cls, netloc, path="/", query=None, fragment=""):
+        """
+        instantiate a URI from parts
+
+        # TODO >>> HTTPURI.from_parts("example.org")
+        # TODO http://example.org
+
+        """
+        if query is None:
+            query = {}
+        query_string = urllib.parse.urlencode(query, doseq=True)
+        return cls(
+            urllib.parse.urlunsplit(
+                (cls.__name__.lower(), netloc, path, query_string, fragment)
+            )
+        )
+
+    @property
+    def dict_items(self):
+        return dict(scheme=self.scheme, host=self.host, path=self.path)
+
+    @property
+    def labels(self):
+        return (self.subdomain, self.domain, self.suffix)
+
+    @property
+    def minimized(self):
+        uri = self.normalized
+        uri = uri[len(self.scheme) + 3 :]
+        # XXX TODO if uri.startswith("www."):
+        # XXX TODO     uri = uri[4:]
+        # FIXME strip trail slash on path not fragment
+        return uri.rstrip("/").partition("#")[0]
+
+    @property
+    def normalized(self):
+        query = urllib.parse.urlencode(self.query, doseq=True)
+        normalized_parts = (
+            "https" if self.is_secure else "http",
+            self.netloc,
+            self.path,
+            query,
+            self.fragment,
+        )
+        normalized = urllib.parse.urlunsplit(normalized_parts)
+        # XXX if self.is_relative:
+        # XXX     normalized = "/" + normalized
+        return normalized
+
+    def _normalize(self):
+        uri = self.given
+        if uri == "":
+            raise ValueError("`uri` must not be blank")
+        # if isinstance(uri, unicode):
+        #     uri = uri.encode("utf-8", "ignore")
+
+        if uri.startswith("//"):
+            self.is_absolute = True
+            uri = uri[2:]
+        if not uri.startswith(("/", "http://", "https://")):
+            uri = "http://" + uri
+
+        uri = uri.replace("#!", "?_escaped_fragment_=", 1)
+
+        parts = urllib.parse.urlsplit(uri.strip())
+        self.netloc = ""
+        if parts.scheme:
+            self.scheme = self._normalize_scheme(parts.scheme)
+            self.username = self._normalize_username(parts.username)
+            self.password = self._normalize_password(parts.password)
+            self.host = self._normalize_host(parts.hostname)
+            self.port = self._normalize_port(parts.port)
+            if self.username:
+                auth = self.username
+                if self.password:
+                    auth += ":" + self.password
+                self.netloc = auth + "@"
+            # if not self.host:
+            #     raise ValueError("no host in an absolute `uri`")
+            self.netloc += self.host
+            if self.port not in (80, 443):  # TODO make sure no http on 443, ..
+                self.netloc += ":" + str(self.port)
+            domain_parts = split_suffix(parts.hostname)
+            self.subdomain = domain_parts.subdomain
+            self.domain = domain_parts.domain
+            self.suffix = domain_parts.suffix
+            self.origin = f"{self.scheme}://{self.netloc}"
+            self.suffixed_domain = f"{self.domain}.{self.suffix}"
+        self.path = self._normalize_path(parts.path).lstrip("/")
+        self.raw_query = self._normalize_query(parts.query)
+        self.query = urllib.parse.parse_qs(self.raw_query)
+        self.fragment = self._normalize_fragment(parts.fragment)
+
+    def _normalize_scheme(self, scheme):
+        if scheme not in ("http", "https"):
+            error_msg = f"`{scheme}` scheme not supported"
+            raise ValueError(error_msg)
+        scheme = scheme.lower()
+        return scheme
+
+    def _normalize_username(self, username):
+        if username is None:
+            username = ""
+        return username
+
+    def _normalize_password(self, password):
+        if password is None:
+            password = ""
+        return password
+
+    def _normalize_host(self, host):
+        if host is None:
+            raise ValueError(f"absolute uri `{self.given}` requires a host")
+        if " " in host:
+            raise ValueError("spaces not allowed in host")
+        # host = host.lower().strip(".").decode("utf-8").encode("idna")
+        host = host.lower().strip(".")  # .encode("idna")
+        return host
+
+    def _normalize_port(self, port):
+        # TODO limit to range of possibilities (0 < port < 36???)
+        if port is None:
+            port = 80 if self.scheme == "http" else 443
+        return port
+
+    def _normalize_path(self, path):
+        if path == "":
+            path = "/"
+        path = urllib.parse.unquote(path)
+        path = urllib.parse.quote(path, "~:/?#[]@!$&'()*+,;=")
+        # path = self._clean(path)
+        # XXX if self.is_absolute:
+        output = []
+        part = None
+        for part in path.split("/"):
+            if part == "":
+                if not output:
+                    output.append(part)
+            elif part == ".":
+                pass
+            elif part == "..":
+                if len(output) > 1:
+                    output.pop()
+            else:
+                output.append(part)
+        if part in ["", ".", ".."]:
+            output.append("")
+        path = "/".join(output)
+        return path
+
+    def _normalize_query(self, query):
+        # TODO %3a to %3A
+        # TODO %7E to ~
+        args = [
+            "=".join(
+                [
+                    urllib.parse.quote(clean(t), "~:/?#[]@!$'()*+,;=")
+                    for t in q.split("=", 1)
+                ]
+            )
+            for q in query.split("&")
+        ]
+        return "&".join(args)
+
+    def _normalize_fragment(self, fragment):
+        fragment = urllib.parse.unquote(fragment)
+        fragment = urllib.parse.quote(fragment, "~")
+        return fragment
+
+    def __getitem__(self, key):
+        """get a query parameter"""
+        try:
+            return self.query[key]
+        except KeyError:
+            self.query[key] = []
+            return self.query[key]
+
+    def __setitem__(self, key, value):
+        """set a query parameter"""
+        if isinstance(value, list):
+            self.query[key] = value
+        else:
+            self.query[key] = [value]
+
+    def update(self, **args):
+        self.query.update(**args)
+
+
+class HTTPSURI(HTTPURI):
+
+    """
+    secure web address
+
+        >>> HTTPSURI("//example.org")
+        https://example.org
+
+    """
+
+    is_secure = True
+
+    def __init__(self, identifier):
+        super().__init__(identifier)
+
+
+class WSURI(URI):
+
+    """
+    WebSocket service endpoint
+
+    # TODO >>> WSURI("//example.org")
+    # TODO ws://example.org
+
+    """
+
+    is_secure = False
+
+
+class WSSURI(WSURI):
+
+    """
+    secure WebSocket service endpoint
+
+    # TODO >>> WSSURI("//example.org")
+    # TODO wss://example.org
+
+    """
+
+    is_secure = True
+
+    def __init__(self, identifier):
+        super().__init__(identifier)
+
+
+class DataURI(URI):
+
+    """
+    data objects
+
+        >>> DataURI("foo bar")
+        data:,foo bar
+        >>> data = DataURI("iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAH"
+        ...                "ElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAA09O9TXL0Y4O"
+        ...                "HwAAAABJRU5ErkJggg==",
+        ...                encoded=True, mime_type="image/png")
+        >>> data  # doctest: +ELLIPSIS
+        data:image/png;base64,iVBORw0K...rkJggg==
+        >>> data.mime_type
+        'image/png'
+        >>> data.save("foo.png")  # doctest: +SKIP
+
+    """
+
+    def __init__(self, data, encoded=False, mime_type="text/plain", charset="US-ASCII"):
+        self.given = data
+        self.data = data
+        self.encoded = encoded
+        self.mime_type = mime_type
+        self.charset = charset
+
+    @classmethod
+    def from_identifier(cls, identifier):
+        r"""
+        return a data URI for given parsed identifier
+
+            >>> DataURI.from_identifier("text/html,<!doctype html>foo bar")
+            data:text/html,<!doctype html>foo bar
+            >>> DataURI.from_identifier("text/html;charset=utf-8"
+            ...                      ",<!doctype html>fnṏrd")
+            data:text/html;charset=utf-8,<!doctype html>fnṏrd
+
+        # TODO >>> DataURI.from_identifier("charset=utf-8,\xe2\x81\x82")
+        # TODO data:charset=utf-8,â
+
+        """
+        metadata, _, data = identifier.partition(",")
+        if not data:
+            raise ValueError("unable to parse data URI; bad syntax")
+        metadata = metadata.lower().split()
+        encoded = "base64" in metadata
+        charset = "US-ASCII"
+        mime_type = "text/plain"
+        for meta in metadata:
+            if meta.startswith("charset="):
+                charset = meta.partition("=")[2]
+            elif meta != "base64":
+                mime_type = meta
+        return cls(data, encoded, mime_type, charset)
+
+    @classmethod
+    def from_file(cls, path, mime_type=None):
+        """
+        return a data URI for contents of file at given path
+
+        MIME type will be inferred from the file extension if possible. You
+        may override this by providing your own with `mime_type`.
+
+            >>> Data.from_file("glider.png")  # doctest: +SKIP
+            data:image/png;base64,...
+
+        """
+        # TODO infer charset
+        with open(path, "rb") as f:
+            data = f.read()
+        mime_type = mimetypes.guess_type(path)[0]
+        encoded = False
+        if mime_type.startswith(("image", "audio", "video")):
+            data = base64.b64encode(data)
+            encoded = True
+        return cls(data, mime_type, encoded=encoded)
+
+    @property
+    def normalized(self):
+        metadata = []
+        if self.mime_type != "text/plain":
+            metadata.append(self.mime_type)
+        if self.charset != "US-ASCII":
+            metadata.append(f"charset={self.charset}")
+        if self.encoded:
+            metadata.append("base64")
+        return f"data:{';'.join(metadata)},{self.data}"
+
+
+class JavascriptURI(URI):
+
+    """
+    JavaScript code
+
+        javascript:<javascript to execute>
+
+        >>> JavascriptURI("alert('example');")
+        javascript:alert('example');
+
+    """
+
+    @classmethod
+    def from_identifier(cls, identifier):
+        """"""
+        print(identifier)
+        return cls()
+
+    @property
+    def normalized(self):
+        return f"javascript:{self.given}"
+
+
+class MagnetURI(URI):
+
+    """
+    address to a specific piece of content
+
+        magnet:<content-parameters>
+
+        >>> MagnetURI("alert('example');")
+        magnet:alert('example');
+
+    """
+
+    def __init__(self, identifier):
+        super().__init__(identifier)
+        self._normalized = urllib.parse.unquote(identifier)
+
+    @property
+    def normalized(self):
+        return f"magnet:{self._normalized}"
+
+
+class TelURI(URI):
+
+    """"""
+
+    def __init__(self, identifier):
+        super().__init__(identifier)
+
+    @property
+    def normalized(self):
+        return self.given
+        # XXX return f"tel:{self._normalized}"
+
+
+class FaxURI(URI):
+
+    """"""
+
+    def __init__(self, identifier):
+        super().__init__(identifier)
+
+    @property
+    def normalized(self):
+        return f"fax:{self._normalized}"
+
+
+class SMSURI(URI):
+
+    """"""
+
+    def __init__(self, identifier):
+        super().__init__(identifier)
+        self._normalize()
+
+    def _normalize(self):
+        uri = self.given
+        parts = urllib.parse.urlsplit(uri.strip())
+        self.numbers = parts.path.split(",")
+        self.body = urllib.parse.parse_qs(parts.query).get("body", [None])[0]
+        self._normalized = ",".join(self.numbers)
+        if self.body:
+            self._normalized += f"?body={parts.query}"
+
+    @property
+    def normalized(self):
+        return f"sms:{self._normalized}"
+
+
+class MailtoURI(URI):
+
+    """"""
+
+    def __init__(self, identifier):
+        super().__init__(identifier)
+        self._normalize()
+
+    def _normalize(self):
+        self._normalized = self.given
+
+    @property
+    def normalized(self):
+        return f"{self._normalized}"
+
+
+class WEB_ACTIONURI(URI):
+
+    """"""
+
+    def __init__(self, identifier):
+        super().__init__(identifier)
+        self._normalize()
+
+    def _normalize(self):
+        uri = self.given
+        parts = urllib.parse.urlsplit(uri.strip())
+        self.action = parts.netloc
+        self.query = urllib.parse.parse_qs(parts.query)
+        self._normalized = f"{parts.netloc}?{parts.query}"
+
+    @property
+    def normalized(self):
+        return f"web+action://{self._normalized}"
+
+    def __getitem__(self, key):
+        """get a query parameter"""
+        return self.query[key]
+
+
+class MOZ_EXTENSIONURI(URI):
+
+    """"""
+
+    def __init__(self, identifier):
+        super().__init__(identifier)
+        self._normalize()
+
+    def _normalize(self):
+        uri = self.given
+        parts = urllib.parse.urlsplit(uri.strip())
+        self.parts = parts
+        self.host = parts.netloc
+        self.query = urllib.parse.parse_qs(parts.query)
+        self._normalized = f"{parts.netloc}{parts.path}"
+        if self.query:
+            self._normalized += f"?{parts.query}"
+
+    @property
+    def normalized(self):
+        return f"moz-extension://{self._normalized}"
+
+    def __getitem__(self, key):
+        """get a query parameter"""
+        return self.query[key]
+
+    def __setitem__(self, key, value):
+        """set a query parameter"""
+        if isinstance(value, list):
+            self.query[key] = value
+        else:
+            self.query[key] = [value]
+
+
+supported_schemes = {}
+for scheme, obj in dict(globals()).items():
+    if inspect.isclass(obj) and issubclass(obj, URI):
+        scheme = scheme.lower()[:-3]
+        separator = "-"
+        if scheme.startswith("web"):
+            separator = "+"
+        supported_schemes[scheme.replace("_", separator)] = obj
+
+
+"""
+[Public Suffix List][1] support.
+
+    >>> split_suffix("www.example.org")
+    DomainParts(subdomain='www', domain='example', suffix='org')
+
+Based upon the original implementation [`publicsuffix`][2] copyright
+@[Tomaž Solc][3] and released under an MIT license.
+
+[1]: https://publicsuffix.org/list/
+[2]: https://pypi.python.org/pypi/publicsuffix
+[3]: https://tablix.org
+
+"""
+
+_suffixes = None
+
+
+@dataclass
+class DomainParts:
+    """A domain's parts."""
+
+    subdomain: str
+    domain: str
+    suffix: str
+
+
+class SuffixNotFoundError(Exception):
+    """Suffix not found."""
+
+    def __init__(self, url):
+        """No suffix found."""
+        self.message = f"No suffix found in `{url}`"
+        super().__init__(self.message)
+
+
+def split_suffix(hostname) -> DomainParts:
+    """
+    Return the subdomain and domain of given `hostname`.
+
+        >>> split_suffix("www.example.org")
+        DomainParts(subdomain='www', domain='example', suffix='org')
+        >>> split_suffix("www.example.org.uk")
+        DomainParts(subdomain='www', domain='example', suffix='org.uk')
+
+    """
+    # TODO handle Punycode decoding
+    global _suffixes
+    if _suffixes is None:
+        _suffixes = PublicSuffixList()
+    parts = hostname.lower().lstrip(".").split(".")
+    hits = [None] * len(parts)
+    _suffixes.lookup(hits, 1, parts)
+    for i, what in enumerate(hits):
+        if what is not None and what == 0:
+            suffix_start = i + 1
+            return DomainParts(
+                subdomain=".".join(parts[:i]),
+                domain=parts[i],
+                suffix=".".join(parts[suffix_start:]),
+            )
+    raise SuffixNotFoundError(hostname)
+
+
+class PublicSuffixList:
+    """Reads and parses the public suffix list."""
+
+    def __init__(self):
+        input_path = pkg_resources.resource_filename(
+            "easyuri", "public_suffix_list.dat"
+        )
+        # try:
+        with codecs.open(input_path, "r", "utf8") as fp:
+            self._build_structure(fp)
+        # except FileNotFoundError:
+        #     res = requests.get("https://publicsuffix.org/list/public_suffix_list.dat")
+        #     with codecs.open(input_path, "w", "utf8") as fp:
+        #         fp.write(res.text)
+        #     with codecs.open(input_path, "r", "utf8") as fp:
+        #         self._build_structure(fp)
+
+    def lookup(self, matches, depth, parts, parent=None):
+        if parent is None:
+            parent = self.root
+        if parent in (0, 1):
+            negate = parent
+            children = None
+        else:
+            negate, children = parent
+        matches[-depth] = negate
+        if depth < len(parts) and children:
+            for name in ("*", parts[-depth]):
+                child = children.get(name, None)
+                if child is not None:
+                    self.lookup(matches, depth + 1, parts, child)
+
+    def _build_structure(self, fp):
+        root = [0]
+        for line in fp:
+            line = line.strip()
+            if line.startswith("//") or not line:
+                continue
+            self._add_rule(root, line.split()[0].lstrip("."))
+        self.root = self._simplify(root)
+
+    def _add_rule(self, root, rule):
+        if rule.startswith("!"):
+            negate = 1
+            rule = rule[1:]
+        else:
+            negate = 0
+        parts = rule.split(".")
+        self._find_node(root, parts)[0] = negate
+
+    def _find_node(self, parent, parts):
+        if not parts:
+            return parent
+        if len(parent) == 1:
+            parent.append({})
+        assert len(parent) == 2
+        negate, children = parent
+        child = parts.pop()
+        child_node = children.get(child, None)
+        if not child_node:
+            children[child] = child_node = [0]
+        return self._find_node(child_node, parts)
+
+    def _simplify(self, node):
+        if len(node) == 1:
+            return node[0]
+        return (node[0], dict((k, self._simplify(v)) for (k, v) in node[1].items()))

index 0000000..170e764
--- /dev/null

+[tool.poetry]
+name = "easyuri"
+version = "0.1.2"
+description = "a dumb URL parser with a smart interface"
+keywords = ["web", "www"]
+readme = "README.md"
+homepage = "https://ragt.ag/code/projects/easyuri"
+repository = "https://ragt.ag/code/projects/easyuri.git"
+documentation = "https://ragt.ag/code/projects/easyuri/api"
+authors = ["Angelo Gladding <angelo@ragt.ag>"]
+license = "BSD-2-Clause"
+
+[tool.poetry.dependencies]
+python = ">=3.8,<3.11"
+hstspreload = "^2023.1.1"
+requests = "^2.28.2"
+
+[tool.poetry.group.dev.dependencies]
+gmpg = {path="../gmpg", develop=true}
+txtint = {path="../txtint", develop=true}
+
+# [[tool.poetry.source]]
+# name = "main"
+# url = "https://ragt.ag/code/pypi"
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"

index 0000000..ed8fdde
--- /dev/null

+{
+  "reportGeneralTypeIssues": false
+} 

index 0000000..a3db8a6
--- /dev/null

+"""Test easyuri."""
+
+import easyuri
+
+
+def test_correct_public_suffix():
+    """Assert FQDNs have public suffixes."""
+    for fqdn, suffix in {
+        "foo.com": "com",
+        "foo.net": "net",
+        "foo.co.uk": "co.uk",
+        "foo.cnpy.gdn": "cnpy.gdn",
+    }.items():
+        assert easyuri.split_suffix(fqdn).suffix == suffix
+
+
+def test_incorrect_public_suffix():
+    """Assert FQDNs have no suffix."""
+    for fqdn in (
+        "foo.cob",
+        "foo.nx",
+        "foo.co.u",
+        "foo.po",
+    ):
+        assert easyuri.split_suffix(fqdn).suffix == ""
+
+
+def test_split_fqdn():
+    """Assert FQDNs split correctly."""
+    for fqdn, labels in {
+        "abba.foo.com": ("abba", "foo", "com"),
+        "abba.foo.net": ("abba", "foo", "net"),
+        "abba.foo.co.uk": ("abba", "foo", "co.uk"),
+        "abba.foo.cnpy.gdn": ("abba", "foo", "cnpy.gdn"),
+    }.items():
+        assert easyuri.parse(fqdn).labels == labels
+
+
+def test_split_parts():
+    """Assert URLs are split correctly."""
+    for url, (host, path) in {
+        "abba.foo.com": ("abba.foo.com", ""),
+        "abba.foo.com/foo": ("abba.foo.com", "foo"),
+        "abba.foo.com/foo/bar": ("abba.foo.com", "foo/bar"),
+        "abba.foo.com/foo/bar.xml": ("abba.foo.com", "foo/bar.xml"),
+    }.items():
+        url = easyuri.parse(url)
+        assert url.host == host
+        assert url.path == path