Skip to content

URI API

URI (pure URI paths), URIPath (URI paths with I/O and scheme dispatch), Source and Query. The built-in schemes are documented under Schemes.

uripath

pathlib-style paths for URIs.

QueryLike = 'str | Query | _ty.Mapping[str, str | None | _ty.Sequence[str | None]] | _ty.Sequence[tuple[str, str | None]]' module-attribute

What URI.with_query() accepts in place of a Query: query text (taken as already percent-encoded), a mapping or a sequence of pairs, or a Query; None removes the query.

URILike = 'str | URI | os.PathLike' module-attribute

Query(pairs=(), *, separator=None, encoding=None)

A URI query as an immutable value: the percent-encoded text and the (name, value) pairs it stands for.

Build one from pairs or a mapping (a sequence value repeats the name, a None value is a bare name) with Query(...), which encodes them so that to_pairs() returns what was given, or from text with Query.parse(), which keeps the text byte for byte: it is what URI.query holds, as received, and what is sent. str(query) is that text.

Two queries are equal, and hash alike, when they decode to the same pairs, whatever their spelling (a=%41 and a=A); a query is never equal to a str, and has no length, items or iteration. to_pairs() and to_dict() follow RFC 3986, not application/x-www-form-urlencoded: only %XX escapes are decoded and a + stays a plus (urllib.parse.parse_qsl reads form text), and an escape that is not valid in encoding survives as a surrogate escape instead of raising.

separator splits the text into terms (default &) and encoding is the charset of the escapes (default utf-8); both say how the text is read and are not part of the value.

Source code in src/uripath/query.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def __init__(
    self,
    pairs: (
        _ty.Mapping[str, str | None | _ty.Sequence[str | None]]
        | _ty.Sequence[tuple[str, str | None]]
        | Query
    ) = (),
    *,
    separator: str | None = None,
    encoding: str | None = None,
):
    if isinstance(pairs, (str, bytes)):
        raise TypeError(
            "a Query is built from pairs or a mapping; "
            "use Query.parse() for query text"
        )
    text = None
    if isinstance(pairs, Query):
        separator = separator or pairs._separator
        encoding = encoding or pairs._encoding
        if separator == pairs._separator and encoding == pairs._encoding:
            text = pairs._text
        else:
            pairs = pairs.to_pairs()
    else:
        separator = separator or self.SEPARATOR
        encoding = encoding or self.ENCODING
    if text is None:
        if isinstance(pairs, _abc.Mapping):
            text = _querydict(pairs, separator, encoding)
        else:
            text = _querylist(pairs, separator, encoding)
    self._set(text, separator, encoding)

ENCODING = 'utf-8' class-attribute instance-attribute

SEPARATOR = '&' class-attribute instance-attribute

encoding property

The charset the escapes of the text are read in.

separator property

What splits the text into terms.

parse(text, *, separator=SEPARATOR, encoding=ENCODING) classmethod

The query that text spells, percent-encoded as received and kept byte for byte. Any text parses; TypeError for what is not a str.

Source code in src/uripath/query.py
120
121
122
123
124
125
126
127
128
129
130
@classmethod
def parse(
    cls, text: str, *, separator: str = SEPARATOR, encoding: str = ENCODING
) -> Query:
    """The query that `text` spells, percent-encoded as received and kept
    byte for byte. Any text parses; `TypeError` for what is not a `str`."""
    if not isinstance(text, str):
        raise TypeError(f"query text must be a str, not {type(text).__name__!r}")
    query = object.__new__(cls)
    query._set(text, separator, encoding)
    return query

to_dict(*, single=False)

The pairs by name: each name maps to the list of its values, or with single to its last value.

Source code in src/uripath/query.py
147
148
149
150
151
152
153
154
155
156
def to_dict(self, *, single: bool = False):
    """The pairs by name: each name maps to the list of its values, or with
    `single` to its last value."""
    query_: dict[str, list[str | None]] = {}
    for k, v in self._decoded():
        if single:
            query_[k] = v
        else:
            query_.setdefault(k, []).append(v)
    return query_

to_pairs()

The decoded (name, value) pairs in order; value is None for a name with no =.

Source code in src/uripath/query.py
142
143
144
145
def to_pairs(self) -> list[tuple[str, str | None]]:
    """The decoded `(name, value)` pairs in order; `value` is `None` for a
    name with no `=`."""
    return list(self._decoded())

Source

Bases: NamedTuple

A URI's scheme/userinfo/host/port -- everything before the path. Falsy (bool(source) is False) when every field is empty/None. A host of "" is an authority with no host (file:///x) and None is no authority (file:/x): they render differently and name the same endpoint.

host instance-attribute

port instance-attribute

scheme instance-attribute

userinfo instance-attribute

get_scheme_cls(schemesmap=None)

The URIPath subclass registered for this scheme, or URIPath itself. An explicit schemesmap is the complete set of classes to choose from: a scheme missing from it gives URIPath, and neither plugins nor the global registry are consulted.

Source code in src/uripath/source/_source.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def get_scheme_cls(self, schemesmap: _ty.Mapping[str, type["URIPath"]] = None):
    """The `URIPath` subclass registered for this scheme, or `URIPath`
    itself. An explicit `schemesmap` is the complete set of classes to
    choose from: a scheme missing from it gives `URIPath`, and neither
    plugins nor the global registry are consulted."""
    from ..schemes._path import URIPath

    if self.scheme:
        if schemesmap is not None:
            return schemesmap.get(self.scheme, None) or URIPath
        # A scheme no class registers is looked up in the built-in modules and the
        # entry points once; `_load_entry_point` caches that negative answer instead
        # of rescanning every installed distribution on each construction.
        return URIPath._find_scheme(self.scheme) or URIPath
    return URIPath

is_local()

Whether host resolves to this machine.

The answer depends on the host alone (userinfo, port and scheme play no part) and is remembered for _LOCAL_TTL seconds, at most _LOCAL_CACHE_SIZE hosts: it comes from a DNS/hosts-file lookup and the machine's interfaces, so it is neither free nor forever.

The hostname->address step uses netimps.resolve() (default backend chain: dnspython, then the OS resolver via getaddrinfo() -- hosts file, NSS, DNS, OS cache -- then nslookup as a last resort), trying both "a"/"aaaa" record types and treating host as local if ANY resolved address is. The "is this address MINE" comparison uses netimps.is_local_address(), which enumerates the real network interfaces (netimps.get_interfaces()), so VMs, containers, VPN interfaces and additional NICs count.

