pathapi-uri 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. pathapi_uri-0.1.0.dist-info/METADATA +259 -0
  2. pathapi_uri-0.1.0.dist-info/RECORD +48 -0
  3. pathapi_uri-0.1.0.dist-info/WHEEL +4 -0
  4. pathapi_uri-0.1.0.dist-info/entry_points.txt +17 -0
  5. pathapi_uri-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. uripath/AGENTS.md +1094 -0
  7. uripath/README.md +213 -0
  8. uripath/__init__.py +27 -0
  9. uripath/__main__.py +7 -0
  10. uripath/_pathtext.py +32 -0
  11. uripath/_rfc3986.py +67 -0
  12. uripath/_transfer.py +108 -0
  13. uripath/_uri.py +1007 -0
  14. uripath/_version.py +11 -0
  15. uripath/cli/__init__.py +54 -0
  16. uripath/cli/_common.py +48 -0
  17. uripath/cli/_root.py +70 -0
  18. uripath/cli/cp.py +61 -0
  19. uripath/cli/read.py +22 -0
  20. uripath/cli/rm.py +38 -0
  21. uripath/cli/sync.py +78 -0
  22. uripath/cli/write.py +49 -0
  23. uripath/exceptions.py +38 -0
  24. uripath/py.typed +1 -0
  25. uripath/query.py +240 -0
  26. uripath/schemes/__init__.py +120 -0
  27. uripath/schemes/_archive.py +338 -0
  28. uripath/schemes/_data.py +99 -0
  29. uripath/schemes/_dav.py +459 -0
  30. uripath/schemes/_delegate.py +127 -0
  31. uripath/schemes/_elsewhere.py +102 -0
  32. uripath/schemes/_file.py +186 -0
  33. uripath/schemes/_ftp.py +200 -0
  34. uripath/schemes/_http/__init__.py +4 -0
  35. uripath/schemes/_http/_backend.py +132 -0
  36. uripath/schemes/_http/_headers.py +8 -0
  37. uripath/schemes/_http/_listing.py +403 -0
  38. uripath/schemes/_http/_path.py +371 -0
  39. uripath/schemes/_http/_settings.py +6 -0
  40. uripath/schemes/_http/_streams.py +230 -0
  41. uripath/schemes/_httpdate.py +48 -0
  42. uripath/schemes/_path.py +629 -0
  43. uripath/schemes/_registry.py +134 -0
  44. uripath/source/__init__.py +20 -0
  45. uripath/source/_compose.py +212 -0
  46. uripath/source/_locality.py +44 -0
  47. uripath/source/_parse.py +183 -0
  48. uripath/source/_source.py +239 -0
