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,98 @@
|
|
|
1
|
+
"""Fixtures of the HTTP transport suite.
|
|
2
|
+
|
|
3
|
+
The suite checks one transport per run (`--http-transports`), against servers
|
|
4
|
+
on the loopback interface only, and needs nothing but `pytest`, the standard
|
|
5
|
+
library and `pathapi` (the TLS tests also need `cryptography`, and skip
|
|
6
|
+
without it). It is shipped with `pathapi.http.testing` so that another
|
|
7
|
+
distribution can hold its transport to the same promises.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import socket
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
import transport_guard
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pytest_addoption(parser):
|
|
19
|
+
parser.addoption(
|
|
20
|
+
"--http-transports",
|
|
21
|
+
default="builtin",
|
|
22
|
+
help="the HTTP transports the transport suite runs against, by the "
|
|
23
|
+
"names of pathapi.http.transport_names(), separated by commas "
|
|
24
|
+
"(for example builtin,requests)",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pytest_configure(config):
|
|
29
|
+
# The session's environment is set before the first test's snapshot of it
|
|
30
|
+
# is taken, and put back when pytest exits.
|
|
31
|
+
home = tempfile.TemporaryDirectory(prefix="pathapi-transport-home-")
|
|
32
|
+
patch = pytest.MonkeyPatch()
|
|
33
|
+
transport_guard.isolate_environment(patch, home.name)
|
|
34
|
+
config.add_cleanup(home.cleanup)
|
|
35
|
+
config.add_cleanup(patch.undo)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(autouse=True)
|
|
39
|
+
def _environment_unchanged():
|
|
40
|
+
"""A test that leaves the process environment changed fails; the
|
|
41
|
+
environment is put back first so that the next test starts clean."""
|
|
42
|
+
before = transport_guard.environment_snapshot()
|
|
43
|
+
yield
|
|
44
|
+
changed = transport_guard.changed_variables(
|
|
45
|
+
before, transport_guard.environment_snapshot()
|
|
46
|
+
)
|
|
47
|
+
if changed:
|
|
48
|
+
transport_guard.restore_environment(before)
|
|
49
|
+
pytest.fail(
|
|
50
|
+
"the test left the process environment changed (use monkeypatch): "
|
|
51
|
+
+ ", ".join(f"{k}: {old!r} -> {new!r}" for k, (old, new) in changed.items())
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture(autouse=True)
|
|
56
|
+
def _block_non_loopback_network(_environment_unchanged, monkeypatch):
|
|
57
|
+
"""Any connect, send or name lookup aimed at a host other than loopback
|
|
58
|
+
raises `NetworkAccessBlocked`, and a blocked attempt fails the test at
|
|
59
|
+
teardown even if the code under test swallowed the error."""
|
|
60
|
+
blocked = []
|
|
61
|
+
transport_guard.install_network_guard(monkeypatch, blocked)
|
|
62
|
+
yield blocked
|
|
63
|
+
if blocked:
|
|
64
|
+
pytest.fail(f"non-loopback network access attempted: {blocked}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pytest_generate_tests(metafunc):
|
|
68
|
+
if "http_transport_kind" in metafunc.fixturenames:
|
|
69
|
+
option = metafunc.config.getoption("--http-transports", default="builtin")
|
|
70
|
+
kinds = [name.strip() for name in option.split(",") if name.strip()]
|
|
71
|
+
metafunc.parametrize("http_transport_kind", kinds, indirect=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.fixture
|
|
75
|
+
def http_transport_kind(request, monkeypatch):
|
|
76
|
+
"""Runs the test once per HTTP transport (`--http-transports`): the
|
|
77
|
+
default transport of every client the test builds is that kind
|
|
78
|
+
(`PATHAPI_HTTP_TRANSPORT`), and `transport_fakes.make_transport(kind)`
|
|
79
|
+
builds one of it."""
|
|
80
|
+
monkeypatch.setenv("PATHAPI_HTTP_TRANSPORT", request.param)
|
|
81
|
+
return request.param
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.fixture
|
|
85
|
+
def http_transport(http_transport_kind):
|
|
86
|
+
"""A transport of the kind under test, closed after the test."""
|
|
87
|
+
from transport_fakes import make_transport
|
|
88
|
+
|
|
89
|
+
with make_transport(http_transport_kind) as transport:
|
|
90
|
+
yield transport
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.fixture
|
|
94
|
+
def unused_tcp_port():
|
|
95
|
+
"""A port nobody is listening on, for connection-refused coverage."""
|
|
96
|
+
with socket.socket() as probe:
|
|
97
|
+
probe.bind(("127.0.0.1", 0))
|
|
98
|
+
return probe.getsockname()[1]
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""The client: which transport it sends with when it is not given one (the
|
|
2
|
+
built-in one, the `requests` one only when asked, and nothing imported that is
|
|
3
|
+
not needed), what it closes, and the headers and timeout every request gets."""
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import textwrap
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
import pathapi
|
|
15
|
+
import pathapi.http as http
|
|
16
|
+
from pathapi.http import HTTPClient, HTTPTransport
|
|
17
|
+
from transport_fakes import FakeTransport, reply
|
|
18
|
+
from transport_wire import Hang, Wire
|
|
19
|
+
|
|
20
|
+
_SRC = pathlib.Path(pathapi.__file__).resolve().parent.parent
|
|
21
|
+
VARIABLE = "PATHAPI_HTTP_TRANSPORT"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_the_variable_in_the_module_is_the_one_the_code_reads():
|
|
25
|
+
assert http.TRANSPORT_ENV == VARIABLE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_without_a_setting_the_built_in_transport_is_used(monkeypatch):
|
|
29
|
+
monkeypatch.delenv(VARIABLE, raising=False)
|
|
30
|
+
with HTTPClient() as client:
|
|
31
|
+
assert type(client.transport) is HTTPTransport
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_a_client_builds_its_transport_on_first_use_and_keeps_it(monkeypatch):
|
|
35
|
+
monkeypatch.delenv(VARIABLE, raising=False)
|
|
36
|
+
with HTTPClient() as client:
|
|
37
|
+
assert client._transport is None
|
|
38
|
+
first = client.transport
|
|
39
|
+
assert client.transport is first
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_the_variable_chooses_the_transport(monkeypatch):
|
|
43
|
+
monkeypatch.setenv(VARIABLE, "builtin")
|
|
44
|
+
with HTTPClient() as client:
|
|
45
|
+
assert type(client.transport) is HTTPTransport
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_the_uripath_variable_does_not_choose_the_transport(monkeypatch):
|
|
49
|
+
monkeypatch.delenv(VARIABLE, raising=False)
|
|
50
|
+
monkeypatch.setenv("URIPATH_HTTP_TRANSPORT", "requests")
|
|
51
|
+
with HTTPClient() as client:
|
|
52
|
+
assert type(client.transport) is HTTPTransport
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize("value", ["", "Requests", "BUILTIN", "urllib3", " builtin"])
|
|
56
|
+
def test_a_value_that_is_not_a_choice_is_a_value_error_naming_the_choices(
|
|
57
|
+
monkeypatch, value
|
|
58
|
+
):
|
|
59
|
+
monkeypatch.setenv(VARIABLE, value)
|
|
60
|
+
client = HTTPClient()
|
|
61
|
+
with pytest.raises(ValueError) as excinfo:
|
|
62
|
+
client.transport
|
|
63
|
+
message = str(excinfo.value)
|
|
64
|
+
assert VARIABLE in message and "'builtin'" in message and "'requests'" in message
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_a_bad_setting_is_reported_when_the_client_sends_not_when_it_is_built(
|
|
68
|
+
monkeypatch,
|
|
69
|
+
):
|
|
70
|
+
monkeypatch.setenv(VARIABLE, "nonsense")
|
|
71
|
+
client = HTTPClient()
|
|
72
|
+
with pytest.raises(ValueError):
|
|
73
|
+
client.request("GET", "http://127.0.0.1:1/x")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_a_client_closes_the_transport_it_built_and_not_one_it_was_given(monkeypatch):
|
|
77
|
+
monkeypatch.delenv(VARIABLE, raising=False)
|
|
78
|
+
given = FakeTransport(default=reply(200))
|
|
79
|
+
client = HTTPClient(given)
|
|
80
|
+
client.close()
|
|
81
|
+
assert given.closed == 0
|
|
82
|
+
|
|
83
|
+
built = HTTPClient()
|
|
84
|
+
transport = built.transport
|
|
85
|
+
closed = []
|
|
86
|
+
transport.close = lambda: closed.append(True)
|
|
87
|
+
built.close()
|
|
88
|
+
built.close()
|
|
89
|
+
assert closed == [True]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_a_transport_is_an_object_with_a_send_method():
|
|
93
|
+
with pytest.raises(TypeError):
|
|
94
|
+
HTTPClient(object())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_the_representation_of_a_client_shows_no_header():
|
|
98
|
+
client = HTTPClient(headers={"Authorization": "Bearer SECRET"})
|
|
99
|
+
assert "SECRET" not in repr(client) and "Authorization" not in repr(client)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- what every request carries -------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_a_request_names_the_library_and_its_version_as_its_user_agent():
|
|
106
|
+
transport = FakeTransport(default=reply(200))
|
|
107
|
+
HTTPClient(transport).request("GET", "http://h/x")
|
|
108
|
+
sent = transport.sent[0].headers
|
|
109
|
+
assert sent["User-Agent"] == f"pathapi/{pathapi.__version__}"
|
|
110
|
+
assert sent["Accept"] == "*/*"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_the_user_agent_can_be_named_by_the_caller_of_the_client():
|
|
114
|
+
transport = FakeTransport(default=reply(200))
|
|
115
|
+
client = HTTPClient(transport, user_agent="tool/1.2")
|
|
116
|
+
assert client.user_agent == "tool/1.2"
|
|
117
|
+
client.request("GET", "http://h/x")
|
|
118
|
+
assert transport.sent[0].headers["User-Agent"] == "tool/1.2"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_headers_are_merged_by_name_the_call_over_the_client_over_the_base():
|
|
122
|
+
transport = FakeTransport(default=reply(200))
|
|
123
|
+
client = HTTPClient(transport, headers={"accept": "text/html", "X-A": "client"})
|
|
124
|
+
client.request("GET", "http://h/x", headers={"x-a": "call", "X-B": "call"})
|
|
125
|
+
sent = transport.sent[0].headers
|
|
126
|
+
assert {k.lower(): v for k, v in sent.items()} == {
|
|
127
|
+
"user-agent": f"pathapi/{pathapi.__version__}",
|
|
128
|
+
"accept": "text/html",
|
|
129
|
+
"x-a": "call",
|
|
130
|
+
"x-b": "call",
|
|
131
|
+
}
|
|
132
|
+
assert len(sent) == 4
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_a_header_set_to_none_in_the_call_is_not_sent():
|
|
136
|
+
transport = FakeTransport(default=reply(200))
|
|
137
|
+
HTTPClient(transport).request("GET", "http://h/x", headers={"Accept": None})
|
|
138
|
+
assert "Accept" not in transport.sent[0].headers
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_credentials_given_as_auth_are_sent_as_a_basic_authorization():
|
|
142
|
+
transport = FakeTransport(default=reply(200))
|
|
143
|
+
HTTPClient(transport).request("GET", "http://h/x", auth=("al@ice", "s3cr3t"))
|
|
144
|
+
expected = "Basic " + base64.b64encode(b"al@ice:s3cr3t").decode("ascii")
|
|
145
|
+
assert transport.sent[0].headers["Authorization"] == expected
|
|
146
|
+
assert transport.sent[0].url == "http://h/x"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_a_string_given_as_auth_is_the_authorization_value():
|
|
150
|
+
transport = FakeTransport(default=reply(200))
|
|
151
|
+
HTTPClient(transport).request("GET", "http://h/x", auth="Bearer TOKEN")
|
|
152
|
+
assert transport.sent[0].headers["Authorization"] == "Bearer TOKEN"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.parametrize("where", ["client", "call"])
|
|
156
|
+
def test_an_authorization_header_wins_over_auth(where):
|
|
157
|
+
transport = FakeTransport(default=reply(200))
|
|
158
|
+
options = {"headers": {"authorization": "Bearer B"}}
|
|
159
|
+
client = HTTPClient(transport, **(options if where == "client" else {}))
|
|
160
|
+
client.request(
|
|
161
|
+
"GET",
|
|
162
|
+
"http://h/x",
|
|
163
|
+
auth=("alice", "pw"),
|
|
164
|
+
**(options if where == "call" else {}),
|
|
165
|
+
)
|
|
166
|
+
sent = transport.sent[0].headers
|
|
167
|
+
assert [v for k, v in sent.items() if k.lower() == "authorization"] == ["Bearer B"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_a_url_with_credentials_is_refused_before_anything_is_sent():
|
|
171
|
+
transport = FakeTransport(default=reply(200))
|
|
172
|
+
with pytest.raises(ValueError) as excinfo:
|
|
173
|
+
HTTPClient(transport).request("GET", "http://alice:s3cr3t@h/x")
|
|
174
|
+
assert "s3cr3t" not in str(excinfo.value)
|
|
175
|
+
assert transport.sent == []
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_a_header_or_url_with_a_line_break_is_refused_before_any_transport_call():
|
|
179
|
+
transport = FakeTransport(default=reply(200))
|
|
180
|
+
for headers in ({"X-A": "1\r\nX-B: 2"}, {"X-A": "1\n2"}):
|
|
181
|
+
with pytest.raises(ValueError):
|
|
182
|
+
HTTPClient(transport, headers=headers).request("GET", "http://h/x")
|
|
183
|
+
with pytest.raises(ValueError):
|
|
184
|
+
HTTPClient(transport).request("GET", "http://h/x", headers=headers)
|
|
185
|
+
with pytest.raises(ValueError):
|
|
186
|
+
HTTPClient(transport).request("GET", "http://h/x\r\nX-A: 1")
|
|
187
|
+
assert transport.sent == []
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_a_text_body_is_sent_as_utf_8():
|
|
191
|
+
transport = FakeTransport(default=reply(200))
|
|
192
|
+
HTTPClient(transport).request("PUT", "http://h/x", data="café")
|
|
193
|
+
assert transport.sent[0].body == "café".encode("utf-8")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_a_reply_is_asked_for_streamed_whatever_the_caller_asked():
|
|
197
|
+
transport = FakeTransport(default=reply(200, b"abc"))
|
|
198
|
+
client = HTTPClient(transport)
|
|
199
|
+
assert client.request("GET", "http://h/x").read() == b"abc"
|
|
200
|
+
client.request("GET", "http://h/x", stream=True).close()
|
|
201
|
+
assert [call.stream for call in transport.sent] == [True, True]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- the timeout -----------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_a_request_without_a_timeout_gets_the_default():
|
|
208
|
+
transport = FakeTransport()
|
|
209
|
+
HTTPClient(transport).request("GET", "http://h/x")
|
|
210
|
+
assert transport.sent[-1].timeout == (10, 60) == http.DEFAULT_TIMEOUT
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@pytest.mark.parametrize("timeout", [5, None])
|
|
214
|
+
def test_a_timeout_of_the_client_or_the_call_wins(timeout):
|
|
215
|
+
transport = FakeTransport()
|
|
216
|
+
HTTPClient(transport, timeout=timeout).request("GET", "http://h/x")
|
|
217
|
+
assert transport.sent[-1].timeout == timeout
|
|
218
|
+
HTTPClient(transport).request("GET", "http://h/x", timeout=timeout)
|
|
219
|
+
assert transport.sent[-1].timeout == timeout
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_a_stalled_server_times_out_when_the_client_has_a_timeout(http_transport_kind):
|
|
223
|
+
with Wire(lambda r: Hang()) as wire:
|
|
224
|
+
with HTTPClient(timeout=(2, 0.5)) as client:
|
|
225
|
+
with pytest.raises(TimeoutError):
|
|
226
|
+
client.request("GET", wire.url + "/x")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --- no third-party library ---------------------------------------------------
|
|
230
|
+
|
|
231
|
+
_CHILD = """
|
|
232
|
+
import sys
|
|
233
|
+
|
|
234
|
+
import pathapi
|
|
235
|
+
|
|
236
|
+
assert not {"ssl", "http.client"} & set(sys.modules)
|
|
237
|
+
sys.modules["requests"] = None
|
|
238
|
+
sys.modules["urllib3"] = None
|
|
239
|
+
|
|
240
|
+
import http.server, threading
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
244
|
+
protocol_version = "HTTP/1.1"
|
|
245
|
+
|
|
246
|
+
def log_message(self, *args):
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
def do_GET(self):
|
|
250
|
+
body = b"hello"
|
|
251
|
+
self.send_response(200)
|
|
252
|
+
self.send_header("Content-Length", str(len(body)))
|
|
253
|
+
self.end_headers()
|
|
254
|
+
self.wfile.write(body)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
258
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
259
|
+
|
|
260
|
+
from pathapi.http import HTTPClient
|
|
261
|
+
|
|
262
|
+
with HTTPClient() as client:
|
|
263
|
+
reply = client.request("GET", "http://127.0.0.1:%d/f" % server.server_port)
|
|
264
|
+
print(reply.status, reply.read())
|
|
265
|
+
print(sorted(m for m in ("requests", "urllib3") if sys.modules.get(m) is not None))
|
|
266
|
+
server.shutdown()
|
|
267
|
+
server.server_close()
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_a_request_works_with_requests_and_urllib3_hidden_and_the_root_loads_no_ssl():
|
|
272
|
+
env = dict(os.environ)
|
|
273
|
+
env["PYTHONPATH"] = os.pathsep.join(
|
|
274
|
+
filter(None, [str(_SRC), env.get("PYTHONPATH")])
|
|
275
|
+
)
|
|
276
|
+
env.pop(VARIABLE, None)
|
|
277
|
+
result = subprocess.run(
|
|
278
|
+
[sys.executable, "-c", textwrap.dedent(_CHILD)],
|
|
279
|
+
capture_output=True,
|
|
280
|
+
text=True,
|
|
281
|
+
timeout=120,
|
|
282
|
+
env=env,
|
|
283
|
+
)
|
|
284
|
+
assert result.returncode == 0, result.stderr
|
|
285
|
+
assert result.stdout.splitlines() == ["200 b'hello'", "[]"]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""How a failed request becomes the `OSError` a path raises: the status of a
|
|
2
|
+
reply maps to the `pathlib` exception type for its meaning, and the error of a
|
|
3
|
+
transport is raised again without its cause, so that a URL, a header or a
|
|
4
|
+
credential it quotes is in no message or traceback."""
|
|
5
|
+
|
|
6
|
+
import errno
|
|
7
|
+
import traceback
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from pathapi.http import (
|
|
12
|
+
HTTPClient,
|
|
13
|
+
HTTPStatusError,
|
|
14
|
+
os_error,
|
|
15
|
+
raise_for_status,
|
|
16
|
+
server_size,
|
|
17
|
+
translate_errors,
|
|
18
|
+
)
|
|
19
|
+
from transport_fakes import FakeTransport, reply
|
|
20
|
+
from transport_wire import Wire, refusing_port, response
|
|
21
|
+
|
|
22
|
+
SUBJECT = "http://host.test/f"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def formatted(error: BaseException) -> str:
|
|
26
|
+
"""Everything a log line or traceback could show for `error`."""
|
|
27
|
+
return "".join(
|
|
28
|
+
traceback.format_exception(type(error), error, error.__traceback__)
|
|
29
|
+
) + "\n".join((str(error), repr(error)))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def failing_with(status, reason="", **options):
|
|
33
|
+
client = HTTPClient(FakeTransport({("GET", SUBJECT): reply(status, reason=reason)}))
|
|
34
|
+
with translate_errors(SUBJECT, **options):
|
|
35
|
+
raise_for_status(client.request("GET", SUBJECT))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize(
|
|
39
|
+
"status, error, number",
|
|
40
|
+
[
|
|
41
|
+
(404, FileNotFoundError, errno.ENOENT),
|
|
42
|
+
(410, FileNotFoundError, errno.ENOENT),
|
|
43
|
+
(401, PermissionError, errno.EACCES),
|
|
44
|
+
(403, PermissionError, errno.EACCES),
|
|
45
|
+
(409, FileExistsError, errno.EEXIST),
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
def test_a_status_becomes_the_exception_pathlib_raises_for_its_meaning(
|
|
49
|
+
status, error, number
|
|
50
|
+
):
|
|
51
|
+
with pytest.raises(error) as excinfo:
|
|
52
|
+
failing_with(status)
|
|
53
|
+
assert type(excinfo.value) is error
|
|
54
|
+
assert excinfo.value.errno == number
|
|
55
|
+
assert excinfo.value.filename == SUBJECT
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_a_conflict_means_a_missing_parent_for_a_write():
|
|
59
|
+
with pytest.raises(FileNotFoundError):
|
|
60
|
+
failing_with(409, conflict=FileNotFoundError)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.parametrize("status", [405, 501])
|
|
64
|
+
def test_a_method_the_server_does_not_allow_is_a_permission_error_naming_the_status(
|
|
65
|
+
status,
|
|
66
|
+
):
|
|
67
|
+
with pytest.raises(PermissionError) as excinfo:
|
|
68
|
+
failing_with(status)
|
|
69
|
+
assert f"HTTP {status}" in str(excinfo.value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize("status", [400, 500, 502, 503, 429])
|
|
73
|
+
def test_any_other_status_is_oserror_eio_naming_it_and_the_subject(status):
|
|
74
|
+
with pytest.raises(OSError) as excinfo:
|
|
75
|
+
failing_with(status, "Reason")
|
|
76
|
+
assert type(excinfo.value) is OSError
|
|
77
|
+
assert excinfo.value.errno == errno.EIO
|
|
78
|
+
assert f"HTTP Error {status} Reason for {SUBJECT}" in str(excinfo.value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_a_status_error_keeps_the_reply_it_was_raised_for():
|
|
82
|
+
answer = reply(404)
|
|
83
|
+
with pytest.raises(HTTPStatusError) as excinfo:
|
|
84
|
+
raise_for_status(answer)
|
|
85
|
+
assert excinfo.value.response is answer
|
|
86
|
+
raise_for_status(reply(399))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_an_error_is_raised_without_its_cause():
|
|
90
|
+
with pytest.raises(FileNotFoundError) as excinfo:
|
|
91
|
+
failing_with(404)
|
|
92
|
+
assert excinfo.value.__cause__ is None
|
|
93
|
+
assert excinfo.value.__suppress_context__
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize(
|
|
97
|
+
"make, expected",
|
|
98
|
+
[
|
|
99
|
+
(ConnectionError, ConnectionError),
|
|
100
|
+
(ConnectionRefusedError, ConnectionError),
|
|
101
|
+
(TimeoutError, TimeoutError),
|
|
102
|
+
],
|
|
103
|
+
)
|
|
104
|
+
def test_the_error_of_a_transport_never_reaches_the_raised_error(make, expected):
|
|
105
|
+
# A transport's error can quote a proxy URL taken from the environment,
|
|
106
|
+
# credentials included; none of it may be in the error or its traceback.
|
|
107
|
+
quoted = "http://proxyuser:pr0xyS3cret@proxy.example:3128"
|
|
108
|
+
transport = FakeTransport()
|
|
109
|
+
transport.fail = make(f"ProxyError({quoted})")
|
|
110
|
+
with pytest.raises(expected) as excinfo:
|
|
111
|
+
with translate_errors(SUBJECT):
|
|
112
|
+
HTTPClient(transport).request("GET", SUBJECT)
|
|
113
|
+
assert "pr0xyS3cret" not in formatted(excinfo.value)
|
|
114
|
+
assert SUBJECT in str(excinfo.value)
|
|
115
|
+
assert type(make("")).__name__ in str(excinfo.value)
|
|
116
|
+
assert excinfo.value.__cause__ is None
|
|
117
|
+
assert excinfo.value.__suppress_context__
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_a_refused_connection_carries_no_cause(http_transport_kind):
|
|
121
|
+
with HTTPClient() as client:
|
|
122
|
+
with pytest.raises(ConnectionError) as excinfo:
|
|
123
|
+
with translate_errors(SUBJECT):
|
|
124
|
+
client.request("GET", f"http://127.0.0.1:{refusing_port()}/x")
|
|
125
|
+
assert excinfo.value.__cause__ is None
|
|
126
|
+
assert excinfo.value.__suppress_context__
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_a_stalled_server_is_a_timeout_error_without_a_cause(http_transport_kind):
|
|
130
|
+
from transport_wire import Hang
|
|
131
|
+
|
|
132
|
+
with Wire(lambda r: Hang()) as wire:
|
|
133
|
+
with HTTPClient(timeout=0.5) as client:
|
|
134
|
+
with pytest.raises(TimeoutError) as excinfo:
|
|
135
|
+
with translate_errors(SUBJECT):
|
|
136
|
+
client.request("GET", wire.url + "/x")
|
|
137
|
+
assert SUBJECT in str(excinfo.value)
|
|
138
|
+
assert excinfo.value.__cause__ is None
|
|
139
|
+
assert excinfo.value.__suppress_context__
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_an_error_that_is_not_a_request_failure_passes_through():
|
|
143
|
+
with pytest.raises(KeyError):
|
|
144
|
+
with translate_errors(SUBJECT):
|
|
145
|
+
raise KeyError("x")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_os_error_is_built_as_pathlib_builds_it():
|
|
149
|
+
error = os_error(IsADirectoryError, SUBJECT)
|
|
150
|
+
assert (type(error), error.errno, error.filename) == (
|
|
151
|
+
IsADirectoryError,
|
|
152
|
+
errno.EISDIR,
|
|
153
|
+
SUBJECT,
|
|
154
|
+
)
|
|
155
|
+
other = os_error(OSError, SUBJECT)
|
|
156
|
+
assert (other.errno, other.filename) == (errno.EIO, SUBJECT)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.parametrize(
|
|
160
|
+
"text, size",
|
|
161
|
+
[("5", 5), (" 12 ", 12), ("0", 0), (None, None), ("", None)]
|
|
162
|
+
+ [("-1", None), ("1e3", None), ("1.5", None), ("٣", None)]
|
|
163
|
+
+ [("9" * 20, None), ("0x10", None)],
|
|
164
|
+
)
|
|
165
|
+
def test_a_size_a_server_states_is_a_plain_count_or_nothing(text, size):
|
|
166
|
+
assert server_size(text) == size
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_a_reply_status_error_text_has_the_reason_escaped():
|
|
170
|
+
esc = "\x1b[31m"
|
|
171
|
+
with Wire(
|
|
172
|
+
lambda r: (
|
|
173
|
+
f"HTTP/1.1 500 Server {esc}Error\r\nContent-Length: 0\r\n\r\n"
|
|
174
|
+
).encode("latin-1")
|
|
175
|
+
) as wire:
|
|
176
|
+
with HTTPClient() as client:
|
|
177
|
+
with pytest.raises(OSError) as excinfo:
|
|
178
|
+
with translate_errors(SUBJECT):
|
|
179
|
+
raise_for_status(client.request("GET", wire.url + "/x"))
|
|
180
|
+
assert "\x1b" not in str(excinfo.value)
|
|
181
|
+
assert "Server \\x1b[31mError" in str(excinfo.value)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Text from the other end that goes into an exception message: a reason
|
|
2
|
+
phrase, a `Location`. It is shown with its control characters escaped and cut
|
|
3
|
+
to a length, whichever transport sent the request."""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from pathapi.http import HTTPClient, peer_text, raise_for_status, translate_errors
|
|
8
|
+
from transport_wire import Reply, Wire
|
|
9
|
+
|
|
10
|
+
pytestmark = pytest.mark.usefixtures("http_transport_kind")
|
|
11
|
+
|
|
12
|
+
ESC = "\x1b[31m"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def reply_with_reason(reason, status=500):
|
|
16
|
+
raw = f"HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\n\r\n".encode("latin-1")
|
|
17
|
+
return lambda request: Reply(raw)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --- the helper --------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize(
|
|
24
|
+
"text, shown",
|
|
25
|
+
[
|
|
26
|
+
("plain", "plain"),
|
|
27
|
+
("café 日本", "café 日本"),
|
|
28
|
+
("a\x1b[31mb", "a\\x1b[31mb"),
|
|
29
|
+
("a\r\nb\tc\x00d", "a\\r\\nb\\tc\\x00d"),
|
|
30
|
+
("ab", "a\\u202eb"), # a right-to-left override
|
|
31
|
+
("a
b", "a\\u2028b"), # a line separator
|
|
32
|
+
("a\x9bb", "a\\x9bb"), # the C1 control that starts an escape sequence
|
|
33
|
+
],
|
|
34
|
+
)
|
|
35
|
+
def test_peer_text_escapes_what_a_terminal_would_act_on(text, shown):
|
|
36
|
+
assert peer_text(text) == shown
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_peer_text_is_cut_to_its_limit():
|
|
40
|
+
assert peer_text("x" * 500) == "x" * 120 + "..."
|
|
41
|
+
assert peer_text("x" * 20, limit=20) == "x" * 20
|
|
42
|
+
assert peer_text("\x1b" * 500, limit=10) == "\\x1b" * 10 + "..."
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --- where the library shows it --------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_a_reason_phrase_is_shown_escaped():
|
|
49
|
+
with Wire(reply_with_reason(f"Server {ESC}Error")) as wire:
|
|
50
|
+
with HTTPClient() as client:
|
|
51
|
+
with pytest.raises(OSError) as excinfo:
|
|
52
|
+
with translate_errors(wire.url):
|
|
53
|
+
raise_for_status(client.request("GET", wire.url + "/x"))
|
|
54
|
+
message = str(excinfo.value)
|
|
55
|
+
assert "\x1b" not in message
|
|
56
|
+
assert "Server \\x1b[31mError" in message
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_a_long_reason_phrase_is_cut():
|
|
60
|
+
with Wire(reply_with_reason("R" * 20000)) as wire:
|
|
61
|
+
with HTTPClient() as client:
|
|
62
|
+
with pytest.raises(OSError) as excinfo:
|
|
63
|
+
with translate_errors(wire.url):
|
|
64
|
+
raise_for_status(client.request("GET", wire.url + "/x"))
|
|
65
|
+
assert len(str(excinfo.value)) < 400
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_a_redirect_that_is_refused_is_shown_cut_and_with_an_escaped_reason(
|
|
69
|
+
http_transport,
|
|
70
|
+
):
|
|
71
|
+
location = "/" + "a" * 5000
|
|
72
|
+
|
|
73
|
+
def script(request):
|
|
74
|
+
raw = (
|
|
75
|
+
f"HTTP/1.1 302 Found {ESC}\r\nLocation: {location}\r\n"
|
|
76
|
+
"Content-Length: 0\r\n\r\n"
|
|
77
|
+
).encode("latin-1")
|
|
78
|
+
return Reply(raw)
|
|
79
|
+
|
|
80
|
+
with Wire(script) as wire:
|
|
81
|
+
client = HTTPClient(http_transport)
|
|
82
|
+
with pytest.raises(OSError) as excinfo:
|
|
83
|
+
client.request("PUT", wire.url + "/f", data=b"x")
|
|
84
|
+
message = str(excinfo.value)
|
|
85
|
+
assert "\x1b" not in message and "\\x1b[31m" in message
|
|
86
|
+
assert len(message) < 500
|