pathapi 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/AGENTS.md +2355 -0
- pathapi/README.md +205 -0
- pathapi/__init__.py +62 -0
- pathapi/_commit.py +116 -0
- pathapi/_discard.py +92 -0
- pathapi/_errorpolicy.py +106 -0
- pathapi/_filestat.py +194 -0
- pathapi/_lru.py +138 -0
- pathapi/_names.py +70 -0
- pathapi/_permissions.py +102 -0
- pathapi/_stubs.py +76 -0
- pathapi/_tree.py +136 -0
- pathapi/archive/__init__.py +44 -0
- pathapi/archive/_backend.py +393 -0
- pathapi/archive/_names.py +63 -0
- pathapi/archive/_pack.py +394 -0
- pathapi/archive/_path.py +352 -0
- pathapi/archive/_snapshot.py +269 -0
- pathapi/archive/_tar.py +126 -0
- pathapi/archive/_write.py +359 -0
- pathapi/archive/_zip.py +563 -0
- pathapi/archive/_zipentries.py +272 -0
- pathapi/checksum.py +61 -0
- pathapi/exceptions.py +22 -0
- pathapi/fspath.py +513 -0
- pathapi/ftp/__init__.py +27 -0
- pathapi/ftp/_backend.py +418 -0
- pathapi/ftp/_facts.py +117 -0
- pathapi/ftp/_path.py +582 -0
- pathapi/glob/__init__.py +27 -0
- pathapi/glob/_entries.py +103 -0
- pathapi/glob/_patterns.py +164 -0
- pathapi/glob/_recursion.py +180 -0
- pathapi/glob/_selection.py +303 -0
- pathapi/http/__init__.py +84 -0
- pathapi/http/_body.py +156 -0
- pathapi/http/_builtin.py +553 -0
- pathapi/http/_client.py +211 -0
- pathapi/http/_errors.py +79 -0
- pathapi/http/_follow.py +230 -0
- pathapi/http/_headers.py +233 -0
- pathapi/http/_pool.py +169 -0
- pathapi/http/_proxies.py +142 -0
- pathapi/http/_response.py +255 -0
- pathapi/http/_settings.py +12 -0
- pathapi/http/_status.py +68 -0
- pathapi/http/_transports.py +173 -0
- pathapi/http/testing/__init__.py +80 -0
- pathapi/http/testing/__main__.py +10 -0
- pathapi/http/testing/suite/conftest.py +98 -0
- pathapi/http/testing/suite/test_client_default.py +285 -0
- pathapi/http/testing/suite/test_client_errors.py +181 -0
- pathapi/http/testing/suite/test_client_peer_text.py +86 -0
- pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
- pathapi/http/testing/suite/test_client_redirects.py +231 -0
- pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
- pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
- pathapi/http/testing/suite/test_transport_body.py +302 -0
- pathapi/http/testing/suite/test_transport_send.py +662 -0
- pathapi/http/testing/suite/transport_fakes.py +133 -0
- pathapi/http/testing/suite/transport_guard.py +174 -0
- pathapi/http/testing/suite/transport_wire.py +511 -0
- pathapi/mempath.py +438 -0
- pathapi/path/__init__.py +19 -0
- pathapi/path/_info.py +10 -0
- pathapi/path/_path.py +198 -0
- pathapi/path/_pure.py +522 -0
- pathapi/path/_relation.py +10 -0
- pathapi/path/_transfer.py +10 -0
- pathapi/protocols/__init__.py +35 -0
- pathapi/protocols/_bundles.py +22 -0
- pathapi/protocols/_errors.py +12 -0
- pathapi/protocols/_info.py +65 -0
- pathapi/protocols/_relation.py +191 -0
- pathapi/protocols/_removal.py +69 -0
- pathapi/protocols/_support.py +122 -0
- pathapi/protocols/_transfer.py +100 -0
- pathapi/protocols/checksum.py +80 -0
- pathapi/protocols/fs.py +429 -0
- pathapi/protocols/io.py +224 -0
- pathapi/protocols/links.py +135 -0
- pathapi/protocols/pure.py +31 -0
- pathapi/protocols/resolve.py +97 -0
- pathapi/protocols/transfer.py +656 -0
- pathapi/protocols/tree.py +492 -0
- pathapi/providers.py +48 -0
- pathapi/py.typed +1 -0
- pathapi/sync/__init__.py +8 -0
- pathapi/sync/_compare.py +157 -0
- pathapi/sync/_events.py +97 -0
- pathapi/sync/_links.py +93 -0
- pathapi/sync/_offer.py +33 -0
- pathapi/sync/_paths.py +50 -0
- pathapi/sync/_replace.py +98 -0
- pathapi/sync/_syncer.py +989 -0
- pathapi/testing/__init__.py +96 -0
- pathapi/testing/_contract.py +522 -0
- pathapi/testing/_fixtures.py +83 -0
- pathapi/testing/_read.py +279 -0
- pathapi-0.1.0.dist-info/METADATA +242 -0
- pathapi-0.1.0.dist-info/RECORD +104 -0
- pathapi-0.1.0.dist-info/WHEEL +4 -0
- pathapi-0.1.0.dist-info/entry_points.txt +2 -0
- pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Stand-ins for an HTTP transport, for tests that need no server: a
|
|
2
|
+
table-driven fake and a wrapper that records what a real one sends."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
from pathapi.http import HTTPResponse, get_transport
|
|
10
|
+
|
|
11
|
+
_REASONS = {
|
|
12
|
+
200: "OK",
|
|
13
|
+
201: "Created",
|
|
14
|
+
204: "No Content",
|
|
15
|
+
207: "Multi-Status",
|
|
16
|
+
301: "Moved Permanently",
|
|
17
|
+
302: "Found",
|
|
18
|
+
307: "Temporary Redirect",
|
|
19
|
+
404: "Not Found",
|
|
20
|
+
409: "Conflict",
|
|
21
|
+
500: "Internal Server Error",
|
|
22
|
+
502: "Bad Gateway",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def current_kind():
|
|
27
|
+
"""The transport kind the test runs with: what `http_transport_kind` set."""
|
|
28
|
+
return os.environ.get("PATHAPI_HTTP_TRANSPORT", "builtin")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_client_error(error):
|
|
32
|
+
"""Whether `error` comes from a client library (`requests`, `urllib3`)
|
|
33
|
+
rather than being a builtin or pathlib exception."""
|
|
34
|
+
return type(error).__module__.split(".")[0] in ("requests", "urllib3")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def make_transport(kind, **arguments):
|
|
38
|
+
"""A real transport of the kind `get_transport()` names."""
|
|
39
|
+
return get_transport(kind)(**arguments)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def spy_on_sends(monkeypatch, project=lambda method, url, **kwargs: (method, url)):
|
|
43
|
+
"""A list that gains `project(method, url, **keywords)` for every request
|
|
44
|
+
the real transport of the kind under test sends; the requests still go to
|
|
45
|
+
the server."""
|
|
46
|
+
transport_class = get_transport(current_kind())
|
|
47
|
+
log = []
|
|
48
|
+
|
|
49
|
+
def spy(self, method, url, *args, _original=transport_class.send, **kwargs):
|
|
50
|
+
log.append(project(method, url, **kwargs))
|
|
51
|
+
return _original(self, method, url, *args, **kwargs)
|
|
52
|
+
|
|
53
|
+
monkeypatch.setattr(transport_class, "send", spy)
|
|
54
|
+
return log
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Sent(typing.NamedTuple):
|
|
58
|
+
"""One call of `send()`."""
|
|
59
|
+
|
|
60
|
+
method: str
|
|
61
|
+
url: str
|
|
62
|
+
headers: dict
|
|
63
|
+
body: object
|
|
64
|
+
timeout: object
|
|
65
|
+
stream: bool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def reply(status=200, content=b"", headers=None, reason=None, url=""):
|
|
69
|
+
"""An `HTTPResponse` of `status` holding `content`."""
|
|
70
|
+
if reason is None:
|
|
71
|
+
reason = _REASONS.get(status, "")
|
|
72
|
+
return HTTPResponse(status, reason, headers or {}, url, content)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class FakeTransport:
|
|
76
|
+
"""Answers from `responses`, keyed `(method, url)`, or `(method, url,
|
|
77
|
+
depth)` for a PROPFIND, and records every call in `sent`. A value is an
|
|
78
|
+
`HTTPResponse` or a callable returning one; a call with no entry goes to
|
|
79
|
+
`handler(method, url, headers=, body=)` when given, else gets `default`
|
|
80
|
+
(a 404 unless given). `fail` is raised by the next call, if set."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, responses=None, default=None, handler=None):
|
|
83
|
+
self.responses = dict(responses or {})
|
|
84
|
+
self.default = default
|
|
85
|
+
self.handler = handler
|
|
86
|
+
self.sent: "list[Sent]" = []
|
|
87
|
+
self.closed = 0
|
|
88
|
+
self.fail: "BaseException | None" = None
|
|
89
|
+
|
|
90
|
+
def send(self, method, url, *, headers, body=None, timeout, stream=False):
|
|
91
|
+
self.sent.append(Sent(method, url, dict(headers), body, timeout, stream))
|
|
92
|
+
if self.fail is not None:
|
|
93
|
+
error, self.fail = self.fail, None
|
|
94
|
+
raise error
|
|
95
|
+
key = (method, url)
|
|
96
|
+
if method == "PROPFIND":
|
|
97
|
+
key = (method, url, headers.get("Depth"))
|
|
98
|
+
found = self.responses.get(key, self.responses.get((method, url)))
|
|
99
|
+
if found is None and self.handler is not None:
|
|
100
|
+
found = self.handler(method, url, headers=headers, body=body)
|
|
101
|
+
if found is None:
|
|
102
|
+
found = self.default if self.default is not None else reply(404)
|
|
103
|
+
response = found() if callable(found) else found
|
|
104
|
+
if not response.url:
|
|
105
|
+
response.url = url
|
|
106
|
+
return response
|
|
107
|
+
|
|
108
|
+
def close(self):
|
|
109
|
+
self.closed += 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class RecordingTransport:
|
|
113
|
+
"""Passes every call to `inner` and records it in `sent`."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, inner):
|
|
116
|
+
self.inner = inner
|
|
117
|
+
self.sent: "list[Sent]" = []
|
|
118
|
+
|
|
119
|
+
def send(self, method, url, *, headers, body=None, timeout, stream=False):
|
|
120
|
+
self.sent.append(Sent(method, url, dict(headers), body, timeout, stream))
|
|
121
|
+
return self.inner.send(
|
|
122
|
+
method, url, headers=headers, body=body, timeout=timeout, stream=stream
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def close(self):
|
|
126
|
+
self.inner.close()
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def methods(self):
|
|
130
|
+
return [call.method for call in self.sent]
|
|
131
|
+
|
|
132
|
+
def clear(self):
|
|
133
|
+
self.sent.clear()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""What keeps the transport suite on this machine and independent of it.
|
|
2
|
+
|
|
3
|
+
`install_network_guard` refuses every route off the loopback interface that
|
|
4
|
+
Python code can take, and `isolate_environment` removes the proxy and
|
|
5
|
+
certificate settings a developer's shell carries. `conftest.py` applies them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
#: Variables that are not the session's own: anything ending in `_PROXY`, and
|
|
16
|
+
#: the single names below.
|
|
17
|
+
AMBIENT_NAMES = ("NETRC", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE")
|
|
18
|
+
|
|
19
|
+
#: What a proxy-aware client is told never to proxy. A non-empty setting also
|
|
20
|
+
#: keeps `urllib` from reading the Windows registry or the macOS system
|
|
21
|
+
#: configuration for a proxy.
|
|
22
|
+
LOOPBACK_NO_PROXY = "127.0.0.1,localhost,::1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NetworkAccessBlocked(RuntimeError):
|
|
26
|
+
"""A test tried to reach a non-loopback host. Not an OSError on purpose:
|
|
27
|
+
code that treats a network failure as "offline" must not swallow it."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_loopback_host(host) -> bool:
|
|
31
|
+
if host is None:
|
|
32
|
+
return True
|
|
33
|
+
if isinstance(host, bytes):
|
|
34
|
+
host = host.decode("ascii", "replace")
|
|
35
|
+
host = str(host).strip("[]").split("%", 1)[0]
|
|
36
|
+
if host.lower() in ("", "localhost"):
|
|
37
|
+
return True
|
|
38
|
+
try:
|
|
39
|
+
return ipaddress.ip_address(host).is_loopback
|
|
40
|
+
except ValueError:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _address_host(address):
|
|
45
|
+
# AF_INET/AF_INET6 tuples carry the host first; AF_UNIX paths are local.
|
|
46
|
+
if isinstance(address, tuple) and address:
|
|
47
|
+
return address[0]
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def install_network_guard(monkeypatch, blocked):
|
|
52
|
+
"""Replace, for the rest of the test, every call that would send a packet
|
|
53
|
+
off the loopback interface or resolve a name: it raises
|
|
54
|
+
`NetworkAccessBlocked` before the original is reached, and appends a
|
|
55
|
+
line to `blocked` so the test fails at teardown even if the error was
|
|
56
|
+
swallowed."""
|
|
57
|
+
|
|
58
|
+
def check(host, what):
|
|
59
|
+
if not is_loopback_host(host):
|
|
60
|
+
blocked.append(f"{what} {host!r}")
|
|
61
|
+
raise NetworkAccessBlocked(
|
|
62
|
+
f"{what} to non-loopback host {host!r}; the transport suite "
|
|
63
|
+
"only talks to the loopback interface"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def wrap_method(owner, name, what, address_of):
|
|
67
|
+
original = getattr(owner, name)
|
|
68
|
+
|
|
69
|
+
def guarded(self, *args, **kwargs):
|
|
70
|
+
check(_address_host(address_of(args, kwargs)), what)
|
|
71
|
+
return original(self, *args, **kwargs)
|
|
72
|
+
|
|
73
|
+
guarded.__name__ = name
|
|
74
|
+
monkeypatch.setattr(owner, name, guarded)
|
|
75
|
+
|
|
76
|
+
def wrap_function(owner, name, what, host_of):
|
|
77
|
+
original = getattr(owner, name)
|
|
78
|
+
|
|
79
|
+
def guarded(*args, **kwargs):
|
|
80
|
+
check(host_of(args, kwargs), what)
|
|
81
|
+
return original(*args, **kwargs)
|
|
82
|
+
|
|
83
|
+
guarded.__name__ = name
|
|
84
|
+
monkeypatch.setattr(owner, name, guarded)
|
|
85
|
+
|
|
86
|
+
def first(args, kwargs):
|
|
87
|
+
return args[0] if args else None
|
|
88
|
+
|
|
89
|
+
def sockaddr(args, kwargs):
|
|
90
|
+
return _address_host(args[0] if args else None)
|
|
91
|
+
|
|
92
|
+
wrap_method(socket.socket, "connect", "connect", lambda a, k: first(a, k))
|
|
93
|
+
wrap_method(socket.socket, "connect_ex", "connect", lambda a, k: first(a, k))
|
|
94
|
+
wrap_method(
|
|
95
|
+
socket.socket,
|
|
96
|
+
"sendto",
|
|
97
|
+
"sendto",
|
|
98
|
+
lambda a, k: a[-1] if a else None,
|
|
99
|
+
)
|
|
100
|
+
if hasattr(socket.socket, "sendmsg"): # absent on Windows
|
|
101
|
+
wrap_method(
|
|
102
|
+
socket.socket,
|
|
103
|
+
"sendmsg",
|
|
104
|
+
"sendto",
|
|
105
|
+
lambda a, k: a[3] if len(a) > 3 else k.get("address"),
|
|
106
|
+
)
|
|
107
|
+
wrap_function(socket, "getaddrinfo", "getaddrinfo", first)
|
|
108
|
+
wrap_function(socket, "gethostbyname", "gethostbyname", first)
|
|
109
|
+
wrap_function(socket, "gethostbyname_ex", "gethostbyname", first)
|
|
110
|
+
wrap_function(socket, "gethostbyaddr", "gethostbyaddr", first)
|
|
111
|
+
wrap_function(socket, "getnameinfo", "getnameinfo", sockaddr)
|
|
112
|
+
|
|
113
|
+
if sys.platform == "win32":
|
|
114
|
+
# The default Windows loop connects and sends through overlapped I/O,
|
|
115
|
+
# which never calls the socket methods above.
|
|
116
|
+
try:
|
|
117
|
+
import asyncio.windows_events as windows_events
|
|
118
|
+
except ImportError: # pragma: no cover - an unusual build
|
|
119
|
+
return
|
|
120
|
+
proactor = windows_events.IocpProactor
|
|
121
|
+
wrap_method(
|
|
122
|
+
proactor, "connect", "connect", lambda a, k: a[1] if len(a) > 1 else None
|
|
123
|
+
)
|
|
124
|
+
wrap_method(
|
|
125
|
+
proactor,
|
|
126
|
+
"sendto",
|
|
127
|
+
"sendto",
|
|
128
|
+
lambda a, k: a[3] if len(a) > 3 else k.get("addr"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_ambient_variable(name: str) -> bool:
|
|
133
|
+
upper = name.upper()
|
|
134
|
+
return upper.endswith("_PROXY") or upper in AMBIENT_NAMES
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def isolate_environment(patch, home) -> None:
|
|
138
|
+
"""Make the process environment the session's own: no proxy, `.netrc` or
|
|
139
|
+
CA-bundle setting of the developer's, and a home directory (`home`, a path)
|
|
140
|
+
that holds nothing, so `~/.netrc` is not there to be read. `patch` is a
|
|
141
|
+
`MonkeyPatch`, which restores everything when it is undone."""
|
|
142
|
+
home = os.fspath(home)
|
|
143
|
+
for name in list(os.environ):
|
|
144
|
+
if is_ambient_variable(name):
|
|
145
|
+
patch.delenv(name)
|
|
146
|
+
patch.setenv("NO_PROXY", LOOPBACK_NO_PROXY)
|
|
147
|
+
patch.setenv("no_proxy", LOOPBACK_NO_PROXY)
|
|
148
|
+
patch.setenv("HOME", home)
|
|
149
|
+
patch.setenv("USERPROFILE", home)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
#: Set and cleared by pytest itself around every phase of a test.
|
|
153
|
+
_PYTEST_OWN = ("PYTEST_CURRENT_TEST",)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def environment_snapshot() -> dict:
|
|
157
|
+
return {k: v for k, v in os.environ.items() if k not in _PYTEST_OWN}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def changed_variables(before: dict, after: dict) -> dict:
|
|
161
|
+
"""`{name: (old, new)}` for every variable that differs; `None` stands
|
|
162
|
+
for absent."""
|
|
163
|
+
return {
|
|
164
|
+
name: (before.get(name), after.get(name))
|
|
165
|
+
for name in sorted(set(before) | set(after))
|
|
166
|
+
if before.get(name) != after.get(name)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def restore_environment(snapshot: dict) -> None:
|
|
171
|
+
for name in list(os.environ):
|
|
172
|
+
if name not in snapshot and name not in _PYTEST_OWN:
|
|
173
|
+
del os.environ[name]
|
|
174
|
+
os.environ.update(snapshot)
|