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.
@@ -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()