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.
- pathapi_uri-0.1.0.dist-info/METADATA +259 -0
- pathapi_uri-0.1.0.dist-info/RECORD +48 -0
- pathapi_uri-0.1.0.dist-info/WHEEL +4 -0
- pathapi_uri-0.1.0.dist-info/entry_points.txt +17 -0
- pathapi_uri-0.1.0.dist-info/licenses/LICENSE +21 -0
- uripath/AGENTS.md +1094 -0
- uripath/README.md +213 -0
- uripath/__init__.py +27 -0
- uripath/__main__.py +7 -0
- uripath/_pathtext.py +32 -0
- uripath/_rfc3986.py +67 -0
- uripath/_transfer.py +108 -0
- uripath/_uri.py +1007 -0
- uripath/_version.py +11 -0
- uripath/cli/__init__.py +54 -0
- uripath/cli/_common.py +48 -0
- uripath/cli/_root.py +70 -0
- uripath/cli/cp.py +61 -0
- uripath/cli/read.py +22 -0
- uripath/cli/rm.py +38 -0
- uripath/cli/sync.py +78 -0
- uripath/cli/write.py +49 -0
- uripath/exceptions.py +38 -0
- uripath/py.typed +1 -0
- uripath/query.py +240 -0
- uripath/schemes/__init__.py +120 -0
- uripath/schemes/_archive.py +338 -0
- uripath/schemes/_data.py +99 -0
- uripath/schemes/_dav.py +459 -0
- uripath/schemes/_delegate.py +127 -0
- uripath/schemes/_elsewhere.py +102 -0
- uripath/schemes/_file.py +186 -0
- uripath/schemes/_ftp.py +200 -0
- uripath/schemes/_http/__init__.py +4 -0
- uripath/schemes/_http/_backend.py +132 -0
- uripath/schemes/_http/_headers.py +8 -0
- uripath/schemes/_http/_listing.py +403 -0
- uripath/schemes/_http/_path.py +371 -0
- uripath/schemes/_http/_settings.py +6 -0
- uripath/schemes/_http/_streams.py +230 -0
- uripath/schemes/_httpdate.py +48 -0
- uripath/schemes/_path.py +629 -0
- uripath/schemes/_registry.py +134 -0
- uripath/source/__init__.py +20 -0
- uripath/source/_compose.py +212 -0
- uripath/source/_locality.py +44 -0
- uripath/source/_parse.py +183 -0
- uripath/source/_source.py +239 -0
uripath/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# uripath
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pathapi-uri/)
|
|
4
|
+
[](https://pypi.org/project/pathapi-uri/)
|
|
5
|
+
[](https://github.com/jose-pr/uripath/blob/main/LICENSE)
|
|
6
|
+
[](https://jose-pr.github.io/uripath/)
|
|
7
|
+
[](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
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)
|