scythe-sql 0.13.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.
- scythe_sql-0.13.0/.gitignore +60 -0
- scythe_sql-0.13.0/PKG-INFO +59 -0
- scythe_sql-0.13.0/README.md +38 -0
- scythe_sql-0.13.0/pyproject.toml +40 -0
- scythe_sql-0.13.0/src/scythe_sql/__init__.py +7 -0
- scythe_sql-0.13.0/src/scythe_sql/cache.py +34 -0
- scythe_sql-0.13.0/src/scythe_sql/checksum.py +67 -0
- scythe_sql-0.13.0/src/scythe_sql/cli.py +62 -0
- scythe_sql-0.13.0/src/scythe_sql/download.py +128 -0
- scythe_sql-0.13.0/src/scythe_sql/errors.py +16 -0
- scythe_sql-0.13.0/src/scythe_sql/extract.py +89 -0
- scythe_sql-0.13.0/src/scythe_sql/platform_resolver.py +146 -0
- scythe_sql-0.13.0/src/scythe_sql/preinstalled.py +33 -0
- scythe_sql-0.13.0/src/scythe_sql/proxy.py +22 -0
- scythe_sql-0.13.0/src/scythe_sql/version_utils.py +38 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Rust build artifacts
|
|
2
|
+
/target/
|
|
3
|
+
**/*.rs.bk
|
|
4
|
+
site/
|
|
5
|
+
.playwright-mcp/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
.mcp.json
|
|
8
|
+
# IDE
|
|
9
|
+
.idea/
|
|
10
|
+
.claude/
|
|
11
|
+
.rumdl_cache/
|
|
12
|
+
.vscode/
|
|
13
|
+
*.swp
|
|
14
|
+
*.swo
|
|
15
|
+
*~
|
|
16
|
+
|
|
17
|
+
# OS
|
|
18
|
+
.DS_Store
|
|
19
|
+
Thumbs.db
|
|
20
|
+
|
|
21
|
+
# Python
|
|
22
|
+
__pycache__/
|
|
23
|
+
*.pyc
|
|
24
|
+
.venv/
|
|
25
|
+
|
|
26
|
+
# Ruby
|
|
27
|
+
.bundle/
|
|
28
|
+
|
|
29
|
+
# Generated output (except integration test fixtures)
|
|
30
|
+
**/generated/
|
|
31
|
+
generated/
|
|
32
|
+
output/
|
|
33
|
+
!integration_tests/**/generated/
|
|
34
|
+
!crates/scythe-cli/tests/generated/
|
|
35
|
+
!crates/scythe-cli/tests/generated/*.rs
|
|
36
|
+
|
|
37
|
+
# Environment
|
|
38
|
+
.env
|
|
39
|
+
.env.*
|
|
40
|
+
!.env.example
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# poly cache
|
|
44
|
+
.polylint/
|
|
45
|
+
|
|
46
|
+
# BEGIN ai-rulez (DO NOT EDIT - managed by ai-rulez)
|
|
47
|
+
.ai-rulez/.generated-manifest.json
|
|
48
|
+
.codex/
|
|
49
|
+
AGENTS.md
|
|
50
|
+
CLAUDE.md
|
|
51
|
+
# END ai-rulez
|
|
52
|
+
poly.local.toml
|
|
53
|
+
|
|
54
|
+
# Astro docs site
|
|
55
|
+
website/node_modules/
|
|
56
|
+
website/dist/
|
|
57
|
+
website/.astro/
|
|
58
|
+
|
|
59
|
+
# snowflake-sdk writes this on every run
|
|
60
|
+
snowflake.log
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scythe-sql
|
|
3
|
+
Version: 0.13.0
|
|
4
|
+
Summary: SQL compilation and code generation CLI -- PyPI wrapper that downloads the prebuilt scythe binary
|
|
5
|
+
Project-URL: Homepage, https://goldziher.github.io/scythe
|
|
6
|
+
Project-URL: Repository, https://github.com/Goldziher/scythe
|
|
7
|
+
Project-URL: Issues, https://github.com/Goldziher/scythe/issues
|
|
8
|
+
Author: Na'aman Hirschfeld
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: cli,codegen,database,sql,sqlc
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# scythe-sql
|
|
23
|
+
|
|
24
|
+
PyPI wrapper for [scythe](https://github.com/Goldziher/scythe), the polyglot SQL-to-code generator.
|
|
25
|
+
Installs a pinned `scythe` binary without requiring a Rust toolchain.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install scythe-sql
|
|
29
|
+
scythe --version
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`pip install` does not run any code for a wheel install, so there is no download-on-install step.
|
|
33
|
+
Instead, the installed `scythe` console script downloads the matching prebuilt binary for your
|
|
34
|
+
platform on first run (verifying its SHA-256 checksum) and caches it under
|
|
35
|
+
`~/.cache/scythe/<version>/` (`%LOCALAPPDATA%\scythe\cache` on Windows). Every subsequent invocation
|
|
36
|
+
execs the cached binary directly. The script never writes into `site-packages`, so it works under
|
|
37
|
+
read-only installs, containers, and `pip install --user`.
|
|
38
|
+
|
|
39
|
+
## Warming the cache in a Dockerfile
|
|
40
|
+
|
|
41
|
+
```dockerfile
|
|
42
|
+
RUN pip install scythe-sql && scythe --version
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Supported platforms
|
|
46
|
+
|
|
47
|
+
- Linux x64 / arm64 (glibc only -- musl/Alpine is not supported; use `cargo install scythe-cli`)
|
|
48
|
+
- macOS x64 / arm64
|
|
49
|
+
- Windows x64 (Windows on ARM64 falls back to the x64 binary via emulation)
|
|
50
|
+
|
|
51
|
+
## Environment variables
|
|
52
|
+
|
|
53
|
+
- `SCYTHE_BINARY` -- absolute path to a `scythe` binary to use instead of downloading.
|
|
54
|
+
- `SCYTHE_SKIP_DOWNLOAD=1` -- never download; fail if no cached or PATH binary is available.
|
|
55
|
+
- `SCYTHE_CACHE_DIR` -- override the cache root directory.
|
|
56
|
+
- `PIP_CERT` / `REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` -- custom CA bundle.
|
|
57
|
+
|
|
58
|
+
Proxy configuration (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`) is honoured natively via
|
|
59
|
+
`urllib.request`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# scythe-sql
|
|
2
|
+
|
|
3
|
+
PyPI wrapper for [scythe](https://github.com/Goldziher/scythe), the polyglot SQL-to-code generator.
|
|
4
|
+
Installs a pinned `scythe` binary without requiring a Rust toolchain.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install scythe-sql
|
|
8
|
+
scythe --version
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`pip install` does not run any code for a wheel install, so there is no download-on-install step.
|
|
12
|
+
Instead, the installed `scythe` console script downloads the matching prebuilt binary for your
|
|
13
|
+
platform on first run (verifying its SHA-256 checksum) and caches it under
|
|
14
|
+
`~/.cache/scythe/<version>/` (`%LOCALAPPDATA%\scythe\cache` on Windows). Every subsequent invocation
|
|
15
|
+
execs the cached binary directly. The script never writes into `site-packages`, so it works under
|
|
16
|
+
read-only installs, containers, and `pip install --user`.
|
|
17
|
+
|
|
18
|
+
## Warming the cache in a Dockerfile
|
|
19
|
+
|
|
20
|
+
```dockerfile
|
|
21
|
+
RUN pip install scythe-sql && scythe --version
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Supported platforms
|
|
25
|
+
|
|
26
|
+
- Linux x64 / arm64 (glibc only -- musl/Alpine is not supported; use `cargo install scythe-cli`)
|
|
27
|
+
- macOS x64 / arm64
|
|
28
|
+
- Windows x64 (Windows on ARM64 falls back to the x64 binary via emulation)
|
|
29
|
+
|
|
30
|
+
## Environment variables
|
|
31
|
+
|
|
32
|
+
- `SCYTHE_BINARY` -- absolute path to a `scythe` binary to use instead of downloading.
|
|
33
|
+
- `SCYTHE_SKIP_DOWNLOAD=1` -- never download; fail if no cached or PATH binary is available.
|
|
34
|
+
- `SCYTHE_CACHE_DIR` -- override the cache root directory.
|
|
35
|
+
- `PIP_CERT` / `REQUESTS_CA_BUNDLE` / `SSL_CERT_FILE` -- custom CA bundle.
|
|
36
|
+
|
|
37
|
+
Proxy configuration (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`) is honoured natively via
|
|
38
|
+
`urllib.request`.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "scythe-sql"
|
|
3
|
+
version = "0.13.0"
|
|
4
|
+
description = "SQL compilation and code generation CLI -- PyPI wrapper that downloads the prebuilt scythe binary"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
authors = [{ name = "Na'aman Hirschfeld" }]
|
|
9
|
+
keywords = ["sql", "codegen", "cli", "database", "sqlc"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Topic :: Database",
|
|
18
|
+
"Topic :: Software Development :: Code Generators",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://goldziher.github.io/scythe"
|
|
23
|
+
Repository = "https://github.com/Goldziher/scythe"
|
|
24
|
+
Issues = "https://github.com/Goldziher/scythe/issues"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
scythe = "scythe_sql.cli:main"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/scythe_sql"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.sdist]
|
|
37
|
+
include = ["src", "README.md"]
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = ["pytest>=8.0.0"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Cache directory resolution for the downloaded scythe binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_cache_root(*, env: dict[str, str], sys_platform: str, home: Path) -> Path:
|
|
10
|
+
"""Resolves the root cache directory, honouring `SCYTHE_CACHE_DIR` and XDG conventions."""
|
|
11
|
+
override = env.get("SCYTHE_CACHE_DIR")
|
|
12
|
+
if override:
|
|
13
|
+
return Path(override)
|
|
14
|
+
|
|
15
|
+
if sys_platform.startswith("win"):
|
|
16
|
+
base = env.get("LOCALAPPDATA")
|
|
17
|
+
base_path = Path(base) if base else home / "AppData" / "Local"
|
|
18
|
+
return base_path / "scythe" / "cache"
|
|
19
|
+
|
|
20
|
+
xdg_cache_home = env.get("XDG_CACHE_HOME")
|
|
21
|
+
if xdg_cache_home:
|
|
22
|
+
return Path(xdg_cache_home) / "scythe"
|
|
23
|
+
|
|
24
|
+
return home / ".cache" / "scythe"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cached_binary_path(*, env: dict[str, str], sys_platform: str, home: Path, version: str, binary_name: str) -> Path:
|
|
28
|
+
"""Resolves the cached binary path for a given version."""
|
|
29
|
+
return resolve_cache_root(env=env, sys_platform=sys_platform, home=home) / version / binary_name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def default_home() -> Path:
|
|
33
|
+
"""Returns the current user's home directory."""
|
|
34
|
+
return Path(os.path.expanduser("~"))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Checksum parsing and verification for goreleaser checksum files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from scythe_sql.errors import ScytheSqlError
|
|
9
|
+
|
|
10
|
+
_ROW_RE = re.compile(r"^([0-9a-fA-F]{64})\s+\*?(\S+)$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_checksums(contents: str) -> dict[str, str]:
|
|
14
|
+
"""Parses a `scythe_<version>_checksums.txt` file into filename -> lowercase hex sha256."""
|
|
15
|
+
result: dict[str, str] = {}
|
|
16
|
+
for raw_line in contents.splitlines():
|
|
17
|
+
line = raw_line.strip()
|
|
18
|
+
if not line:
|
|
19
|
+
continue
|
|
20
|
+
match = _ROW_RE.match(line)
|
|
21
|
+
if not match:
|
|
22
|
+
continue
|
|
23
|
+
digest, filename = match.groups()
|
|
24
|
+
result[filename] = digest.lower()
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MissingChecksumError(ScytheSqlError):
|
|
29
|
+
"""Raised when the checksums file has no row for the requested asset."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def expected_checksum(checksums: dict[str, str], asset_filename: str, checksums_url: str) -> str:
|
|
33
|
+
"""Looks up the expected checksum for `asset_filename`.
|
|
34
|
+
|
|
35
|
+
Raises :class:`MissingChecksumError` when the row is missing -- a
|
|
36
|
+
missing row means the release is malformed and verification must not be
|
|
37
|
+
silently skipped.
|
|
38
|
+
"""
|
|
39
|
+
expected = checksums.get(asset_filename)
|
|
40
|
+
if expected is None:
|
|
41
|
+
raise MissingChecksumError(
|
|
42
|
+
f"scythe-sql: no checksum entry for '{asset_filename}' in {checksums_url}. "
|
|
43
|
+
"The release appears malformed; refusing to install without verification."
|
|
44
|
+
)
|
|
45
|
+
return expected
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sha256_hex(data: bytes) -> str:
|
|
49
|
+
"""Computes the lowercase hex sha256 digest of `data`."""
|
|
50
|
+
return hashlib.sha256(data).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ChecksumMismatchError(ScytheSqlError):
|
|
54
|
+
"""Raised when a downloaded asset's checksum does not match the expected value."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def verify_checksum(data: bytes, expected_hex: str, asset_url: str) -> None:
|
|
58
|
+
"""Verifies `data` against `expected_hex`, case-insensitively.
|
|
59
|
+
|
|
60
|
+
Raises :class:`ChecksumMismatchError` naming both hashes and the source
|
|
61
|
+
URL on mismatch.
|
|
62
|
+
"""
|
|
63
|
+
actual = sha256_hex(data)
|
|
64
|
+
if actual.lower() != expected_hex.lower():
|
|
65
|
+
raise ChecksumMismatchError(
|
|
66
|
+
f"scythe-sql: checksum mismatch for {asset_url}\n expected: {expected_hex.lower()}\n actual: {actual}"
|
|
67
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Entry point installed as the `scythe` console script.
|
|
2
|
+
|
|
3
|
+
`pip install` does not run post-install code for a wheel, so there is no way
|
|
4
|
+
to download the binary at install time. Instead, each invocation ensures the
|
|
5
|
+
cached binary is present (downloading + verifying on first run only, which
|
|
6
|
+
prints one line to stderr) and then execs it, forwarding argv and letting the
|
|
7
|
+
kernel propagate the exit code. Never writes into site-packages at runtime --
|
|
8
|
+
that breaks read-only installs, containers, and `pip install --user`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from scythe_sql import __version__
|
|
17
|
+
from scythe_sql.download import ensure_binary
|
|
18
|
+
from scythe_sql.errors import ScytheSqlError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> int:
|
|
22
|
+
"""Resolves the pinned scythe binary and execs it with the current argv."""
|
|
23
|
+
try:
|
|
24
|
+
binary_path = ensure_binary(__version__)
|
|
25
|
+
except ScytheSqlError as exc:
|
|
26
|
+
# Every error in this hierarchy already carries the `scythe-sql:` prefix
|
|
27
|
+
# and its own remediation advice; re-wrapping it below would double the
|
|
28
|
+
# prefix. Catching the shared base rather than naming subclasses is what
|
|
29
|
+
# keeps checksum and platform failures on this branch as they are added.
|
|
30
|
+
print(str(exc), file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
except Exception as exc: # noqa: BLE001 -- surface any unexpected failure with context
|
|
33
|
+
print(f"scythe-sql: failed to resolve the scythe binary: {exc}", file=sys.stderr)
|
|
34
|
+
return 1
|
|
35
|
+
|
|
36
|
+
argv = [str(binary_path), *sys.argv[1:]]
|
|
37
|
+
|
|
38
|
+
# Windows has no real exec: its `os.execv` spawns a new process and kills
|
|
39
|
+
# this one, so the shell regains control immediately with exit code 0 while
|
|
40
|
+
# scythe is still running. Every failure would be reported as success --
|
|
41
|
+
# `scythe check` could never fail a Windows CI job. Wait on a child instead
|
|
42
|
+
# and hand back its status.
|
|
43
|
+
if os.name == "nt":
|
|
44
|
+
import subprocess # noqa: PLC0415 -- POSIX execs and never needs this
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
return subprocess.run(argv, check=False).returncode # noqa: S603 -- resolved via our own cache logic
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
print(f"scythe-sql: failed to execute {binary_path}: {exc}", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
# POSIX: exec is strictly better than spawning -- no extra process sits in
|
|
53
|
+
# the tree, and signals reach scythe directly rather than this wrapper.
|
|
54
|
+
try:
|
|
55
|
+
os.execv(str(binary_path), argv) # noqa: S606 -- resolved via our own cache/PATH logic
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
print(f"scythe-sql: failed to execute {binary_path}: {exc}", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
sys.exit(main())
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Download and cache the platform-specific scythe binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from scythe_sql.cache import cached_binary_path, default_home
|
|
14
|
+
from scythe_sql.checksum import expected_checksum, parse_checksums, verify_checksum
|
|
15
|
+
from scythe_sql.errors import ScytheSqlError
|
|
16
|
+
from scythe_sql.extract import extract_tar_gz, extract_zip
|
|
17
|
+
from scythe_sql.platform_resolver import UnsupportedPlatformError, is_musl_linux, resolve_target
|
|
18
|
+
from scythe_sql.proxy import resolve_ca_file
|
|
19
|
+
from scythe_sql.version_utils import assert_real_version
|
|
20
|
+
|
|
21
|
+
REPO = "https://github.com/Goldziher/scythe"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DownloadError(ScytheSqlError):
|
|
25
|
+
"""Raised when fetching a release asset fails."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fetch(url: str) -> bytes:
|
|
29
|
+
ca_file = resolve_ca_file(dict(os.environ))
|
|
30
|
+
context = None
|
|
31
|
+
if ca_file:
|
|
32
|
+
import ssl
|
|
33
|
+
|
|
34
|
+
context = ssl.create_default_context(cafile=ca_file)
|
|
35
|
+
|
|
36
|
+
# urllib.request honours HTTPS_PROXY/HTTP_PROXY/NO_PROXY natively via
|
|
37
|
+
# the environment (ProxyHandler is installed by default in build_opener()).
|
|
38
|
+
request = urllib.request.Request(url, headers={"User-Agent": "scythe-sql-installer"}) # noqa: S310
|
|
39
|
+
try:
|
|
40
|
+
with urllib.request.urlopen(request, context=context) as response: # noqa: S310
|
|
41
|
+
if response.status != 200:
|
|
42
|
+
raise DownloadError(f"scythe-sql: failed to download {url}: HTTP {response.status}")
|
|
43
|
+
return response.read()
|
|
44
|
+
except urllib.error.URLError as exc:
|
|
45
|
+
raise DownloadError(f"scythe-sql: failed to download {url}: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_usable_cached_binary(path: Path) -> bool:
|
|
49
|
+
"""Reports whether `path` holds a complete, runnable cached binary.
|
|
50
|
+
|
|
51
|
+
Existence is not enough. A cache entry written by an older, non-atomic
|
|
52
|
+
version of this package -- or by any interrupted write -- can be an empty or
|
|
53
|
+
truncated file, and returning it would fail at exec time with an opaque
|
|
54
|
+
error on every subsequent run. A zero-length file is never a valid binary,
|
|
55
|
+
so treat it as absent and re-download over it.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
stat_result = path.stat()
|
|
59
|
+
except OSError:
|
|
60
|
+
return False
|
|
61
|
+
return stat.S_ISREG(stat_result.st_mode) and stat_result.st_size > 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ensure_binary(version: str, *, env: dict[str, str] | None = None) -> Path:
|
|
65
|
+
"""Ensures the pinned scythe binary is present locally, downloading it if needed.
|
|
66
|
+
|
|
67
|
+
Returns the path to a usable `scythe` binary.
|
|
68
|
+
"""
|
|
69
|
+
env = dict(os.environ) if env is None else env
|
|
70
|
+
assert_real_version(version)
|
|
71
|
+
|
|
72
|
+
if env.get("SCYTHE_BINARY"):
|
|
73
|
+
return Path(env["SCYTHE_BINARY"])
|
|
74
|
+
|
|
75
|
+
resolved = resolve_target(
|
|
76
|
+
sys_platform=sys.platform,
|
|
77
|
+
machine=platform.machine(),
|
|
78
|
+
is_musl=sys.platform.startswith("linux") and is_musl_linux(platform.libc_ver()),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
dest_path = cached_binary_path(
|
|
82
|
+
env=env,
|
|
83
|
+
sys_platform=sys.platform,
|
|
84
|
+
home=default_home(),
|
|
85
|
+
version=version,
|
|
86
|
+
binary_name=resolved.binary_name,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if is_usable_cached_binary(dest_path):
|
|
90
|
+
return dest_path
|
|
91
|
+
|
|
92
|
+
from scythe_sql.preinstalled import has_matching_path_binary
|
|
93
|
+
|
|
94
|
+
if has_matching_path_binary(version):
|
|
95
|
+
return Path("scythe")
|
|
96
|
+
|
|
97
|
+
if env.get("SCYTHE_SKIP_DOWNLOAD") == "1":
|
|
98
|
+
raise DownloadError(
|
|
99
|
+
f"scythe-sql: SCYTHE_SKIP_DOWNLOAD=1 set but no cached or PATH binary matching {version} found."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
asset_name = f"scythe-{resolved.target}.{resolved.archive_ext}"
|
|
103
|
+
checksums_name = f"scythe_{version}_checksums.txt"
|
|
104
|
+
base_url = f"{REPO}/releases/download/v{version}"
|
|
105
|
+
asset_url = f"{base_url}/{asset_name}"
|
|
106
|
+
checksums_url = f"{base_url}/{checksums_name}"
|
|
107
|
+
|
|
108
|
+
print(f"scythe-sql: downloading scythe {version} for {resolved.target}...", file=sys.stderr) # noqa: T201
|
|
109
|
+
|
|
110
|
+
checksums_text = _fetch(checksums_url).decode("utf-8")
|
|
111
|
+
checksums = parse_checksums(checksums_text)
|
|
112
|
+
expected = expected_checksum(checksums, asset_name, checksums_url)
|
|
113
|
+
|
|
114
|
+
asset_bytes = _fetch(asset_url)
|
|
115
|
+
verify_checksum(asset_bytes, expected, asset_url)
|
|
116
|
+
|
|
117
|
+
# Both extractors stage into a temp file, set the mode, then rename, so
|
|
118
|
+
# dest_path only ever appears complete and executable.
|
|
119
|
+
if resolved.archive_ext == "zip":
|
|
120
|
+
extract_zip(asset_bytes, resolved.binary_name, dest_path)
|
|
121
|
+
else:
|
|
122
|
+
extract_tar_gz(asset_bytes, resolved.binary_name, dest_path)
|
|
123
|
+
|
|
124
|
+
print(f"scythe-sql: installed scythe {version} to {dest_path}", file=sys.stderr) # noqa: T201
|
|
125
|
+
return dest_path
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = ["DownloadError", "ScytheSqlError", "UnsupportedPlatformError", "ensure_binary", "is_usable_cached_binary"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Shared base exception for every failure this wrapper raises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ScytheSqlError(RuntimeError):
|
|
7
|
+
"""Base class for all scythe-sql wrapper failures.
|
|
8
|
+
|
|
9
|
+
Every message in this hierarchy is already prefixed with ``scythe-sql:`` and
|
|
10
|
+
is written to be printed verbatim, so :mod:`scythe_sql.cli` can catch this
|
|
11
|
+
one type instead of enumerating subclasses -- an enumeration that silently
|
|
12
|
+
fell out of date and double-prefixed the security-relevant checksum errors.
|
|
13
|
+
|
|
14
|
+
Subclasses :class:`RuntimeError` so any caller already catching
|
|
15
|
+
``RuntimeError`` keeps working.
|
|
16
|
+
"""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Archive extraction: locate and unpack the scythe binary by basename.
|
|
2
|
+
|
|
3
|
+
The archive also contains LICENSE/README (goreleaser default), so callers
|
|
4
|
+
must not assume a fixed path inside the archive -- search by basename.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import tarfile
|
|
13
|
+
import zipfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from scythe_sql.errors import ScytheSqlError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BinaryNotFoundError(ScytheSqlError):
|
|
20
|
+
"""Raised when the archive does not contain an entry matching the binary name."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_binary_entry(entry_names: list[str], binary_name: str) -> str:
|
|
24
|
+
"""Finds the archive entry whose basename matches `binary_name`."""
|
|
25
|
+
for name in entry_names:
|
|
26
|
+
if name.rstrip("/").split("/")[-1] == binary_name:
|
|
27
|
+
return name
|
|
28
|
+
listing = ", ".join(entry_names) if entry_names else "(empty)"
|
|
29
|
+
raise BinaryNotFoundError(
|
|
30
|
+
f"scythe-sql: could not find '{binary_name}' inside the downloaded archive. Archive contained: {listing}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_binary_atomically(dest_path: Path, data: bytes) -> None:
|
|
35
|
+
"""Writes `data` to `dest_path` atomically, already marked executable.
|
|
36
|
+
|
|
37
|
+
Writing straight to the cached path is not safe: a process killed mid-write,
|
|
38
|
+
an OOM kill, or a full disk leaves a truncated file that the cache -- which
|
|
39
|
+
keys only on the path -- then trusts forever, so every later run fails with
|
|
40
|
+
an opaque exec error. Two concurrent installs sharing a cache can also
|
|
41
|
+
interleave their writes into the same file.
|
|
42
|
+
|
|
43
|
+
Staging into a sibling temp file and renaming makes the cached path either
|
|
44
|
+
absent or complete, never partial, because `os.replace` is atomic within a
|
|
45
|
+
filesystem (a sibling guarantees the same filesystem). The pid in the temp
|
|
46
|
+
name keeps concurrent writers off each other's staging file; the rename
|
|
47
|
+
itself makes the last writer win harmlessly, since both wrote verified
|
|
48
|
+
bytes. The mode is set on the temp file so the binary is never observable
|
|
49
|
+
at its final path without the executable bit.
|
|
50
|
+
"""
|
|
51
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
tmp_path = dest_path.with_name(f".{dest_path.name}.{os.getpid()}.tmp")
|
|
53
|
+
try:
|
|
54
|
+
with tmp_path.open("wb") as handle:
|
|
55
|
+
handle.write(data)
|
|
56
|
+
handle.flush()
|
|
57
|
+
# Without fsync the rename can land before the data on a crash,
|
|
58
|
+
# recreating the truncated-file problem the staging file prevents.
|
|
59
|
+
os.fsync(handle.fileno())
|
|
60
|
+
if os.name != "nt":
|
|
61
|
+
make_executable(tmp_path)
|
|
62
|
+
os.replace(tmp_path, dest_path)
|
|
63
|
+
finally:
|
|
64
|
+
tmp_path.unlink(missing_ok=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_tar_gz(data: bytes, binary_name: str, dest_path: Path) -> None:
|
|
68
|
+
"""Extracts the scythe binary from a `.tar.gz` archive to `dest_path`."""
|
|
69
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
|
|
70
|
+
names = [member.name for member in tar.getmembers() if member.isfile()]
|
|
71
|
+
entry_name = find_binary_entry(names, binary_name)
|
|
72
|
+
member = tar.getmember(entry_name)
|
|
73
|
+
extracted = tar.extractfile(member)
|
|
74
|
+
if extracted is None:
|
|
75
|
+
raise BinaryNotFoundError(f"scythe-sql: archive entry '{entry_name}' is not a regular file")
|
|
76
|
+
write_binary_atomically(dest_path, extracted.read())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_zip(data: bytes, binary_name: str, dest_path: Path) -> None:
|
|
80
|
+
"""Extracts the scythe binary from a `.zip` archive to `dest_path`."""
|
|
81
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
82
|
+
names = [info.filename for info in zf.infolist() if not info.is_dir()]
|
|
83
|
+
entry_name = find_binary_entry(names, binary_name)
|
|
84
|
+
write_binary_atomically(dest_path, zf.read(entry_name))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def make_executable(path: Path) -> None:
|
|
88
|
+
"""chmod 0o755 on POSIX; a no-op is not needed on Windows since .exe requires no bit."""
|
|
89
|
+
path.chmod(stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Platform+arch -> Rust target triple resolution for scythe release assets.
|
|
2
|
+
|
|
3
|
+
Release assets published by goreleaser (verified against the v0.12.0 release):
|
|
4
|
+
scythe-x86_64-unknown-linux-gnu.tar.gz
|
|
5
|
+
scythe-aarch64-unknown-linux-gnu.tar.gz
|
|
6
|
+
scythe-x86_64-apple-darwin.tar.gz
|
|
7
|
+
scythe-aarch64-apple-darwin.tar.gz
|
|
8
|
+
scythe-x86_64-pc-windows-gnu.zip
|
|
9
|
+
|
|
10
|
+
There is no musl target and no aarch64 Windows asset.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Callable, Iterable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from scythe_sql.errors import ScytheSqlError
|
|
21
|
+
|
|
22
|
+
TRIPLES: dict[tuple[str, str], str] = {
|
|
23
|
+
("linux", "x64"): "x86_64-unknown-linux-gnu",
|
|
24
|
+
("linux", "arm64"): "aarch64-unknown-linux-gnu",
|
|
25
|
+
("darwin", "x64"): "x86_64-apple-darwin",
|
|
26
|
+
("darwin", "arm64"): "aarch64-apple-darwin",
|
|
27
|
+
("win32", "x64"): "x86_64-pc-windows-gnu",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_MACHINE_ALIASES: dict[str, str] = {
|
|
31
|
+
"x86_64": "x64",
|
|
32
|
+
"amd64": "x64",
|
|
33
|
+
"aarch64": "arm64",
|
|
34
|
+
"arm64": "arm64",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ResolvedTarget:
|
|
40
|
+
"""Resolved Rust target triple, archive extension, and binary filename."""
|
|
41
|
+
|
|
42
|
+
target: str
|
|
43
|
+
archive_ext: str
|
|
44
|
+
binary_name: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UnsupportedPlatformError(ScytheSqlError):
|
|
48
|
+
"""Raised when no release asset exists for the current platform/arch."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normalize_platform(sys_platform: str) -> str:
|
|
52
|
+
"""Maps ``sys.platform`` values to the npm-style keys used by ``TRIPLES``."""
|
|
53
|
+
if sys_platform.startswith("linux"):
|
|
54
|
+
return "linux"
|
|
55
|
+
if sys_platform == "darwin":
|
|
56
|
+
return "darwin"
|
|
57
|
+
if sys_platform.startswith("win"):
|
|
58
|
+
return "win32"
|
|
59
|
+
return sys_platform
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalize_machine(machine: str) -> str:
|
|
63
|
+
"""Maps ``platform.machine()`` values to the npm-style arch keys."""
|
|
64
|
+
return _MACHINE_ALIASES.get(machine.lower(), machine.lower())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_MUSL_LOADER_DIRS: tuple[str, ...] = ("/lib", "/usr/lib")
|
|
68
|
+
_MUSL_LOADER_GLOB = "ld-musl-*.so.1"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def musl_loader_present(loader_dirs: Iterable[str] = _MUSL_LOADER_DIRS) -> bool:
|
|
72
|
+
"""Reports whether a musl dynamic loader (``ld-musl-<arch>.so.1``) exists on disk."""
|
|
73
|
+
return any(any(Path(directory).glob(_MUSL_LOADER_GLOB)) for directory in loader_dirs)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_musl_linux(
|
|
77
|
+
libc_ver: tuple[str, str],
|
|
78
|
+
*,
|
|
79
|
+
loader_present: Callable[[], bool] = musl_loader_present,
|
|
80
|
+
) -> bool:
|
|
81
|
+
"""Detects musl libc (e.g. Alpine) from :func:`platform.libc_ver` output.
|
|
82
|
+
|
|
83
|
+
``platform.libc_ver()`` alone cannot answer this across the supported
|
|
84
|
+
interpreter range. CPython only taught it to recognize musl in 3.14, where a
|
|
85
|
+
musl host reports ``("musl", "1.2.x")``; on 3.9 through 3.13 the same host
|
|
86
|
+
reports ``("", "")``. Treating ``("", "")`` as proof of musl is therefore
|
|
87
|
+
wrong in both directions: it misses musl on 3.14+, and it misfires on glibc
|
|
88
|
+
whenever the lookup merely fails -- ``sys.executable`` is unset (embedded
|
|
89
|
+
interpreters), or the binary is statically linked or stripped of the symbols
|
|
90
|
+
the function scans for. The docs describe it as a heuristic with "intimate
|
|
91
|
+
knowledge of how different libc versions add symbols", not a contract.
|
|
92
|
+
|
|
93
|
+
So: trust an explicit answer in either direction, and fall back to positive
|
|
94
|
+
on-disk evidence of a musl loader when the tuple is empty and thus tells us
|
|
95
|
+
nothing.
|
|
96
|
+
"""
|
|
97
|
+
reported_lib = libc_ver[0].lower()
|
|
98
|
+
if reported_lib == "musl":
|
|
99
|
+
return True
|
|
100
|
+
if reported_lib:
|
|
101
|
+
return False
|
|
102
|
+
return loader_present()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_target(
|
|
106
|
+
*,
|
|
107
|
+
sys_platform: str = sys.platform,
|
|
108
|
+
machine: str,
|
|
109
|
+
is_musl: bool = False,
|
|
110
|
+
warn: Callable[[str], None] | None = None,
|
|
111
|
+
) -> ResolvedTarget:
|
|
112
|
+
"""Resolves the release target for the current platform/arch.
|
|
113
|
+
|
|
114
|
+
Raises :class:`UnsupportedPlatformError` naming the platform and arch,
|
|
115
|
+
or the musl-specific guidance, when no asset is published.
|
|
116
|
+
"""
|
|
117
|
+
platform_key = normalize_platform(sys_platform)
|
|
118
|
+
arch_key = normalize_machine(machine)
|
|
119
|
+
warn_fn = warn or (lambda message: print(message, file=sys.stderr)) # noqa: T201
|
|
120
|
+
|
|
121
|
+
if platform_key == "linux" and is_musl:
|
|
122
|
+
raise UnsupportedPlatformError(
|
|
123
|
+
f"scythe-sql: unsupported platform 'linux/{arch_key}' (musl libc, e.g. Alpine). "
|
|
124
|
+
"No musl build is published. Install a glibc-based image, or install scythe via "
|
|
125
|
+
"'cargo install scythe-cli' instead."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if platform_key == "win32" and arch_key == "arm64":
|
|
129
|
+
warn_fn(
|
|
130
|
+
"scythe-sql: no native Windows ARM64 build is published; falling back to the "
|
|
131
|
+
"x86_64-pc-windows-gnu binary, which runs under Windows 11 ARM64 x64 emulation."
|
|
132
|
+
)
|
|
133
|
+
return ResolvedTarget(target=TRIPLES[("win32", "x64")], archive_ext="zip", binary_name="scythe.exe")
|
|
134
|
+
|
|
135
|
+
target = TRIPLES.get((platform_key, arch_key))
|
|
136
|
+
if target is None:
|
|
137
|
+
supported = ", ".join(f"{p}/{a}" for p, a in TRIPLES)
|
|
138
|
+
raise UnsupportedPlatformError(
|
|
139
|
+
f"scythe-sql: unsupported platform '{platform_key}/{arch_key}'. Supported platforms: "
|
|
140
|
+
f"{supported} (plus win32/arm64 via x64 emulation). Install scythe via "
|
|
141
|
+
"'cargo install scythe-cli' instead."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
archive_ext = "zip" if platform_key == "win32" else "tar.gz"
|
|
145
|
+
binary_name = "scythe.exe" if platform_key == "win32" else "scythe"
|
|
146
|
+
return ResolvedTarget(target=target, archive_ext=archive_ext, binary_name=binary_name)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Detects a matching scythe binary already on PATH."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from scythe_sql.version_utils import extract_version
|
|
9
|
+
|
|
10
|
+
_ExecFn = Callable[[], str]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _default_exec() -> str:
|
|
14
|
+
result = subprocess.run( # noqa: S603
|
|
15
|
+
["scythe", "--version"], # noqa: S607
|
|
16
|
+
capture_output=True,
|
|
17
|
+
text=True,
|
|
18
|
+
check=True,
|
|
19
|
+
)
|
|
20
|
+
return result.stdout
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def has_matching_path_binary(want_version: str, exec_fn: _ExecFn = _default_exec) -> bool:
|
|
24
|
+
"""Checks whether a `scythe` binary already on PATH matches `want_version` exactly.
|
|
25
|
+
|
|
26
|
+
Exact equality only: a newer binary on PATH does not satisfy a pin, since
|
|
27
|
+
silently upgrading defeats the point of pinning the dependency version.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
output = exec_fn()
|
|
31
|
+
except (OSError, subprocess.SubprocessError):
|
|
32
|
+
return False
|
|
33
|
+
return extract_version(output) == want_version
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""CA bundle resolution.
|
|
2
|
+
|
|
3
|
+
Proxy handling itself is not reimplemented here: `urllib.request` honours
|
|
4
|
+
`HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` natively via `urllib.request.getproxies`
|
|
5
|
+
and `ProxyHandler`, unlike Node's `fetch`. Only the CA bundle path needs
|
|
6
|
+
explicit resolution, since Python has no single standard env var for it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_ca_file(env: dict[str, str]) -> str | None:
|
|
13
|
+
"""Resolves the CA bundle file path to trust, honouring pip's and requests' conventions.
|
|
14
|
+
|
|
15
|
+
An exported-but-empty variable counts as unset, and yields ``None`` rather
|
|
16
|
+
than the empty string an ``or`` chain would otherwise fall through to.
|
|
17
|
+
"""
|
|
18
|
+
for variable in ("PIP_CERT", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"):
|
|
19
|
+
value = env.get(variable)
|
|
20
|
+
if value:
|
|
21
|
+
return value
|
|
22
|
+
return None
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Version string parsing and the 0.0.0 placeholder guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from scythe_sql.errors import ScytheSqlError
|
|
8
|
+
|
|
9
|
+
_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_version(output: str) -> str | None:
|
|
13
|
+
"""Permissively extracts a semver-ish version token from CLI output.
|
|
14
|
+
|
|
15
|
+
The exact output format of the real ``scythe --version`` binary is not
|
|
16
|
+
pinned by design, so this finds the first ``MAJOR.MINOR.PATCH[-pre][+build]``
|
|
17
|
+
token rather than anchoring to the full line.
|
|
18
|
+
"""
|
|
19
|
+
match = _VERSION_RE.search(output)
|
|
20
|
+
return match.group(1) if match else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PlaceholderVersionError(ScytheSqlError):
|
|
24
|
+
"""Raised when the package still carries the unbuilt 0.0.0 placeholder version."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def assert_real_version(version: str) -> None:
|
|
28
|
+
"""Refuses to proceed when `version` is the unbuilt `0.0.0` placeholder.
|
|
29
|
+
|
|
30
|
+
A wrapper built from a dirty/unversioned checkout would otherwise
|
|
31
|
+
construct a nonsense download URL.
|
|
32
|
+
"""
|
|
33
|
+
if version == "0.0.0":
|
|
34
|
+
raise PlaceholderVersionError(
|
|
35
|
+
"scythe-sql: this package was built incorrectly -- it still carries the placeholder "
|
|
36
|
+
"version '0.0.0'. The publish workflow must inject the real release version (RELEASE_VERSION) "
|
|
37
|
+
"before building. Please report this at https://github.com/Goldziher/scythe/issues."
|
|
38
|
+
)
|