diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4eff4cd..5790bd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,71 @@ jobs: - name: Integration Tests (no auth) run: go test ./tests/ -v -count=1 -timeout 120s + # The npm and PyPI packagers consume goreleaser's output. Nothing else in CI + # exercises them, so without this a packaging break is only discovered by a + # tag push — i.e. by a broken release. + packaging: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: build --snapshot --clean + + - name: Collect binaries + run: | + set -euo pipefail + jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \ + dist/artifacts.json | + while IFS=$'\t' read -r target path; do + mkdir -p "artifacts/$target" + cp "$path" "artifacts/$target/" + done + test "$(find artifacts -type f | wc -l)" -eq 6 + + - name: Build npm packages + run: node packaging/npm/build.mjs v0.0.0 artifacts dist/npm + + - name: Install and run the npm package + run: | + set -euo pipefail + mkdir -p /tmp/npmcheck && cd /tmp/npmcheck && npm init -y >/dev/null + npm install --no-audit --no-fund \ + "$GITHUB_WORKSPACE/dist/npm/modelslab-cli-linux-x64" \ + "$GITHUB_WORKSPACE/dist/npm/modelslab-cli" + # The real check: the shim resolves the binary and the binary runs. + ./node_modules/.bin/modelslab --version + + - name: Build PyPI wheels + run: python3 packaging/pypi/build.py v0.0.0 artifacts dist/pypi + + - name: Install and run the wheel + run: | + set -euo pipefail + python3 -m pip install --quiet twine + python3 -m twine check dist/pypi/*.whl + python3 -m pip install --quiet \ + dist/pypi/modelslab_cli-0.0.0-py3-none-manylinux2014_x86_64.whl + # Catches the executable-bit bug: twine check passes a wheel whose + # binary unpacks 0644, and only running it fails. + modelslab --version + build-matrix: runs-on: ubuntu-latest needs: test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 507bcd5..5e5e74c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,3 +43,65 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }} + + # Both registries package the SAME binaries goreleaser just built, taken + # from dist/ rather than rebuilt, so npm, PyPI, Homebrew and Scoop can + # never ship different bytes for one tag. + - name: Collect release binaries + run: | + set -euo pipefail + # Read goreleaser's own manifest rather than parsing dist/ directory + # names: those carry microarchitecture suffixes (_v1, _v8.0) that move + # between goreleaser versions, and artifacts.json states goos/goarch + # outright. + jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \ + dist/artifacts.json | + while IFS=$'\t' read -r target path; do + mkdir -p "artifacts/$target" + cp "$path" "artifacts/$target/" + done + find artifacts -type f | sort + test "$(find artifacts -type f | wc -l)" -eq 6 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Build npm packages + run: node packaging/npm/build.mjs "${GITHUB_REF_NAME}" artifacts dist/npm + + # `secrets` is not an available context in a step-level `if`, so the token + # is mapped to env and the guard reads that. Without the guard, a fork or a + # repo that has not configured the token fails the whole release. + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + if: ${{ env.NODE_AUTH_TOKEN != '' }} + run: | + set -euo pipefail + # Platform packages first: the entry package depends on them, and an + # entry published against versions that do not exist yet is an install + # that fails for everyone until the next step lands. + for pkg in dist/npm/modelslab-cli-*; do + npm publish "$pkg" --access public --provenance + done + npm publish dist/npm/modelslab-cli --access public --provenance + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build PyPI wheels + run: python3 packaging/pypi/build.py "${GITHUB_REF_NAME}" artifacts dist/pypi + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + if: ${{ env.TWINE_PASSWORD != '' }} + run: | + set -euo pipefail + python3 -m pip install --quiet twine + python3 -m twine check dist/pypi/*.whl + python3 -m twine upload dist/pypi/*.whl diff --git a/.gitignore b/.gitignore index a6b98f4..d3a7739 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ go.work.sum # GoReleaser dist/ +# Binaries collected from a release for the npm/PyPI packagers +artifacts/ + # IDE .idea/ .vscode/ @@ -35,3 +38,4 @@ Thumbs.db # Generated output generated/ +__pycache__/ diff --git a/README.md b/README.md index 8d1ab82..41edad5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,26 @@ The official command-line interface for [ModelsLab](https://modelslab.com) — m ## Installation +### npm + +```bash +npm install -g modelslab-cli +``` + +Ships the prebuilt binary for your platform as an optional dependency — nothing +is compiled and no install script runs. + +### PyPI + +```bash +pip install modelslab-cli +``` + +Platform wheels; no build step and no Python dependencies. + +Both register the `modelslab` command. The `modelslab` packages on npm and +PyPI are the **SDKs**, not this CLI — hence the `-cli` suffix. + ### macOS (Homebrew) ```bash diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..5112403 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,78 @@ +# Packaging + +The CLI is a single Go binary. Homebrew, Scoop, deb and rpm are produced by +goreleaser directly; npm and PyPI are not registries goreleaser publishes to, so +they are built here from the binaries goreleaser has already produced. + +Nothing in this directory compiles anything. Both builders take a directory of +extracted release binaries and repackage them, so every channel ships the same +bytes for a given tag. + +``` +artifacts/ + darwin_amd64/modelslab + darwin_arm64/modelslab + linux_amd64/modelslab + linux_arm64/modelslab + windows_amd64/modelslab.exe + windows_arm64/modelslab.exe +``` + +## npm — `packaging/npm/build.mjs` + +```bash +node packaging/npm/build.mjs v1.2.3 artifacts dist/npm +``` + +Produces seven packages: one entry package (`modelslab-cli`) whose only content +is a launcher shim, plus one package per platform holding just the binary and +the `os`/`cpu` fields npm filters on. The entry package declares the six as +`optionalDependencies`, so npm installs exactly the one that matches. + +The alternative — one package with a postinstall that downloads a binary — was +rejected deliberately. It needs network at install time and produces a silently +broken install under `npm ci --ignore-scripts`, which many CI and agent sandboxes +set. Six small packages buy an install that cannot half-work. + +**Publish platform packages before the entry package.** The entry package pins +exact versions of all six; publishing it first leaves a window where every +install fails. + +## PyPI — `packaging/pypi/build.py` + +```bash +python3 packaging/pypi/build.py v1.2.3 artifacts dist/pypi +``` + +Produces one wheel per platform, each containing the binary and a console script +that `execv`s it. Wheels are written directly rather than through a build +backend: there is nothing to compile, so a backend would only add a dependency, +and writing them here keeps the platform tag explicit instead of inferred from +whatever host ran the build. + +Two things that are easy to get wrong and are covered by CI: + +- **Version normalisation.** Tags are semver, wheel filenames are PEP 440. A + `v1.2.3-rc1` tag naively becomes `modelslab_cli-1.2.3-rc1-py3-none-*.whl`, + which pip reads as version `1.2.3` with build tag `rc1` — and build tags must + start with a digit, so the file is invalid. `normalise_version()` converts it + to `1.2.3rc1`. +- **The executable bit.** pip decides whether to mark an unpacked file executable + with `stat.S_ISREG(mode) and mode & 0o111`, so the zip entry's mode has to + carry the file-type bits, not just permissions. A bare `0o755` fails `S_ISREG`, + the binary lands `0o644`, and the first run dies with `EPERM`. `twine check` + passes either way; only installing and running catches it. + +## Releasing + +`.github/workflows/release.yml` runs both builders on a tag and publishes if the +corresponding token is configured. Missing tokens skip that registry rather than +failing the release. + +| Secret | Registry | +| --- | --- | +| `NPM_TOKEN` | npm (automation token with publish rights) | +| `PYPI_TOKEN` | PyPI (project or account API token, used as `__token__`) | + +`.github/workflows/ci.yml` builds both on every PR and installs and *runs* the +result, because both of the failure modes above pass every static check. diff --git a/packaging/npm/README.md b/packaging/npm/README.md new file mode 100644 index 0000000..a64d7c9 --- /dev/null +++ b/packaging/npm/README.md @@ -0,0 +1,20 @@ +# ModelsLab CLI + +AI generation and account management from the terminal. One command surface over +the ModelsLab API: image, video, audio, 3D and LLM generation, plus authentication, +billing, wallet, subscriptions and model discovery. + +```bash +npm install -g modelslab-cli +modelslab auth login +modelslab generate image --prompt "a lighthouse at dusk" --model flux +``` + +The package installs a prebuilt binary for your platform as an optional +dependency — nothing is compiled and no install script runs. + +Supported: macOS (Intel, Apple Silicon), Linux (x64, arm64), Windows (x64, arm64). + +- Docs: https://docs.modelslab.com +- Source: https://github.com/ModelsLab/modelslab-cli +- Other install methods (Homebrew, Scoop, shell): https://modelslab.sh diff --git a/packaging/npm/build.mjs b/packaging/npm/build.mjs new file mode 100644 index 0000000..be50c1b --- /dev/null +++ b/packaging/npm/build.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/** + * Builds the npm packages for a released version. + * + * Layout follows the esbuild/biome pattern: one entry package that declares an + * optionalDependency on a package per platform, each containing nothing but the + * binary and `os`/`cpu` fields. npm installs exactly the one that matches and + * skips the rest. + * + * The obvious alternative — one package with a postinstall script that downloads + * the right binary — was rejected on purpose. It needs network at install time + * and silently produces a broken install under `npm ci --ignore-scripts`, which + * plenty of CI and agent sandboxes set. Six small packages are the cost of an + * install that cannot half-work. + * + * Usage: node packaging/npm/build.mjs [out-dir] + * holds the goreleaser tarballs already extracted into + * /_/modelslab[.exe] + */ +import { mkdirSync, writeFileSync, copyFileSync, existsSync, chmodSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const NAME = 'modelslab-cli'; +const BIN = 'modelslab'; +const REPO = 'https://github.com/ModelsLab/modelslab-cli'; +const DESCRIPTION = + 'ModelsLab CLI — AI generation and account management from the terminal'; + +/** goreleaser target -> npm os/cpu. Windows is `win32` to npm, whatever Go calls it. */ +const TARGETS = [ + { go: 'darwin_amd64', os: 'darwin', cpu: 'x64' }, + { go: 'darwin_arm64', os: 'darwin', cpu: 'arm64' }, + { go: 'linux_amd64', os: 'linux', cpu: 'x64' }, + { go: 'linux_arm64', os: 'linux', cpu: 'arm64' }, + { go: 'windows_amd64', os: 'win32', cpu: 'x64' }, + { go: 'windows_arm64', os: 'win32', cpu: 'arm64' }, +]; + +const [, , rawVersion, artifactsDir, outDirArg] = process.argv; + +if (!rawVersion || !artifactsDir) { + console.error('usage: build.mjs [out-dir]'); + process.exit(1); +} + +// Tags are v-prefixed; npm versions are not. +const version = rawVersion.replace(/^v/, ''); +const outDir = resolve(outDirArg ?? 'dist/npm'); + +if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { + console.error(`refusing to build: "${version}" is not a semver version`); + process.exit(1); +} + +const common = { + version, + description: DESCRIPTION, + homepage: 'https://modelslab.sh', + repository: { type: 'git', url: `git+${REPO}.git` }, + bugs: { url: `${REPO}/issues` }, + license: 'MIT', + author: 'ModelsLab ', +}; + +const platformPackages = []; + +for (const target of TARGETS) { + const exe = target.os === 'win32' ? `${BIN}.exe` : BIN; + const source = join(artifactsDir, target.go, exe); + + if (!existsSync(source)) { + console.error(`missing binary for ${target.go}: ${source}`); + process.exit(1); + } + + const pkgName = `${NAME}-${target.os}-${target.cpu}`; + const pkgDir = join(outDir, pkgName); + mkdirSync(join(pkgDir, 'bin'), { recursive: true }); + + const dest = join(pkgDir, 'bin', exe); + copyFileSync(source, dest); + // npm preserves the mode in the tarball; without this the shim cannot exec it. + chmodSync(dest, 0o755); + + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify( + { + name: pkgName, + ...common, + description: `${DESCRIPTION} (${target.os} ${target.cpu} binary)`, + os: [target.os], + cpu: [target.cpu], + // Only the binary. No lifecycle scripts, nothing to execute at install. + files: ['bin'], + preferUnplugged: true, + }, + null, + 2 + ) + '\n' + ); + + platformPackages.push(pkgName); + console.log(`built ${pkgName}`); +} + +// --- entry package ----------------------------------------------------------- +const entryDir = join(outDir, NAME); +mkdirSync(join(entryDir, 'bin'), { recursive: true }); + +writeFileSync( + join(entryDir, 'package.json'), + JSON.stringify( + { + name: NAME, + ...common, + keywords: [ + 'modelslab', + 'cli', + 'ai', + 'image-generation', + 'video-generation', + 'text-to-speech', + 'llm', + 'agent', + ], + bin: { [BIN]: 'bin/modelslab.cjs' }, + files: ['bin', 'README.md'], + // Optional so an unsupported platform fails at run time with a + // readable message instead of failing the whole install. + optionalDependencies: Object.fromEntries( + platformPackages.map((name) => [name, version]) + ), + engines: { node: '>=16' }, + }, + null, + 2 + ) + '\n' +); + +copyFileSync( + resolve(import.meta.dirname, 'shim.cjs'), + join(entryDir, 'bin', 'modelslab.cjs') +); +copyFileSync( + resolve(import.meta.dirname, 'README.md'), + join(entryDir, 'README.md') +); + +console.log(`built ${NAME} (entry) with ${platformPackages.length} optional deps`); +console.log(`output: ${outDir}`); diff --git a/packaging/npm/shim.cjs b/packaging/npm/shim.cjs new file mode 100644 index 0000000..8f4790c --- /dev/null +++ b/packaging/npm/shim.cjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Locates the platform binary and hands the process over to it. + * + * `spawnSync` with `stdio: 'inherit'` rather than an exec: Node has no execv, and + * inheriting the streams keeps interactive prompts, pipes and TTY detection + * behaving as if the binary were invoked directly. The child's exit code and + * terminating signal are both propagated — a wrapper that swallowed a non-zero + * exit would break every script that checks it. + */ +const { spawnSync } = require('node:child_process'); +const { existsSync, realpathSync } = require('node:fs'); +const { join, dirname, sep } = require('node:path'); + +const PLATFORM_PACKAGES = { + 'darwin x64': 'modelslab-cli-darwin-x64', + 'darwin arm64': 'modelslab-cli-darwin-arm64', + 'linux x64': 'modelslab-cli-linux-x64', + 'linux arm64': 'modelslab-cli-linux-arm64', + 'win32 x64': 'modelslab-cli-win32-x64', + 'win32 arm64': 'modelslab-cli-win32-arm64', +}; + +function resolveBinary() { + const key = `${process.platform} ${process.arch}`; + const pkg = PLATFORM_PACKAGES[key]; + + if (!pkg) { + throw new Error( + `ModelsLab CLI does not ship a binary for ${key}.\n` + + `Supported: ${Object.keys(PLATFORM_PACKAGES).join(', ')}.\n` + + `Build from source: https://github.com/ModelsLab/modelslab-cli` + ); + } + + const exe = process.platform === 'win32' ? 'modelslab.exe' : 'modelslab'; + const subpath = `${pkg}/bin/${exe}`; + + /* + * Three lookups, because one is not enough in practice. + * + * The plain resolve covers a normal `npm i -g`. It fails whenever this + * package is reached through a symlink — `npm install `, `npm link`, + * and every pnpm install — because module resolution then starts from the + * link target, which has no node_modules of its own. Anchoring the search at + * the real directory and at the caller's cwd covers those. + */ + const anchors = [__dirname]; + try { + anchors.push(realpathSync(__dirname)); + } catch { + // realpath can fail on a broken link; the other anchors still apply. + } + anchors.push(process.cwd()); + + for (const resolver of [ + () => require.resolve(subpath), + () => require.resolve(subpath, { paths: anchors }), + ]) { + try { + return resolver(); + } catch { + // try the next strategy + } + } + + /* + * Last resort: walk up from here looking for a sibling inside any + * node_modules directory. Catches layouts where the package is present but + * unreachable by Node's algorithm from where this file physically lives. + */ + for (const anchor of anchors) { + let dir = anchor; + while (true) { + const candidate = join(dir, 'node_modules', pkg, 'bin', exe); + if (existsSync(candidate)) { + return candidate; + } + const parent = dirname(dir); + if (parent === dir || !parent.includes(sep)) { + break; + } + dir = parent; + } + } + + { + /* + * The optional dependency is missing. Almost always one of: + * `--no-optional`, an npm version that skipped it, or an install that + * partially failed. Say which package so the fix is one command. + */ + throw new Error( + `ModelsLab CLI is installed but the binary for ${key} is not.\n` + + `Expected the optional dependency "${pkg}".\n\n` + + `Fix it with: npm install ${pkg}\n` + + `Or reinstall: npm install -g modelslab-cli\n\n` + + `If you installed with --no-optional or --ignore-optional, that is why.` + ); + } +} + +let binary; +try { + binary = resolveBinary(); +} catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(1); +} + +const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' }); + +if (result.error) { + process.stderr.write(`failed to run ModelsLab CLI: ${result.error.message}\n`); + process.exit(1); +} + +// A signalled child has a null status; re-raise so the parent shell sees the +// same thing it would have seen running the binary directly. +if (result.signal) { + process.kill(process.pid, result.signal); +} + +process.exit(result.status ?? 1); diff --git a/packaging/pypi/README.md b/packaging/pypi/README.md new file mode 100644 index 0000000..a64d7c9 --- /dev/null +++ b/packaging/pypi/README.md @@ -0,0 +1,20 @@ +# ModelsLab CLI + +AI generation and account management from the terminal. One command surface over +the ModelsLab API: image, video, audio, 3D and LLM generation, plus authentication, +billing, wallet, subscriptions and model discovery. + +```bash +npm install -g modelslab-cli +modelslab auth login +modelslab generate image --prompt "a lighthouse at dusk" --model flux +``` + +The package installs a prebuilt binary for your platform as an optional +dependency — nothing is compiled and no install script runs. + +Supported: macOS (Intel, Apple Silicon), Linux (x64, arm64), Windows (x64, arm64). + +- Docs: https://docs.modelslab.com +- Source: https://github.com/ModelsLab/modelslab-cli +- Other install methods (Homebrew, Scoop, shell): https://modelslab.sh diff --git a/packaging/pypi/build.py b/packaging/pypi/build.py new file mode 100644 index 0000000..2d0e6ab --- /dev/null +++ b/packaging/pypi/build.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Build platform wheels for the ModelsLab CLI. + +One wheel per platform, each containing the prebuilt Go binary and a console +script that hands the process over to it. + +Wheels are written directly rather than through setuptools. The binary is not +Python and there is nothing to compile, so a build backend would only add a +dependency and a temp directory to the same zip file. The wheel format is +specified well enough (PEP 427, PEP 425) to emit correctly, and doing it here +keeps the platform tag explicit instead of inferred from the build host. + +Usage: python3 packaging/pypi/build.py [out-dir] + holds the goreleaser tarballs already extracted into + /_/modelslab[.exe] +""" +from __future__ import annotations + +import base64 +import csv +import hashlib +import io +import os +import re +import stat +import sys +import zipfile +from pathlib import Path + +NAME = "modelslab-cli" +DIST = "modelslab_cli" +SUMMARY = "ModelsLab CLI — AI generation and account management from the terminal" + +# goreleaser target -> PEP 425 platform tag. +# +# manylinux2014 (glibc 2.17, CentOS 7) rather than a newer baseline: the binary +# is CGO_ENABLED=0, so it has no libc dependency at all and the tag only needs to +# be old enough that pip on any live distro will accept it. +TARGETS = [ + ("darwin_amd64", "macosx_10_12_x86_64"), + ("darwin_arm64", "macosx_11_0_arm64"), + ("linux_amd64", "manylinux2014_x86_64"), + ("linux_arm64", "manylinux2014_aarch64"), + ("windows_amd64", "win_amd64"), + ("windows_arm64", "win_arm64"), +] + +LAUNCHER = '''"""Console entry point for the ModelsLab CLI.""" +import os +import sys +from pathlib import Path + + +def _binary() -> Path: + exe = "modelslab.exe" if os.name == "nt" else "modelslab" + return Path(__file__).resolve().parent / "bin" / exe + + +def main() -> int: + binary = _binary() + + if not binary.exists(): + sys.stderr.write( + "ModelsLab CLI binary is missing from the installed package.\\n" + "Reinstall with: pip install --force-reinstall modelslab-cli\\n" + ) + return 1 + + argv = [str(binary), *sys.argv[1:]] + + if os.name == "nt": + # Windows has no exec that replaces the process in a way cmd respects; + # spawn and forward the exit code instead. + import subprocess + + return subprocess.call(argv) + + # Replace this process so signals, exit codes and TTY behaviour are the + # binary's own. A subprocess wrapper here would swallow SIGINT handling. + os.execv(str(binary), argv) + return 1 # unreachable; execv does not return + + +if __name__ == "__main__": + raise SystemExit(main()) +''' + + +def _read_description() -> str: + readme = Path(__file__).resolve().parent / "README.md" + return readme.read_text(encoding="utf-8") if readme.is_file() else SUMMARY + + +def _metadata(version: str) -> str: + return "\n".join( + [ + "Metadata-Version: 2.1", + f"Name: {NAME}", + f"Version: {version}", + f"Summary: {SUMMARY}", + "Home-page: https://modelslab.sh", + "Author: ModelsLab", + "Author-email: support@modelslab.com", + "License: MIT", + "Project-URL: Source, https://github.com/ModelsLab/modelslab-cli", + "Project-URL: Documentation, https://docs.modelslab.com", + "Classifier: Development Status :: 4 - Beta", + "Classifier: Environment :: Console", + "Classifier: Intended Audience :: Developers", + "Classifier: License :: OSI Approved :: MIT License", + "Classifier: Programming Language :: Go", + "Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence", + "Requires-Python: >=3.8", + "Description-Content-Type: text/markdown", + "", + _read_description(), + ] + ) + + +def _wheel_tag(platform_tag: str) -> str: + # py3-none-: pure launcher, no Python ABI, platform-specific payload. + return f"py3-none-{platform_tag}" + + +def _urlsafe_digest(payload: bytes) -> str: + digest = hashlib.sha256(payload).digest() + return "sha256=" + base64.urlsafe_b64encode(digest).decode().rstrip("=") + + +def build_wheel(version: str, artifacts: Path, out_dir: Path, go_target: str, platform_tag: str) -> Path: + exe = "modelslab.exe" if go_target.startswith("windows") else "modelslab" + source = artifacts / go_target / exe + + if not source.is_file(): + raise SystemExit(f"missing binary for {go_target}: {source}") + + tag = _wheel_tag(platform_tag) + dist_info = f"{DIST}-{version}.dist-info" + out_dir.mkdir(parents=True, exist_ok=True) + wheel_path = out_dir / f"{DIST}-{version}-{tag}.whl" + + records: list[tuple[str, str, int]] = [] + + def add(zf: zipfile.ZipFile, arcname: str, payload: bytes, *, executable: bool = False) -> None: + info = zipfile.ZipInfo(arcname, date_time=(1980, 1, 1, 0, 0, 0)) + # The mode must carry the file-TYPE bits, not just the permission bits. + # pip decides whether to make an unpacked file executable with + # `stat.S_ISREG(mode) and mode & 0o111`, so a bare 0o755 fails S_ISREG, + # the binary lands as 0o644, and execv dies with EPERM at first run. + # twine check does not catch this — only installing and running does. + mode = stat.S_IFREG | (0o755 if executable else 0o644) + info.external_attr = (mode << 16) | 0o600 + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, payload) + records.append((arcname, _urlsafe_digest(payload), len(payload))) + + with zipfile.ZipFile(wheel_path, "w", zipfile.ZIP_DEFLATED) as zf: + add(zf, f"{DIST}/__init__.py", b'"""ModelsLab CLI."""\n') + add(zf, f"{DIST}/__main__.py", LAUNCHER.encode("utf-8")) + add(zf, f"{DIST}/bin/{exe}", source.read_bytes(), executable=True) + + add( + zf, + f"{dist_info}/METADATA", + _metadata(version).encode("utf-8"), + ) + add( + zf, + f"{dist_info}/WHEEL", + ( + "Wheel-Version: 1.0\n" + "Generator: modelslab-cli packaging/pypi/build.py\n" + "Root-Is-Purelib: false\n" + f"Tag: {tag}\n" + ).encode("utf-8"), + ) + add( + zf, + f"{dist_info}/entry_points.txt", + f"[console_scripts]\nmodelslab = {DIST}.__main__:main\n".encode("utf-8"), + ) + add(zf, f"{dist_info}/top_level.txt", f"{DIST}\n".encode("utf-8")) + + # RECORD lists every file including itself, with its own hash left blank. + buffer = io.StringIO() + writer = csv.writer(buffer, lineterminator="\n") + for arcname, digest, size in records: + writer.writerow([arcname, digest, size]) + writer.writerow([f"{dist_info}/RECORD", "", ""]) + + record_info = zipfile.ZipInfo(f"{dist_info}/RECORD", date_time=(1980, 1, 1, 0, 0, 0)) + record_info.external_attr = ((stat.S_IFREG | 0o644) << 16) | 0o600 + record_info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(record_info, buffer.getvalue()) + + return wheel_path + + +def normalise_version(raw: str) -> str: + """Turn a git tag into a PEP 440 version. + + Tags are semver (`v1.2.3`, `v1.2.3-rc1`); PEP 440 is not. The hyphen matters + more than it looks: it is the field separator in a wheel filename, so + `1.2.3-rc1` produces `modelslab_cli-1.2.3-rc1-py3-none-*.whl`, which pip + parses as version `1.2.3` with a build tag of `rc1` — and build tags must + start with a digit, so the file is simply invalid. + """ + version = raw.lstrip("v").strip() + + match = re.fullmatch( + r"(\d+\.\d+\.\d+)" + r"(?:[-.]?(a|b|rc|alpha|beta)\.?(\d+))?", + version, + re.IGNORECASE, + ) + + if not match: + raise SystemExit( + f'refusing to build: "{raw}" is not a version this can express in PEP 440' + ) + + release, phase, number = match.groups() + + if not phase: + return release + + canonical = {"alpha": "a", "beta": "b", "a": "a", "b": "b", "rc": "rc"}[phase.lower()] + + return f"{release}{canonical}{number}" + + +def main() -> int: + if len(sys.argv) < 3: + sys.stderr.write("usage: build.py [out-dir]\n") + return 1 + + version = normalise_version(sys.argv[1]) + artifacts = Path(sys.argv[2]) + out_dir = Path(sys.argv[3]) if len(sys.argv) > 3 else Path("dist/pypi") + + for go_target, platform_tag in TARGETS: + path = build_wheel(version, artifacts, out_dir, go_target, platform_tag) + print(f"built {path.name}") + + print(f"output: {out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())