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.
@@ -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,7 @@
1
+ """PyPI wrapper for the scythe CLI.
2
+
3
+ Downloads and execs the prebuilt ``scythe`` binary for the current platform;
4
+ see :mod:`scythe_sql.cli` for the entry point.
5
+ """
6
+
7
+ __version__ = "0.13.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
+ )