audioreconstructor 1.0.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.
- audioreconstructor-1.0.0/.gitignore +31 -0
- audioreconstructor-1.0.0/PKG-INFO +78 -0
- audioreconstructor-1.0.0/README.md +54 -0
- audioreconstructor-1.0.0/pyproject.toml +40 -0
- audioreconstructor-1.0.0/src/audioreconstructor/__init__.py +3 -0
- audioreconstructor-1.0.0/src/audioreconstructor/__main__.py +5 -0
- audioreconstructor-1.0.0/src/audioreconstructor/cli.py +442 -0
- audioreconstructor-1.0.0/tests/test_cli.py +196 -0
- audioreconstructor-1.0.0/tools/generate_manifest.py +50 -0
- audioreconstructor-1.0.0/uv.lock +8 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# dataset files
|
|
2
|
+
data/
|
|
3
|
+
|
|
4
|
+
# Python-generated files
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[oc]
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
wheels/
|
|
10
|
+
*.egg-info
|
|
11
|
+
|
|
12
|
+
# Virtual environments
|
|
13
|
+
.venv
|
|
14
|
+
|
|
15
|
+
# outputfiles
|
|
16
|
+
output/
|
|
17
|
+
|
|
18
|
+
# model checkpoints
|
|
19
|
+
model/checkpoints/*
|
|
20
|
+
!model/checkpoints/best/*
|
|
21
|
+
!model/checkpoints/config.json
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
media/recordings/*
|
|
25
|
+
!media/recordings/*.gif
|
|
26
|
+
|
|
27
|
+
design/index.html
|
|
28
|
+
|
|
29
|
+
.claude/skills/impeccable/scripts
|
|
30
|
+
.github/skills/impeccable/scripts
|
|
31
|
+
.opencode/skills/impeccable/scripts/
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: audioreconstructor
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Command-line audio reconstruction powered by the Audioreconstruction ONNX model
|
|
5
|
+
Project-URL: Homepage, https://github.com/rohan-prasen/audioreconstruction
|
|
6
|
+
Project-URL: Repository, https://github.com/rohan-prasen/audioreconstruction
|
|
7
|
+
Project-URL: Issues, https://github.com/rohan-prasen/audioreconstruction/issues
|
|
8
|
+
Author-email: Rohan Prasen Kedari <rohanprasenkedari@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: audio,cli,onnx,super-resolution
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# audioreconstructor
|
|
26
|
+
|
|
27
|
+
`audioreconstructor` is the command-line release of Audioreconstruction's ONNX
|
|
28
|
+
audio enhancer. It installs a small Python launcher; the ONNX model and the native
|
|
29
|
+
runtime are downloaded only when you explicitly run setup.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install audioreconstructor
|
|
35
|
+
audioreconstructor --setup
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`--setup` downloads the native executable, `model.onnx`, and `config.json` from the
|
|
39
|
+
GitHub Release matching the installed package version. It verifies SHA-256 hashes
|
|
40
|
+
before making them available locally.
|
|
41
|
+
|
|
42
|
+
The current release supports 64-bit Linux and 64-bit Windows.
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
audioreconstructor --input song.mp3 --output song_enhanced.flac
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Choose the ONNX execution provider when needed:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
audioreconstructor --input song.mp3 --output song_enhanced.flac --provider cpu
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`auto` is the default. Windows first tries DirectML and falls back to CPU; Linux uses
|
|
57
|
+
CPU. The native executable reads supported audio through libsndfile and always writes
|
|
58
|
+
FLAC output.
|
|
59
|
+
|
|
60
|
+
## Verify installation
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
audioreconstructor --doctor
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Doctor verifies all cached release assets and runs an actual synthetic-audio ONNX
|
|
67
|
+
inference test. A healthy installation exits with status `0`.
|
|
68
|
+
|
|
69
|
+
## Cache locations
|
|
70
|
+
|
|
71
|
+
Setup prints the exact locations it uses. By default they are:
|
|
72
|
+
|
|
73
|
+
- Linux: `$XDG_CACHE_HOME/audioreconstructor/<version>` or
|
|
74
|
+
`~/.cache/audioreconstructor/<version>`
|
|
75
|
+
- Windows: `%LOCALAPPDATA%\\audioreconstructor\\Cache\\<version>`
|
|
76
|
+
|
|
77
|
+
Running setup for an upgraded package version removes older cached versions after the
|
|
78
|
+
new version has been fully verified.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# audioreconstructor
|
|
2
|
+
|
|
3
|
+
`audioreconstructor` is the command-line release of Audioreconstruction's ONNX
|
|
4
|
+
audio enhancer. It installs a small Python launcher; the ONNX model and the native
|
|
5
|
+
runtime are downloaded only when you explicitly run setup.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install audioreconstructor
|
|
11
|
+
audioreconstructor --setup
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`--setup` downloads the native executable, `model.onnx`, and `config.json` from the
|
|
15
|
+
GitHub Release matching the installed package version. It verifies SHA-256 hashes
|
|
16
|
+
before making them available locally.
|
|
17
|
+
|
|
18
|
+
The current release supports 64-bit Linux and 64-bit Windows.
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
audioreconstructor --input song.mp3 --output song_enhanced.flac
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Choose the ONNX execution provider when needed:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
audioreconstructor --input song.mp3 --output song_enhanced.flac --provider cpu
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`auto` is the default. Windows first tries DirectML and falls back to CPU; Linux uses
|
|
33
|
+
CPU. The native executable reads supported audio through libsndfile and always writes
|
|
34
|
+
FLAC output.
|
|
35
|
+
|
|
36
|
+
## Verify installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
audioreconstructor --doctor
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Doctor verifies all cached release assets and runs an actual synthetic-audio ONNX
|
|
43
|
+
inference test. A healthy installation exits with status `0`.
|
|
44
|
+
|
|
45
|
+
## Cache locations
|
|
46
|
+
|
|
47
|
+
Setup prints the exact locations it uses. By default they are:
|
|
48
|
+
|
|
49
|
+
- Linux: `$XDG_CACHE_HOME/audioreconstructor/<version>` or
|
|
50
|
+
`~/.cache/audioreconstructor/<version>`
|
|
51
|
+
- Windows: `%LOCALAPPDATA%\\audioreconstructor\\Cache\\<version>`
|
|
52
|
+
|
|
53
|
+
Running setup for an upgraded package version removes older cached versions after the
|
|
54
|
+
new version has been fully verified.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "audioreconstructor"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Command-line audio reconstruction powered by the Audioreconstruction ONNX model"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Rohan Prasen Kedari", email = "rohanprasenkedari@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: Microsoft :: Windows",
|
|
20
|
+
"Operating System :: POSIX :: Linux",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
27
|
+
]
|
|
28
|
+
keywords = ["audio", "onnx", "super-resolution", "cli"]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/rohan-prasen/audioreconstruction"
|
|
33
|
+
Repository = "https://github.com/rohan-prasen/audioreconstruction"
|
|
34
|
+
Issues = "https://github.com/rohan-prasen/audioreconstruction/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
audioreconstructor = "audioreconstructor.cli:main"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/audioreconstructor"]
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Install and invoke versioned Audioreconstructor GitHub Release assets."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version as installed_version
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Callable, Mapping, Sequence
|
|
19
|
+
from urllib.error import HTTPError, URLError
|
|
20
|
+
from urllib.parse import quote
|
|
21
|
+
from urllib.request import Request, urlopen
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
|
|
25
|
+
PACKAGE_NAME = "audioreconstructor"
|
|
26
|
+
REPOSITORY = "rohan-prasen/audioreconstruction"
|
|
27
|
+
MANIFEST_NAME = "manifest.json"
|
|
28
|
+
MODEL_NAME = "model.onnx"
|
|
29
|
+
CONFIG_NAME = "config.json"
|
|
30
|
+
DOWNLOAD_TIMEOUT_SECONDS = 30
|
|
31
|
+
SELF_TEST_TIMEOUT_SECONDS = 600
|
|
32
|
+
CHUNK_SIZE = 1024 * 1024
|
|
33
|
+
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
34
|
+
VERSION_DIR_RE = re.compile(r"^\d+(?:\.\d+)+(?:[a-zA-Z0-9._-]+)?$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CliError(Exception):
|
|
38
|
+
"""An expected CLI failure that should be rendered without a traceback."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Target:
|
|
43
|
+
system: str
|
|
44
|
+
architecture: str
|
|
45
|
+
asset_name: str
|
|
46
|
+
executable_name: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Artifact:
|
|
51
|
+
name: str
|
|
52
|
+
sha256: str
|
|
53
|
+
size: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_package_version() -> str:
|
|
57
|
+
try:
|
|
58
|
+
return installed_version(PACKAGE_NAME)
|
|
59
|
+
except PackageNotFoundError:
|
|
60
|
+
return __version__
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def release_tag(version: str) -> str:
|
|
64
|
+
return f"audioreconstructor-v{version}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def release_url(version: str, asset_name: str) -> str:
|
|
68
|
+
tag = quote(release_tag(version), safe="")
|
|
69
|
+
asset = quote(asset_name, safe="")
|
|
70
|
+
return f"https://github.com/{REPOSITORY}/releases/download/{tag}/{asset}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def detect_target(system: str | None = None, machine: str | None = None) -> Target:
|
|
74
|
+
system = system or platform.system()
|
|
75
|
+
machine = (machine or platform.machine()).lower()
|
|
76
|
+
if machine not in {"x86_64", "amd64"}:
|
|
77
|
+
raise CliError(f"unsupported architecture: {machine}. Only x86-64 is supported.")
|
|
78
|
+
if system == "Linux":
|
|
79
|
+
return Target(system, "x86_64", "audioreconstructor-linux-x86_64", "audioreconstructor")
|
|
80
|
+
if system == "Windows":
|
|
81
|
+
return Target(system, "x86_64", "audioreconstructor-windows-x86_64.exe", "audioreconstructor.exe")
|
|
82
|
+
raise CliError(f"unsupported operating system: {system}. Only Linux and Windows are supported.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_cache_root(
|
|
86
|
+
system: str,
|
|
87
|
+
environ: Mapping[str, str] | None = None,
|
|
88
|
+
home: Path | None = None,
|
|
89
|
+
) -> Path:
|
|
90
|
+
environ = os.environ if environ is None else environ
|
|
91
|
+
home = Path.home() if home is None else home
|
|
92
|
+
if system == "Linux":
|
|
93
|
+
return Path(environ["XDG_CACHE_HOME"]) / PACKAGE_NAME if environ.get("XDG_CACHE_HOME") else home / ".cache" / PACKAGE_NAME
|
|
94
|
+
if system == "Windows":
|
|
95
|
+
local_app_data = environ.get("LOCALAPPDATA")
|
|
96
|
+
base = Path(local_app_data) if local_app_data else home / "AppData" / "Local"
|
|
97
|
+
return base / PACKAGE_NAME / "Cache"
|
|
98
|
+
raise CliError(f"unsupported operating system: {system}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def installation_paths(cache_root: Path, version: str, target: Target) -> dict[str, Path]:
|
|
102
|
+
directory = cache_root / version
|
|
103
|
+
return {
|
|
104
|
+
"directory": directory,
|
|
105
|
+
"manifest": directory / MANIFEST_NAME,
|
|
106
|
+
"binary": directory / target.executable_name,
|
|
107
|
+
"model": directory / MODEL_NAME,
|
|
108
|
+
"config": directory / CONFIG_NAME,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _hash_file(path: Path) -> tuple[str, int]:
|
|
113
|
+
digest = hashlib.sha256()
|
|
114
|
+
size = 0
|
|
115
|
+
with path.open("rb") as handle:
|
|
116
|
+
while data := handle.read(CHUNK_SIZE):
|
|
117
|
+
digest.update(data)
|
|
118
|
+
size += len(data)
|
|
119
|
+
return digest.hexdigest(), size
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def artifact_is_valid(path: Path, artifact: Artifact) -> tuple[bool, str]:
|
|
123
|
+
if not path.is_file():
|
|
124
|
+
return False, "missing"
|
|
125
|
+
try:
|
|
126
|
+
digest, size = _hash_file(path)
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
return False, str(exc)
|
|
129
|
+
if size != artifact.size:
|
|
130
|
+
return False, f"size is {size} bytes; expected {artifact.size}"
|
|
131
|
+
if digest != artifact.sha256:
|
|
132
|
+
return False, "SHA-256 mismatch"
|
|
133
|
+
return True, "valid"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read_manifest(path: Path, package_version: str, required_assets: Sequence[str]) -> dict[str, Artifact]:
|
|
137
|
+
try:
|
|
138
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
139
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
140
|
+
raise CliError(f"could not read {MANIFEST_NAME}: {exc}") from exc
|
|
141
|
+
|
|
142
|
+
if raw.get("schemaVersion") != 1:
|
|
143
|
+
raise CliError(f"{MANIFEST_NAME} has an unsupported schema version")
|
|
144
|
+
if raw.get("version") != package_version:
|
|
145
|
+
raise CliError(f"{MANIFEST_NAME} is for version {raw.get('version')!r}, expected {package_version!r}")
|
|
146
|
+
if raw.get("releaseTag") != release_tag(package_version):
|
|
147
|
+
raise CliError(f"{MANIFEST_NAME} does not match release {release_tag(package_version)}")
|
|
148
|
+
files = raw.get("files")
|
|
149
|
+
if not isinstance(files, dict):
|
|
150
|
+
raise CliError(f"{MANIFEST_NAME} has no files mapping")
|
|
151
|
+
|
|
152
|
+
artifacts: dict[str, Artifact] = {}
|
|
153
|
+
for name in required_assets:
|
|
154
|
+
item = files.get(name)
|
|
155
|
+
if not isinstance(item, dict):
|
|
156
|
+
raise CliError(f"{MANIFEST_NAME} is missing metadata for {name}")
|
|
157
|
+
digest = item.get("sha256")
|
|
158
|
+
size = item.get("bytes")
|
|
159
|
+
if not isinstance(digest, str) or not SHA256_RE.fullmatch(digest):
|
|
160
|
+
raise CliError(f"{MANIFEST_NAME} has an invalid SHA-256 for {name}")
|
|
161
|
+
if not isinstance(size, int) or size <= 0:
|
|
162
|
+
raise CliError(f"{MANIFEST_NAME} has an invalid byte count for {name}")
|
|
163
|
+
artifacts[name] = Artifact(name=name, sha256=digest, size=size)
|
|
164
|
+
return artifacts
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _temporary_path(directory: Path, filename: str) -> Path:
|
|
168
|
+
handle = tempfile.NamedTemporaryFile(prefix=f".{filename}.", suffix=".part", dir=directory, delete=False)
|
|
169
|
+
handle.close()
|
|
170
|
+
return Path(handle.name)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def download(url: str, destination: Path, expected: Artifact | None = None) -> None:
|
|
174
|
+
"""Download one release asset and validate it before returning."""
|
|
175
|
+
request = Request(url, headers={"User-Agent": f"{PACKAGE_NAME}/{get_package_version()}"})
|
|
176
|
+
try:
|
|
177
|
+
with urlopen(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response, destination.open("wb") as handle:
|
|
178
|
+
digest = hashlib.sha256()
|
|
179
|
+
size = 0
|
|
180
|
+
while data := response.read(CHUNK_SIZE):
|
|
181
|
+
handle.write(data)
|
|
182
|
+
digest.update(data)
|
|
183
|
+
size += len(data)
|
|
184
|
+
except HTTPError as exc:
|
|
185
|
+
raise CliError(f"download failed ({exc.code}) for {url}") from exc
|
|
186
|
+
except URLError as exc:
|
|
187
|
+
raise CliError(f"download failed for {url}: {exc.reason}") from exc
|
|
188
|
+
except OSError as exc:
|
|
189
|
+
raise CliError(f"could not save {destination.name}: {exc}") from exc
|
|
190
|
+
|
|
191
|
+
if expected is not None:
|
|
192
|
+
if size != expected.size:
|
|
193
|
+
raise CliError(f"downloaded {expected.name} has {size} bytes; expected {expected.size}")
|
|
194
|
+
if digest.hexdigest() != expected.sha256:
|
|
195
|
+
raise CliError(f"downloaded {expected.name} failed SHA-256 verification")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _make_executable(path: Path, target: Target) -> None:
|
|
199
|
+
if target.system != "Linux":
|
|
200
|
+
return
|
|
201
|
+
try:
|
|
202
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
raise CliError(f"could not make {path.name} executable: {exc}") from exc
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _load_or_download_manifest(paths: Mapping[str, Path], version: str, required_assets: Sequence[str]) -> dict[str, Artifact]:
|
|
208
|
+
manifest = paths["manifest"]
|
|
209
|
+
try:
|
|
210
|
+
return _read_manifest(manifest, version, required_assets)
|
|
211
|
+
except CliError:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
temporary = _temporary_path(paths["directory"], MANIFEST_NAME)
|
|
215
|
+
try:
|
|
216
|
+
download(release_url(version, MANIFEST_NAME), temporary)
|
|
217
|
+
artifacts = _read_manifest(temporary, version, required_assets)
|
|
218
|
+
os.replace(temporary, manifest)
|
|
219
|
+
return artifacts
|
|
220
|
+
finally:
|
|
221
|
+
temporary.unlink(missing_ok=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _prune_old_versions(cache_root: Path, current_version: str, report: Callable[[str], None]) -> None:
|
|
225
|
+
try:
|
|
226
|
+
children = list(cache_root.iterdir())
|
|
227
|
+
except OSError:
|
|
228
|
+
return
|
|
229
|
+
for child in children:
|
|
230
|
+
if child.name == current_version or not VERSION_DIR_RE.fullmatch(child.name):
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
if child.is_symlink():
|
|
234
|
+
child.unlink()
|
|
235
|
+
elif child.is_dir():
|
|
236
|
+
shutil.rmtree(child)
|
|
237
|
+
except OSError as exc:
|
|
238
|
+
report(f"Warning: could not remove old cache {child}: {exc}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def setup_assets(
|
|
242
|
+
*,
|
|
243
|
+
package_version: str | None = None,
|
|
244
|
+
target: Target | None = None,
|
|
245
|
+
cache_root: Path | None = None,
|
|
246
|
+
report: Callable[[str], None] = print,
|
|
247
|
+
) -> dict[str, Path]:
|
|
248
|
+
"""Install the current package version's native assets into the user cache."""
|
|
249
|
+
package_version = package_version or get_package_version()
|
|
250
|
+
target = target or detect_target()
|
|
251
|
+
cache_root = cache_root or get_cache_root(target.system)
|
|
252
|
+
paths = installation_paths(cache_root, package_version, target)
|
|
253
|
+
paths["directory"].mkdir(parents=True, exist_ok=True)
|
|
254
|
+
|
|
255
|
+
required_assets = (target.asset_name, MODEL_NAME, CONFIG_NAME)
|
|
256
|
+
artifacts = _load_or_download_manifest(paths, package_version, required_assets)
|
|
257
|
+
path_by_asset = {
|
|
258
|
+
target.asset_name: paths["binary"],
|
|
259
|
+
MODEL_NAME: paths["model"],
|
|
260
|
+
CONFIG_NAME: paths["config"],
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for asset_name in required_assets:
|
|
264
|
+
destination = path_by_asset[asset_name]
|
|
265
|
+
valid, _ = artifact_is_valid(destination, artifacts[asset_name])
|
|
266
|
+
if valid:
|
|
267
|
+
report(f"Using cached {asset_name}")
|
|
268
|
+
else:
|
|
269
|
+
report(f"Downloading {asset_name}")
|
|
270
|
+
temporary = _temporary_path(paths["directory"], asset_name)
|
|
271
|
+
try:
|
|
272
|
+
download(release_url(package_version, asset_name), temporary, artifacts[asset_name])
|
|
273
|
+
if asset_name == target.asset_name:
|
|
274
|
+
_make_executable(temporary, target)
|
|
275
|
+
os.replace(temporary, destination)
|
|
276
|
+
finally:
|
|
277
|
+
temporary.unlink(missing_ok=True)
|
|
278
|
+
if asset_name == target.asset_name:
|
|
279
|
+
_make_executable(destination, target)
|
|
280
|
+
|
|
281
|
+
_prune_old_versions(cache_root, package_version, report)
|
|
282
|
+
report("Setup complete.")
|
|
283
|
+
report(f"Cache: {paths['directory']}")
|
|
284
|
+
report(f"Binary: {paths['binary']}")
|
|
285
|
+
report(f"Model: {paths['model']}")
|
|
286
|
+
report(f"Config: {paths['config']}")
|
|
287
|
+
return paths
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _self_test(paths: Mapping[str, Path]) -> tuple[bool, str]:
|
|
291
|
+
command = [
|
|
292
|
+
str(paths["binary"]),
|
|
293
|
+
"--model",
|
|
294
|
+
str(paths["model"]),
|
|
295
|
+
"--config",
|
|
296
|
+
str(paths["config"]),
|
|
297
|
+
"--provider",
|
|
298
|
+
"auto",
|
|
299
|
+
"--self-test",
|
|
300
|
+
]
|
|
301
|
+
try:
|
|
302
|
+
result = subprocess.run(command, capture_output=True, text=True, timeout=SELF_TEST_TIMEOUT_SECONDS, check=False)
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
return False, str(exc)
|
|
305
|
+
except subprocess.TimeoutExpired:
|
|
306
|
+
return False, f"timed out after {SELF_TEST_TIMEOUT_SECONDS} seconds"
|
|
307
|
+
if result.returncode == 0 and "SELF-TEST PASS" in result.stdout:
|
|
308
|
+
return True, "passed"
|
|
309
|
+
detail = (result.stderr or result.stdout).strip().splitlines()
|
|
310
|
+
return False, detail[-1] if detail else f"exited with code {result.returncode}"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def doctor(
|
|
314
|
+
*,
|
|
315
|
+
package_version: str | None = None,
|
|
316
|
+
target: Target | None = None,
|
|
317
|
+
cache_root: Path | None = None,
|
|
318
|
+
report: Callable[[str], None] = print,
|
|
319
|
+
) -> int:
|
|
320
|
+
package_version = package_version or get_package_version()
|
|
321
|
+
try:
|
|
322
|
+
target = target or detect_target()
|
|
323
|
+
except CliError as exc:
|
|
324
|
+
report(f"[FAIL] Platform: {exc}")
|
|
325
|
+
report("Status: UNHEALTHY")
|
|
326
|
+
return 1
|
|
327
|
+
cache_root = cache_root or get_cache_root(target.system)
|
|
328
|
+
paths = installation_paths(cache_root, package_version, target)
|
|
329
|
+
required_assets = (target.asset_name, MODEL_NAME, CONFIG_NAME)
|
|
330
|
+
report(f"Version: {package_version}")
|
|
331
|
+
report(f"Platform: {target.system} {target.architecture}")
|
|
332
|
+
report(f"Cache: {paths['directory']}")
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
artifacts = _read_manifest(paths["manifest"], package_version, required_assets)
|
|
336
|
+
except CliError as exc:
|
|
337
|
+
report(f"[FAIL] Manifest: {exc}")
|
|
338
|
+
report("Status: UNHEALTHY")
|
|
339
|
+
return 1
|
|
340
|
+
report("[PASS] Manifest")
|
|
341
|
+
|
|
342
|
+
path_by_asset = {
|
|
343
|
+
target.asset_name: paths["binary"],
|
|
344
|
+
MODEL_NAME: paths["model"],
|
|
345
|
+
CONFIG_NAME: paths["config"],
|
|
346
|
+
}
|
|
347
|
+
healthy = True
|
|
348
|
+
for asset_name in required_assets:
|
|
349
|
+
valid, detail = artifact_is_valid(path_by_asset[asset_name], artifacts[asset_name])
|
|
350
|
+
label = "PASS" if valid else "FAIL"
|
|
351
|
+
report(f"[{label}] {asset_name}: {detail}")
|
|
352
|
+
healthy = healthy and valid
|
|
353
|
+
|
|
354
|
+
if target.system == "Linux":
|
|
355
|
+
executable = paths["binary"].is_file() and os.access(paths["binary"], os.X_OK)
|
|
356
|
+
report(f"[{'PASS' if executable else 'FAIL'}] Binary execute permission")
|
|
357
|
+
healthy = healthy and executable
|
|
358
|
+
|
|
359
|
+
if healthy:
|
|
360
|
+
report("[PASS] Bundled runtime dependencies")
|
|
361
|
+
passed, detail = _self_test(paths)
|
|
362
|
+
report(f"[{'PASS' if passed else 'FAIL'}] Runtime self-test: {detail}")
|
|
363
|
+
healthy = healthy and passed
|
|
364
|
+
else:
|
|
365
|
+
report("[SKIP] Runtime self-test: setup is incomplete")
|
|
366
|
+
|
|
367
|
+
report(f"Status: {'HEALTHY' if healthy else 'UNHEALTHY'}")
|
|
368
|
+
return 0 if healthy else 1
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def run_inference(
|
|
372
|
+
input_path: Path,
|
|
373
|
+
output_path: Path,
|
|
374
|
+
provider: str,
|
|
375
|
+
*,
|
|
376
|
+
package_version: str | None = None,
|
|
377
|
+
target: Target | None = None,
|
|
378
|
+
cache_root: Path | None = None,
|
|
379
|
+
) -> int:
|
|
380
|
+
package_version = package_version or get_package_version()
|
|
381
|
+
target = target or detect_target()
|
|
382
|
+
cache_root = cache_root or get_cache_root(target.system)
|
|
383
|
+
paths = installation_paths(cache_root, package_version, target)
|
|
384
|
+
missing = [key for key in ("manifest", "binary", "model", "config") if not paths[key].is_file()]
|
|
385
|
+
if missing:
|
|
386
|
+
names = ", ".join(paths[key].name for key in missing)
|
|
387
|
+
raise CliError(f"setup is incomplete ({names}). Run '{PACKAGE_NAME} --setup'.")
|
|
388
|
+
if target.system == "Linux" and not os.access(paths["binary"], os.X_OK):
|
|
389
|
+
raise CliError(f"{paths['binary']} is not executable. Run '{PACKAGE_NAME} --setup'.")
|
|
390
|
+
command = [
|
|
391
|
+
str(paths["binary"]),
|
|
392
|
+
"--model",
|
|
393
|
+
str(paths["model"]),
|
|
394
|
+
"--config",
|
|
395
|
+
str(paths["config"]),
|
|
396
|
+
"--input",
|
|
397
|
+
str(input_path),
|
|
398
|
+
"--output",
|
|
399
|
+
str(output_path),
|
|
400
|
+
"--provider",
|
|
401
|
+
provider,
|
|
402
|
+
]
|
|
403
|
+
try:
|
|
404
|
+
return subprocess.run(command, check=False).returncode
|
|
405
|
+
except OSError as exc:
|
|
406
|
+
raise CliError(f"could not start {paths['binary']}: {exc}") from exc
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
410
|
+
parser = argparse.ArgumentParser(description="Enhance audio with the Audioreconstructor ONNX model.")
|
|
411
|
+
actions = parser.add_mutually_exclusive_group()
|
|
412
|
+
actions.add_argument("--setup", action="store_true", help="download and verify the native runtime and model")
|
|
413
|
+
actions.add_argument("--doctor", action="store_true", help="verify cached assets and run a runtime self-test")
|
|
414
|
+
parser.add_argument("--version", action="version", version=get_package_version())
|
|
415
|
+
parser.add_argument("--input", type=Path, help="input audio file")
|
|
416
|
+
parser.add_argument("--output", type=Path, help="output FLAC file")
|
|
417
|
+
parser.add_argument("--provider", choices=("auto", "cpu", "directml"), default="auto", help="ONNX provider (default: auto)")
|
|
418
|
+
return parser
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
422
|
+
parser = build_parser()
|
|
423
|
+
args = parser.parse_args(argv)
|
|
424
|
+
try:
|
|
425
|
+
if args.setup:
|
|
426
|
+
setup_assets()
|
|
427
|
+
return 0
|
|
428
|
+
if args.doctor:
|
|
429
|
+
return doctor()
|
|
430
|
+
if args.input is None and args.output is None:
|
|
431
|
+
parser.print_help()
|
|
432
|
+
return 0
|
|
433
|
+
if args.input is None or args.output is None:
|
|
434
|
+
parser.error("--input and --output must be used together")
|
|
435
|
+
return run_inference(args.input, args.output, args.provider)
|
|
436
|
+
except CliError as exc:
|
|
437
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
438
|
+
return 1
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
if __name__ == "__main__":
|
|
442
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
from unittest import mock
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
16
|
+
|
|
17
|
+
from audioreconstructor import cli
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
TARGET = cli.Target("Linux", "x86_64", "audioreconstructor-linux-x86_64", "audioreconstructor")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def description(data: bytes) -> dict[str, int | str]:
|
|
24
|
+
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ReleaseFixture:
|
|
28
|
+
version = "1.0.0"
|
|
29
|
+
|
|
30
|
+
def __init__(self, root: Path) -> None:
|
|
31
|
+
self.root = root
|
|
32
|
+
self.assets = {
|
|
33
|
+
TARGET.asset_name: b"linux executable\n",
|
|
34
|
+
"audioreconstructor-windows-x86_64.exe": b"windows executable\r\n",
|
|
35
|
+
cli.MODEL_NAME: b"model data",
|
|
36
|
+
cli.CONFIG_NAME: b'{"sample_rate": 44100}',
|
|
37
|
+
}
|
|
38
|
+
self.sources: dict[str, Path] = {}
|
|
39
|
+
for name, data in self.assets.items():
|
|
40
|
+
source = root / name
|
|
41
|
+
source.write_bytes(data)
|
|
42
|
+
self.sources[f"test://{name}"] = source
|
|
43
|
+
manifest = {
|
|
44
|
+
"schemaVersion": 1,
|
|
45
|
+
"version": self.version,
|
|
46
|
+
"releaseTag": cli.release_tag(self.version),
|
|
47
|
+
"files": {name: description(data) for name, data in self.assets.items()},
|
|
48
|
+
}
|
|
49
|
+
manifest_path = root / cli.MANIFEST_NAME
|
|
50
|
+
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
51
|
+
self.sources[f"test://{cli.MANIFEST_NAME}"] = manifest_path
|
|
52
|
+
self.downloaded: list[str] = []
|
|
53
|
+
|
|
54
|
+
def download(self, url: str, destination: Path, expected: cli.Artifact | None = None) -> None:
|
|
55
|
+
self.downloaded.append(url)
|
|
56
|
+
shutil.copyfile(self.sources[url], destination)
|
|
57
|
+
if expected is not None:
|
|
58
|
+
valid, detail = cli.artifact_is_valid(destination, expected)
|
|
59
|
+
if not valid:
|
|
60
|
+
raise AssertionError(detail)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CliTests(unittest.TestCase):
|
|
64
|
+
def setUp(self) -> None:
|
|
65
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
66
|
+
self.root = Path(self.temp_dir.name)
|
|
67
|
+
self.cache = self.root / "cache"
|
|
68
|
+
(self.root / "release").mkdir(exist_ok=True)
|
|
69
|
+
self.release = ReleaseFixture(self.root / "release")
|
|
70
|
+
|
|
71
|
+
def tearDown(self) -> None:
|
|
72
|
+
self.temp_dir.cleanup()
|
|
73
|
+
|
|
74
|
+
def install(self) -> tuple[dict[str, Path], list[str]]:
|
|
75
|
+
messages: list[str] = []
|
|
76
|
+
with mock.patch.object(cli, "release_url", side_effect=lambda _version, name: f"test://{name}"), mock.patch.object(
|
|
77
|
+
cli, "download", side_effect=self.release.download
|
|
78
|
+
):
|
|
79
|
+
paths = cli.setup_assets(
|
|
80
|
+
package_version=self.release.version,
|
|
81
|
+
target=TARGET,
|
|
82
|
+
cache_root=self.cache,
|
|
83
|
+
report=messages.append,
|
|
84
|
+
)
|
|
85
|
+
return paths, messages
|
|
86
|
+
|
|
87
|
+
def test_setup_downloads_validates_and_reuses_assets(self) -> None:
|
|
88
|
+
old_cache = self.cache / "0.9.0"
|
|
89
|
+
old_cache.mkdir(parents=True)
|
|
90
|
+
(old_cache / "unused").write_text("old", encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
paths, messages = self.install()
|
|
93
|
+
|
|
94
|
+
self.assertTrue(paths["binary"].is_file())
|
|
95
|
+
self.assertTrue(os.access(paths["binary"], os.X_OK))
|
|
96
|
+
self.assertEqual(paths["model"].read_bytes(), self.release.assets[cli.MODEL_NAME])
|
|
97
|
+
self.assertFalse(old_cache.exists())
|
|
98
|
+
self.assertIn("Setup complete.", messages)
|
|
99
|
+
self.assertEqual(len(self.release.downloaded), 4)
|
|
100
|
+
|
|
101
|
+
self.release.downloaded.clear()
|
|
102
|
+
_, messages = self.install()
|
|
103
|
+
self.assertEqual(self.release.downloaded, [])
|
|
104
|
+
self.assertIn(f"Using cached {TARGET.asset_name}", messages)
|
|
105
|
+
|
|
106
|
+
def test_setup_replaces_a_corrupt_asset(self) -> None:
|
|
107
|
+
paths, _ = self.install()
|
|
108
|
+
paths["model"].write_bytes(b"corrupt")
|
|
109
|
+
self.release.downloaded.clear()
|
|
110
|
+
|
|
111
|
+
self.install()
|
|
112
|
+
|
|
113
|
+
self.assertEqual(paths["model"].read_bytes(), self.release.assets[cli.MODEL_NAME])
|
|
114
|
+
self.assertEqual(self.release.downloaded, [f"test://{cli.MODEL_NAME}"])
|
|
115
|
+
|
|
116
|
+
def test_doctor_runs_self_test_after_integrity_checks(self) -> None:
|
|
117
|
+
self.install()
|
|
118
|
+
messages: list[str] = []
|
|
119
|
+
with mock.patch.object(cli, "_self_test", return_value=(True, "passed")) as self_test:
|
|
120
|
+
result = cli.doctor(
|
|
121
|
+
package_version=self.release.version,
|
|
122
|
+
target=TARGET,
|
|
123
|
+
cache_root=self.cache,
|
|
124
|
+
report=messages.append,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self.assertEqual(result, 0)
|
|
128
|
+
self_test.assert_called_once()
|
|
129
|
+
self.assertIn("[PASS] Runtime self-test: passed", messages)
|
|
130
|
+
self.assertEqual(messages[-1], "Status: HEALTHY")
|
|
131
|
+
|
|
132
|
+
def test_doctor_fails_without_a_required_asset(self) -> None:
|
|
133
|
+
paths, _ = self.install()
|
|
134
|
+
paths["config"].unlink()
|
|
135
|
+
messages: list[str] = []
|
|
136
|
+
|
|
137
|
+
result = cli.doctor(
|
|
138
|
+
package_version=self.release.version,
|
|
139
|
+
target=TARGET,
|
|
140
|
+
cache_root=self.cache,
|
|
141
|
+
report=messages.append,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self.assertEqual(result, 1)
|
|
145
|
+
self.assertIn("[FAIL] config.json: missing", messages)
|
|
146
|
+
self.assertEqual(messages[-1], "Status: UNHEALTHY")
|
|
147
|
+
|
|
148
|
+
def test_inference_injects_cached_model_paths_and_preserves_exit_code(self) -> None:
|
|
149
|
+
paths, _ = self.install()
|
|
150
|
+
with mock.patch.object(cli.subprocess, "run", return_value=SimpleNamespace(returncode=3)) as run:
|
|
151
|
+
result = cli.run_inference(
|
|
152
|
+
Path("source song.mp3"),
|
|
153
|
+
Path("result.flac"),
|
|
154
|
+
"cpu",
|
|
155
|
+
package_version=self.release.version,
|
|
156
|
+
target=TARGET,
|
|
157
|
+
cache_root=self.cache,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
self.assertEqual(result, 3)
|
|
161
|
+
command = run.call_args.args[0]
|
|
162
|
+
self.assertEqual(command[0], str(paths["binary"]))
|
|
163
|
+
self.assertEqual(command[command.index("--model") + 1], str(paths["model"]))
|
|
164
|
+
self.assertEqual(command[command.index("--config") + 1], str(paths["config"]))
|
|
165
|
+
self.assertEqual(command[command.index("--input") + 1], "source song.mp3")
|
|
166
|
+
|
|
167
|
+
def test_platform_and_cache_resolution(self) -> None:
|
|
168
|
+
linux = cli.get_cache_root("Linux", {"XDG_CACHE_HOME": "/tmp/xdg"}, Path("/home/test"))
|
|
169
|
+
windows = cli.get_cache_root("Windows", {"LOCALAPPDATA": "C:/Users/test/AppData/Local"}, Path("/home/test"))
|
|
170
|
+
self.assertEqual(linux, Path("/tmp/xdg") / cli.PACKAGE_NAME)
|
|
171
|
+
self.assertEqual(windows, Path("C:/Users/test/AppData/Local") / cli.PACKAGE_NAME / "Cache")
|
|
172
|
+
with self.assertRaises(cli.CliError):
|
|
173
|
+
cli.detect_target("Darwin", "arm64")
|
|
174
|
+
|
|
175
|
+
def test_release_manifest_generator_records_each_asset(self) -> None:
|
|
176
|
+
assets_dir = self.root / "release-assets"
|
|
177
|
+
assets_dir.mkdir()
|
|
178
|
+
for name, data in self.release.assets.items():
|
|
179
|
+
(assets_dir / name).write_bytes(data)
|
|
180
|
+
generator = Path(__file__).resolve().parents[1] / "tools" / "generate_manifest.py"
|
|
181
|
+
|
|
182
|
+
result = subprocess.run(
|
|
183
|
+
[sys.executable, str(generator), "--version", self.release.version, "--assets-dir", str(assets_dir)],
|
|
184
|
+
capture_output=True,
|
|
185
|
+
text=True,
|
|
186
|
+
check=False,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
190
|
+
manifest = json.loads((assets_dir / cli.MANIFEST_NAME).read_text(encoding="utf-8"))
|
|
191
|
+
self.assertEqual(manifest["releaseTag"], "audioreconstructor-v1.0.0")
|
|
192
|
+
self.assertEqual(manifest["files"][cli.MODEL_NAME], description(self.release.assets[cli.MODEL_NAME]))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
unittest.main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Create the manifest.json required by an Audioreconstructor GitHub Release."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
ASSET_NAMES = (
|
|
10
|
+
"audioreconstructor-linux-x86_64",
|
|
11
|
+
"audioreconstructor-windows-x86_64.exe",
|
|
12
|
+
"model.onnx",
|
|
13
|
+
"config.json",
|
|
14
|
+
)
|
|
15
|
+
CHUNK_SIZE = 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def describe(path: Path) -> dict[str, int | str]:
|
|
19
|
+
digest = hashlib.sha256()
|
|
20
|
+
size = 0
|
|
21
|
+
with path.open("rb") as handle:
|
|
22
|
+
while chunk := handle.read(CHUNK_SIZE):
|
|
23
|
+
digest.update(chunk)
|
|
24
|
+
size += len(chunk)
|
|
25
|
+
return {"sha256": digest.hexdigest(), "bytes": size}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> None:
|
|
29
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
30
|
+
parser.add_argument("--version", required=True, help="PyPI version, for example 1.0.0")
|
|
31
|
+
parser.add_argument("--assets-dir", type=Path, required=True, help="directory containing the four release assets")
|
|
32
|
+
parser.add_argument("--output", type=Path, help="manifest path (default: <assets-dir>/manifest.json)")
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
|
|
35
|
+
missing = [name for name in ASSET_NAMES if not (args.assets_dir / name).is_file()]
|
|
36
|
+
if missing:
|
|
37
|
+
parser.error(f"missing release assets: {', '.join(missing)}")
|
|
38
|
+
output = args.output or args.assets_dir / "manifest.json"
|
|
39
|
+
manifest = {
|
|
40
|
+
"schemaVersion": 1,
|
|
41
|
+
"version": args.version,
|
|
42
|
+
"releaseTag": f"audioreconstructor-v{args.version}",
|
|
43
|
+
"files": {name: describe(args.assets_dir / name) for name in ASSET_NAMES},
|
|
44
|
+
}
|
|
45
|
+
output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
46
|
+
print(output)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|