requ 0.1.0__tar.gz
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.
- requ-0.1.0/CHANGELOG.md +8 -0
- requ-0.1.0/LICENSE +21 -0
- requ-0.1.0/PKG-INFO +65 -0
- requ-0.1.0/README.md +45 -0
- requ-0.1.0/docs/api.md +97 -0
- requ-0.1.0/examples/local_http.py +36 -0
- requ-0.1.0/pyproject.toml +41 -0
- requ-0.1.0/pyproject.toml.orig +32 -0
- requ-0.1.0/scripts/check_distribution.py +213 -0
- requ-0.1.0/src/requ/__init__.py +434 -0
- requ-0.1.0/src/requ/_pool.py +88 -0
- requ-0.1.0/src/requ/py.typed +0 -0
- requ-0.1.0/tests/test_async.py +57 -0
- requ-0.1.0/tests/test_requ.py +218 -0
- requ-0.1.0/tests/test_transport.py +211 -0
requ-0.1.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Bounded HTTP(S) methods, query pairs, bytes and finite JSON over a client-owned
|
|
6
|
+
HTTP/1.1 pool with explicit lifecycle and asynchronous worker-thread facade.
|
|
7
|
+
- Verified TLS, explicit proxies, isolated redirects, strict framing, transport
|
|
8
|
+
errors and owned buffered responses without automatic retries or decompression.
|
requ-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Outside Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
requ-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: requ
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bounded HTTP(S) requests with client-owned connection pooling
|
|
5
|
+
Author: ellainng
|
|
6
|
+
Author-email: ellainng <299958882+ellainng@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Project-URL: Homepage, https://github.com/wispsy/requ
|
|
17
|
+
Project-URL: Repository, https://github.com/wispsy/requ
|
|
18
|
+
Project-URL: Issues, https://github.com/wispsy/requ/issues
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# requ
|
|
22
|
+
|
|
23
|
+
Explicit, bounded HTTP(S) requests with Python's standard library. Python 3.14+;
|
|
24
|
+
no runtime dependencies.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install requ
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from requ import Client
|
|
32
|
+
|
|
33
|
+
with Client(timeout=10, max_bytes=2_000_000, max_connections=4) as client:
|
|
34
|
+
response = client.post('https://api.example.com/users', json={'name': 'Ash'})
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
user = response.json()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Use `AsyncClient` with `async with` and `await client.request(...)` for asynchronous
|
|
40
|
+
applications. Its I/O runs in worker threads. Canceling an await does not undo or
|
|
41
|
+
stop a dispatched request; the connection remains leased until the worker returns.
|
|
42
|
+
|
|
43
|
+
- General methods, query pairs, bounded bytes/finite JSON and owned responses.
|
|
44
|
+
- Client-owned HTTP/1.1 pool with finite capacity, lease wait and idle expiry.
|
|
45
|
+
- Verified TLS, validated headers, strict framing and bounded redirects.
|
|
46
|
+
- No retries, cookies, automatic decompression or global opener changes.
|
|
47
|
+
- Environment proxies require `trust_env=True`; proxy requests use non-pooled
|
|
48
|
+
urllib transport while retaining the client's concurrency/body/socket bounds.
|
|
49
|
+
|
|
50
|
+
`timeout` bounds individual blocking socket operations. It is **not** a DNS or total
|
|
51
|
+
wall-clock deadline. Direct sockets remain in the pool until expiry or `close()`;
|
|
52
|
+
responses themselves contain only owned bytes. Always use a context or close the
|
|
53
|
+
client when finished. GET/HEAD may follow bounded redirects; mutations return their
|
|
54
|
+
redirect response without replay. Cross-origin redirect opt-in strips caller headers.
|
|
55
|
+
|
|
56
|
+
See [the API reference](docs/api.md) for the precise limits and error contract.
|
|
57
|
+
API resources, credentials and GraphQL belong in a higher-level library such as
|
|
58
|
+
[apiconn](https://github.com/wispsy/apiconn).
|
|
59
|
+
|
|
60
|
+
Development: `uv sync`, then
|
|
61
|
+
`uv run python -W error::ResourceWarning -m unittest discover -s tests -v`.
|
|
62
|
+
Build and independently test both distributions with
|
|
63
|
+
`uv run python scripts/check_distribution.py`.
|
|
64
|
+
|
|
65
|
+
MIT.
|
requ-0.1.0/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# requ
|
|
2
|
+
|
|
3
|
+
Explicit, bounded HTTP(S) requests with Python's standard library. Python 3.14+;
|
|
4
|
+
no runtime dependencies.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install requ
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from requ import Client
|
|
12
|
+
|
|
13
|
+
with Client(timeout=10, max_bytes=2_000_000, max_connections=4) as client:
|
|
14
|
+
response = client.post('https://api.example.com/users', json={'name': 'Ash'})
|
|
15
|
+
response.raise_for_status()
|
|
16
|
+
user = response.json()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Use `AsyncClient` with `async with` and `await client.request(...)` for asynchronous
|
|
20
|
+
applications. Its I/O runs in worker threads. Canceling an await does not undo or
|
|
21
|
+
stop a dispatched request; the connection remains leased until the worker returns.
|
|
22
|
+
|
|
23
|
+
- General methods, query pairs, bounded bytes/finite JSON and owned responses.
|
|
24
|
+
- Client-owned HTTP/1.1 pool with finite capacity, lease wait and idle expiry.
|
|
25
|
+
- Verified TLS, validated headers, strict framing and bounded redirects.
|
|
26
|
+
- No retries, cookies, automatic decompression or global opener changes.
|
|
27
|
+
- Environment proxies require `trust_env=True`; proxy requests use non-pooled
|
|
28
|
+
urllib transport while retaining the client's concurrency/body/socket bounds.
|
|
29
|
+
|
|
30
|
+
`timeout` bounds individual blocking socket operations. It is **not** a DNS or total
|
|
31
|
+
wall-clock deadline. Direct sockets remain in the pool until expiry or `close()`;
|
|
32
|
+
responses themselves contain only owned bytes. Always use a context or close the
|
|
33
|
+
client when finished. GET/HEAD may follow bounded redirects; mutations return their
|
|
34
|
+
redirect response without replay. Cross-origin redirect opt-in strips caller headers.
|
|
35
|
+
|
|
36
|
+
See [the API reference](docs/api.md) for the precise limits and error contract.
|
|
37
|
+
API resources, credentials and GraphQL belong in a higher-level library such as
|
|
38
|
+
[apiconn](https://github.com/wispsy/apiconn).
|
|
39
|
+
|
|
40
|
+
Development: `uv sync`, then
|
|
41
|
+
`uv run python -W error::ResourceWarning -m unittest discover -s tests -v`.
|
|
42
|
+
Build and independently test both distributions with
|
|
43
|
+
`uv run python scripts/check_distribution.py`.
|
|
44
|
+
|
|
45
|
+
MIT.
|
requ-0.1.0/docs/api.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# HTTP client contract
|
|
2
|
+
|
|
3
|
+
`Client` and `AsyncClient` accept these keyword-only configuration fields (the
|
|
4
|
+
synchronous dataclass also accepts positional configuration for compatibility):
|
|
5
|
+
|
|
6
|
+
| Option | Default | Meaning |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| timeout | 10 seconds | Each blocking socket operation; not DNS or total time |
|
|
9
|
+
| max_bytes | 5,000,000 | Maximum buffered response bytes |
|
|
10
|
+
| max_request_bytes | 5,000,000 | Maximum transmitted body bytes |
|
|
11
|
+
| max_connections | 10 | Total concurrent leases/retained direct connections across origins |
|
|
12
|
+
| pool_timeout | 10 seconds | Maximum wait for an available lease |
|
|
13
|
+
| keepalive_expiry | 30 seconds | Idle direct connections expire on the next acquisition |
|
|
14
|
+
| max_redirects | 5 | Maximum GET/HEAD redirects |
|
|
15
|
+
| allow_cross_origin_redirects | False | Opt in, stripping all caller headers |
|
|
16
|
+
| trust_env | False | Opt in to urllib environment/system proxy discovery |
|
|
17
|
+
|
|
18
|
+
Numeric time bounds must be finite and positive; count/byte bounds must be positive
|
|
19
|
+
integers, except `max_redirects` permits zero. Booleans are not accepted as numbers.
|
|
20
|
+
Clients can be shared between threads. Async clients use the same core via
|
|
21
|
+
`asyncio.to_thread`, not native event-loop sockets. Cancellation stops waiting and
|
|
22
|
+
does not roll back mutations or cancel the active worker. Applications needing a
|
|
23
|
+
hard process-level deadline must arrange it outside this client.
|
|
24
|
+
|
|
25
|
+
## Requests
|
|
26
|
+
|
|
27
|
+
`request(method, url, *, headers=None, body=None, json=..., params=None)` accepts
|
|
28
|
+
absolute ASCII HTTP(S) URLs without credentials, whitespace or control characters.
|
|
29
|
+
Fragments are removed. Unicode paths/hosts must already be URL/IDNA encoded.
|
|
30
|
+
`params` uses standard `urlencode(..., doseq=True)` and may be a mapping or ordered
|
|
31
|
+
pairs, preserving repeated keys. CONNECT, TRACE and GET/HEAD bodies are unsupported.
|
|
32
|
+
Conveniences: `get`, `head`, `post`, `put`, `patch`, `delete`.
|
|
33
|
+
|
|
34
|
+
Bodies are immutable bytes or strict finite JSON (`json=None` sends JSON null).
|
|
35
|
+
`body` and `json` are exclusive. JSON uses UTF-8, string object keys, JSON primitive
|
|
36
|
+
values, lists and dictionaries; tuple/custom-value coercion is rejected. Encoding
|
|
37
|
+
occurs before the byte limit is checked, so the caller must also bound input memory.
|
|
38
|
+
`encode_json` exposes the same encoder and `JSONValue` its recursive type.
|
|
39
|
+
|
|
40
|
+
Headers are copied, case-insensitively deduplicated and validated as token names
|
|
41
|
+
and Latin-1 values without controls. The transport owns Host, Connection,
|
|
42
|
+
Content-Length, Transfer-Encoding, Proxy-Authorization, Proxy-Connection,
|
|
43
|
+
Accept-Encoding, Keep-Alive, TE, Trailer, Upgrade and Expect. JSON defaults to
|
|
44
|
+
Content-Type application/json; callers may select a specific JSON media type.
|
|
45
|
+
No cookie store, ambient API credentials or automatic retries are installed.
|
|
46
|
+
|
|
47
|
+
## Redirects, proxies and pooling
|
|
48
|
+
|
|
49
|
+
Only GET/HEAD redirects are followed. Other methods return their 3xx response;
|
|
50
|
+
`raise_for_status()` follows the usual 4xx/5xx convention, so inspect 3xx explicitly
|
|
51
|
+
if your application requires 2xx. HTTPS downgrade, cycles, invalid targets and
|
|
52
|
+
unselected cross-origin redirects fail. Cross-origin opt-in drops **all** caller
|
|
53
|
+
headers permanently for the remaining redirect chain. Automatic decompression is
|
|
54
|
+
absent and non-identity Content-Encoding fails for representation bodies.
|
|
55
|
+
|
|
56
|
+
TLS uses the platform default verified context with HTTP/1.1 ALPN. There is no
|
|
57
|
+
certificate-verification bypass. By default proxies and global urllib openers are
|
|
58
|
+
ignored. `trust_env=True` selects urllib's proxy discovery, CONNECT and proxy auth;
|
|
59
|
+
these requests hold a bounded lease but always close their actual sockets and do
|
|
60
|
+
not pool proxy connections. HTTP proxies can observe unencrypted HTTP traffic.
|
|
61
|
+
URLs are not an SSRF policy; callers select permitted destinations themselves.
|
|
62
|
+
|
|
63
|
+
Direct connections are pooled by scheme, host and port. Only completely consumed,
|
|
64
|
+
validly framed keep-alive responses return connections to the pool. Errors evict
|
|
65
|
+
connections. Idle connections may be evicted for another origin; stale connections
|
|
66
|
+
can fail and are never automatically retried, including safe methods. `close()` is
|
|
67
|
+
idempotent, closes idle sockets, wakes lease waiters and prevents new requests.
|
|
68
|
+
Active leases finish and close on return. Closing does not wait for or cancel them.
|
|
69
|
+
Use `with Client(...)` / `async with AsyncClient(...)`; async close is `aclose()`.
|
|
70
|
+
|
|
71
|
+
## Responses and errors
|
|
72
|
+
|
|
73
|
+
Frozen `Response(url, status, headers, body)` owns immutable bytes and ordered
|
|
74
|
+
header pairs, retaining duplicate response headers. `header(name)` selects the
|
|
75
|
+
first; `headers_all(name)` returns all. `text` / `decode(encoding=None,
|
|
76
|
+
errors='strict')` uses a declared charset or UTF-8, without sniffing. `json()`
|
|
77
|
+
requires UTF-8 finite JSON and rejects duplicate object members. JSON/status errors
|
|
78
|
+
retain no connection lease. Response content type is data; JSON parsing is explicit.
|
|
79
|
+
|
|
80
|
+
Response framing accepts a valid Content-Length or exactly chunked transfer
|
|
81
|
+
encoding, rejects conflicting/invalid lengths, TE+CL ambiguity and unsupported TE,
|
|
82
|
+
and detects truncated bodies. Header line/count bounds come from `http.client`.
|
|
83
|
+
HEAD/204/304 ignore representation length when checking body limits.
|
|
84
|
+
|
|
85
|
+
| Error | Meaning |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| TransportError (OSError) | Connection, TLS, socket or HTTP protocol failure; chained original error |
|
|
88
|
+
| TransportTimeoutError (also TimeoutError) | Socket operation timed out |
|
|
89
|
+
| PoolTimeoutError (TimeoutError) | Lease wait expired; no request was sent |
|
|
90
|
+
| ClientClosedError (RuntimeError) | No new request/lease is accepted |
|
|
91
|
+
| RequestLimitError / ResponseLimitError (ValueError) | Selected byte bound exceeded |
|
|
92
|
+
| RedirectError / ContentEncodingError (ValueError) | Selected redirect/encoding policy failed |
|
|
93
|
+
| HTTPStatusError (RuntimeError) | Explicit raise_for_status rejected 4xx/5xx; `.response` preserved |
|
|
94
|
+
| TypeError / ValueError | Invalid input, framing or JSON; no implicit correction |
|
|
95
|
+
|
|
96
|
+
Transport failure or await cancellation can occur after a mutation reached the
|
|
97
|
+
server. Applications must reconcile state before any explicit retry.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Run a complete request without contacting an external service."""
|
|
2
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
3
|
+
import threading
|
|
4
|
+
from requ import Client
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Handler(BaseHTTPRequestHandler):
|
|
8
|
+
def log_message(self, *args):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
def do_POST(self):
|
|
12
|
+
body = self.rfile.read(int(self.headers['Content-Length']))
|
|
13
|
+
self.send_response(200)
|
|
14
|
+
self.send_header('Content-Type', 'application/json')
|
|
15
|
+
self.send_header('Content-Length', str(len(body)))
|
|
16
|
+
self.end_headers()
|
|
17
|
+
self.wfile.write(body)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
with ThreadingHTTPServer(('127.0.0.1', 0), Handler) as server:
|
|
22
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
23
|
+
thread.start()
|
|
24
|
+
try:
|
|
25
|
+
with Client() as client:
|
|
26
|
+
response = client.post(f'http://127.0.0.1:{server.server_port}/', json={'name': 'Ash'})
|
|
27
|
+
response.raise_for_status()
|
|
28
|
+
assert response.json() == {'name': 'Ash'}
|
|
29
|
+
print(response.json())
|
|
30
|
+
finally:
|
|
31
|
+
server.shutdown()
|
|
32
|
+
thread.join()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == '__main__':
|
|
36
|
+
main()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "requ"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Bounded HTTP(S) requests with client-owned connection pooling"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
dependencies = []
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.14",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[[project.authors]]
|
|
20
|
+
name = "ellainng"
|
|
21
|
+
email = "299958882+ellainng@users.noreply.github.com"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/wispsy/requ"
|
|
25
|
+
Repository = "https://github.com/wispsy/requ"
|
|
26
|
+
Issues = "https://github.com/wispsy/requ/issues"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.13.6,<0.14.0"]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[tool.uv.build-backend]
|
|
33
|
+
source-include = [
|
|
34
|
+
"tests/**",
|
|
35
|
+
"docs/**",
|
|
36
|
+
"scripts/**",
|
|
37
|
+
"examples/**",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "requ"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Bounded HTTP(S) requests with client-owned connection pooling"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "ellainng", email = "299958882+ellainng@users.noreply.github.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.14"
|
|
12
|
+
dependencies = []
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/wispsy/requ"
|
|
24
|
+
Repository = "https://github.com/wispsy/requ"
|
|
25
|
+
Issues = "https://github.com/wispsy/requ/issues"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.13.6,<0.14.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
[tool.uv.build-backend]
|
|
32
|
+
source-include = ["tests/**", "docs/**", "scripts/**", "examples/**", "LICENSE", "README.md", "CHANGELOG.md"]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Build and verify exact distributions and independent installs."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import csv
|
|
6
|
+
from email.parser import BytesParser
|
|
7
|
+
from email.policy import default
|
|
8
|
+
import hashlib
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tarfile
|
|
17
|
+
import tempfile
|
|
18
|
+
import tomllib
|
|
19
|
+
import venv
|
|
20
|
+
import zipfile
|
|
21
|
+
|
|
22
|
+
PACKAGE_FILES = {'requ/__init__.py', 'requ/_pool.py', 'requ/py.typed'}
|
|
23
|
+
TEST_FILES = {'test_requ.py', 'test_transport.py', 'test_async.py'}
|
|
24
|
+
TEST_DATA_FILES = set()
|
|
25
|
+
EXAMPLE_FILES = {'local_http.py'}
|
|
26
|
+
DIAGNOSTIC_FILES = set()
|
|
27
|
+
SOURCE_FILES = ({'README.md', 'LICENSE', 'CHANGELOG.md', 'docs/api.md',
|
|
28
|
+
'scripts/check_distribution.py'}
|
|
29
|
+
| {f'src/{name}' for name in PACKAGE_FILES}
|
|
30
|
+
| {f'tests/{name}' for name in TEST_FILES}
|
|
31
|
+
| {f'examples/{name}' for name in EXAMPLE_FILES})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def require(condition: bool, message: str) -> None:
|
|
35
|
+
if not condition:
|
|
36
|
+
raise RuntimeError(message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(args: list[str], *, cwd: Path, capture: bool = False) -> str:
|
|
40
|
+
env = os.environ.copy()
|
|
41
|
+
env.pop('PYTHONPATH', None)
|
|
42
|
+
env.pop('VIRTUAL_ENV', None)
|
|
43
|
+
result = subprocess.run(args, cwd=cwd, env=env, text=True, check=True,
|
|
44
|
+
stdout=subprocess.PIPE if capture else None)
|
|
45
|
+
return result.stdout if capture else ''
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def wheel_files(path: Path) -> dict[str, bytes]:
|
|
49
|
+
with zipfile.ZipFile(path) as archive:
|
|
50
|
+
entries = [entry for entry in archive.infolist() if not entry.is_dir()]
|
|
51
|
+
names = [entry.filename for entry in entries]
|
|
52
|
+
require(len(names) == len(set(names)), 'duplicate wheel entries')
|
|
53
|
+
return {name: archive.read(name) for name in names}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def source_files(path: Path, prefix: str) -> dict[str, bytes]:
|
|
57
|
+
result: dict[str, bytes] = {}
|
|
58
|
+
with tarfile.open(path, 'r:gz') as archive:
|
|
59
|
+
for member in archive:
|
|
60
|
+
require(not member.issym() and not member.islnk(), 'links are forbidden in sdist')
|
|
61
|
+
require(member.isdir() or member.isfile(), 'unexpected sdist member type')
|
|
62
|
+
if member.isdir():
|
|
63
|
+
continue
|
|
64
|
+
require(member.name.startswith(prefix + '/'), 'unexpected sdist root')
|
|
65
|
+
name = member.name.removeprefix(prefix + '/')
|
|
66
|
+
require(name not in result, 'duplicate sdist member')
|
|
67
|
+
file = archive.extractfile(member)
|
|
68
|
+
require(file is not None, 'unreadable sdist member')
|
|
69
|
+
with file:
|
|
70
|
+
result[name] = file.read()
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def verify_metadata(data: bytes, readme: bytes, version: str) -> None:
|
|
75
|
+
metadata = BytesParser(policy=default).parsebytes(data)
|
|
76
|
+
for key, expected in {'Name': 'requ', 'Version': version,
|
|
77
|
+
'Requires-Python': '>=3.14', 'License-Expression': 'MIT',
|
|
78
|
+
'Description-Content-Type': 'text/markdown'}.items():
|
|
79
|
+
require(metadata.get(key) == expected, f'incorrect metadata: {key}')
|
|
80
|
+
require(not metadata.get_all('Requires-Dist'), 'runtime dependencies must be empty')
|
|
81
|
+
require(metadata.get_all('License-File') == ['LICENSE'], 'incorrect license metadata')
|
|
82
|
+
require('Typing :: Typed' in metadata.get_all('Classifier', []), 'missing typing classifier')
|
|
83
|
+
payload = metadata.get_payload(decode=True)
|
|
84
|
+
require(payload is not None, 'README metadata payload is missing')
|
|
85
|
+
require(payload.decode('utf-8').strip() == readme.decode('utf-8').strip(), 'README metadata differs')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def verify_record(files: dict[str, bytes], dist_info: str) -> None:
|
|
89
|
+
record = f'{dist_info}/RECORD'
|
|
90
|
+
rows = list(csv.reader(io.StringIO(files[record].decode())))
|
|
91
|
+
require(len(rows) == len(files), 'RECORD file count differs')
|
|
92
|
+
require(len({row[0] for row in rows}) == len(rows), 'duplicate RECORD paths')
|
|
93
|
+
require({row[0] for row in rows} == set(files), 'RECORD paths differ')
|
|
94
|
+
for name, digest, size in rows:
|
|
95
|
+
if name == record:
|
|
96
|
+
require(digest == size == '', 'RECORD must not hash itself')
|
|
97
|
+
continue
|
|
98
|
+
expected = base64.urlsafe_b64encode(hashlib.sha256(files[name]).digest()).rstrip(b'=').decode()
|
|
99
|
+
require(digest == f'sha256={expected}', f'RECORD digest differs: {name}')
|
|
100
|
+
require(size == str(len(files[name])), f'RECORD size differs: {name}')
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def verify_install(artifact: Path, sandbox: Path, root: Path, version: str) -> None:
|
|
104
|
+
sandbox.mkdir()
|
|
105
|
+
environment = sandbox / 'environment'
|
|
106
|
+
venv.EnvBuilder(with_pip=False).create(environment)
|
|
107
|
+
python = environment / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python')
|
|
108
|
+
# Source installation may resolve only the already-declared build backend.
|
|
109
|
+
args = ['uv', 'pip', 'install', '--python', str(python), '--no-deps']
|
|
110
|
+
if artifact.suffix == '.whl':
|
|
111
|
+
args.append('--no-index')
|
|
112
|
+
run([*args, str(artifact)], cwd=sandbox)
|
|
113
|
+
probe = (
|
|
114
|
+
'import requ, importlib.metadata, json; '
|
|
115
|
+
'print(json.dumps({"file": requ.__file__, "version": requ.__version__, '
|
|
116
|
+
'"metadata": importlib.metadata.version("requ"), '
|
|
117
|
+
'"requirements": importlib.metadata.requires("requ")}))'
|
|
118
|
+
)
|
|
119
|
+
installed = json.loads(run([str(python), '-I', '-c', probe], cwd=sandbox, capture=True))
|
|
120
|
+
require(Path(installed['file']).resolve().is_relative_to(environment.resolve()),
|
|
121
|
+
'package imported from outside its isolated environment')
|
|
122
|
+
require(installed['version'] == installed['metadata'] == version, 'installed version differs')
|
|
123
|
+
require(installed['requirements'] in (None, []), 'installed runtime requirements are nonempty')
|
|
124
|
+
tests, examples = sandbox / 'tests', sandbox / 'examples'
|
|
125
|
+
tests.mkdir()
|
|
126
|
+
examples.mkdir()
|
|
127
|
+
for name in TEST_FILES:
|
|
128
|
+
shutil.copyfile(root / 'tests' / name, tests / name)
|
|
129
|
+
for name in TEST_DATA_FILES:
|
|
130
|
+
target = tests / name
|
|
131
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
shutil.copyfile(root / 'tests' / name, target)
|
|
133
|
+
for name in EXAMPLE_FILES:
|
|
134
|
+
shutil.copyfile(root / 'examples' / name, examples / name)
|
|
135
|
+
scripts = sandbox / 'scripts'
|
|
136
|
+
scripts.mkdir()
|
|
137
|
+
for name in DIAGNOSTIC_FILES | {'check_distribution.py'}:
|
|
138
|
+
shutil.copyfile(root / 'scripts' / name, scripts / name)
|
|
139
|
+
run([str(python), '-I', '-W', 'error::ResourceWarning', '-m', 'unittest',
|
|
140
|
+
'discover', '-s', str(tests), '-v'], cwd=sandbox)
|
|
141
|
+
for name in sorted(EXAMPLE_FILES):
|
|
142
|
+
run([str(python), '-I', str(examples / name)], cwd=sandbox)
|
|
143
|
+
print(f'Independent install passed: {artifact.name}', flush=True)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main() -> None:
|
|
147
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
148
|
+
parser.add_argument('--tag', default='')
|
|
149
|
+
parser.add_argument('--output-dir', type=Path, default=Path('dist'))
|
|
150
|
+
args = parser.parse_args()
|
|
151
|
+
root = Path(__file__).resolve().parents[1]
|
|
152
|
+
config = tomllib.loads((root / 'pyproject.toml').read_text())
|
|
153
|
+
version = config['project']['version']
|
|
154
|
+
require(not args.tag or args.tag == f'v{version}',
|
|
155
|
+
'release tag must match the package version')
|
|
156
|
+
require(config['project']['dependencies'] == [], 'runtime dependencies must be empty')
|
|
157
|
+
output = args.output_dir.resolve()
|
|
158
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
wheel = output / f'requ-{version}-py3-none-any.whl'
|
|
160
|
+
source = output / f'requ-{version}.tar.gz'
|
|
161
|
+
require({path.name for path in output.iterdir()} <= {wheel.name, source.name, '.gitignore'},
|
|
162
|
+
'output directory contains unexpected files; use an empty build directory')
|
|
163
|
+
run(['uv', 'build', '--sdist', '--wheel', '--no-create-gitignore',
|
|
164
|
+
'--out-dir', str(output), str(root)], cwd=root)
|
|
165
|
+
require(wheel.is_file() and source.is_file(), 'expected distributions were not built')
|
|
166
|
+
files = wheel_files(wheel)
|
|
167
|
+
dist_info = f'requ-{version}.dist-info'
|
|
168
|
+
expected_wheel = PACKAGE_FILES | {f'{dist_info}/{name}' for name in
|
|
169
|
+
('METADATA', 'WHEEL', 'RECORD', 'licenses/LICENSE')}
|
|
170
|
+
require(set(files) == expected_wheel, f'wheel allowlist differs: {set(files) ^ expected_wheel}')
|
|
171
|
+
for name in PACKAGE_FILES:
|
|
172
|
+
require(files[name] == (root / 'src' / name).read_bytes(), f'wheel source differs: {name}')
|
|
173
|
+
require(files[f'{dist_info}/licenses/LICENSE'] == (root / 'LICENSE').read_bytes(), 'wheel license differs')
|
|
174
|
+
wheel_metadata = BytesParser(policy=default).parsebytes(files[f'{dist_info}/WHEEL'])
|
|
175
|
+
require(wheel_metadata.get('Root-Is-Purelib') == 'true', 'wheel must be pure Python')
|
|
176
|
+
require(wheel_metadata.get_all('Tag') == ['py3-none-any'], 'unexpected wheel tag')
|
|
177
|
+
verify_metadata(files[f'{dist_info}/METADATA'], (root / 'README.md').read_bytes(), version)
|
|
178
|
+
verify_record(files, dist_info)
|
|
179
|
+
archived = source_files(source, f'requ-{version}')
|
|
180
|
+
expected_source = SOURCE_FILES | {'PKG-INFO', 'pyproject.toml', 'pyproject.toml.orig'}
|
|
181
|
+
require(set(archived) == expected_source, f'sdist allowlist differs: {set(archived) ^ expected_source}')
|
|
182
|
+
for name in SOURCE_FILES:
|
|
183
|
+
require(archived[name] == (root / name).read_bytes(), f'sdist source differs: {name}')
|
|
184
|
+
require(archived['pyproject.toml.orig'] == (root / 'pyproject.toml').read_bytes(), 'original configuration differs')
|
|
185
|
+
require(tomllib.loads(archived['pyproject.toml'].decode()) == config, 'sdist configuration semantics differ')
|
|
186
|
+
require(archived['PKG-INFO'] == files[f'{dist_info}/METADATA'], 'sdist and wheel metadata differ')
|
|
187
|
+
with tempfile.TemporaryDirectory(prefix='requ-distributions-') as temporary:
|
|
188
|
+
sandbox = Path(temporary)
|
|
189
|
+
rebuild = sandbox / 'rebuild'
|
|
190
|
+
run(['uv', 'build', '--wheel', '--no-create-gitignore', '--out-dir', str(rebuild), str(source)], cwd=sandbox)
|
|
191
|
+
rebuilt = wheel_files(rebuild / wheel.name)
|
|
192
|
+
require(set(rebuilt) == expected_wheel, 'source rebuild allowlist differs')
|
|
193
|
+
build_headers = {f'{dist_info}/WHEEL', f'{dist_info}/RECORD'}
|
|
194
|
+
require({name: data for name, data in rebuilt.items() if name not in build_headers}
|
|
195
|
+
== {name: data for name, data in files.items() if name not in build_headers},
|
|
196
|
+
'source rebuild package, license or metadata bytes differ')
|
|
197
|
+
rebuilt_metadata = BytesParser(policy=default).parsebytes(rebuilt[f'{dist_info}/WHEEL'])
|
|
198
|
+
# The declared backend range can resolve a newer uv_build than the
|
|
199
|
+
# invoking uv binary. Only the build-tool Generator header may differ.
|
|
200
|
+
require([(key, value) for key, value in rebuilt_metadata.items() if key != 'Generator']
|
|
201
|
+
== [(key, value) for key, value in wheel_metadata.items() if key != 'Generator'],
|
|
202
|
+
'source rebuild wheel compatibility metadata differs')
|
|
203
|
+
require(len(rebuilt_metadata.get_all('Generator', [])) == 1, 'missing build generator')
|
|
204
|
+
verify_record(rebuilt, dist_info)
|
|
205
|
+
verify_install(wheel, sandbox / 'wheel-install', root, version)
|
|
206
|
+
verify_install(source, sandbox / 'source-install', root, version)
|
|
207
|
+
print('Exact archives, metadata, RECORD, rebuild parity, installed tests and examples passed.')
|
|
208
|
+
for artifact in (wheel, source):
|
|
209
|
+
print(f'{hashlib.sha256(artifact.read_bytes()).hexdigest()} {artifact.name}')
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == '__main__':
|
|
213
|
+
main()
|