Changelog
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.1.0 - 2026-10-14
Published on PyPI as pathapi-uri (pip install pathapi-uri); the import
package and the command are uripath.
The first release of uripath: the URI layer of pathlib-next 0.9.14
(pathlib_next.uri and the uripath command) as a distribution of its own. It
needs Python 3.9 or newer, pathapi>=0.1.0,<0.2 and
netimps>=0.4.0,<0.5, and nothing else; its one extra, cli, adds the command
(duho>=0.7.3,<0.8). Every scheme it implements runs on the standard library.
Under Changed, the migration from pathlib_next.uri comes in this order: the
table of names, the table of extras, then every change of behaviour with what to
do about it.
It is built on pathapi, which was published as pathlib-next up to 0.10.0 (import
name pathlib_next); the old names below are the ones before the move.
Added
URI,URIPath,SourceandQuery, the schemesfile:,data:,ftp:,ftps:,zip:,tar:,archive:,http:,https:,dav:anddavs:, and theuripathcommand, withuripath.__version__(read from the installed metadata).pathapi.protocols.supports()andcapabilities()tell the truth about a URI class:data:does not supportListing, a scheme that lists through_listdir()or its own_scandir()does, and an instance of aDelegatingURIPathscheme answers with its core path (_supports()), so a delegated hook the core path lacks reads as absent.sftp:,s3:,gs:,az:,repo:,github:andgitlab:are known scheme names that this distribution does not implement. A path of one joins, compares and prints without the distribution that provides it; the first operation that needs the server (stat(),open(),iterdir(),exists(), ...) raises anImportErrornaming what to install:sftppath[paramiko](or[asyncssh]) forsftp:,objpath[s3],[gs]or[az], andrepopathfor the other three.URIPath.schemes()lists the seven, andURIPath.scheme_class()raises the same error for them. The user slot ofrepo:,github:andgitlab:is an access token, sostr()andrepr()of aSourceof those schemes show no userinfo. A distribution that registers one of these names in theuripath.schemesentry-point group takes it.http:,https:,dav:anddavs:send through the HTTP client and transport ofpathapi.http, whichuripathdoes not copy:HTTPTransport,HTTPResponse, the transport contract, the rules for redirects and credentials, the cap on a reply read whole (pathapi.http.MAX_REPLY_BYTES),PATHAPI_HTTP_TRANSPORTand the entry-point grouppathapi.http_transportsall live there, and so doespython -m pathapi.http.testing --http-transports NAME, the suite that a distribution providing a transport runs over it.HTTPBackendis apathapi.http.HTTPClientthat adds what a path needs.uripath.exceptions:URIPathError, the base of what this package raises on its own, withURISyntaxErrorandUnsupportedSchemeError(both alsoValueError) andOutputClosedError(alsoBrokenPipeError).uripath.schemes.DelegatingURIPathis the base of a scheme whose I/O is done by apathapi.Paththat needs no URI, and the onefile:,ftp:and the archive schemes use. A distribution that provides a scheme this way (thesftp:ofsftppath) lists the hooks it hands over in_DELEGATEDand builds the path in_make_core(); the header documents both under "For scheme implementers".uripath.source.fold_host(host)returns the form of a host that comparisons and caches key on.DataURIPath.charsetis the charset adata:URI declares.FTPURIBackendandBaseFTPURIBackendhaveclose()and are context managers:close()closes the control connection every thread holds to the servers of that backend, may be called twice, and a later use connects again. Leaving awithblock calls it.- Building an
FTPURIBackendwithverify=Falseand nossl_contextlogs a warning on theuripathlogger each time (anHTTPTransportdoes the same on thepathapilogger). uripath.as_path(text)returns theURIPathorLocalPaththat command-line style text names (C:/data,12:30.txtandnotes:draftare local paths;ftp://h/xanddata:,abcare URIs), anduripath.copy_stream(source, target)copies the bytes of a path or a binary stream into another in chunks, withexclusive=Truefor an exclusive create. Theuripathcommand is built on them.copy_streamraisesOutputClosedErrorwhen the reader of a target stream went away.python -m uripathruns the command, anduripath --versionprints the version. The command describes itself to an agent (AGENT_HELP=1 uripath --helpprints one JSON document) and can be served as tools (URIPATH_MCP=stdio uripath):read,write,rm,cpandsync, with the schemas their fields give. A tool has no standard input, so-as a source andwritewithoutDATAfail with a message.
Changed
- Import from
uripathwhere the code imported frompathlib_next.uri. There is no alias for an old name or path: change the import. The classes spell their acronyms in capitals, and the class of a scheme is named<Scheme>URIPath, so<Scheme>Pathstays the class that needs no URI. A scheme's module is private: every public name of a scheme is exported byuripath.schemes. Everything that was renamed or moved:
Was (in pathlib_next.uri, pathlib_next 0.9.14) |
Is |
|---|---|
the package pathlib_next.uri |
uripath |
Path, Pathname, FileStat imported from pathlib_next.uri |
the same names in pathapi |
Uri, UriPath, UriLike |
uripath.URI, URIPath, URILike |
source.Source |
uripath.Source, also uripath.source.Source |
source.compose_uri, compose_host, percent_encode, is_drive, SAFE_PATH, ERRORS |
the same names in uripath.source |
same_authority |
uripath.source.same_authority |
DerivedBackend |
uripath.schemes.DerivedBackend |
query.Query |
uripath.Query, also uripath.query.Query; no longer a str (see below) |
Query(text), Query.decode() |
Query.parse(text), Query.to_pairs() |
Source.from_str(text, strict) |
Source.parse(text, *, strict=True), and Source.try_parse(text, default=None, *, strict=True) |
Source.as_str(sanitize=True) |
str(source) |
Source.as_str(sanitize=False) |
Source.reveal() |
the modules schemes.file, data, dav, ftp, http, archive |
private; each public name is uripath.schemes.<Name> |
schemes.file.FileUri, schemes.data.DataUri |
uripath.schemes.FileURIPath, DataURIPath |
schemes.archive.ZipUri, TarUri, ArchiveUri |
uripath.schemes.ZipURIPath, TarURIPath, ArchiveURIPath |
schemes.archive.ArchiveZipUri, ArchiveTarUri |
uripath.schemes.ArchiveZipURIPath, ArchiveTarURIPath |
schemes.ftp.FtpPath |
uripath.schemes.FTPURIPath |
schemes.ftp.FtpBackend, BaseFtpBackend |
uripath.schemes.FTPURIBackend, BaseFTPURIBackend |
schemes.ftp.DEFAULT_TIMEOUT, IDLE_PROBE_SECONDS; schemes.archive.MAX_LINKS, LinkTarget |
pathapi.ftp and pathapi.archive, which define them |
schemes.http.HttpPath, HttpBackend |
uripath.schemes.HTTPURIPath, HTTPBackend |
schemes.http.HttpWriteStream, HttpAppendStream |
uripath.schemes.HTTPWriteStream, HTTPAppendStream |
schemes.http.HTTPTransport, HTTPResponse |
pathapi.http.HTTPTransport, HTTPResponse |
schemes.http.MAX_LISTING_BYTES |
pathapi.http.MAX_REPLY_BYTES (assign it there) |
schemes.http.DEFAULT_TIMEOUT |
none: the default is the timeout= of the backend, (10, 60) |
schemes.archive._base.MEMBER_SPOOL_BYTES |
uripath.schemes.MEMBER_SPOOL_BYTES |
schemes.dav.DavPath |
uripath.schemes.DAVURIPath |
schemes.github.GitHubPath, schemes.gitlab.GitLabPath, schemes.git.GitPath, GitHubGitPath, GitLabGitPath |
the distribution repopath: repopath.uri.GitHubURIPath, GitLabURIPath and RepoURIPath (the repo: scheme); repopath.GitHubPath and GitLabPath need no URI |
schemes.github.RepoBackend; schemes._gitrepo.BaseRepoBackend |
repopath.RepoBackend, BaseRepoBackend |
schemes.github.InsecureTransportWarning (also in schemes.gitlab) |
repopath.InsecureTransportWarning |
schemes.sftp, schemes.s3, schemes.gs, schemes.az and the classes in them |
the distributions sftppath and objpath, which name their own classes |
schemes.http.RequestsTransport |
the distribution pathapi-requests |
pathlib_next.tools.uripath: main(argv, *, stdin, stdout, stderr), build_parser() |
uripath.cli.main(argv=None); no parser to build |
PATHLIB_NEXT_SFTP_BACKEND |
SFTPPATH_BACKEND, read by sftppath |
the entry-point group pathlib_next.schemes |
uripath.schemes |
the script uripath = pathlib_next.tools.uripath:main |
uripath = uripath.cli:main, which needs the cli extra; python -m uripath runs it too |
The ftp: backends are not pathapi.ftp.FTPBackend and BaseFTPBackend:
those belong to one host, while FTPURIBackend connects to whichever server a
path names.
- What to install where
pathlib-nexthad an extra.pathlib-next0.10 has nouri,http,sftp,sftp-async,s3,gsorazextra.
| Was | Install now |
|---|---|
pathlib-next[uri] |
uripath |
pathlib-next[http] |
uripath; add pathapi-requests only for a requests.Session or PATHAPI_HTTP_TRANSPORT=requests |
pathlib-next[sftp] |
sftppath[paramiko] |
pathlib-next[sftp-async] |
sftppath[asyncssh] |
pathlib-next[s3], [gs], [az] |
objpath[s3], [gs], [az] |
the uripath command (part of pathlib-next[uri]) |
uripath[cli] |
The github: and gitlab: schemes of pathlib-next[uri] are in the
distribution repopath (pip install "repopath[uri]"), together with
repo:, which takes the place of git:. git:, git+github: and
git+gitlab: do not exist any more: a URI of one of them is a path of an
unknown scheme, which joins and prints and raises NotImplementedError for
a request. Rewrite the URIs:
| Was | Is now |
|---|---|
git://github.com/o/r/f, git://gitlab.com/o/r/f |
repo://github.com/o/r/f, repo://gitlab.com/o/r/f (the kind is detected from the host) |
git+github://host/o/r/f |
github://host/o/r/f |
git+gitlab://host/o/r/f |
gitlab://host/o/r/f |
github://host/o/r/f, gitlab://host/o/r/f |
the same text, with repopath[uri] installed |
uritools is no longer installed with the URI layer; netimps is a required
dependency.
Queryis an immutable value and no longer a subclass ofstr. Was: astrthat also took pairs, solen(query),query[0],"x" in query,query.split("&")andquery == "a=1"worked as on a string while iterating it gave decoded pairs; equality and hash were those of the text, soQuery("a=1;b=2", separator=";") == Query("a=1;b=2");Query(query)reset the separator;Query("a=%E9").decode()raisedUnicodeDecodeError. Is:str(query)is the text,Query.parse(text)builds one from text (kept byte for byte, asURI.queryholds it),Query(pairs)from a mapping or pairs, andquery.to_pairs()andquery.to_dict()give the pairs; two queries are equal when they decode to the same pairs, a query is equal to nothing that is not a query, and an escape that is not valid UTF-8 survives as a surrogate escape instead of raising. A query has no length, items or iteration. What to do:Query("a=1")becomesQuery.parse("a=1")(it is aTypeErrornow),query.decode()becomesquery.to_pairs(),for pair in querybecomesfor pair in query.to_pairs(), and a comparison ofuri.querywith text becomesstr(uri.query) == text.URI.queryis still aQueryandwith_query()still takes text, a mapping, pairs or aQuery.Sourcefollows the conversion names. Was:Source.from_str(text, strict),source.as_str(sanitize=True)(the same text asstr(source)) andsource.as_str(sanitize=False)(the text with the password). Is:Source.parse(text, *, strict=True),Source.try_parse(text, default=None, *, strict=True)(the default instead of the error, and still aTypeErrorfor what is not text),str(source)(the canonical text, without the password) andsource.reveal()(the text with the password, named as an action because it carries the secret).strictis keyword-only. What to do: replace the call.Sourceis still a named tuple, so positional construction and unpacking are unchanged.- A join keeps the query and the fragment of the path it starts from. Was:
Uri("unk://h/d?sig=abc#frag") / "f"andjoinpath("f")gaveunk://h/d/f, and so did the childreniterdir(),glob()andwalk()yield, whileparent,with_name(),with_stem(),with_suffix()andrelative_to()kept both, sop.parent / p.name == pwas false for a path with a query. Is: every derivation that changes only the path keeps both, and a token in the query follows the children (unk://h/d/f?sig=abc#frag). The children of anhttp:ordav:listing are the exception: they are thehrefs of the reply resolved against the directory, so they carry neither, and?sort=name, which shapes an index, does not reach the files it lists (directory / "f.txt"keeps it). Astrdestination ofrename(),copy()andmove()is another resource and takes neither. What to do: nothing, unless a caller relied on the join dropping them; callwith_query(None)andwith_fragment("")on the result (or on the base) there. URI.with_query(None)removes the query. Was: aTypeError("'NoneType' object is not iterable").with_fragment("")removes the fragment as before.file:URIs keep an empty authority, and==reads astroperand as a URI. Was:Uri("file:///etc/passwd")was writtenfile:/etc/passwdandUriPath(LocalPath("C:/Windows/win.ini")).as_uri()wasfile:/C:/Windows/win.ini, wherepathlib.Path.as_uri()givesfile:///C:/Windows/win.ini;Uri("file:///etc/passwd") == "file:///etc/passwd"was false, and so wereUri("HTTP://H/x") == "HTTP://H/x"andUri("http://h/a b") == "http://h/a b", because the text was compared as written. Is: an empty authority (file:///x,Source.host == "") and none (file:/x,Source.host is None) are different URIs, each written as it was read; a path built from a local path object is written aspathlib.Path.as_uri()writes it (the characters!$&'()*+,;=:@stay literal in the path, wherepathlibescapes them); and==parses astroperand before comparing it, so a URI equals any text that names it, and text that is not a URI is not equal to anything. What to do: compare URIs with==instead of comparing their text, and expectfile:///where code testedfile:/. ASourcebuilt by hand with host""now means an empty authority (file://): useNonefor none.- The path of a
data:URI is its payload, andread_text()uses the declared charset. Was:name,parent,with_name()and/cut the payload at a/(data:text/plain;charset=utf-8,%C3%BCberhad the nameplain;charset=utf-8,überandd / "x"was the other file...%C3%BCber/x), andread_text()decoded with the locale ("über"on Windows). Is: those derivations (name,stem,suffix,suffixes,parent,parents,segments,with_name(),with_stem(),with_suffix(),with_segments(),/,joinpath(),relative_to(),is_relative_to(),match()and a constructor given several parts) raiseNotImplementedError, andopen()in text mode andread_text()decode with the charset the URI declares (DataURIPath.charset,"utf-8"when it declares none). What to do: name a destination yourself when you copy adata:URI into a directory, passencoding=to override the charset, and read the payload withread_bytes()instead of slicing the path. - A write stream whose
withblock raised is not committed. Was: leaving awith path.open("wb")block through an exception closed thehttp:ordav:stream like a normal exit, so the partial buffer was sent withPUTand replaced the complete file (write(b"tru")then an error leftgood.binholdingtru). Is: any exception (aKeyboardInterrupttoo) makes the block discard the stream, in binary and in text mode, and nothing is sent; the file keeps its content or is not created.close()outside a block and a block that ended normally send once, as before. The same holds forftp:andzip:, whose streams are thepathapiones. What to do: nothing, unless code relied on the partial upload; a local file still keeps what a failed block wrote, aspathlibdoes. - A host keeps the spelling it was given. Was: the parser and the composer
folded a host name to lower case, so
Source.parse("wss://root:pw@nasA:8443").hostwasnasaandstr(Uri("wss://root:pw@nasA:8443/api"))waswss://root@nasa:8443/api. Is:Source.hostand the text keepnasA, and everything that compares hosts or keys on one folds the case itself:==,hash()and ordering of URIs,same_authority(),is_relative_to(), backend sharing,rename(), the same-file decisions ofcopy()andmove(), the redirect origin that keepsAuthorization, the proxy rules,Source.is_local()and the connection cache offtp:.http://NASA/xandhttp://nasa/xare one server and equal URIs; their text differs. TwoSourcevalues compare as tuples, exactly;compose_host()andcompose_uri()keep the case of a name; an internationalized name is still written in IDNA form. What to do: compare hosts withuripath.source.same_authority()oruripath.source.fold_host()instead ofsource.host == "name", and key anything you keep per server onfold_host(source.host). - A constructor raises
TypeErrorfor a keyword argument it does not name. Was:URI("x", anything=1)andURIPath("https://h/x", backnd=backend)were accepted and the keyword ignored, so a misspeltbackend=meant "use the default backend" (and the ambient credentials). Is:TypeError: HTTPURIPath() got an unexpected keyword argument 'backnd'.URItakes no keyword; a path takesbackend,schemesmap,findclassand the ones its scheme class names in_OPTIONS; the path of a scheme that another distribution implements, when that distribution is not installed, takes any. What to do: correct or remove the stray keyword; a scheme class of your own that reads keywords lists them (_OPTIONS = URIPath._OPTIONS | {"flavor"}). - Text that is not a URI raises
URISyntaxErrorwhere it raised a bareValueError: a port outside 0-65535, a malformed host or IPv6 address,Source.parse()of text with a path, azip:URI without the scheme of its archive. It is a subclass ofValueError, soexcept ValueErrorstill catches it.URIPath.scheme_class()of a scheme nobody registered raisesUnsupportedSchemeError, also aValueError. Both derive fromURIPathError; a caller's bad argument stays a plainValueErrororTypeError. What to do: nothing; catchURIPathErrorto catch what the package raises on its own. - Requests of
http:anddav:sendUser-Agent: uripath/<version>instead ofUser-Agent: pathlib-next; the version isuripath.__version__. A backend'sheaders=still replaces it. - A plugin registered in the
pathlib_next.schemesentry-point group is not found. What to do: register it inuripath.schemes. - The
requestsHTTP transport is provided bypathapi-requests.HTTPURIPath.with_session()andPATHAPI_HTTP_TRANSPORT=requestsraise anImportErrornaming that distribution without it, and a proxy URL the built-in transport cannot use (socks5:,https:) names it in its error too. What to do: installpathapi-requestswhere one of them is used; with it installed they send through its transport and nothing else is configured. uritoolsis not a dependency. The RFC 3986 split of a URI and its percent-encoding and -decoding are done onurllib.parse, with the resultsuritoolsgave for the same text: the same safe sets, invalid escapes left as they are, non-ASCII text kept, a lone surrogate or an escape that is not UTF-8 carried throughsurrogateescape, an empty authority told from none.import uripathloads neitheruritoolsnornetimps;netimpsis imported where it is used. What to do: code that importsuritoolsitself installs it.- The
uripathcommand is rebuilt onduhoand needs thecliextra:pip install "uripath[cli]". Was: part of the base install. Without the extra the command (andpython -m uripath) prints which extra to install on stderr and exits with status 1. The subcommands, their options and meanings,-for stdin and stdout, the exit statuses (0, 1 the operation failed, 2 a wrong invocation, 130 Ctrl-C, 141 the reader of stdout went away) and theuripath: <Type>: <message>line on stderr are as they were. What differs: uripath.cli.main(argv=None)returns the status and reads and writes the process's standard streams. Was:main(argv, *, stdin=, stdout=, stderr=)with injectable streams, andbuild_parser()returning anargparseparser. Neither exists any more; build a command withduho.parseto test one.- Usage,
--helpand the wording of a usage error areduho's, notargparse's; a wrong invocation still exits with status 2. - Every command takes
-v,-qand--loglevel, which set the level of theuripathlogger.sync -v, which listed the changes made, is now a count (-vvis accepted) and lists them while-vis given more often than-q. - A boolean option that is on by default has both spellings:
--follow-symlinksbeside--no-follow-symlinks,--preserve-metadatabeside--no-preserve-metadata,--follow-mountsbeside--no-follow-mounts. --ends the options and accepts no argument after it. Was:argparsetook what followed as positional arguments, souripath rm -- -nameremoved-name; writeuripath rm ./-name.- An
http:ordav:write stream that is garbage collected without being closed sends nothing and emits aResourceWarning;pathlib-nextclosed it, and so uploaded it. What to do: close the file, or usewith.