Repository navigation
v0.9.12
Added
Path._node_key(), an override hook answering where a path's node
lives:(namespace, names), the object two paths share when they resolve
names in one tree and the node's normalized position in it.copy()and
move()compare two paths that both answer it across classes.MemPath
and the archive paths override it; any other type keeps comparing two paths
of one type with==, so nothing needs to change.PathContractcallsrm(recursive=True)withfollow_symlinks=and
follow_binds=(test_rm_recursive_accepts_the_follow_policies, and
test_rm_rejects_a_policy_that_is_not_one). An implementation that
overridesrm()must accept both keywords, asPath.rm()does; its contract
subclass fails these two tests until it does. No fixture or capability
attribute changed.
Changed
- The scheme extras now declare version ranges.
uritools,requests,
paramiko,boto3,google-cloud-storage,azure-storage-blob,
azure-identityandasyncsshhad no lower bound (asyncsshhad only the
Python 3.9 cap) and no upper bound, andnetimpshad only a floor. The
ranges are nowuritools>=5.0,<7,netimps>=0.4.0,<0.5(was>=0.3.1),
requests>=2.22.0,<3,paramiko>=5.0,<6,asyncssh>=2.20.0,<3(below 2.22
on Python 3.9),boto3>=1.36.0,<2,google-cloud-storage>=3.0,<4,
azure-storage-blob>=12.4.0,<13andazure-identity>=1.0,<2. An
environment that pins one of them below its floor must raise the pin; one
that needs a new major version must wait for a release that admits it. copy(preserve_metadata=True)drops the setuid, setgid and sticky bits
between two different classes. A mode read from a tar member, a zip entry
or a remote server was applied verbatim, so04755in an archive made a
setuid file on the local disk. A copy between two classes now applies the
permission bits without those three; a copy within one class keeps them.
Runchmod()afterwards where the bit is wanted.move()callsrename()only onto a path of the same class. The default
ofPath._rename_compatible()was True, so aPathsubclass with a
rename()of its own was handed a path of another store and ran the rename
inside its own (aMemPathsubclass moved onto aLocalPathlooked for the
local path in memory). The target of another class now gets copy + delete.
A subclass whoserename()handles other classes overrides
_rename_compatible();UriPathandLocalPathdecide for themselves as
before.
Fixed
- A
..or.name in a remote listing no longer escapes the tree. The
s3:,gs:,az:,github:andgitlab:listings and the default
UriPathlisting yielded a name such as..,.,a/bor an empty one as
a child (http:,dav:andsftp:already skipped them), and theftp:
listing yielded one holding NUL. Joined onto the directory,..named its
parent, sowalk()left the directory it was given and never ended,
rm(recursive=True)deleted the parent tree, and
PathSyncer(remove_missing=True)removed the parent or copied a sibling
tree into the destination. All of those listings now skip such a name, and
walk(),rm(recursive=True)andPathSynceralso refuse one that a custom
scheme's_scandir()yields, before building any child from it:walk()
leaves it out ofdirnames/filenamesand, whenon_erroris given, calls
it with aValueError;rm(recursive=True)offers aValueErrorto
ignore_error(error, directory)once per entry and removes nothing for it;
PathSyncerraisesValueErrorthroughignore_errorfor the source's and
the target's names alike. A custom_scandir()should skip such names
itself (utils.is_safe_child_name()decides). An object key with a.or
..segment is no longer listed. - Names Windows rewrites are refused for a Windows destination.
utils.is_safe_child_name(name, windows=True)accepted a name ending in a
dot or a space and the reserved device names. Windows storesreport.as
report, socopy(recursive=True)of a tree holding both merged them into
one file with no error, and a memberNULof an archive was written to the
device.
Withwindows=Truethe function now also refuses a trailing dot or space
andCON,PRN,AUX,NUL,COM1-COM9andLPT1-LPT9, in any case,
with or without an extension (nul.txt);windows=Falseis unchanged.
copy(recursive=True)andPathSyncerreport such a name as a
ValueErrorper entry for a Windows-flavoured target, and
unpack_archive()skips such a member for a Windows destination with a
UserWarning. Rename a source file with such a name, or extract it on
another platform. - A failed
open("a")onhttp:no longer empties the remote file. In the
defaultappend_mode="rewrite", a failure of theGETthat reads the
current content (a 500, a 403, a timeout, a body cut short) raised to the
caller, and then the half-built stream was garbage-collected and uploaded
an empty body: the file was replaced by a zero-byte one.open("a")now
raises and sends nothing, in both append modes; a write stream whose
constructor did not finish never uploads. - A write through a redirect no longer reports success without storing the
bytes.http:anddav:followed a redirect forPUT,PATCH,
DELETE,MKCOLandMOVEthe way a browser does. A 301 re-sent the
PUTwithout its body and replaced the file with an empty one; a 302 or
303 turned the request into aGET, sowrite_bytes()returned having
written nothing andunlink(),mkdir()andrename()returned having
done nothing. Those requests now go out withallow_redirects=False
(anallow_redirectsinwith_session()does not apply to them). A 307
or 308 to the same scheme, host and port is sent once more with the same
method and body; any other 3xx, a redirect to another origin, a second
redirect and a body that cannot be sent twice raiseOSError(EIO)
naming the status and theLocation, without its userinfo. A server that
redirects writes must be addressed by its final URL. open("x")onhttp:anddav:no longer replaces a file whose
existence check failed, and patch-mode append no longer overwrites the start
of a file whose size is unknown.open("x")read any error of its
existence check (a 5xx, a 403, a 429, a timeout) as "missing" and sent the
PUT, replacing the file. It now raises that error and sends nothing, and
the upload carriesIf-None-Match: *, which a server that honours it
answers with 412, nowFileExistsError. Withappend_mode="patch", a
HEADreply withoutContent-Lengthwas read as size 0 and the appended
bytes were written at offset 0 (Content-Range: bytes 0-2/*);open("a")
now raisesOSErrornaming the path and sends noPATCH.stat()still
reportsst_size0 for such a reply. A server whose existence check fails
for a missing file needsopen("w")instead ofopen("x"); one that does
not state the size of its files needsappend_mode="rewrite".- A session or token no longer follows a path to another endpoint, and the
mirroring idiom keeps the destination's backend. Since 0.9.7, a path that
crossed endpoints (UriPath(base, "http://other/d/"),base.joinpath(Uri(...), "x"),base / Uri(...)) keptbase's backend for the nextstrjoin,
with_name()orcopy()/move()destination written as a URL, until its
first read, so anHttpPath.with_session(auth=...)session or a
github://TOKEN@...token was sent to the other host. And
dst_root / src.relative_to(src_root)dropped the backend given to
dst_rootand built the default one, so the copy ran on the ambient S3
client, or on an HTTP session without its headers. A derived path now takes
the backend its source holds only when it is on the same scheme, userinfo,
host and port, the rightmost joined path on the result's endpoint
supplies it, and the sourceless result ofrelative_to()holds none. A
path that crosses endpoints needs its ownwith_session()or
with_backend(). - Deriving a path no longer builds a backend.
parent,parents,
with_name(),with_suffix(),with_query(),relative_to(),
UriPath(base, x)and a join with aUriorPurePathbuilt the source's
backend: the firstparentof ansftp:path imported asyncssh, and
raisedImportErrorwhere neither SSH library is installed. They now copy
the backend the source already holds, or leave the new path without one
until its first I/O. Paths derived before any I/O therefore
build their own backend instead of sharing one made for the source; read
path.backendfirst, or passbackend=, to share a connection. - A backend that cannot be weakly referenced no longer turns off the
same-file guard. When_initbackend()returned adict, anobject()or
an instance of a__slots__class without"__weakref__", two separately
built paths to one URL were judged to be on different filesystems and
a.copy(b, overwrite=True)onto itself was not refused. A path now records
that it built its backend and passes the record on, so such a backend reads
as derived and the copy is refused. A backend given throughbackend=or
with_backend()still reads as supplied. - Dot segments are removed from a joined path, as the constructor removes
them. From 0.9.7 to 0.9.11,Uri("http://h/d/") / "../x"(astrjoin
onto a path ending in a slash) addressedhttp://h/d/x, because..was
resolved against the empty last segment, whileUri("http://h/d/../x")is
http://h/x, and a join with aUriargument (/ Uri("../x"),
Uri(base, "../x")) kepthttp://h/d/../xunresolved. All of them now give
http://h/x. The dot segments of the whole joined path are removed, so
Uri("http://h/d/x") / ".."ishttp://h/d/(it washttp://h/d),
Uri("http://h/d/x") / "."ishttp://h/d/x/(it washttp://h/d/x) and
Uri("a") / "../../x"is../x, as the constructor gives for
Uri("a/../../x")(the join gavex). Code that relied on..after a
trailing slash staying inside the directory must join the name instead
(base / "x"). A join key that ends in.or..now gives a path ending
in a slash, whosenameis empty: use.parentfor the parent, and skip a
"."key (base if rel == "." else base / rel) where the result must
equalbase. - A percent-encoded dot segment is removed after decoding.
Uri("http://h/safe/%2e%2e/secret")kept the segment.., so.parent,
.nameandas_uri()disagreed with the path that was fetched
(http://h/secret) and the URI did not parse back to itself. A%2e%2e,
.%2eor..%2Fin a parsed URI now resolves like... Astrjoin key
is a decoded path, where%2e%2eis still a literal name. - Dot segments never climb above a Windows drive.
UriPath("file:///D:/data/../../x")was\xon the current drive, and a
join such asUriPath("file:///C:/d/") / "../../../x"lost the drive the
same way. On Windows they now stay onC:/, asPureWindowsPathdoes. FileUri.parentson a Windows drive path ends at the drive root.
UriPath(LocalPath("C:/Windows/System32")).parentsended infile:/C:and
file:, two paths relative to the working directory, so an upward search
(for p in path.parents: (p / "marker").exists()) looked in the current
directory. It is now[file:/C:/Windows, file:/C:/], what repeated
.parentandLocalPath.parentsgive. Theparentof a bare drive
(file:///C:) is the drive itself; it was the empty path.- A Windows path string works as a
file:join key and as a
copy()/move()/rename()destination on Windows. A destination such
asstr(pathlib.Path)gives (C:\Temp\x) was joined as one file name and
failed withOSError(WinError 123) inFileUri.copy(),move()and
rename(), andbase / r"sub\x"named a filesub\x. On Windows a
file:path now reads\in such a string as a separator, so
C:\Temp\xis an absolute path andsub\xis two names. Elsewhere a
backslash is still an ordinary filename character, and every other scheme
splits on/only. move(overwrite=True)no longer deletes the source when the target holds
it.dst/sub.move(dst, overwrite=True)removed the whole ofdst, the
source included, and then raisedFileNotFoundError; the same happened on
MemPath. A target that holds the source now raises
OSError(ENOTEMPTY)and one that lies inside itOSError(EINVAL), with both
trees intact, before anything is removed. Move the source out first, or
name a different target.copy()andmove()recognise one file under another class or
spelling.LocalPath(f).move(target, overwrite=True)deletedfwhen
targetwas aLocalPathsubclass, afile:URI or apathlib.Pathfor
the same file, and so did a relative and an absoluteMemPathof one file
(a.txt,/a.txt,/d/../a.txt) and thezip:,archive:and
query-carrying spellings of one archive member;copy()across the same
spellings was not refused either. The move is now an in-place rename and the
copy raisesOSError(EINVAL), with the content kept. A directory reached
through a symlink is the same directory, socopy(recursive=True)into
it is refused as a copy into itself. A type whose segments spell one node
several ways overrides the new_node_key()hook.move()between two hard links of one file ends with the target name.
On POSIX it reported success and left both names; on Windows it removed the
source even withoutoverwrite. An existing target now needs
overwrite=True(FileExistsErrorotherwise), and the source name is then
removed. On POSIX a symlink moved onto the file it points at replaced the
file with a link to itself; it now raisesOSError(EINVAL).copy(overwrite=True)removes the target withunlink(missing_ok=True).
A backend whose write replaces the file and which cannot delete had to make
unlink()lie; it now implementsunlink()so thatmissing_ok=True
returns without removing.rm(recursive=True, follow_symlinks=True)works on POSIX. It emptied
the directory behind a followed symlink and then calledrmdir()on the
link itself, which fails withNotADirectoryErroron Linux and macOS: the
files behind the link were gone, the link and the rest of the tree stayed.
The link is now unlinked once what is behind it has been removed.rm()appliesfollow_symlinks=andfollow_binds=to the path it is
called on. They decided only the entries found on the way down: a symlink
named directly was unlinked whateverfollow_symlinkssaid, while a
junction named directly honouredfollow_binds. A symlink named directly
now follows the same policy:Truewithrecursive=Trueempties what is
behind it and then removes it,Noneleaves it, and withoutrecursive
it is only unlinked. A call that passedfollow_symlinks=TrueorNonefor
a link it names directly now does what the keyword documents.- An invalid follow policy is raised, not ignored. A callable that
answered anything butTrue,FalseorNonehad itsValueError
reported throughignore_error, soignore_error=Trueleft the tree half
removed with no error. The error now reaches the caller, and a keyword
value that is not a policy raises before anything is removed. - A class that mixes a concrete
pathlibpath withPathbut not
LocalPathhasLocalPath's local primitives. The precedence guard
replaced stdlib'sis_junction()andis_mount()with the generic
answers (alwaysFalse), sorm(recursive=True)on such a class walked
into a junction and deleted the files of the directory it points at, even
withfollow_binds=None. It also leftsymlink_to()raising
NotImplementedError,chmod("0644")raisingTypeErroron 3.10 and
later,is_dir(follow_symlinks=False)andis_file(follow_symlinks=False)
raisingTypeErroron 3.9, andchown()raisingNotImplementedError.
Every functionLocalPathdefines is now installed on such a class
wherever stdlib or a generic default would answer, from the class itself
so the two cannot drift; a method of the class or of a mixin of its own
still wins. rm()onsftp:,dav:,s3:,gs:andaz:accepts
follow_symlinks=andfollow_binds=. The five overrides stopped at
ignore_error, so naming either keyword raisedTypeErrorbefore any I/O.
They take both withPath.rm()'s defaults, check them before sending
anything, and forward them wherever the generic removal runs.dav:and the
object stores have no symlinks or bindings, so the keywords decide nothing
there;sftp:runs its concurrent native walk only for the default
follow_symlinks=Falseand the generic walk for any other value.move()keeps a symlink a symlink when it has to copy. Whererename()
was unavailable (another device, a backend withoutrename()) the fallback
followed the link: the destination became a full copy of the linked tree or
file, and so did every link inside a moved tree. The fallback now recreates
the links wherever the target's class can hold them, so the result no
longer depends on whether the two paths share a filesystem. A target that
cannot hold links (MemPath, an object store) still receives the content.copy(recursive=True, preserve_metadata=True)keeps directory
permissions. Files kept their mode but directories were created with the
default one, so a0o700directory copied as0o755and the files that
were private through their parent became reachable; a move of a tree across
devices did the same. Each directory now takes the source's reported mode
after its children are copied (so a read-only directory still receives
them), asshutil.copytreeand pathlib 3.14 do.PathSyncerno longer deletes the target entry behind a source link that
cannot be resolved. With the defaultfollow_symlinks=True, a source
child that is a symlink to nothing (an unmounted volume, a removed
directory) was read as "vanished", andremove_missing=Trueremoved the
same-named target entry recursively: a target directory holding the only
copy of its files was emptied and the run reported success. (A link in a
loop failed the listing of the whole directory instead.) A name the source
directory lists is now never missing.
Such a link is an error for that entry, offered toignore_errorwith
eventSyncEvent.SyncStart; the target entry stays as it is, with or
withoutremove_missing, and the rest of the directory is still synced
once the policy tolerates it. A sync that met such a link and finished
silently now raises (FileNotFoundErrorfor a dangling link, the stat's
OSErrorfor a loop) under the default policy: passignore_error=True
(or a callable) to carry on past it, orfollow_symlinks=Falseto mirror
the link itself.ignore_errorreceivesPathAndStatentries when the root stat fails.
A failingstat()of the rootsourceortargetoffered the caller's raw
path objects to the policy while the hook gotPathAndStat, so a policy
readingsource.pathorsource.statraisedAttributeErrorand hid the
real error. Both arguments are now alwaysPathAndStat(with an unknown
statwhen the stat is what failed).PathSyncerno longer lists, writes or deletes through a Windows
junction inside the target. A symlink found below the roottargetwas
replaced, but a junction looks like an ordinary directory, so
remove_missing=Trueremoved the files of the directory it names and
wrote the source's files into it. A junction below the root is now treated
like a symlink: it is replaced by a real directory
(SyncEvent.TypeMismatch;rmdir()removes the junction and nothing
behind it). A mount point inside the target is still synced into, and the
targetpassed tosync()is used as given, junction or not. A sync that
relied on writing through a junction must name the junction's directory as
its target.- A failed rename fallback in
PathSyncerno longer deletes the only copy of
a file. Onto a backend whoserename()refuses an existing target (plain
SFTP withoutposix-rename), the old file is removed and the temporary file
renamed in its place; when that second rename failed, the clean-up deleted
the temporary file too and the directory held neither version. Once the old
entry is gone the temporary file (and, replacing a link, the temporary link)
is kept and theOSErrornames it;ignore_errorreceives that error. The
fallback is now exercised by the tests. - A changed file whose name is near the length limit can be updated. The
temporary sibling is.NAME.<12 hex digits>.pathlib-next-tmp, 31 characters
longer thanNAME, so a file with a 225-character name was created by the
first sync and failed (ENAMETOOLONG,WinError 123) on every later one that
found it changed.NAMEis now cut so that the temporary name fits in 255
UTF-8 bytes, keeping the random part and the suffix. - The temporary files of a killed
PathSyncerrun are no longer copied
onward, and a stale one is removed. The leftover.NAME.<hex>.pathlib-next-tmp
of an interrupted transfer stayed beside the intact target for good under
the defaultremove_missing=False, and a sync from that directory copied it
to the next destination. A name of exactly that form is now the library's
own: it is never a sync source, andremove_missing=Trueno longer removes
one as "missing" (a transfer running in another process writes one). It is
removed from a target directory when it is stale -- not being written by
this process, with a known modification time over 24 hours old -- when a
changed file or link in that directory is next written, and by
remove_missing=True; a younger one, or one whose store reports no
modification time, stays, and so does anything that does not match the
full pattern. A file you named.x.<12 hex digits>.pathlib-next-tmpby
hand is skipped as a source; rename it to have it synced. The removal is a
SyncEvent.RemovedMissing. - A
PathSyncerdry run refuses a symlink the target cannot hold, as the
real run does. Withfollow_symlinks=False, a symlink in the source and a
target that cannot create links (MemPath, an object store),dry_run=True
returned and reportedSyncEvent.Symlinkwhile the real run raised
NotImplementedError, souripath sync --dry-runprintedwould symlink
for something the real run refuses. Both now take the decision before
anything is touched, offer the sameNotImplementedErrortoignore_error
and fire noSymlinkevent for it; the command's dry run exits 1 with the
same message. PathSyncersees one tree under two spellings or classes as overlapping.
The overlap check compared two paths of the same class lexically, so
MemPath("r")andMemPath("/r")on one backend, one archive directory
underzip:andarchive:(or with a query), and aLocalPathand the
file:URI of the same directory were not reported, so a sync could copy a
tree onto or into itself. It now asks the questioncopy()andmove()ask
about "same file", so all of these raiseValueErrorthroughignore_error
before anything is touched; two paths of an unrelated class are still never
reported.- A recursive
copy()orrm()on the asyncssh backend has stopped when it
raises. After the first error, a timeout or aKeyboardInterruptin the
calling thread, the tasks below the failing entry kept copying and deleting
on the server in the background, so a caller that retried or cleaned up
raced them, and a failed task's own error was logged as never retrieved.
Every task of the walk is now cancelled and awaited before the call returns
or raises, an exception delivered to the waiting thread cancels the walk
like a timeout does, and a destination file whose copy did not complete (an
error, a cancel, an interrupt) is removed instead of being left truncated.
Code that relied on the work finishing after an error has to wait for the
call itself. - The asyncssh
copy(recursive=True, overwrite=True)no longer deletes a
destination file before its source is known to be readable. It unlinked
an existing destination and only then opened the source, so a source that
could not be opened (permission denied, locked) left the destination gone,
where the paramiko backend andPath.copy()keep it. The source is opened
first. - A recursive
sftp:copy between two paths with different supplied
backends goes through the generic copy. Two paths of one authority that
hold distinct backends (a different ssh_configHostName, a tunnel, other
credentials) made the asyncssh fan-out create the target directory over the
target's connection and write every file over the source's. The fan-out now
runs only when_same_filesystem()holds, so each file reaches the target's
server. - The asyncssh copy applies modes as
Path.copy()does. It sent
st_modewith its file-type bits (0o100644,0o40755), created
sub-directories without a mode and never set the mode of the directory
copied into. It now sends the permission bits only, creates directories
with0o777likemkdir(), settles every directory, the target included,
after what is in it, and drops the setuid, setgid and sticky bits between
two different classes, as the generic copy does. - On asyncssh,
rm(recursive=True)treatsmissing_okandignore_error
as the genericrm()does.missing_ok=Trueswallowed a
FileNotFoundErrorfrom an entry that vanished below the path and
returned with the tree still there; it now covers the pathrm()was
called on only. A callableignore_errorthat returned False was handed
the same error again at every enclosing directory (five calls for a file
four levels down); each error is offered once. - The asyncssh backend honours an ssh_config
Portfor a host alias. It
always passed a port to asyncssh (22 when the URI had none), which outranks
the config, sosftp://box/withHost box/HostName 127.0.0.1/
Port 2222connected to port 22 and offered the URI's password to whatever
answered there. A port is now passed only when the URI names one; the
URI's port still outranks the config's. - An
sftp:host that is not a plain host name or address is refused before
any connection or config lookup. The host reached the config lookup and
then aProxyCommandcommand line unchanged, sosftp://u@host%20--flag/x
withProxyCommand prog %h %pranprog host --flag 22, and a host
starting with-arrived as an option.ValueErrornaming the host is
now raised where a connection or a config lookup would start, on both
backends; building, joining and comparing such a path still work. A host is
accepted when it is an IP address (an IPv6 one may carry a zone) or a name
of ASCII letters, digits,.,_and-(not first) and the letters and
digits of other scripts (an IDN); anything else is refused. - The paramiko backend expands
ProxyCommandtokens with the connection's
own values.%pand%rcame from the config or its defaults, so
sftp://alice@example.invalid:2222/xwithProxyCommand prog %h %p %rran
prog example.invalid 22and the local user name: the tunnel went to port
22 as the local user while the client believed it was talking to port 2222
asalice.%h,%n,%p,%rand%%now expand, in one pass, with
the host name, the host as written, the port and the user this connection
uses. A user name with white space, a quote, a shell metacharacter or a
leading-is refused (ValueError) when the command holds%r. - An
Includeinside aHostorMatchblock stays conditional on the
paramiko backend. The included file's own blocks were spliced in as
top-level blocks, so aHost *section in a file included from
Host internalset the user andProxyCommandof every host. Each of the
included file's blocks now applies only where the enclosing block does, as
in OpenSSH. AnIncludeoutside any block is unchanged. - Removing the last member of a zip directory no longer removes the
directory, andrm(recursive=True)of a zip directory finishes. A
directory that exists only through its members (whatZipFile.write()
produces) vanished with its last member:unlink()left its parent
missing, the next write into it raisedFileNotFoundError, and
rm(recursive=True)deleted every member and then failed on thermdir()
of a directory that was gone (the archive root failed the same way).
unlink(),rename(),rmdir()andrm()now write the directory's own
entry (name/) in the rewrite that empties it,rmdir()of an emptied
directory or of an empty archive root succeeds, andrm(recursive=True)of
a directory is one rewrite of the archive instead of one per member. A tree
that holds a member under a file is refused withOSError(ENOTEMPTY)and
nothing is removed. A zip edited this way may now hold empty directory
entries. ZipUri.rename()checks where it renames to. A destination below a
file or below a missing directory was accepted and made a member nothing
could list, and a directory could be renamed into its own subtree. It now
raisesNotADirectoryError,FileNotFoundErrorandOSError(EINVAL)
respectively, aspathlibdoes; create the directory first.- A zip member that the archive spells twice is removed and renamed as one.
In an archive holdingnormand./norm,unlink()andrename()acted
on the later entry only: the earlier one came back with its older content,
andrm(recursive=True)of the enclosing directory failed with "Directory
not empty".unlink(),rename(),rmdir()andrm()now act on every
entry that names the member, and renaming over a name that has two
spellings replaces them all. - A member below a file does not exist. An archive holding a file
y
and a membery/zlisted onlyy, yety/zcould still be statted, read
and removed, socopy(recursive=True)andwalk()skipped data that
read_bytes()returned.exists(),stat(),open(),unlink()and
rename()ofy/znow report it missing (FileNotFoundError;pathlib
saysNotADirectoryErrorthere), onzip:andtar:alike, and writing or
mkdir()belowyraisesNotADirectoryError. Remove or renameyto
reachy/z. - The root of an archive that is missing, or is not an archive, no longer
exists.exists(),is_dir()andstat()ofzip:file:///missing.zip!/
(and oftar:,archive:and an HTTP outer answering 404) said it was a
directory whileiterdir()raised. They now open the archive:exists()
isFalsefor a missing one andstat()raisesFileNotFoundError, and a
file that is not an archive raiseszipfile.BadZipFileor
tarfile.ReadError, as a listing and a member'sexists()do.mkdir()of
the root of a missing archive raisesFileNotFoundError; an archive is
still created by writing its first member. move()of an object-store prefix no longer deletes keys it did not copy,
and a recursive copy no longer leaves them behind. Ons3:,gs:and
az:a move copies by walking the prefix one level at a time and then
removes everything under it with a flat listing. Keys the walk cannot reach
were deleted without being copied: a key with an empty,.or..segment
(d//y.txt), the subtree under a key that is also an object (d/logsand
d/logs/2026.txt), and aname/key that holds data.copy(recursive=True)
skipped the same keys without a word.copy(recursive=True),move()and
rm(recursive=True)of a prefix that holds such keys now raise
OSError(EINVAL)naming them and change nothing (a copy writes nothing, a
move leaves the source whole).rm(recursive=True, ignore_error=...)is
offered the error and, if it is ignored, removes only the keys a walk
reaches. Remove the named keys first, orunlink()the object that hides a
subtree, and repeat. A prefix of ordinary keys is unchanged, down to the one
batched delete.iterdir()andwalk()are unchanged.- On
az:a name that is both a blob and a prefix lists as the blob. The
SDK returns every prefix of a page before its blobs, so the prefix won, the
blob's content was neither listed nor copied, andstat()disagreed with the
listing.iterdir(),walk()andcopy(recursive=True)now show the blob,
ass3:andgs:do. - Writing or renaming a file onto an object-store prefix directory is
refused.write_bytes()ondwhiled/x.txtexisted stored an object
dthat every listing then showed instead of the directory, and
rename()of a file ontodstdid the same, leaving the keys below it
unreachable byiterdir(),walk(),copy()andsync. Both now raise
IsADirectoryErrorons3:,gs:andaz:, at the cost of one list
request peropen("w")andrename(); credentials that may write but not
list are not stopped. Remove the directory (rm(recursive=True)) first. mkdir()no longer writes a marker when its existence probe fails. The
object stores askedexists(), which reads any error as "missing", so a 403,
a 503 or a timeout on the probe wroteexisting.txt/beside the object
existing.txtand returned. The probe is nowstat(), only a not-found
answer means absent, and any other error is raised with nothing written.rmdir()of ags:bucket root or anaz:container root is refused.
It returned success and removed nothing; it now raisesPermissionError, as
S3Path.rmdir()does for a bucket, and so does a non-recursiverm()of the
root.- A failed
ftp:listing no longer leaves the cached connection out of step.
ftplibreads a directory listing while the listing is iterated, outside the
guard that drops a connection on a failed transfer. A name the client could
not decode (UnicodeDecodeError) or a stalled data connection left the
server's final reply queued, and every laterstat(),exists(),
iterdir()andwalk()on that thread failed withftplib.error_reply
until an unrelated read or write happened to reconnect. A listing is now
read whole inside the guard: a failure mid-transfer drops the connection and
the next request reconnects, a name that is not valid UTF-8 raises
OSError(EILSEQ)instead ofUnicodeDecodeError, and a4xxreply to
MLSD(425 Can't open data connection) raisesOSError(EAGAIN)instead of
a rawftplib.error_temp, as it does for a read or a write. Code that
caughtUnicodeDecodeErrororftplib.error_temparound a listing must
catchOSError. ftp://hostwith no path lists the root. A path of""sentMLSD,
NLST,RETR,STOR,DELE,MKD,RMDandSITE CHMODwith an empty
argument, which a server reads as its working directory: the login
directory when it is not/, and whatever directory an earlieris_dir()
had changed into (CWD) when it is.ftp://host/always listed the root.
An empty path now sends/in every command that takes the path, so both
spellings name the same directory.
Full Changelog: v0.9.11...v0.9.12