uripath/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # uripath
2
+
3
+ [![Version](https://img.shields.io/pypi/v/pathapi-uri.svg)](https://pypi.org/project/pathapi-uri/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/pathapi-uri.svg)](https://pypi.org/project/pathapi-uri/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/uripath/blob/main/LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/uripath/)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/uripath/test.yml)](https://github.com/jose-pr/uripath/actions/workflows/test.yml)
8
+
9
+ Installed as `pathapi-uri` from PyPI; imported, and run as a command, as `uripath`.
10
+
11
+ **One `pathlib`-style API for URIs.** `URIPath("https://...")`, `URIPath("zip:...")`
12
+ and `URIPath("ftp://...")` return the class registered for the scheme, and each
13
+ has the method names, signatures and exception types of `pathlib.Path`: write
14
+ code once and run it against a local file, an archive member, an FTP or WebDAV
15
+ server, a web directory or, through the `repopath` distribution, a GitHub or GitLab
16
+ repository. It builds on
17
+ [`pathapi`](https://github.com/jose-pr/pathapi) and is documented at
18
+ <https://jose-pr.github.io/uripath/>.
19
+
20
+ ## Features
21
+
22
+ | Scheme (class) | Needs | Read | Write | List | Stat | mkdir | Delete | rename |
23
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- |
24
+ | `file:` (`FileURIPath`) | nothing | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
25
+ | `data:` (`DataURIPath`, RFC 2397) | nothing | Yes | No | No | Yes | No | No | No |
26
+ | `zip:` (`ZipURIPath`) | nothing | Yes | Local `file:` archive only | Yes | Yes | Local archive only | Local archive only | Local archive only |
27
+ | `tar:` (`TarURIPath`, incl. gz/bz2/xz) | nothing | Yes | No | Yes | Yes | No | No | No |
28
+ | `archive:`, `archive+zip:`, `archive+tar:` | nothing | As `zip:`/`tar:` for the detected or pinned format | | | | | | |
29
+ | `ftp:` / `ftps:` (`FTPURIPath`) | nothing | Yes | Yes | Yes (MLSD, NLST fallback) | Yes | Yes | Yes | Yes |
30
+ | `http:` / `https:` (`HTTPURIPath`) | nothing | Yes | Yes (PUT, configurable) | Yes (HTML index) | Yes | No | Yes (DELETE) | No |
31
+ | `dav:` / `davs:` (`DAVURIPath`, WebDAV) | nothing | Yes | Yes (no append) | Yes (PROPFIND) | Yes | Yes | Yes | Yes |
32
+ | `sftp:` | `sftppath[paramiko]` or `[asyncssh]` | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
33
+ | `s3:` | `objpath[s3]` | Yes | Yes (no append) | Yes (prefixes) | Yes | Yes (marker object) | Yes | Yes (same bucket) |
34
+ | `gs:` | `objpath[gs]` | Yes | Yes (no append) | Yes (prefixes) | Yes | Yes (marker object) | Yes | Yes (same bucket) |
35
+ | `az:` | `objpath[az]` | Yes | Yes (no append) | Yes (prefixes) | Yes | Yes (marker blob) | Yes | Yes (same container) |
36
+ | `repo:` | `repopath` | Yes | No | Yes | Yes | No | No | No |
37
+ | `github:` | `repopath` | Yes | No | Yes | Yes | No | No | No |
38
+ | `gitlab:` | `repopath` | Yes | No | Yes | Yes | No | No | No |
39
+
40
+ The last seven rows are other distributions: a path of one of those schemes
41
+ builds, joins and prints without it, and its first use of the server raises an
42
+ `ImportError` that names what to install. The `requests` HTTP transport
43
+ (`with_session()`, `~/.netrc`, SOCKS proxies) is the distribution
44
+ `pathapi-requests`: once it is installed, `with_session()` and
45
+ `PATHAPI_HTTP_TRANSPORT=requests` use it, with nothing to configure.
46
+
47
+ Every scheme gets `glob()`, `walk()`, `copy()`/`move()`, `rm()` and `PathSyncer`
48
+ from the `pathapi` base classes. The per-scheme notes (timeouts,
49
+ certificate verification, authentication, limits) are in
50
+ [Schemes](https://jose-pr.github.io/uripath/guides/schemes/).
51
+
52
+ - **Pure URI algebra**: `URI` parses, joins and compares URIs with no I/O and
53
+ no scheme class needed; `Source` and `Query` are its authority and query.
54
+ - **HTTP on the standard library**: `http:` and `dav:` send through a
55
+ replaceable transport built on `http.client`.
56
+ - **Extensible**: subclass `URIPath` and declare `__SCHEMES` for a new scheme,
57
+ or register it through a `uripath.schemes` entry point.
58
+ - **`uripath` command**: `read`, `write`, `cp`, `rm` and `sync` across local
59
+ paths and URIs, with `-` for stdin/stdout (`pip install "pathapi-uri[cli]"`).
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install pathapi-uri
65
+ ```
66
+
67
+ Requires `pathapi` and `netimps`, which pip installs with it.
68
+
69
+ | Install | Adds |
70
+ | --- | --- |
71
+ | `pip install pathapi-uri` | every scheme whose Needs is nothing in the table above |
72
+ | `pip install "pathapi-uri[cli]"` | the `uripath` command (its only extra) |
73
+ | `pip install "sftppath[paramiko]"` or `[asyncssh]` | `sftp:` |
74
+ | `pip install "objpath[s3]"`, `[gs]` or `[az]` | `s3:`, `gs:`, `az:` |
75
+ | `pip install pathapi-requests` | the `requests` HTTP transport |
76
+
77
+ ## Quick start
78
+
79
+ **Any URI**: `URIPath` returns the class registered for the scheme:
80
+
81
+ ```python
82
+ from uripath import URIPath
83
+
84
+ report = URIPath("file:data/report.txt") # FileURIPath
85
+ report.parent.mkdir(parents=True, exist_ok=True)
86
+ report.write_text("hello")
87
+ print(report.read_text())
88
+ print(URIPath("data:text/plain;base64,aGVsbG8=").read_text()) # DataURIPath
89
+ ```
90
+
91
+ **Archive members**: `<scheme>:<archive-uri>!/<member>`, where the archive
92
+ itself is any URI (`file:`, `http:`, `ftp:`, ...):
93
+
94
+ ```python
95
+ import zipfile
96
+ from uripath import URIPath
97
+
98
+ with zipfile.ZipFile("backup.zip", "w") as archive:
99
+ archive.writestr("etc/config.ini", "[main]\n")
100
+
101
+ member = URIPath("zip:file:backup.zip!/etc/config.ini")
102
+ print(member.read_text())
103
+ print([child.name for child in member.parent.iterdir()])
104
+ ```
105
+
106
+ **Remote paths** use the same API:
107
+
108
+ <!-- example: skip: reads from remote hosts -->
109
+ ```python
110
+ from uripath import URIPath
111
+
112
+ for child in URIPath("https://example.com/data/").iterdir():
113
+ print(child.name, child.stat().st_size)
114
+
115
+ print(URIPath("ftp://user@host/pub/README").read_text())
116
+ ```
117
+
118
+ **Copy and sync across schemes**, with the `pathapi` syncer:
119
+
120
+ ```python
121
+ from pathapi.sync import PathSyncer
122
+ from uripath import URIPath
123
+
124
+ source = URIPath("file:site")
125
+ (source / "css").mkdir(parents=True)
126
+ (source / "index.html").write_text("<h1>hi</h1>")
127
+
128
+ PathSyncer(remove_missing=True).sync(source, URIPath("file:site-copy"))
129
+ print(sorted(p.name for p in URIPath("file:site-copy").iterdir()))
130
+ ```
131
+
132
+ ## Command line
133
+
134
+ `uripath` reads, writes, copies, removes and syncs local paths and URIs, with
135
+ `-` for stdin or stdout. It is built on `duho` and needs the `cli` extra,
136
+ `pip install "pathapi-uri[cli]"`; without it the command says so and exits with
137
+ status 1. These commands run as written in an empty directory:
138
+
139
+ ```bash
140
+ mkdir site
141
+ uripath write site/index.html "<h1>hi</h1>"
142
+ uripath read site/index.html
143
+ uripath cp site/index.html site/copy.html
144
+ uripath sync --dry-run --remove-missing site site-copy
145
+ uripath sync site site-copy
146
+ uripath rm --recursive site-copy
147
+ ```
148
+
149
+ Either side can be a URI instead:
150
+
151
+ <!-- example: skip: needs an FTP host and a web server -->
152
+ ```bash
153
+ uripath cp report.txt ftp://host/tmp/report.txt
154
+ uripath sync --dry-run --remove-missing site/ ftp://host/site/
155
+ ```
156
+
157
+ The exit status is 0 on success, 1 when the operation failed, 2 for a wrong
158
+ invocation, 130 on Ctrl-C and 141 when the reader of stdout went away. Every
159
+ flag is described in the
160
+ [CLI guide](https://jose-pr.github.io/uripath/guides/cli/), and `uripath` can
161
+ be served to an agent as a set of tools.
162
+
163
+ ## API overview
164
+
165
+ | Module | Purpose |
166
+ | --- | --- |
167
+ | `uripath` | `URI`, `URIPath`, `URILike`, `Source`, `Query`, `QueryLike`, the functions `as_path` and `copy_stream`, the errors `URIPathError`, `URISyntaxError` and `UnsupportedSchemeError`, and `__version__` |
168
+ | `uripath.source` | `Source` (scheme, userinfo, host, port), `same_authority` and the helpers that read and compose a URI's text |
169
+ | `uripath.query` | `Query`, an immutable query value, and `QueryLike` |
170
+ | `uripath.exceptions` | The errors above |
171
+ | `uripath.schemes` | The contract and registry of schemes, `DerivedBackend`, `DelegatingURIPath`, and every built-in scheme's classes: `FileURIPath`, `DataURIPath`, `ZipURIPath`, `TarURIPath`, `ArchiveURIPath`, `FTPURIPath`, `HTTPURIPath`, `DAVURIPath` and their backends |
172
+
173
+ The full reference is on the [documentation site](https://jose-pr.github.io/uripath/);
174
+ an agent-oriented API summary ships inside the package as `uripath/AGENTS.md`.
175
+
176
+ ## Extending
177
+
178
+ Subclass `URIPath` and declare `__SCHEMES` for a new URI scheme; it is
179
+ dispatched as soon as its module is imported, or through a `uripath.schemes`
180
+ entry point. A scheme name belongs to one class: a second claim raises unless
181
+ the class statement says `replace=True`, and `URIPath.schemes()` /
182
+ `URIPath.scheme_class(name)` read the registry. Worked examples:
183
+ [Extending](https://jose-pr.github.io/uripath/guides/extending/). The pytest
184
+ contracts that verify a scheme are in `pathapi.testing`.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ pip install -e ".[dev]"
190
+ python -m pytest -q -rs
191
+ ```
192
+
193
+ Tests for a server that is not installed are skipped, so check the skip list
194
+ (`-rs`). Contributor notes (virtual environments, formatting, CI, releases) are
195
+ in the repository's
196
+ [`AGENTS.md`](https://github.com/jose-pr/uripath/blob/main/AGENTS.md). To
197
+ preview the documentation: `pip install -e ".[docs]"` then `mkdocs serve`.
198
+
199
+ Changes are recorded in the
200
+ [changelog](https://github.com/jose-pr/uripath/blob/main/CHANGELOG.md).
201
+
202
+ ### Releasing
203
+
204
+ This project follows [Semantic Versioning](https://semver.org/) and keeps a
205
+ [`CHANGELOG.md`](https://github.com/jose-pr/uripath/blob/main/CHANGELOG.md). Pushing a tag matching `v*` triggers the release
206
+ workflow: test gate → build (checking the tag names the version built) → a strict
207
+ docs build as a gate → GitHub release → publish. The release workflow never deploys
208
+ the docs site itself: for a final release its last job dispatches the docs workflow
209
+ at the tag, which owns every Pages deploy.
210
+
211
+ ## License
212
+
213
+ MIT — see [LICENSE](https://github.com/jose-pr/uripath/blob/main/LICENSE).
uripath/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """pathlib-style paths for URIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._pathtext import as_path
6
+ from ._transfer import copy_stream
7
+ from ._uri import URI, URILike
8
+ from ._version import __version__
9
+ from .exceptions import URIPathError, URISyntaxError, UnsupportedSchemeError
10
+ from .query import Query, QueryLike
11
+ from .schemes import URIPath
12
+ from .source import Source
13
+
14
+ __all__ = [
15
+ "__version__",
16
+ "as_path",
17
+ "copy_stream",
18
+ "Query",
19
+ "QueryLike",
20
+ "Source",
21
+ "URI",
22
+ "URILike",
23
+ "URIPath",
24
+ "URIPathError",
25
+ "URISyntaxError",
26
+ "UnsupportedSchemeError",
27
+ ]
uripath/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m uripath`: the same program as the `uripath` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ raise SystemExit(main())
uripath/_pathtext.py ADDED
@@ -0,0 +1,32 @@
1
+ """`as_path`: text that is a local path or a URI, read as the path it names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing as _ty
6
+
7
+ from pathapi import LocalPath
8
+
9
+ from .schemes._path import URIPath, _looks_like_uri
10
+
11
+ __all__ = ["as_path"]
12
+
13
+
14
+ def as_path(text: str) -> "_ty.Union[LocalPath, URIPath]":
15
+ """The path `text` names: a `URIPath` when it is a URI, a `LocalPath` when
16
+ it is a local path.
17
+
18
+ `text` is a URI when it starts with an RFC 3986 scheme and either holds
19
+ `://` or the scheme is one a class registers (`data:`, `zip:`, `file:`,
20
+ `sftp:`, ...). Anything else is local: `C:/data` (a one-letter scheme is a
21
+ drive), `12:30.txt`, `notes:draft` and `./a:b`. A scheme another
22
+ distribution provides gives a `URIPath` whose first I/O raises the
23
+ `ImportError` that names the distribution to install.
24
+
25
+ Raises `TypeError` for what is not a `str`. Nothing is read or connected
26
+ to.
27
+ """
28
+ if not isinstance(text, str):
29
+ raise TypeError(f"path text must be a str, not {type(text).__name__!r}")
30
+ if _looks_like_uri(text):
31
+ return URIPath(text, findclass=True)
32
+ return LocalPath(text)
uripath/_rfc3986.py ADDED
@@ -0,0 +1,67 @@
1
+ """The RFC 3986 pieces uripath needs, on the standard library: the split of a
2
+ URI reference into its five components, and percent-decoding and -encoding."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re as _re
7
+ import typing as _ty
8
+ from urllib.parse import quote_from_bytes as _quote_from_bytes
9
+ from urllib.parse import unquote_to_bytes as _unquote_to_bytes
10
+
11
+ __all__ = ["Split", "decode", "encode", "split"]
12
+
13
+
14
+ class Split(_ty.NamedTuple):
15
+ """The components of a URI reference as written: `None` for one that is
16
+ absent, `""` for one that is present and empty (`http://h/x?` has the query
17
+ `""`, `http://h/x` has none), the path always a `str`."""
18
+
19
+ scheme: "str | None"
20
+ authority: "str | None"
21
+ path: str
22
+ query: "str | None"
23
+ fragment: "str | None"
24
+
25
+
26
+ # RFC 3986 appendix B. Like the reference expression, `.` does not match a
27
+ # newline, so a fragment ends at one and what follows is dropped.
28
+ _SPLIT = _re.compile(
29
+ r"(?:([A-Za-z][A-Za-z0-9+.-]*):)?" # scheme (3.1)
30
+ r"(?://([^/?#]*))?" # authority (3.2)
31
+ r"([^?#]*)" # path (3.3)
32
+ r"(?:\?([^#]*))?" # query (3.4)
33
+ r"(?:#(.*))?" # fragment (3.5)
34
+ )
35
+
36
+
37
+ def split(text: str) -> Split:
38
+ """The five components of `text`. Any text splits: nothing is validated."""
39
+ return Split(*_SPLIT.match(text).groups())
40
+
41
+
42
+ def decode(text: str, errors: str = "strict", encoding: str = "utf-8") -> str:
43
+ """`text` with each `%XX` escape replaced by its byte, the bytes read as
44
+ `encoding`.
45
+
46
+ Text that is not an escape stays as it is: a `%` that is not followed by two
47
+ hexadecimal digits (in either case) is a `%`, and non-ASCII characters are
48
+ kept. Bytes that are not valid in `encoding` are handled by `errors`, and
49
+ `surrogateescape` carries each through the result."""
50
+ if encoding == "utf-8" and "%" not in text and text.isascii():
51
+ return text
52
+ return _unquote_to_bytes(text.encode(encoding, errors)).decode(encoding, errors)
53
+
54
+
55
+ def encode(
56
+ data: "str | bytes",
57
+ safe: str = "",
58
+ *,
59
+ encoding: str = "utf-8",
60
+ errors: str = "strict",
61
+ ) -> str:
62
+ """`data` percent-encoded: every byte that is not unreserved (RFC 3986 2.3)
63
+ or in `safe` becomes `%XX` with upper-case digits. A `str` is first encoded
64
+ as `encoding` with `errors`; `bytes` are used as they are. `safe` is ASCII."""
65
+ if not isinstance(data, bytes):
66
+ data = data.encode(encoding, errors)
67
+ return _quote_from_bytes(data, safe)
uripath/_transfer.py ADDED
@@ -0,0 +1,108 @@
1
+ """`copy_stream`: the bytes of a path or a binary stream into another."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno as _errno
6
+ import os as _os
7
+ import shutil as _shutil
8
+ import typing as _ty
9
+
10
+ from pathapi import Path
11
+
12
+ from ._pathtext import as_path
13
+ from .exceptions import OutputClosedError
14
+
15
+ __all__ = ["copy_stream"]
16
+
17
+ #: Bytes read and written at a time.
18
+ _CHUNK_SIZE = 1024 * 1024
19
+
20
+ #: Windows reports a write to a pipe whose reader has gone as EINVAL.
21
+ _WINDOWS = _os.name == "nt"
22
+
23
+ _Endpoint = _ty.Union[str, "_os.PathLike[str]", Path, _ty.BinaryIO]
24
+
25
+
26
+ def _is_closed_pipe(error: OSError) -> bool:
27
+ return isinstance(error, BrokenPipeError) or (
28
+ _WINDOWS and error.errno == _errno.EINVAL
29
+ )
30
+
31
+
32
+ class _PipeWriter:
33
+ """Writes to a stream the caller owns, turning a closed pipe there into
34
+ `OutputClosedError`."""
35
+
36
+ __slots__ = ("_stream",)
37
+
38
+ def __init__(self, stream):
39
+ self._stream = stream
40
+
41
+ def write(self, data):
42
+ try:
43
+ return self._stream.write(data)
44
+ except OSError as error:
45
+ if _is_closed_pipe(error):
46
+ raise OutputClosedError(error.errno, "output closed") from error
47
+ raise
48
+
49
+ def flush(self):
50
+ try:
51
+ self._stream.flush()
52
+ except OSError as error:
53
+ if _is_closed_pipe(error):
54
+ raise OutputClosedError(error.errno, "output closed") from error
55
+ raise
56
+
57
+
58
+ def _path_of(endpoint) -> Path:
59
+ if isinstance(endpoint, Path):
60
+ return endpoint
61
+ if isinstance(endpoint, str):
62
+ return as_path(endpoint)
63
+ if isinstance(endpoint, _os.PathLike):
64
+ return as_path(_os.fspath(endpoint))
65
+ raise TypeError(
66
+ "expected a path, path text or a binary stream, "
67
+ f"not {type(endpoint).__name__!r}"
68
+ )
69
+
70
+
71
+ def _copy(reader, target, exclusive: bool, chunk_size: int) -> None:
72
+ if callable(getattr(target, "write", None)):
73
+ writer = _PipeWriter(target)
74
+ _shutil.copyfileobj(reader, writer, chunk_size)
75
+ writer.flush()
76
+ return
77
+ with _path_of(target).open("xb" if exclusive else "wb") as writer:
78
+ _shutil.copyfileobj(reader, writer, chunk_size)
79
+
80
+
81
+ def copy_stream(
82
+ source: _Endpoint,
83
+ target: _Endpoint,
84
+ *,
85
+ exclusive: bool = False,
86
+ chunk_size: int = _CHUNK_SIZE,
87
+ ) -> None:
88
+ """Copy the bytes of `source` to `target`, `chunk_size` at a time, never
89
+ holding the whole object in memory.
90
+
91
+ Each end is a binary stream (anything with `read()` or `write()`), a path
92
+ object, or text that `as_path()` turns into a path. A path is opened here
93
+ and closed when the copy ends; a stream stays open and is the caller's.
94
+ The target stream is flushed at the end.
95
+
96
+ `exclusive` refuses a target path that exists (`FileExistsError`), as an
97
+ exclusive create does: there is no check-then-write race. A target stream
98
+ has nothing to refuse.
99
+
100
+ A target stream whose reader went away (a pipe whose other end closed,
101
+ which Windows reports as `OSError(EINVAL)`) raises `OutputClosedError`.
102
+ The same error from the connection of a path is not translated.
103
+ """
104
+ if callable(getattr(source, "read", None)):
105
+ _copy(source, target, exclusive, chunk_size)
106
+ return
107
+ with _path_of(source).open("rb") as reader:
108
+ _copy(reader, target, exclusive, chunk_size)