261030.4 · 6182a9aa802e833d238ab04b403b428f42fe37f9
A black-box suite runs the built binary, starting with the 404 coin
make blackbox runs tests/ with pytest and httpx against a server in a throwaway home, on --public and on the form wwwvm's unit uses. Cases are TOML files named after the decision they check.
The coin shows on both forms for a path a served repo lacks. The cache message appears only without --public, for a path that names no repo.
reading declarations
@@ -1,4 +1,4 @@-.PHONY: all build syntax fonts run test bench clean install uninstall deb deb-syntax install-deb uninstall-deb+.PHONY: all build syntax fonts run test blackbox bench clean install uninstall deb deb-syntax install-deb uninstall-deb # WORKING_NAME holds the program's name, provisional until a final one is # chosen. The binary, the package and the paths below take it.@@ -37,6 +37,10 @@ test: go vet ./... go test ./... +# blackbox runs tests/ against the built binary. See doc/testing.md.+blackbox: build+ cd tests && TEST_BIN=$(CURDIR)/build/$(NAME) uv run pytest+ # bench runs the benchmarks in bench_test.go. BENCH picks them by pattern # and COUNT repeats each, eg COUNT=10 for benchstat. BENCH ?= .
@@ -12,7 +12,7 @@ Write new Go tests as tables, with the cases in `testdata/` where they fit. Purp They run the built binary and check what a user sees. They cover what in-process tests cannot reach: flag parsing, the listener, `--public`, process lifecycle and browser pages. The first case is a missing path on `--public`, which shows the 404 coin. -The suite is pytest in `tests/`, with its own `pyproject.toml`, run by `uv run pytest`:+The suite is pytest in `tests/`, with its own `pyproject.toml`. `make blackbox` builds the binary and runs it. Only HTTP cases exist so far; the CLI, browser and process kinds below come with their first case. - CLI: `subprocess` checks stdout, stderr and the exit code apart, including `E{code}` messages - HTTP: `httpx` against a server the suite starts on a free port@@ -21,8 +21,11 @@ The suite is pytest in `tests/`, with its own `pyproject.toml`, run by `uv run p Rules for the suite: -- It reads the binary from `WWW_BIN` and assumes nothing else about it-- A case is a data file: argv with expected stdout and exit code, or a request with the expected page. A parametrized test loads it+- It reads the binary from `TEST_BIN` and assumes nothing else about it+- A case is a TOML file in `tests/cases/`: argv with expected stdout and exit code, or a request with the expected page. A parametrized test loads it+- A case names the server forms it runs on: `public`, the `--public` flag, or `default`, a reader's server, as wwwvm's unit runs it+- A case compares the page without its script and style, since a reader never sees their words+- `tests/harness.py` seeds the cache on disk, since a public server clones only over https from the open internet. It is the one place the suite knows the cache's layout - Name each case after the decision it checks - Make runs repeatable: git repos built with fixed dates, a throwaway `HOME` and `XDG_*`, a free port, no network - Replace volatile parts in expected output before comparing: stardates, timestamps, ports, temp paths
@@ -0,0 +1,2 @@+.venv/+__pycache__/
tests/cases/a-missing-path-s-404-turns-like-a-coin.toml
@@ -0,0 +1,38 @@+# A path a served repo lacks shows the coin, on either form of server.+servers = ["public", "default"]++[[request]]+path = "/forge.invalid/acct/r/nope.md"+status = 404+contains = ['class="coin"', "not found"]+absent = ["cache"]++[[request]]+path = "/forge.invalid/acct/r/dir/nope.go"+status = 404+contains = ['class="coin"']+absent = ["cache"]++[[request]]+path = "/forge.invalid/acct/r/nope/"+status = 404+contains = ['class="coin"']+absent = ["cache"]++[[request]]+path = "/forge.invalid/acct/r/README.md/nope"+status = 404+contains = ['class="coin"']+absent = ["cache"]++[[request]]+path = "/forge.invalid/acct/r/nope.md?log"+status = 404+contains = ['class="coin"']+absent = ["cache"]++[[request]]+path = "/forge.invalid/acct/r/nope.md?raw"+status = 404+contains = ['class="coin"']+absent = ["cache"]
tests/cases/online-repos-open-on-a-second-address.toml
@@ -0,0 +1,14 @@+# Without --public a path outside any repo falls back to the cache's own+# listing. This is the message wwwvm's unit shows, since it runs this form.+servers = ["default"]++[[request]]+path = "/forge.invalid/acct/nope"+status = 404+contains = ["No repo answers at", "listed by the cache", "The cache holds only what was read here"]+absent = ['class="coin"']++[[request]]+path = "/forge.invalid/acct"+status = 200+contains = ["r/", "listed by the cache"]
tests/cases/public-serves-only-online-repos-to-the-open-web.toml
@@ -0,0 +1,13 @@+# A stranger learns nothing of the cache: no word of it, and no repo that+# another visitor read.+servers = ["public"]++[[request]]+path = "/forge.invalid"+status = 502+absent = ["cache", "acct/r"]++[[request]]+path = "/forge.invalid/acct/nope"+status = 502+absent = ["cache"]
@@ -0,0 +1,154 @@+"""Run the binary named by TEST_BIN in a throwaway home and ask it for pages."""++import os+import signal+import socket+import subprocess+import time+from pathlib import Path++import httpx++GIT_DATE = "2026-01-02T03:04:05Z"++# Every repo the seeded cache holds. The host never resolves, so no case+# reaches a network.+SEEDED = {"forge.invalid/acct/r": {"README.md": "# r\n"}}+++def binary():+ path = os.environ.get("TEST_BIN")+ if not path:+ raise RuntimeError("TEST_BIN must name the binary; make blackbox sets it")+ return Path(path).resolve()+++def environment(home):+ """The whole environment of a run: nothing leaks in from the caller's."""+ return {+ "PATH": os.environ["PATH"],+ "HOME": str(home),+ "XDG_CACHE_HOME": str(home / "cache"),+ "XDG_CONFIG_HOME": str(home / "config"),+ "XDG_STATE_HOME": str(home / "state"),+ "GIT_CONFIG_GLOBAL": os.devnull,+ "GIT_CONFIG_NOSYSTEM": "1",+ "TZ": "UTC",+ }+++def git(cwd, *args):+ env = {+ "PATH": os.environ["PATH"],+ "GIT_CONFIG_GLOBAL": os.devnull,+ "GIT_CONFIG_NOSYSTEM": "1",+ "GIT_AUTHOR_NAME": "a",+ "GIT_AUTHOR_EMAIL": "a@forge.invalid",+ "GIT_AUTHOR_DATE": GIT_DATE,+ "GIT_COMMITTER_NAME": "a",+ "GIT_COMMITTER_EMAIL": "a@forge.invalid",+ "GIT_COMMITTER_DATE": GIT_DATE,+ }+ done = subprocess.run(["git", *args], cwd=cwd, env=env, check=True, capture_output=True, text=True)+ return done.stdout.strip()+++def seed(cache, key, files):+ """Lay out one repo as the binary leaves it after a clone.++ A public server fetches only over https from the open internet, so a+ case with no network cannot have it clone. This is the one place the+ suite knows the cache's layout.+ """+ source = cache.parent / "sources" / key+ source.mkdir(parents=True)+ git(source, "init", "-q", "-b", "main")+ for name, text in files.items():+ (source / name).parent.mkdir(parents=True, exist_ok=True)+ (source / name).write_text(text)+ git(source, "add", ".")+ git(source, "commit", "-q", "-m", "first")+ head = git(source, "rev-parse", "HEAD")+ repo = cache / key+ repo.mkdir(parents=True)+ git(repo, "clone", "-q", "--bare", str(source), ".git")+ name = ".wt-" + head[:12]+ git(repo / ".git", "worktree", "add", "-q", "--detach", "--", str(repo / name), head)+ (repo / ".tree").symlink_to(name)+ (repo / ".fetched").touch()+ (repo / ".used").touch()+++def free_port():+ with socket.socket() as s:+ s.bind(("127.0.0.1", 0))+ return s.getsockname()[1]+++class Server:+ """One running binary. origin is where its repos answer."""++ def __init__(self, home, argv, origin, listens):+ self.origin = origin+ self.log = home / "stderr"+ self.proc = subprocess.Popen(+ [str(binary()), *argv],+ cwd=home,+ env=environment(home),+ stdin=subprocess.DEVNULL,+ stdout=subprocess.DEVNULL,+ stderr=self.log.open("w"),+ )+ self._wait(listens)++ def _wait(self, addr):+ end = time.monotonic() + 10+ while time.monotonic() < end:+ if self.proc.poll() is not None:+ raise RuntimeError(f"exit {self.proc.returncode}: {self.log.read_text()}")+ try:+ socket.create_connection(addr, timeout=0.2).close()+ return+ except OSError:+ time.sleep(0.02)+ raise TimeoutError(f"nothing listens on {addr}: {self.log.read_text()}")++ def get(self, path):+ """The page at path once any read it set going has ended."""+ end = time.monotonic() + 30+ while True:+ page = httpx.get(self.origin + path, trust_env=False, timeout=30)+ if page.status_code != 202 or time.monotonic() > end:+ return page+ time.sleep(0.1)++ def stop(self):+ self.proc.send_signal(signal.SIGTERM)+ try:+ self.proc.wait(10)+ except subprocess.TimeoutExpired:+ self.proc.kill()+ self.proc.wait()+++# public: the flag the README names for the open web.+# default: a reader's server, as wwwvm's unit runs it, whose online repos+# answer on the .2 beside the user's default address.+def start(form, home):+ """Start the binary in one of the forms a server runs in."""+ cache = home / "cache" / binary().name+ for key, files in SEEDED.items():+ seed(cache, key, files)+ port = free_port()+ if form == "public":+ return Server(home, ["--public", "--disk=1G", "-a", f"127.0.0.1:{port}"],+ f"http://127.0.0.1:{port}", ("127.0.0.1", port))+ if form == "default":+ n = os.getuid() - 1000+ if not 0 <= n <= 255:+ raise OSError(f"uid {os.getuid()} has no default address")+ tree = home / "tree"+ tree.mkdir()+ return Server(home, ["--foreground", "--no-open", "--no-bug", "--disk=1G", "-a", f"127.31.{n}.1:{port}", str(tree)],+ f"http://127.31.{n}.2:{port}", (f"127.31.{n}.2", port))+ raise ValueError(form)
@@ -0,0 +1,13 @@+[project]+name = "blackbox-tests"+version = "0"+description = "Black-box tests of the binary; see doc/testing.md"+requires-python = ">=3.11"+dependencies = ["httpx==0.28.1", "pytest==8.4.2"]++[tool.uv]+package = false++[tool.pytest.ini_options]+testpaths = ["."]+addopts = "-q -ra -p no:cacheprovider"
@@ -0,0 +1,60 @@+"""Each file in cases/ names the decision it checks and the pages that hold it."""++import re+import shutil+import tempfile+import tomllib+from pathlib import Path++import pytest++import harness++CASES = Path(__file__).parent / "cases"+++def requests():+ for file in sorted(CASES.glob("*.toml")):+ case = tomllib.loads(file.read_text())+ for req in case["request"]:+ for form in req.get("servers", case["servers"]):+ yield pytest.param(form, req, id=f"{file.stem}-{form}-{req['path']}")+++@pytest.fixture(scope="session")+def servers():+ """One server of each form, started when a case first asks for it."""+ running = {}+ homes = []++ def get(form):+ if form not in running:+ home = Path(tempfile.mkdtemp(prefix="blackbox-"))+ homes.append(home)+ try:+ running[form] = harness.start(form, home)+ except OSError as err:+ pytest.skip(f"{form}: {err}")+ return running[form]++ yield get+ for server in running.values():+ server.stop()+ for home in homes:+ shutil.rmtree(home, ignore_errors=True)+++def visible(html):+ """The page without its script and style, whose words a reader never sees."""+ return re.sub(r"<(script|style)\b.*?</\1>", "", html, flags=re.S | re.I)+++@pytest.mark.parametrize("form, req", requests())+def test_page(servers, form, req):+ page = servers(form).get(req["path"])+ text = visible(page.text)+ assert page.status_code == req["status"], text+ for want in req.get("contains", []):+ assert want in text+ for ban in req.get("absent", []):+ assert ban.lower() not in text.lower()
@@ -0,0 +1,156 @@+version = 1+revision = 3+requires-python = ">=3.11"++[[package]]+name = "anyio"+version = "4.15.1"+source = { registry = "https://pypi.org/simple" }+dependencies = [+ { name = "idna" },+ { name = "typing-extensions", marker = "python_full_version < '3.15'" },+]+sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },+]++[[package]]+name = "blackbox-tests"+version = "0"+source = { virtual = "." }+dependencies = [+ { name = "httpx" },+ { name = "pytest" },+]++[package.metadata]+requires-dist = [+ { name = "httpx", specifier = "==0.28.1" },+ { name = "pytest", specifier = "==8.4.2" },+]++[[package]]+name = "certifi"+version = "2026.10.23"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },+]++[[package]]+name = "colorama"+version = "0.4.6"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },+]++[[package]]+name = "h11"+version = "0.16.0"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },+]++[[package]]+name = "httpcore"+version = "1.0.9"+source = { registry = "https://pypi.org/simple" }+dependencies = [+ { name = "certifi" },+ { name = "h11" },+]+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },+]++[[package]]+name = "httpx"+version = "0.28.1"+source = { registry = "https://pypi.org/simple" }+dependencies = [+ { name = "anyio" },+ { name = "certifi" },+ { name = "httpcore" },+ { name = "idna" },+]+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },+]++[[package]]+name = "idna"+version = "3.20"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },+]++[[package]]+name = "iniconfig"+version = "2.3.1"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },+]++[[package]]+name = "packaging"+version = "26.3"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },+]++[[package]]+name = "pluggy"+version = "1.7.0"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },+]++[[package]]+name = "pygments"+version = "2.21.0"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },+]++[[package]]+name = "pytest"+version = "8.4.2"+source = { registry = "https://pypi.org/simple" }+dependencies = [+ { name = "colorama", marker = "sys_platform == 'win32'" },+ { name = "iniconfig" },+ { name = "packaging" },+ { name = "pluggy" },+ { name = "pygments" },+]+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },+]++[[package]]+name = "typing-extensions"+version = "4.16.0"+source = { registry = "https://pypi.org/simple" }+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }+wheels = [+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },+]