host as a bare IP-literal str (e.g. a directly-constructed Source(..., host="::1", ...), bypassing _decode_host()'s usual bracket-literal parsing) is handled by netimps.try_parse() without going through resolution at all.

Source code in src/uripath/source/_source.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def is_local(self) -> bool:
    """Whether `host` resolves to this machine.

    The answer depends on the host alone (userinfo, port and scheme play
    no part) and is remembered for `_LOCAL_TTL` seconds, at most
    `_LOCAL_CACHE_SIZE` hosts: it comes from a DNS/hosts-file lookup and
    the machine's interfaces, so it is neither free nor forever.

    The hostname->address step uses `netimps.resolve()` (default
    backend chain: dnspython, then the OS resolver via
    `getaddrinfo()` -- hosts file, NSS, DNS, OS cache -- then
    `nslookup` as a last resort), trying both `"a"`/`"aaaa"` record
    types and treating `host` as local if ANY resolved address is.
    The "is this address MINE" comparison uses
    `netimps.is_local_address()`, which enumerates the real network
    interfaces (`netimps.get_interfaces()`), so VMs, containers, VPN
    interfaces and additional NICs count.

    `host` as a bare IP-literal `str` (e.g. a directly-constructed
    `Source(..., host="::1", ...)`, bypassing `_decode_host()`'s usual
    bracket-literal parsing) is handled by `netimps.try_parse()`
    without going through resolution at all.
    """
    host = fold_host(self.host)
    if not host or host == "localhost":
        return True
    return _host_is_local(host)

keys()

Source code in src/uripath/source/_source.py
149
150
def keys(self):
    return self._asdict().keys()

parse(text, *, strict=True) classmethod

The Source a URI string names. With strict (the default) a path, query or fragment is a URISyntaxError that names the component and not the text, which may carry a password. A port outside 0-65535, or a ':' followed by anything but digits in a name that is not a bracketed address, is a URISyntaxError as well. TypeError for what is not a str.

Source code in src/uripath/source/_source.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
@classmethod
def parse(cls, text: str, *, strict: bool = True) -> Source:
    """The `Source` a URI string names. With `strict` (the default) a
    path, query or fragment is a `URISyntaxError` that names the component
    and not the text, which may carry a password. A port outside
    0-65535, or a ':' followed by anything but digits in a name that is
    not a bracketed address, is a `URISyntaxError` as well. `TypeError`
    for what is not a `str`."""
    if not isinstance(text, str):
        raise TypeError(f"source text must be a str, not {type(text).__name__!r}")
    uri = _rfc3986.split(text)
    if strict:
        extra = [
            name for name in ("path", "query", "fragment") if getattr(uri, name)
        ]
        if extra:
            raise URISyntaxError(
                "expected scheme and authority only, found a "
                + " and a ".join(extra)
            )
    scheme = uri.scheme.lower() if uri.scheme is not None else None
    return _parse_source(scheme, uri.authority)

parsed_userinfo()

(user, password), "" for a part that is absent. The split is the first colon of the userinfo as it was written (an escaped colon in the user name stays in the name).

Source code in src/uripath/source/_source.py
157
158
159
160
161
162
163
164
def parsed_userinfo(self) -> "tuple[str, str]":
    """`(user, password)`, `""` for a part that is absent. The split is
    the first colon of the userinfo as it was written (an escaped colon
    in the user name stays in the name)."""
    if not self.userinfo:
        return "", ""
    user, password = _split_userinfo_text(self.userinfo)
    return user, password or ""

reveal()

The text of this source with its password, which str() leaves out: the credentialed round trip, as URI.as_uri(sanitize=False) is for a URI. Named as an action because the result carries the secret; it belongs in a connection, never in a log or a message.

Source code in src/uripath/source/_source.py
 98
 99
100
101
102
103
104
def reveal(self) -> str:
    """The text of this source with its password, which `str()` leaves out:
    the credentialed round trip, as `URI.as_uri(sanitize=False)` is for a
    URI. Named as an action because the result carries the secret; it
    belongs in a connection, never in a log or a message."""
    scheme, userinfo, host, port = _source_parts(self, False)
    return _compose_uri(scheme, userinfo, host, port, "", None, None)

try_parse(text, default=None, *, strict=True) classmethod

parse(), but default (None) for text that is not a source. TypeError still for what is not a str.

Source code in src/uripath/source/_source.py
138
139
140
141
142
143
144
145
146
147
@classmethod
def try_parse(
    cls, text: str, default: "Source | None" = None, *, strict: bool = True
) -> "Source | None":
    """`parse()`, but `default` (`None`) for text that is not a source.
    `TypeError` still for what is not a `str`."""
    try:
        return cls.parse(text, strict=strict)
    except URISyntaxError:
        return default

URI(*uris, **options)

Bases: Pathname

A pure (no I/O) RFC 3986 URI, lazily parsed into source (scheme/ userinfo/host/port), path, query, and fragment on first access. Join semantics (multiple constructor args, or /) are pathlib- joinpath-like, not RFC 3986 reference resolution -- see _load_parts's docstring and docs/divergences.md.

Source code in src/uripath/_uri.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def __init__(self, *uris: URILike, **options):
    if options:
        self._check_options(options)
    if self._raw_uris or self._initiated:
        return
    _uris: list[str | URI] = []
    for uri in uris:
        if uri is None or (isinstance(uri, (str, bytes)) and not uri):
            uri = ""
        if isinstance(uri, URI):
            _uris.append(uri)
        elif isinstance(uri, (_pathlib.Path, Path)):
            try:
                uri = uri.as_uri()
            except ValueError:
                # as_uri() raises ValueError for a relative path, which
                # joins like a relative PurePath (see _RelativeLocalPath).
                uri = _RelativeLocalPath(_uriencode(uri.as_posix(), safe="/"))
            _uris.append(uri)
        elif isinstance(uri, (_pathlib.PurePath, Pathname)):
            _uris.append(_path_reference(uri.as_posix()))
        elif hasattr(uri, "as_uri"):
            path = uri.as_uri
            if callable(path):
                path = path()
            _uris.append(path)
        elif isinstance(uri, str):
            _uris.append(uri)
        elif isinstance(uri, bytes):
            _uris.append(uri.decode())
        else:
            path = None
            try:
                path = os.fspath(uri)
            except (TypeError, NotImplementedError):
                pass
            if not isinstance(path, str):
                raise TypeError(
                    "argument should be a str or an os.PathLike "
                    "object where __fspath__ returns a str, "
                    f"not {type(uri).__name__!r}"
                )
            # Only __fspath__ is guaranteed here -- posix-normalize the
            # string itself rather than assuming an as_posix() method.
            posix = _pathlib.PurePath(path).as_posix()
            _uris.append(_path_reference(posix))
    self._raw_uris = _uris

fragment property

normalized_path property

Return the normalized path using posixpath rules.

parent property

The logical parent of the path.

parts property

The tuple of URI components: (source, path, query, fragment).

path property

query property

The query exactly as received: still percent-encoded, so an escaped &, = or + inside a value survives the round trip. Use Query.to_pairs()/to_dict() for decoded pairs.

segments property

source property

stem property

suffix property

as_posix()

Source code in src/uripath/_uri.py
986
987
988
989
990
991
992
993
994
995
996
997
def as_posix(self):
    source = self.source
    host = None
    posix = self.path
    if source.host:
        host = source.host
        user, password = source.parsed_userinfo()
        if user:
            posix = f"{user}@{host}:{posix}"
        else:
            posix = f"{host}:{posix}"
    return posix

as_uri(sanitize=False)

Source code in src/uripath/_uri.py
680
681
682
683
684
685
686
687
def as_uri(self, /, sanitize=False):
    if self._uri is None or sanitize:
        uri = self._text(self.source, sanitize)
        if not sanitize:
            self._uri = uri
        return uri
    else:
        return self._uri

host_fspath()

Return .path for any scheme whose path component is a filesystem path on the URI's own host (see _host_filesystem_path), for building a command line that runs on that host (e.g. via a remote executor). Unlike __fspath__, this never falls back to treating the path as local -- it raises NotImplementedError for schemes with no host-filesystem meaning (http:, s3:, ...).

Source code in src/uripath/_uri.py
662
663
664
665
666
667
668
669
670
671
def host_fspath(self) -> str:
    """Return `.path` for any scheme whose path component is a
    filesystem path on the URI's own host (see `_host_filesystem_path`),
    for building a command line that runs *on that host* (e.g. via a
    remote executor). Unlike `__fspath__`, this never falls back to
    treating the path as local -- it raises `NotImplementedError` for
    schemes with no host-filesystem meaning (`http:`, `s3:`, ...)."""
    if self._host_filesystem_path:
        return self.path
    raise NotImplementedError(f"host_fspath for {self.source.scheme}")

is_absolute()

True if the path is absolute: it starts with "/", with or without a source (as PurePosixPath("/a") is absolute).

Source code in src/uripath/_uri.py
861
862
863
864
def is_absolute(self):
    """True if the path is absolute: it starts with "/", with or without
    a source (as `PurePosixPath("/a")` is absolute)."""
    return self.path.startswith("/")

is_local()

Return True if the URI points to a local resource.

Source code in src/uripath/_uri.py
914
915
916
def is_local(self):
    """Return True if the URI points to a local resource."""
    return self.source.is_local()

is_relative_to(other)

Return True if the path is relative to another path or False.

Source code in src/uripath/_uri.py
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
def is_relative_to(self, other: URILike):
    """Return True if the path is relative to another path or False."""
    self._refuse_data("is_relative_to()")
    # `other` is parsed standalone (`URI(other)`), not anchored at self's root, as
    # `relative_to()` and CPython do. A standalone parse leaves `other.source`
    # empty, which the guard below treats as compatible with any `self.source`.
    other = other if isinstance(other, URI) else URI(other)
    if not (
        _same_authority(other.source, self.source)
        or not (bool(self.source) and bool(other.source))
    ):
        return False
    # Segment-wise prefix comparison: a naive startswith() on the raw
    # strings would report "/foo/bar2" as relative to "/foo/bar".
    _other = other._prefix_segments()
    _self = self._prefix_segments()
    if _self[:1] == [""] and _other[:1] != [""]:
        # An absolute path is never relative to a relative one (the
        # empty relative path included), as in pathlib.
        return False
    return _self[: len(_other)] == _other

joinpath(*args)

Combine this URI with segments; a str is a decoded path (see URI._join_arg), anything else is joined as the constructor joins it -- pass a URI for a scheme-aware join.

Source code in src/uripath/_uri.py
963
964
965
966
967
968
969
970
971
972
973
974
975
def joinpath(self, *args):
    """Combine this URI with segments; a `str` is a decoded path (see
    `URI._join_arg`), anything else is joined as the constructor joins
    it -- pass a `URI` for a scheme-aware join."""
    self._refuse_data("a join")
    result = self
    for arg in args:
        arg = result._join_arg(arg)
        if isinstance(arg, str):
            result = result._join_decoded(arg)
        else:
            result = result._join_object(result, *_as_uri(arg)._raw_uris)
    return result

relative_to(other, *, walk_up=False)

Source code in src/uripath/_uri.py
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
def relative_to(self, other: URILike, *, walk_up=False):
    self._refuse_data("relative_to()")
    other = other if isinstance(other, URI) else URI(other)
    # No upfront `is_relative_to()` check: the step==0 iteration (path=other)
    # raises the same error, and an early one would skip the walk_up loop.
    for step, path in enumerate([other] + list(other.parents)):
        if self.is_relative_to(path):
            break
        elif not walk_up:
            raise ValueError(
                f"{str(self)!r} is not in the subpath of {str(other)!r}"
            )
        elif path.name == "..":
            raise ValueError(f"'..' segment in {str(other)!r} cannot be walked")
    else:
        raise ValueError(f"{str(self)!r} and {str(other)!r} have different anchors")
    # `_prefix_segments()`, not raw `.segments`: the latter gives the URI root ("/")
    # a spurious 2-tuple ("", ""), and `relative_to(<root>)` would drop the
    # child's only real segment.
    self_segs = self._prefix_segments()
    path_segs = path._prefix_segments()
    parts = [".."] * step + self_segs[len(path_segs) :]
    return self._from_parsed_parts(
        _NOSOURCE, "/".join(parts), self.query, self.fragment
    )

with_fragment(fragment)

Return a new URI with the fragment replaced.

Source code in src/uripath/_uri.py
782
783
784
def with_fragment(self, fragment: str):
    """Return a new URI with the fragment replaced."""
    return self._from_parsed_parts(self.source, self.path, self.query, fragment)

with_path(path)

Return a new URI with the path replaced; under an authority a relative path gets the leading / the constructor gives it.

Source code in src/uripath/_uri.py
764
765
766
767
768
769
770
771
def with_path(self, path: str | Pathname):
    """Return a new URI with the path replaced; under an authority a
    relative path gets the leading `/` the constructor gives it."""
    if not isinstance(path, str):
        path = _segment_text(path)
    if path and not path.startswith("/") and self._has_authority():
        path = "/" + path
    return self._from_parsed_parts(self.source, path, self.query, self.fragment)

with_query(query)

Return a new URI with the query replaced.

A str is taken in the percent-encoded form .query returns and is sent as given; a mapping or a sequence of pairs is encoded by Query; None removes the query.

Source code in src/uripath/_uri.py
773
774
775
776
777
778
779
780
def with_query(self, query: "QueryLike | None"):
    """Return a new URI with the query replaced.

    A `str` is taken in the percent-encoded form `.query` returns and is
    sent as given; a mapping or a sequence of pairs is encoded by
    `Query`; `None` removes the query."""
    query = _coerce(query)
    return self._from_parsed_parts(self.source, self.path, query, self.fragment)

with_segments(*segments)

Return a new URI with the path segments replaced.

The segments are the spelling segments returns (a leading "" is the root), joined with /; an element may also be bytes, a PurePath, a Pathname or an os.PathLike, whose posix text is used.

Source code in src/uripath/_uri.py
752
753
754
755
756
757
758
759
760
761
762
def with_segments(self, *segments: "str | os.PathLike"):
    """Return a new URI with the path segments replaced.

    The segments are the spelling `segments` returns (a leading `""` is
    the root), joined with `/`; an element may also be `bytes`, a
    `PurePath`, a `Pathname` or an `os.PathLike`, whose posix text is
    used."""
    self._refuse_data("with_segments()")
    if not segments:
        return self.with_path("")
    return self.with_path("/".join(_segment_text(item) for item in segments))

with_source(source)

Return a new URI with the source replaced.

Source code in src/uripath/_uri.py
748
749
750
def with_source(self, source: Source):
    """Return a new URI with the source replaced."""
    return self._from_parsed_parts(source, self.path, self.query, self.fragment)

URIPath(*uris, **options)

Bases: URI, Path

URI + Path (I/O) + scheme dispatch. URIPath(...) constructs the concrete subclass registered for the URI's scheme (via __SCHEMES) -- e.g. URIPath("http://...") returns an HTTPURIPath. Subclass this and set __SCHEMES to add a new scheme (Track B of extending this library; see docs/guides/extending.md): defining the class registers it, a name another class holds raises ValueError unless the class statement passes replace=True, and schemes() / scheme_class() read the registry. Implement the I/O surface (_listdir or _scandir, stat, _open, ...) documented in docs/guides/extending.md. Prefer overriding _scandir() over _listdir() when the listing call already returns type/size/mtime metadata (PROPFIND, MLSD, listdir_attr, an S3 list page, ...) -- walk()/glob() then answer is_dir() on the results for free, without a stat request per entry.

Source code in src/uripath/_uri.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def __init__(self, *uris: URILike, **options):
    if options:
        self._check_options(options)
    if self._raw_uris or self._initiated:
        return
    _uris: list[str | URI] = []
    for uri in uris:
        if uri is None or (isinstance(uri, (str, bytes)) and not uri):
            uri = ""
        if isinstance(uri, URI):
            _uris.append(uri)
        elif isinstance(uri, (_pathlib.Path, Path)):
            try:
                uri = uri.as_uri()
            except ValueError:
                # as_uri() raises ValueError for a relative path, which
                # joins like a relative PurePath (see _RelativeLocalPath).
                uri = _RelativeLocalPath(_uriencode(uri.as_posix(), safe="/"))
            _uris.append(uri)
        elif isinstance(uri, (_pathlib.PurePath, Pathname)):
            _uris.append(_path_reference(uri.as_posix()))
        elif hasattr(uri, "as_uri"):
            path = uri.as_uri
            if callable(path):
                path = path()
            _uris.append(path)
        elif isinstance(uri, str):
            _uris.append(uri)
        elif isinstance(uri, bytes):
            _uris.append(uri.decode())
        else:
            path = None
            try:
                path = os.fspath(uri)
            except (TypeError, NotImplementedError):
                pass
            if not isinstance(path, str):
                raise TypeError(
                    "argument should be a str or an os.PathLike "
                    "object where __fspath__ returns a str, "
                    f"not {type(uri).__name__!r}"
                )
            # Only __fspath__ is guaranteed here -- posix-normalize the
            # string itself rather than assuming an as_posix() method.
            posix = _pathlib.PurePath(path).as_posix()
            _uris.append(_path_reference(posix))
    self._raw_uris = _uris

backend property

The connection or session state backend instance.

iterdir()

Source code in src/uripath/schemes/_path.py
623
624
625
626
@_providers.derived_from("_scandir")
def iterdir(self) -> "_ty.Iterator[Self]":
    for name, stat in self._scandir():
        yield self._make_child_relpath(name, stat_hint=stat)

scheme_class(name) classmethod

The class registered for the scheme name (case-insensitive), importing its module or loading its entry point first when needed.

Raises UnsupportedSchemeError naming the registered schemes when there is none, and ImportError for a built-in module that cannot be imported and for a scheme another distribution provides that is not installed (the error names what to install).

Source code in src/uripath/schemes/_path.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
@classmethod
def scheme_class(cls, name: str) -> "type[URIPath]":
    """The class registered for the scheme `name` (case-insensitive),
    importing its module or loading its entry point first when needed.

    Raises `UnsupportedSchemeError` naming the registered schemes when there is
    none, and `ImportError` for a built-in module that cannot be
    imported and for a scheme another distribution provides that is not
    installed (the error names what to install).
    """
    found = cls._find_scheme(name.lower(), strict=True)
    if found is None:
        raise UnsupportedSchemeError(
            f"no URI scheme {name!r} is registered; registered: "
            + ", ".join(cls.schemes())
        )
    return found

schemes() classmethod

The registered scheme names, sorted: those of every class defined so far, every built-in scheme and every entry point of the uripath.schemes group, whether or not its module is imported yet. The schemes another distribution provides are listed too.

Source code in src/uripath/schemes/_path.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
@classmethod
def schemes(cls) -> "tuple[str, ...]":
    """The registered scheme names, sorted: those of every class defined
    so far, every built-in scheme and every entry point of the
    `uripath.schemes` group, whether or not its module is imported
    yet. The schemes another distribution provides are listed too."""
    from . import _elsewhere

    with _CLAIMS_LOCK:
        claimed = list(_CLAIMS)
    return tuple(
        sorted(
            {
                *claimed,
                *_BUILTIN_SCHEMES,
                *_elsewhere.SCHEMES,
                *_declared_entry_points(),
            }
        )
    )

with_backend(backend)

Return a new path instance sharing the same backend state.

Source code in src/uripath/schemes/_path.py
480
481
482
def with_backend(self, backend):
    """Return a new path instance sharing the same backend state."""
    return self._from_parsed_parts(*self.parts, backend=backend)

with_source(source)

Source code in src/uripath/schemes/_path.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
def with_source(self, source: Source):
    cls = type(self)
    if not source or not source.scheme:
        # Nothing to dispatch on (`source.scheme + ":"` raised TypeError
        # for a host-only Source): a plain URIPath.
        inst = URI.__new__(URIPath)
    elif source.scheme not in cls._schemes():
        inst = cls.__new__(
            cls,
            source.scheme + ":",
            findclass=True,
            schemesmap=self._schemes_in_use,
        )
    else:
        inst = cls.__new__(cls)
        backend, derived = self._backend_for(source)
        if backend is not None:
            inst._backend, inst._backend_derived = backend, derived
        else:
            inst._derived_cell = self._family_for(source)
    inst._schemes_in_use = self._schemes_in_use
    inst._init(source, self.path, self.query, self.fragment)
    return inst

URIPathError

Bases: Exception

The base of the errors uripath raises.

URISyntaxError

Bases: URIPathError, ValueError

Text that is not a URI, or not the part of one that was asked for: a port that is not 0-65535, a malformed host, a zip: URI without the scheme of its archive. The message names what is wrong and never the text, which can carry a password.

UnsupportedSchemeError

Bases: URIPathError, ValueError

A scheme that no class registered. The message lists the registered ones.

as_path(text)

The path text names: a URIPath when it is a URI, a LocalPath when it is a local path.

text is a URI when it starts with an RFC 3986 scheme and either holds :// or the scheme is one a class registers (data:, zip:, file:, sftp:, ...). Anything else is local: C:/data (a one-letter scheme is a drive), 12:30.txt, notes:draft and ./a:b. A scheme another distribution provides gives a URIPath whose first I/O raises the ImportError that names the distribution to install.

Raises TypeError for what is not a str. Nothing is read or connected to.

Source code in src/uripath/_pathtext.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def as_path(text: str) -> "_ty.Union[LocalPath, URIPath]":
    """The path `text` names: a `URIPath` when it is a URI, a `LocalPath` when
    it is a local path.

    `text` is a URI when it starts with an RFC 3986 scheme and either holds
    `://` or the scheme is one a class registers (`data:`, `zip:`, `file:`,
    `sftp:`, ...). Anything else is local: `C:/data` (a one-letter scheme is a
    drive), `12:30.txt`, `notes:draft` and `./a:b`. A scheme another
    distribution provides gives a `URIPath` whose first I/O raises the
    `ImportError` that names the distribution to install.

    Raises `TypeError` for what is not a `str`. Nothing is read or connected
    to.
    """
    if not isinstance(text, str):
        raise TypeError(f"path text must be a str, not {type(text).__name__!r}")
    if _looks_like_uri(text):
        return URIPath(text, findclass=True)
    return LocalPath(text)

copy_stream(source, target, *, exclusive=False, chunk_size=_CHUNK_SIZE)

Copy the bytes of source to target, chunk_size at a time, never holding the whole object in memory.

Each end is a binary stream (anything with read() or write()), a path object, or text that as_path() turns into a path. A path is opened here and closed when the copy ends; a stream stays open and is the caller's. The target stream is flushed at the end.

exclusive refuses a target path that exists (FileExistsError), as an exclusive create does: there is no check-then-write race. A target stream has nothing to refuse.

A target stream whose reader went away (a pipe whose other end closed, which Windows reports as OSError(EINVAL)) raises OutputClosedError. The same error from the connection of a path is not translated.

Source code in src/uripath/_transfer.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def copy_stream(
    source: _Endpoint,
    target: _Endpoint,
    *,
    exclusive: bool = False,
    chunk_size: int = _CHUNK_SIZE,
) -> None:
    """Copy the bytes of `source` to `target`, `chunk_size` at a time, never
    holding the whole object in memory.

    Each end is a binary stream (anything with `read()` or `write()`), a path
    object, or text that `as_path()` turns into a path. A path is opened here
    and closed when the copy ends; a stream stays open and is the caller's.
    The target stream is flushed at the end.

    `exclusive` refuses a target path that exists (`FileExistsError`), as an
    exclusive create does: there is no check-then-write race. A target stream
    has nothing to refuse.

    A target stream whose reader went away (a pipe whose other end closed,
    which Windows reports as `OSError(EINVAL)`) raises `OutputClosedError`.
    The same error from the connection of a path is not translated.
    """
    if callable(getattr(source, "read", None)):
        _copy(source, target, exclusive, chunk_size)
        return
    with _path_of(source).open("rb") as reader:
        _copy(reader, target, exclusive, chunk_size)

uripath.schemes.DerivedBackend

Marks a backend class whose instances only ever come from _initbackend(), for a backend that cannot be weakly referenced and so cannot be recorded in _DERIVED_BACKENDS (a tuple, say).

__slots__ = () class-attribute instance-attribute

uripath.schemes.DelegatingURIPath(*uris, **options)

Bases: URIPath

A URIPath whose I/O is done by a core path that needs no URI.

A scheme names the hooks it hands over in _DELEGATED and builds the core path for its URI in _make_core(); everything about URIs (parsing, joining, backends, the text of the path) stays here. The core path is made once per instance and used for every call, and an OSError it raises names this path, not the core one.

Source code in src/uripath/_uri.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def __init__(self, *uris: URILike, **options):
    if options:
        self._check_options(options)
    if self._raw_uris or self._initiated:
        return
    _uris: list[str | URI] = []
    for uri in uris:
        if uri is None or (isinstance(uri, (str, bytes)) and not uri):
            uri = ""
        if isinstance(uri, URI):
            _uris.append(uri)
        elif isinstance(uri, (_pathlib.Path, Path)):
            try:
                uri = uri.as_uri()
            except ValueError:
                # as_uri() raises ValueError for a relative path, which
                # joins like a relative PurePath (see _RelativeLocalPath).
                uri = _RelativeLocalPath(_uriencode(uri.as_posix(), safe="/"))
            _uris.append(uri)
        elif isinstance(uri, (_pathlib.PurePath, Pathname)):
            _uris.append(_path_reference(uri.as_posix()))
        elif hasattr(uri, "as_uri"):
            path = uri.as_uri
            if callable(path):
                path = path()
            _uris.append(path)
        elif isinstance(uri, str):
            _uris.append(uri)
        elif isinstance(uri, bytes):
            _uris.append(uri.decode())
        else:
            path = None
            try:
                path = os.fspath(uri)
            except (TypeError, NotImplementedError):
                pass
            if not isinstance(path, str):
                raise TypeError(
                    "argument should be a str or an os.PathLike "
                    "object where __fspath__ returns a str, "
                    f"not {type(uri).__name__!r}"
                )
            # Only __fspath__ is guaranteed here -- posix-normalize the
            # string itself rather than assuming an as_posix() method.
            posix = _pathlib.PurePath(path).as_posix()
            _uris.append(_path_reference(posix))
    self._raw_uris = _uris

__slots__ = ('_core',) class-attribute instance-attribute

__init_subclass__(**kwargs)

Source code in src/uripath/schemes/_delegate.py
71
72
73
74
75
def __init_subclass__(cls, **kwargs):
    super().__init_subclass__(**kwargs)
    for name in cls.__dict__.get("_DELEGATED", ()):
        if name not in cls.__dict__:
            setattr(cls, name, _FORWARDERS[name])

stat(*, follow_symlinks=True)

Source code in src/uripath/schemes/_delegate.py
110
111
112
113
114
115
def stat(self, *, follow_symlinks=True):
    hint = self._pop_stat_hint()
    if hint is not None and not (follow_symlinks and hint.is_symlink()):
        return hint
    with self._delegated():
        return self._core_path.stat(follow_symlinks=follow_symlinks)

uripath.source

Source (the scheme, userinfo, host and port of a URI) and the helpers that read and compose the text of one.

ERRORS = 'surrogateescape' module-attribute

SAFE_PATH = _SUB_DELIMS + ':@/' module-attribute

Source

Bases: NamedTuple

A URI's scheme/userinfo/host/port -- everything before the path. Falsy (bool(source) is False) when every field is empty/None. A host of "" is an authority with no host (file:///x) and None is no authority (file:/x): they render differently and name the same endpoint.

host instance-attribute

port instance-attribute

scheme instance-attribute

userinfo instance-attribute

get_scheme_cls(schemesmap=None)

The URIPath subclass registered for this scheme, or URIPath itself. An explicit schemesmap is the complete set of classes to choose from: a scheme missing from it gives URIPath, and neither plugins nor the global registry are consulted.

Source code in src/uripath/source/_source.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def get_scheme_cls(self, schemesmap: _ty.Mapping[str, type["URIPath"]] = None):
    """The `URIPath` subclass registered for this scheme, or `URIPath`
    itself. An explicit `schemesmap` is the complete set of classes to
    choose from: a scheme missing from it gives `URIPath`, and neither
    plugins nor the global registry are consulted."""
    from ..schemes._path import URIPath

    if self.scheme:
        if schemesmap is not None:
            return schemesmap.get(self.scheme, None) or URIPath
        # A scheme no class registers is looked up in the built-in modules and the
        # entry points once; `_load_entry_point` caches that negative answer instead
        # of rescanning every installed distribution on each construction.
        return URIPath._find_scheme(self.scheme) or URIPath
    return URIPath

is_local()

Whether host resolves to this machine.

The answer depends on the host alone (userinfo, port and scheme play no part) and is remembered for _LOCAL_TTL seconds, at most _LOCAL_CACHE_SIZE hosts: it comes from a DNS/hosts-file lookup and the machine's interfaces, so it is neither free nor forever.

The hostname->address step uses netimps.resolve() (default backend chain: dnspython, then the OS resolver via getaddrinfo() -- hosts file, NSS, DNS, OS cache -- then nslookup as a last resort), trying both "a"/"aaaa" record types and treating host as local if ANY resolved address is. The "is this address MINE" comparison uses netimps.is_local_address(), which enumerates the real network interfaces (netimps.get_interfaces()), so VMs, containers, VPN interfaces and additional NICs count.

host as a bare IP-literal str (e.g. a directly-constructed Source(..., host="::1", ...), bypassing _decode_host()'s usual bracket-literal parsing) is handled by netimps.try_parse() without going through resolution at all.

Source code in src/uripath/source/_source.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def is_local(self) -> bool:
    """Whether `host` resolves to this machine.

    The answer depends on the host alone (userinfo, port and scheme play
    no part) and is remembered for `_LOCAL_TTL` seconds, at most
    `_LOCAL_CACHE_SIZE` hosts: it comes from a DNS/hosts-file lookup and
    the machine's interfaces, so it is neither free nor forever.

    The hostname->address step uses `netimps.resolve()` (default
    backend chain: dnspython, then the OS resolver via
    `getaddrinfo()` -- hosts file, NSS, DNS, OS cache -- then
    `nslookup` as a last resort), trying both `"a"`/`"aaaa"` record
    types and treating `host` as local if ANY resolved address is.
    The "is this address MINE" comparison uses
    `netimps.is_local_address()`, which enumerates the real network
    interfaces (`netimps.get_interfaces()`), so VMs, containers, VPN
    interfaces and additional NICs count.

    `host` as a bare IP-literal `str` (e.g. a directly-constructed
    `Source(..., host="::1", ...)`, bypassing `_decode_host()`'s usual
    bracket-literal parsing) is handled by `netimps.try_parse()`
    without going through resolution at all.
    """
    host = fold_host(self.host)
    if not host or host == "localhost":
        return True
    return _host_is_local(host)

keys()

Source code in src/uripath/source/_source.py
149
150
def keys(self):
    return self._asdict().keys()

parse(text, *, strict=True) classmethod

The Source a URI string names. With strict (the default) a path, query or fragment is a URISyntaxError that names the component and not the text, which may carry a password. A port outside 0-65535, or a ':' followed by anything but digits in a name that is not a bracketed address, is a URISyntaxError as well. TypeError for what is not a str.

Source code in src/uripath/source/_source.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
@classmethod
def parse(cls, text: str, *, strict: bool = True) -> Source:
    """The `Source` a URI string names. With `strict` (the default) a
    path, query or fragment is a `URISyntaxError` that names the component
    and not the text, which may carry a password. A port outside
    0-65535, or a ':' followed by anything but digits in a name that is
    not a bracketed address, is a `URISyntaxError` as well. `TypeError`
    for what is not a `str`."""
    if not isinstance(text, str):
        raise TypeError(f"source text must be a str, not {type(text).__name__!r}")
    uri = _rfc3986.split(text)
    if strict:
        extra = [
            name for name in ("path", "query", "fragment") if getattr(uri, name)
        ]
        if extra:
            raise URISyntaxError(
                "expected scheme and authority only, found a "
                + " and a ".join(extra)
            )
    scheme = uri.scheme.lower() if uri.scheme is not None else None
    return _parse_source(scheme, uri.authority)

parsed_userinfo()

(user, password), "" for a part that is absent. The split is the first colon of the userinfo as it was written (an escaped colon in the user name stays in the name).

Source code in src/uripath/source/_source.py
157
158
159
160
161
162
163
164
def parsed_userinfo(self) -> "tuple[str, str]":
    """`(user, password)`, `""` for a part that is absent. The split is
    the first colon of the userinfo as it was written (an escaped colon
    in the user name stays in the name)."""
    if not self.userinfo:
        return "", ""
    user, password = _split_userinfo_text(self.userinfo)
    return user, password or ""

reveal()

The text of this source with its password, which str() leaves out: the credentialed round trip, as URI.as_uri(sanitize=False) is for a URI. Named as an action because the result carries the secret; it belongs in a connection, never in a log or a message.

Source code in src/uripath/source/_source.py
 98
 99
100
101
102
103
104
def reveal(self) -> str:
    """The text of this source with its password, which `str()` leaves out:
    the credentialed round trip, as `URI.as_uri(sanitize=False)` is for a
    URI. Named as an action because the result carries the secret; it
    belongs in a connection, never in a log or a message."""
    scheme, userinfo, host, port = _source_parts(self, False)
    return _compose_uri(scheme, userinfo, host, port, "", None, None)

try_parse(text, default=None, *, strict=True) classmethod

parse(), but default (None) for text that is not a source. TypeError still for what is not a str.

Source code in src/uripath/source/_source.py
138
139
140
141
142
143
144
145
146
147
@classmethod
def try_parse(
    cls, text: str, default: "Source | None" = None, *, strict: bool = True
) -> "Source | None":
    """`parse()`, but `default` (`None`) for text that is not a source.
    `TypeError` still for what is not a `str`."""
    try:
        return cls.parse(text, strict=strict)
    except URISyntaxError:
        return default

compose_host(host)

Encode a host for composition: an IPv6 address is bracketed, and so is a bare (non-bracketed) string that parses as one, as a manually constructed Source(..., host="::1", ...) holds it. Deliberately not netimps' join_host/FQDN: those would reject names this composes. An IPv6 zone is written as RFC 6874 says.

Source code in src/uripath/source/_compose.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def compose_host(host: "str | _IPAddress") -> str:
    """Encode a host for composition: an IPv6 address is bracketed, and so is a
    bare (non-bracketed) string that parses as one, as a manually constructed
    `Source(..., host="::1", ...)` holds it. Deliberately not netimps'
    `join_host`/`FQDN`: those would reject names this composes. An IPv6 zone is
    written as RFC 6874 says."""
    if isinstance(host, _ip.IPv6Address):
        return _compose_ipv6(host)
    if isinstance(host, _ip.IPv4Address):
        return host.compressed
    if host.startswith("[") and host.endswith("]"):
        return _compose_ipv6(_ipv6_from_literal(host[1:-1]))
    try:
        return _compose_ipv6(_ip.IPv6Address(host))
    except ValueError:
        pass
    if not host.isascii():
        # RFC 3986 3.2.2: a non-ASCII registered name meant for DNS is produced in its
        # IDNA form (HTTP clients IDNA-encode only a non-ASCII host). A name the codec
        # rejects (an empty or over-long label, a surrogate-escaped byte) stays percent-encoded.
        try:
            return _idna_encode(host)
        except UnicodeError:
            pass
    return _encode(host, _SAFE_HOST)

compose_uri(scheme, userinfo, host, port, path, query, fragment)

The URI text of already-decoded components, percent-encoding each as its part of RFC 3986 asks. None leaves a component out; a path that cannot sit beside the authority given (or beside none) is a ValueError, as is an invalid scheme or port.

This is direct string assembly, with no re-validation of every component (a scheme regex, authority re-parsing, IP-literal detection), which only matters for untrusted input. It replicates only what changes the output string (IP-literal bracketing, the "./" escape of a colon in the first segment); the tests compare it with uritools.uricompose() on generated (scheme, userinfo, host, port, path, query, fragment) combinations.

Source code in src/uripath/source/_compose.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def compose_uri(
    scheme: "str | None",
    userinfo: "str | None",
    host: "str | _IPAddress | None",
    port: "int | str | None",
    path: str,
    query: "str | None",
    fragment: "str | None",
) -> str:
    """The URI text of already-decoded components, percent-encoding each as
    its part of RFC 3986 asks. `None` leaves a component out; a path that
    cannot sit beside the authority given (or beside none) is a `ValueError`,
    as is an invalid scheme or port.

    This is direct string assembly, with no re-validation of every component
    (a scheme regex, authority re-parsing, IP-literal detection), which only
    matters for untrusted input. It replicates only what changes the output
    string (IP-literal bracketing, the "./" escape of a colon in the first
    segment); the tests compare it with `uritools.uricompose()` on generated
    (scheme, userinfo, host, port, path, query, fragment) combinations."""
    parts = []
    if scheme is not None:
        if not _SCHEME_RE.fullmatch(scheme):
            raise ValueError("invalid scheme")
        parts.append(scheme)
        parts.append(":")
    has_authority = userinfo is not None or host is not None or port is not None
    if has_authority:
        parts.append("//")
        if userinfo is not None:
            parts.append(_encode_userinfo(userinfo))
            parts.append("@")
        if host is not None:
            parts.append(_compose_host(host))
        if port is not None:
            parts.append(":")
            parts.append(_port_text(port))
    path_enc = _encode(path, _SAFE_PATH) if path else ""
    if has_authority and path_enc and not path_enc.startswith("/"):
        raise ValueError("Invalid path with authority component")
    if not has_authority and path_enc.startswith("//"):
        raise ValueError("Invalid path without authority component")
    if scheme is None and not has_authority and not path_enc.startswith("/"):
        if ":" in path_enc.partition("/")[0]:
            path_enc = "./" + path_enc
    parts.append(path_enc)
    if query is not None:
        parts.append("?")
        parts.append(_encode_raw_query(query))
    if fragment is not None:
        parts.append("#")
        parts.append(_encode(fragment, _SAFE_FRAGMENT))
    return "".join(parts)

fold_host(host)

host as a comparison or a key reads it: a name in lower case, since host names are case-insensitive (RFC 3986 3.2.2), and an address as it is. Source.host keeps the spelling it was given, so anything that compares hosts or uses one as a key folds it at that place.

Source code in src/uripath/source/_parse.py
135
136
137
138
139
140
def fold_host(host: "str | _IPAddress | None") -> "str | _IPAddress | None":
    """`host` as a comparison or a key reads it: a name in lower case, since
    host names are case-insensitive (RFC 3986 3.2.2), and an address as it is.
    `Source.host` keeps the spelling it was given, so anything that compares
    hosts or uses one as a key folds it at that place."""
    return host.lower() if isinstance(host, str) else host

is_drive(segment)

Whether a path segment is a Windows drive ("C:").

Source code in src/uripath/source/_parse.py
143
144
145
def is_drive(segment: str) -> bool:
    """Whether a path segment is a Windows drive ("C:")."""
    return len(segment) == 2 and segment[1] == ":" and segment[0].isalpha()

percent_encode(text, safe)

text percent-encoded as UTF-8, keeping the characters of safe. A decoded path may hold escaped bytes (ERRORS) and, from a Windows listing, a lone surrogate; neither may make the composer raise.

Source code in src/uripath/source/_compose.py
51
52
53
54
55
56
57
58
def percent_encode(text: str, safe: str) -> str:
    """`text` percent-encoded as UTF-8, keeping the characters of `safe`.
    A decoded path may hold escaped bytes (`ERRORS`) and, from a Windows
    listing, a lone surrogate; neither may make the composer raise."""
    try:
        return _rfc3986.encode(text, safe, errors=ERRORS)
    except UnicodeEncodeError:
        return _rfc3986.encode(_lenient_utf8(text), safe)

same_authority(a, b)

Whether two sources name the same endpoint (scheme, userinfo, host, port), treating "" and None alike and a host name without regard to case. The test for whether a backend, connection or rename may cross from one path to another.

Source code in src/uripath/source/_source.py
231
232
233
234
235
236
def same_authority(a: Source, b: Source) -> bool:
    """Whether two sources name the same endpoint (scheme, userinfo, host,
    port), treating `""` and `None` alike and a host name without regard to
    case. The test for whether a backend, connection or rename may cross from
    one path to another."""
    return _authority_key(a) == _authority_key(b)

uripath.query

QueryLike = 'str | Query | _ty.Mapping[str, str | None | _ty.Sequence[str | None]] | _ty.Sequence[tuple[str, str | None]]' module-attribute

What URI.with_query() accepts in place of a Query: query text (taken as already percent-encoded), a mapping or a sequence of pairs, or a Query; None removes the query.

Query(pairs=(), *, separator=None, encoding=None)

A URI query as an immutable value: the percent-encoded text and the (name, value) pairs it stands for.

Build one from pairs or a mapping (a sequence value repeats the name, a None value is a bare name) with Query(...), which encodes them so that to_pairs() returns what was given, or from text with Query.parse(), which keeps the text byte for byte: it is what URI.query holds, as received, and what is sent. str(query) is that text.

Two queries are equal, and hash alike, when they decode to the same pairs, whatever their spelling (a=%41 and a=A); a query is never equal to a str, and has no length, items or iteration. to_pairs() and to_dict() follow RFC 3986, not application/x-www-form-urlencoded: only %XX escapes are decoded and a + stays a plus (urllib.parse.parse_qsl reads form text), and an escape that is not valid in encoding survives as a surrogate escape instead of raising.

separator splits the text into terms (default &) and encoding is the charset of the escapes (default utf-8); both say how the text is read and are not part of the value.

Source code in src/uripath/query.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def __init__(
    self,
    pairs: (
        _ty.Mapping[str, str | None | _ty.Sequence[str | None]]
        | _ty.Sequence[tuple[str, str | None]]
        | Query
    ) = (),
    *,
    separator: str | None = None,
    encoding: str | None = None,
):
    if isinstance(pairs, (str, bytes)):
        raise TypeError(
            "a Query is built from pairs or a mapping; "
            "use Query.parse() for query text"
        )
    text = None
    if isinstance(pairs, Query):
        separator = separator or pairs._separator
        encoding = encoding or pairs._encoding
        if separator == pairs._separator and encoding == pairs._encoding:
            text = pairs._text
        else:
            pairs = pairs.to_pairs()
    else:
        separator = separator or self.SEPARATOR
        encoding = encoding or self.ENCODING
    if text is None:
        if isinstance(pairs, _abc.Mapping):
            text = _querydict(pairs, separator, encoding)
        else:
            text = _querylist(pairs, separator, encoding)
    self._set(text, separator, encoding)

ENCODING = 'utf-8' class-attribute instance-attribute

SEPARATOR = '&' class-attribute instance-attribute

encoding property

The charset the escapes of the text are read in.

separator property

What splits the text into terms.

parse(text, *, separator=SEPARATOR, encoding=ENCODING) classmethod

The query that text spells, percent-encoded as received and kept byte for byte. Any text parses; TypeError for what is not a str.

Source code in src/uripath/query.py
120
121
122
123
124
125
126
127
128
129
130
@classmethod
def parse(
    cls, text: str, *, separator: str = SEPARATOR, encoding: str = ENCODING
) -> Query:
    """The query that `text` spells, percent-encoded as received and kept
    byte for byte. Any text parses; `TypeError` for what is not a `str`."""
    if not isinstance(text, str):
        raise TypeError(f"query text must be a str, not {type(text).__name__!r}")
    query = object.__new__(cls)
    query._set(text, separator, encoding)
    return query

to_dict(*, single=False)

The pairs by name: each name maps to the list of its values, or with single to its last value.

Source code in src/uripath/query.py
147
148
149
150
151
152
153
154
155
156
def to_dict(self, *, single: bool = False):
    """The pairs by name: each name maps to the list of its values, or with
    `single` to its last value."""
    query_: dict[str, list[str | None]] = {}
    for k, v in self._decoded():
        if single:
            query_[k] = v
        else:
            query_.setdefault(k, []).append(v)
    return query_

to_pairs()

The decoded (name, value) pairs in order; value is None for a name with no =.

Source code in src/uripath/query.py
142
143
144
145
def to_pairs(self) -> list[tuple[str, str | None]]:
    """The decoded `(name, value)` pairs in order; `value` is `None` for a
    name with no `=`."""
    return list(self._decoded())