diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0fba68e..98a2534 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,12 +13,6 @@ jobs: build: runs-on: windows-latest - env: - # Nuitka stores the downloaded MinGW toolchain and its ccache results - # here; caching this dir is the single biggest CI speedup. Forward - # slashes are fine on Windows for Nuitka. - NUITKA_CACHE_DIR: ${{ github.workspace }}/nuitka-cache - steps: - uses: actions/checkout@v4 @@ -33,11 +27,10 @@ jobs: run: uv python install 3.12 - name: Install dependencies + # Installs the dev group too (PyInstaller), which build.py imports. run: uv sync --all-extras # Caches the native CMake build trees so unchanged C isn't recompiled. - # Marginal next to Nuitka; drop this step if it ever causes a stale - # "compiler changed" CMake error (just bump/clear the key). - name: Cache native build trees uses: actions/cache@v4 with: @@ -57,33 +50,20 @@ jobs: MSBUILDDISABLENODEREUSE: "1" run: ./build_native.ps1 - # ccache + the downloaded MinGW toolchain live under NUITKA_CACHE_DIR. - # The SHA in the key makes every run save a fresh cache; restore-keys - # fall back to the most recent one so ccache stays warm even when - # dependencies change. - - name: Cache Nuitka build cache - uses: actions/cache@v4 - with: - path: ${{ env.NUITKA_CACHE_DIR }} - key: nuitka-${{ runner.os }}-${{ hashFiles('uv.lock') }}-${{ github.sha }} - restore-keys: | - nuitka-${{ runner.os }}-${{ hashFiles('uv.lock') }}- - nuitka-${{ runner.os }}- - - - name: Build with Nuitka + - name: Build with PyInstaller run: uv run python build.py - name: Upload build artifact uses: actions/upload-artifact@v4 with: name: PubStreamer-windows - path: dist/main.dist/ + path: dist/PubStreamer/ if-no-files-found: error # Create a GitHub Release and attach the build when a version tag is pushed. - name: Zip release folder if: startsWith(github.ref, 'refs/tags/') - run: Compress-Archive -Path dist/main.dist/* -DestinationPath dist/PubStreamer-${{ github.ref_name }}-windows.zip + run: Compress-Archive -Path dist/PubStreamer/* -DestinationPath dist/PubStreamer-${{ github.ref_name }}-windows.zip - name: Create release if: startsWith(github.ref, 'refs/tags/') diff --git a/.gitignore b/.gitignore index 9e682d0..3d55faa 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,6 @@ native/**/Makefile /test_*.py /*.wav C*Temptest*.txt -nuitka-crash-report.xml hook_audio.obj *.obj diff --git a/README.md b/README.md index c2767be..3146cbd 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,19 @@ Releases are built on GitHub Actions. Push a `v*` tag to trigger a build and cre git tag v1.0.0 && git push origin v1.0.0 ``` -The workflow uses Nuitka with MinGW on `windows-latest` and produces a standalone `PubStreamer.dist/` folder. MSVC is not used because certain generated C files (pyasn1, requests) exceed its internal heap limit on constrained machines. +The workflow uses PyInstaller on `windows-latest` and produces a one-dir standalone under `dist/PubStreamer/`, with the launcher at `dist/PubStreamer/PubStreamer.exe`. -To build locally, install MinGW and run: +To build locally: ``` uv run python build.py ``` -Set `GITHUB_ACTIONS=true` in the environment or edit `build.py` to pass `--mingw64` explicitly. +Build the native components first (see below) so the audio hook DLLs and `injector32.exe` get bundled. For a build with a console attached (so tracebacks are visible while debugging), pass `--console`: + +``` +uv run python build.py --console +``` ## Audio sources diff --git a/build.py b/build.py index a8a3901..e4b6db7 100644 --- a/build.py +++ b/build.py @@ -1,90 +1,69 @@ -"""Build Pub-Streamer into a standalone Windows executable using Nuitka. +"""Build Pub-Streamer into a standalone Windows app using PyInstaller. Usage (from project root): - uv run python build.py + uv run python build.py # windowed release build (no console) + uv run python build.py --console # debug build with a console for tracebacks -On CI the GITHUB_ACTIONS env var is set automatically, which switches the -compiler to MinGW (GCC) — it handles the large generated C files that cause -MSVC to run out of heap on constrained machines. +Produces a one-dir standalone under dist/PubStreamer/ with the launcher at +dist/PubStreamer/PubStreamer.exe. + +Note: build the native components first (see build_native.ps1) so the audio +hook DLLs and injector32.exe get bundled — LegacyCapture needs them. """ import os -import subprocess -import sys import pathlib +import sys + +import PyInstaller.__main__ ROOT = pathlib.Path(__file__).parent DIST = ROOT / "dist" -LOCALE = ROOT / "locale" -SOUNDS = ROOT / "sounds" +WORK = ROOT / "build" -ON_CI = os.environ.get("GITHUB_ACTIONS") == "true" - -# Data directories to bundle as-is -data_dirs = [] -if LOCALE.exists(): - data_dirs.append(f"--include-data-dir={LOCALE}=locale") -if SOUNDS.exists(): - data_dirs.append(f"--include-data-dir={SOUNDS}=sounds") -native_dir = ROOT / "native" -if native_dir.exists(): - data_dirs.append(f"--include-data-dir={native_dir}=native") -plugins_dir = ROOT / "plugins" -if plugins_dir.exists(): - data_dirs.append(f"--include-data-dir={plugins_dir}=plugins") +CONSOLE = "--console" in sys.argv icon = ROOT / "pubstreamer" / "ui" / "icon.ico" -cmd = [ - sys.executable, "-m", "nuitka", - "--standalone", - "--windows-console-mode=disable", - "--assume-yes-for-downloads", - - # MinGW on CI — avoids MSVC heap exhaustion on large generated C files. - # Remove this line to force MSVC locally if you have enough RAM. - "--mingw64" if ON_CI else "", - - f"--windows-icon-from-ico={icon}" if icon.exists() else "", - - # Compile everything — no frozen-bytecode shortcuts. - "--follow-import-to=pubstreamer", - "--include-package=wx", - "--include-package=numpy", - "--include-package=pedalboard", - "--include-package=win32com", - "--include-package=win32api", - "--include-package=win32con", - "--include-package=pywintypes", - "--include-package=comtypes", - "--include-package=httpx", - "--include-package=httpcore", - "--include-package=certifi", - "--include-package=pyaudiowpatch", - "--include-package=edge_tts", - "--include-package=gtts", - "--include-package=google", - "--include-package=grpc", - "--include-package=pyasn1", - "--include-package=pyasn1_modules", - "--include-package=piper", - "--include-package=onnxruntime", - "--include-package=cffi", - "--include-package=aiohttp", - - f"--output-dir={DIST}", - "--output-filename=PubStreamer", - - *data_dirs, - +args = [ "main.py", + "--name=PubStreamer", + "--noconfirm", + "--onedir", + "--console" if CONSOLE else "--windowed", + f"--distpath={DIST}", + f"--workpath={WORK}", + f"--specpath={WORK}", ] -cmd = [c for c in cmd if c] - -print("Running Nuitka...") -print(" ".join(cmd)) +if icon.exists(): + args.append(f"--icon={icon}") + +# Data directories bundled as-is (srcdest). +for d in ("locale", "sounds", "plugins"): + p = ROOT / d + if p.exists(): + args.append(f"--add-data={p}{os.pathsep}{d}") + +# Native binaries — land at native/dist/ where capture_legacy.py looks for them. +# PyInstaller's --add-data copies these .dll/.exe files as-is. +native_dist = ROOT / "native" / "dist" +for binary in ("audio_hook32.dll", "audio_hook64.dll", "injector32.exe"): + src = native_dist / binary + if src.exists(): + args.append(f"--add-data={src}{os.pathsep}native/dist") + +# Packages that ship data/binaries/submodules PyInstaller's hooks won't +# fully discover on their own. +for pkg in ("onnxruntime", "grpc", "piper", "pedalboard", "edge_tts", "gtts", + "google", "comtypes", "pyaudiowpatch", "aiohttp", "certifi", + "pyasn1", "pyasn1_modules", "httpx", "httpcore"): + args.append(f"--collect-all={pkg}") + +for m in ("win32com", "win32api", "win32con", "pywintypes"): + args.append(f"--hidden-import={m}") + +print("Running PyInstaller...") +print("\n".join(args)) print() - -result = subprocess.run(cmd, cwd=ROOT) -sys.exit(result.returncode) +PyInstaller.__main__.run(args) diff --git a/pyproject.toml b/pyproject.toml index de2b9d5..db9dfad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,5 +23,5 @@ package = false [dependency-groups] dev = [ - "nuitka>=4.1.1", + "pyinstaller>=6.10", ] diff --git a/uv.lock b/uv.lock index e396bc5..427f7db 100644 --- a/uv.lock +++ b/uv.lock @@ -58,6 +58,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + [[package]] name = "anyio" version = "4.13.0" @@ -411,6 +420,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -438,12 +459,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] -[[package]] -name = "nuitka" -version = "4.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d2/a6/8cf2a663402c057005f0328bdc691a653fa14848248358ef051f86a21880/nuitka-4.1.1.tar.gz", hash = "sha256:777e2c84d6b168f9bf78c3242b3779958865ae5815d26c76170eb830995368cc", size = 4558061, upload-time = "2026-05-19T08:44:16.716Z" } - [[package]] name = "numpy" version = "2.4.5" @@ -517,6 +532,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/76/618da9185591a516f65dbc5c7b4631446efaca2af0ff55b7904b47da6bb7/pedalboard-0.9.23-cp312-cp312-win_amd64.whl", hash = "sha256:2a2b9a37a9fe31a2751f121fe53771e56daf850532b9c4bcb6d960d6bbfffb0a", size = 3594700, upload-time = "2026-05-15T23:43:22.164Z" }, ] +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + [[package]] name = "piper-tts" version = "1.4.2" @@ -608,7 +632,7 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "nuitka" }, + { name = "pyinstaller" }, ] [package.metadata] @@ -628,7 +652,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "nuitka", specifier = ">=4.1.1" }] +dev = [{ name = "pyinstaller", specifier = ">=6.10" }] [[package]] name = "pyasn1" @@ -669,6 +693,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pyinstaller" +version = "6.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" }, + { url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" }, + { url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -679,6 +743,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -694,6 +767,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + [[package]] name = "tabulate" version = "0.10.0"