kuzybuild 0.2.0__py3-none-any.whl
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.
- kuzybuild/__init__.py +1 -0
- kuzybuild/common.py +86 -0
- kuzybuild/pypi.py +42 -0
- kuzybuild/rpm.py +67 -0
- kuzybuild-0.2.0.data/scripts/kuzybuild-deb +277 -0
- kuzybuild-0.2.0.dist-info/METADATA +205 -0
- kuzybuild-0.2.0.dist-info/RECORD +11 -0
- kuzybuild-0.2.0.dist-info/WHEEL +5 -0
- kuzybuild-0.2.0.dist-info/entry_points.txt +3 -0
- kuzybuild-0.2.0.dist-info/licenses/LICENSE +661 -0
- kuzybuild-0.2.0.dist-info/top_level.txt +1 -0
kuzybuild/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local build frontends. These commands never publish artifacts."""
|
kuzybuild/common.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tomllib
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def metadata(root: Path) -> tuple[str, str]:
|
|
14
|
+
from packaging.version import Version
|
|
15
|
+
project = tomllib.loads((root / "pyproject.toml").read_text())["project"]
|
|
16
|
+
name, version = project["name"], project["version"]
|
|
17
|
+
Version(version)
|
|
18
|
+
if not name or any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_." for c in name):
|
|
19
|
+
raise ValueError("invalid distribution name")
|
|
20
|
+
return name, version
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def native_name(root: Path) -> str:
|
|
24
|
+
data = tomllib.loads((root / "pyproject.toml").read_text())
|
|
25
|
+
name = data.get("tool", {}).get("kuzybuild", {}).get("native-name", data["project"]["name"])
|
|
26
|
+
if not isinstance(name, str) or not name or any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_." for c in name):
|
|
27
|
+
raise ValueError("invalid native package name")
|
|
28
|
+
return name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def stage(root: Path, dest: Path) -> None:
|
|
32
|
+
def ignore(directory, names):
|
|
33
|
+
return [name for name in names if name in {
|
|
34
|
+
".git", ".venv", "venv", "dist", "build", ".pybuild", ".pytest_cache",
|
|
35
|
+
".mypy_cache", ".ruff_cache", "__pycache__",
|
|
36
|
+
} or name.endswith((".egg-info", ".pyc"))]
|
|
37
|
+
shutil.copytree(root, dest, ignore=ignore, symlinks=True)
|
|
38
|
+
for path in dest.rglob("*"):
|
|
39
|
+
if path.is_symlink():
|
|
40
|
+
try:
|
|
41
|
+
path.resolve().relative_to(dest.resolve())
|
|
42
|
+
except ValueError:
|
|
43
|
+
raise ValueError(f"source symlink escapes project: {path.relative_to(dest)}") from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run(command: list[str], cwd: Path) -> None:
|
|
47
|
+
subprocess.run(command, cwd=cwd, check=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def publish_local(artifacts: list[Path], output: Path, info: dict) -> None:
|
|
51
|
+
if not artifacts:
|
|
52
|
+
raise ValueError("build produced no artifacts")
|
|
53
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
names = [artifact.name for artifact in artifacts]
|
|
55
|
+
record_path = output / (artifacts[0].name + ".build.json")
|
|
56
|
+
if len(names) != len(set(names)):
|
|
57
|
+
raise ValueError("build produced duplicate artifact names")
|
|
58
|
+
if any((output / name).exists() or (output / name).is_symlink() for name in names) or record_path.exists() or record_path.is_symlink():
|
|
59
|
+
raise ValueError("output artifacts already exist; use another --out-dir (nothing is deleted)")
|
|
60
|
+
hashes = {}
|
|
61
|
+
for artifact in artifacts:
|
|
62
|
+
target = output / artifact.name
|
|
63
|
+
with artifact.open("rb") as source, target.open("xb") as dest:
|
|
64
|
+
shutil.copyfileobj(source, dest)
|
|
65
|
+
with target.open("rb") as stream:
|
|
66
|
+
hashes[target.name] = hashlib.file_digest(stream, "sha256").hexdigest()
|
|
67
|
+
record = {**info, "artifacts": hashes, "published": False}
|
|
68
|
+
with record_path.open("x") as stream:
|
|
69
|
+
json.dump(record, stream, indent=2)
|
|
70
|
+
stream.write("\n")
|
|
71
|
+
print(json.dumps(record))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parser(kind: str) -> argparse.ArgumentParser:
|
|
75
|
+
result = argparse.ArgumentParser(prog=f"kuzybuild-{kind}")
|
|
76
|
+
result.add_argument("--release", action="store_true", help="Build the exact project version; no tag or upload.")
|
|
77
|
+
result.add_argument("--out-dir", type=Path)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main_guard(func) -> int:
|
|
82
|
+
try:
|
|
83
|
+
return func()
|
|
84
|
+
except (ValueError, OSError, subprocess.CalledProcessError) as exc:
|
|
85
|
+
print(f"build failed: {exc}", file=sys.stderr)
|
|
86
|
+
return 1
|
kuzybuild/pypi.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Build/check local PyPI artifacts; deliberately no upload command."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
from .common import main_guard, metadata, parser, publish_local, run, stage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build() -> int:
|
|
11
|
+
ap = parser("pypi")
|
|
12
|
+
ap.add_argument("--no-isolation", action="store_true", help="Use installed build dependencies.")
|
|
13
|
+
args = ap.parse_args()
|
|
14
|
+
root = Path.cwd().resolve()
|
|
15
|
+
config = tomllib.loads((root / "pyproject.toml").read_text())
|
|
16
|
+
enabled = config.get("tool", {}).get("kuzybuild", {}).get("pypi", True)
|
|
17
|
+
if not isinstance(enabled, bool):
|
|
18
|
+
raise ValueError("tool.kuzybuild.pypi must be a boolean")
|
|
19
|
+
if not enabled:
|
|
20
|
+
raise ValueError("PyPI target disabled by tool.kuzybuild.pypi=false; native builds remain available")
|
|
21
|
+
name, version = metadata(root)
|
|
22
|
+
output = (args.out_dir or root / "dist/pypi").absolute()
|
|
23
|
+
with tempfile.TemporaryDirectory(prefix="kuzybuild-pypi-") as tmp:
|
|
24
|
+
source = Path(tmp) / "source"
|
|
25
|
+
stage(root, source)
|
|
26
|
+
command = [sys.executable, "-m", "build", "--outdir", str(Path(tmp) / "artifacts")]
|
|
27
|
+
if args.no_isolation:
|
|
28
|
+
command.append("--no-isolation")
|
|
29
|
+
# build's default builds the wheel from the sdist, not the checkout.
|
|
30
|
+
run(command, source)
|
|
31
|
+
artifacts = sorted((Path(tmp) / "artifacts").iterdir())
|
|
32
|
+
run([sys.executable, "-m", "twine", "check", "--strict", *map(str, artifacts)], source)
|
|
33
|
+
publish_local(artifacts, output, {"format": "pypi", "project": name, "version": version})
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main() -> int:
|
|
38
|
+
return main_guard(build)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
raise SystemExit(main())
|
kuzybuild/rpm.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Native Fedora RPM frontend; never create a repository or upload anything."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import tarfile
|
|
6
|
+
import tempfile
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
from .common import main_guard, metadata, native_name, parser, publish_local, run, stage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build() -> int:
|
|
13
|
+
ap = parser("rpm")
|
|
14
|
+
ap.add_argument("--target-fedora", default="45")
|
|
15
|
+
ap.add_argument("--revision", default="1")
|
|
16
|
+
args = ap.parse_args()
|
|
17
|
+
if not re.fullmatch(r"[1-9][0-9]*", args.target_fedora) or not re.fullmatch(r"[1-9][0-9]*", args.revision):
|
|
18
|
+
raise ValueError("Fedora target and revision must be positive integers")
|
|
19
|
+
root = Path.cwd().resolve()
|
|
20
|
+
config = tomllib.loads((root / "pyproject.toml").read_text())
|
|
21
|
+
enabled = config.get("tool", {}).get("kuzybuild", {}).get("rpm", True)
|
|
22
|
+
if not isinstance(enabled, bool):
|
|
23
|
+
raise ValueError("tool.kuzybuild.rpm must be a boolean")
|
|
24
|
+
if not enabled:
|
|
25
|
+
raise ValueError("RPM target disabled by tool.kuzybuild.rpm=false; Python and Debian builds remain available")
|
|
26
|
+
native = subprocess.check_output(["rpm", "--eval", "%{fedora}"], text=True).strip()
|
|
27
|
+
if native != args.target_fedora:
|
|
28
|
+
raise ValueError(f"build inside Fedora {args.target_fedora}; native RPM target is {native!r}")
|
|
29
|
+
distribution, version = metadata(root)
|
|
30
|
+
name = native_name(root)
|
|
31
|
+
if not re.fullmatch(r"[0-9]+(?:\.[0-9]+)+", version):
|
|
32
|
+
raise ValueError("RPM releases currently require numeric dotted project versions")
|
|
33
|
+
output = (args.out_dir or root / "dist/rpm").absolute()
|
|
34
|
+
if not (root / "packaging/rpm" / f"{name}.spec").is_file():
|
|
35
|
+
raise ValueError(f"missing RPM spec for {name}")
|
|
36
|
+
with tempfile.TemporaryDirectory(prefix="kuzybuild-rpm-") as tmp:
|
|
37
|
+
top = Path(tmp)
|
|
38
|
+
for folder in ("SOURCES", "SPECS", "BUILD", "BUILDROOT", "RPMS", "SRPMS"):
|
|
39
|
+
(top / folder).mkdir()
|
|
40
|
+
source = top / f"{name}-{version}"
|
|
41
|
+
stage(root, source)
|
|
42
|
+
spec = source / "packaging/rpm" / f"{name}.spec"
|
|
43
|
+
text, count = re.subn(r"^Version:\s*\S+", f"Version: {version}", spec.read_text(), flags=re.M)
|
|
44
|
+
if count != 1:
|
|
45
|
+
raise ValueError("spec must declare exactly one Version field")
|
|
46
|
+
text, count = re.subn(r"^Release:\s*\S+", f"Release: {args.revision}%{{?dist}}", text, flags=re.M)
|
|
47
|
+
if count != 1:
|
|
48
|
+
raise ValueError("spec must declare exactly one Release field")
|
|
49
|
+
spec.write_text(text)
|
|
50
|
+
with tarfile.open(top / "SOURCES" / f"{name}-{version}.tar.gz", "w:gz") as tar:
|
|
51
|
+
tar.add(source, arcname=source.name)
|
|
52
|
+
run(["rpmbuild", "--define", f"_topdir {top}", "-ba", str(spec)], source)
|
|
53
|
+
artifacts = sorted((top / "RPMS").rglob("*.rpm")) + sorted((top / "SRPMS").glob("*.rpm"))
|
|
54
|
+
publish_local(artifacts, output, {
|
|
55
|
+
"format": "rpm", "project": name, "version": version,
|
|
56
|
+
"python_distribution": distribution,
|
|
57
|
+
"fedora": native, "revision": args.revision,
|
|
58
|
+
})
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> int:
|
|
63
|
+
return main_guard(build)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT_DIR="$PWD"
|
|
5
|
+
OUTPUT_DIR="${ROOT_DIR}/dist/debian"
|
|
6
|
+
REVISION="1"
|
|
7
|
+
EXACT_VERSION=""
|
|
8
|
+
RELEASE="0"
|
|
9
|
+
TARGET_CODENAME="${KUZYBUILD_TARGET_CODENAME:-}"
|
|
10
|
+
TARGET_REVISION="${KUZYBUILD_TARGET_REVISION:-1}"
|
|
11
|
+
|
|
12
|
+
PROG_NAME="$(basename "${BASH_SOURCE[0]}")"
|
|
13
|
+
|
|
14
|
+
usage() {
|
|
15
|
+
cat <<USAGE
|
|
16
|
+
Usage:
|
|
17
|
+
${PROG_NAME} [options]
|
|
18
|
+
|
|
19
|
+
Run from the project root (must contain pyproject.toml and debian/changelog).
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--version <v> Build exact Debian version, for example
|
|
23
|
+
0.1.0~dev202604271200-1 (incompatible with
|
|
24
|
+
--target-codename: an explicit version is
|
|
25
|
+
used verbatim)
|
|
26
|
+
--revision <n> Debian revision for generated versions (default: 1)
|
|
27
|
+
--release Build the base release version from pyproject.toml
|
|
28
|
+
--target-codename <name> Build for a specific Debian release. Rewrites
|
|
29
|
+
the Distribution field in debian/changelog and
|
|
30
|
+
appends a ~<codename><target-revision> suffix
|
|
31
|
+
to the computed version. Pass "auto" to detect
|
|
32
|
+
the host codename via lsb_release -cs.
|
|
33
|
+
Env var: KUZYBUILD_TARGET_CODENAME
|
|
34
|
+
--target-revision <n> Revision inside a given <codename> suffix
|
|
35
|
+
(default: 1). Env var: KUZYBUILD_TARGET_REVISION
|
|
36
|
+
--out-dir <path> Output directory (default: ./dist/debian)
|
|
37
|
+
--help Show this help
|
|
38
|
+
|
|
39
|
+
Default:
|
|
40
|
+
Build a timestamped snapshot version:
|
|
41
|
+
<pyproject-version>~devYYYYMMDDHHMM-<revision>
|
|
42
|
+
|
|
43
|
+
With --target-codename, the version becomes:
|
|
44
|
+
<computed-version>~<codename><target-revision>
|
|
45
|
+
and the changelog Distribution field is set to <codename> instead of
|
|
46
|
+
"unstable". The working copy of the calling repo is never modified:
|
|
47
|
+
the build runs in a staging copy under \$TMPDIR.
|
|
48
|
+
USAGE
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
while [[ $# -gt 0 ]]; do
|
|
52
|
+
case "$1" in
|
|
53
|
+
--version)
|
|
54
|
+
EXACT_VERSION="${2:-}"
|
|
55
|
+
shift 2
|
|
56
|
+
;;
|
|
57
|
+
--revision)
|
|
58
|
+
REVISION="${2:-}"
|
|
59
|
+
shift 2
|
|
60
|
+
;;
|
|
61
|
+
--release)
|
|
62
|
+
RELEASE="1"
|
|
63
|
+
shift
|
|
64
|
+
;;
|
|
65
|
+
--target-codename)
|
|
66
|
+
TARGET_CODENAME="${2:-}"
|
|
67
|
+
shift 2
|
|
68
|
+
;;
|
|
69
|
+
--target-revision)
|
|
70
|
+
TARGET_REVISION="${2:-}"
|
|
71
|
+
shift 2
|
|
72
|
+
;;
|
|
73
|
+
--out-dir)
|
|
74
|
+
OUTPUT_DIR="${2:-}"
|
|
75
|
+
shift 2
|
|
76
|
+
;;
|
|
77
|
+
--help|-h)
|
|
78
|
+
usage
|
|
79
|
+
exit 0
|
|
80
|
+
;;
|
|
81
|
+
*)
|
|
82
|
+
echo "Unknown argument: $1" >&2
|
|
83
|
+
usage
|
|
84
|
+
exit 1
|
|
85
|
+
;;
|
|
86
|
+
esac
|
|
87
|
+
done
|
|
88
|
+
|
|
89
|
+
if [[ ! -f "${ROOT_DIR}/pyproject.toml" || ! -f "${ROOT_DIR}/debian/changelog" ]]; then
|
|
90
|
+
echo "Error: must be run from a project root containing pyproject.toml and debian/changelog." >&2
|
|
91
|
+
echo " current directory: ${ROOT_DIR}" >&2
|
|
92
|
+
exit 1
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
if [[ "$TARGET_CODENAME" == "auto" ]]; then
|
|
96
|
+
if ! command -v lsb_release >/dev/null 2>&1; then
|
|
97
|
+
echo "Error: --target-codename=auto requires lsb_release (install lsb-release)." >&2
|
|
98
|
+
exit 1
|
|
99
|
+
fi
|
|
100
|
+
TARGET_CODENAME="$(lsb_release -cs)"
|
|
101
|
+
if [[ -z "$TARGET_CODENAME" ]]; then
|
|
102
|
+
echo "Error: lsb_release -cs returned empty codename." >&2
|
|
103
|
+
exit 1
|
|
104
|
+
fi
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
if [[ -n "$TARGET_CODENAME" ]]; then
|
|
108
|
+
if [[ ! "$TARGET_CODENAME" =~ ^[a-z][a-z0-9]*$ ]]; then
|
|
109
|
+
echo "Error: invalid target codename '${TARGET_CODENAME}' (expected lowercase alnum)." >&2
|
|
110
|
+
exit 1
|
|
111
|
+
fi
|
|
112
|
+
if [[ ! "$TARGET_REVISION" =~ ^[0-9]+$ ]]; then
|
|
113
|
+
echo "Error: invalid --target-revision '${TARGET_REVISION}' (expected integer)." >&2
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
if [[ -n "$EXACT_VERSION" ]]; then
|
|
117
|
+
echo "Error: --version and --target-codename are mutually exclusive." >&2
|
|
118
|
+
echo " An explicit version is used verbatim; add the suffix yourself if needed." >&2
|
|
119
|
+
exit 1
|
|
120
|
+
fi
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
base_version() {
|
|
124
|
+
python3 - "$ROOT_DIR/pyproject.toml" <<'PY'
|
|
125
|
+
from __future__ import annotations
|
|
126
|
+
|
|
127
|
+
import sys
|
|
128
|
+
import tomllib
|
|
129
|
+
from pathlib import Path
|
|
130
|
+
|
|
131
|
+
print(tomllib.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["project"]["version"])
|
|
132
|
+
PY
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
check_changelog_matches() {
|
|
136
|
+
local pyproject_version="$1"
|
|
137
|
+
local changelog_path="${ROOT_DIR}/debian/changelog"
|
|
138
|
+
python3 - "$pyproject_version" "$changelog_path" <<'PY'
|
|
139
|
+
from __future__ import annotations
|
|
140
|
+
|
|
141
|
+
import re
|
|
142
|
+
import sys
|
|
143
|
+
from pathlib import Path
|
|
144
|
+
|
|
145
|
+
pyproject_version, changelog_path = sys.argv[1:]
|
|
146
|
+
first_line = Path(changelog_path).read_text(encoding="utf-8").splitlines()[0]
|
|
147
|
+
match = re.match(r"^[^(]+\(([^)]+)\)", first_line)
|
|
148
|
+
if not match:
|
|
149
|
+
raise SystemExit(f"debian/changelog: cannot parse first line: {first_line!r}")
|
|
150
|
+
debian_version = match.group(1)
|
|
151
|
+
if ":" in debian_version:
|
|
152
|
+
debian_version = debian_version.split(":", 1)[1]
|
|
153
|
+
upstream = debian_version.rsplit("-", 1)[0]
|
|
154
|
+
if upstream != pyproject_version:
|
|
155
|
+
raise SystemExit(
|
|
156
|
+
f"version mismatch: pyproject.toml has {pyproject_version!r} "
|
|
157
|
+
f"but debian/changelog top stanza has upstream version {upstream!r}. "
|
|
158
|
+
f"Bump the lagging file before building."
|
|
159
|
+
)
|
|
160
|
+
PY
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
validate_version() {
|
|
164
|
+
local value="$1"
|
|
165
|
+
if [[ ! "$value" =~ ^[0-9][A-Za-z0-9.+:~]*-[A-Za-z0-9.+~]+$ ]]; then
|
|
166
|
+
echo "Invalid Debian version: $value" >&2
|
|
167
|
+
exit 1
|
|
168
|
+
fi
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
SOURCE_NAME="$(dpkg-parsechangelog -l "${ROOT_DIR}/debian/changelog" -S Source)"
|
|
172
|
+
ARCH="$(dpkg --print-architecture)"
|
|
173
|
+
BASE_VERSION="$(base_version)"
|
|
174
|
+
check_changelog_matches "$BASE_VERSION"
|
|
175
|
+
|
|
176
|
+
if [[ -n "$EXACT_VERSION" ]]; then
|
|
177
|
+
VERSION="$EXACT_VERSION"
|
|
178
|
+
elif [[ "$RELEASE" == "1" ]]; then
|
|
179
|
+
VERSION="${BASE_VERSION}-${REVISION}"
|
|
180
|
+
else
|
|
181
|
+
VERSION="${BASE_VERSION}~dev$(date -u +%Y%m%d%H%M)-${REVISION}"
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
if [[ -n "$TARGET_CODENAME" ]]; then
|
|
185
|
+
VERSION="${VERSION}~${TARGET_CODENAME}${TARGET_REVISION}"
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
validate_version "$VERSION"
|
|
189
|
+
|
|
190
|
+
DISTRIBUTION="${TARGET_CODENAME:-unstable}"
|
|
191
|
+
|
|
192
|
+
mkdir -p "${OUTPUT_DIR}"
|
|
193
|
+
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd -P)"
|
|
194
|
+
if [[ -n "$(find "$OUTPUT_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
|
|
195
|
+
echo "Error: output directory is not empty; choose another --out-dir (nothing is deleted)." >&2
|
|
196
|
+
exit 1
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
STAGE_DIR="$(mktemp -d -t "kuzybuild-stage.XXXXXX")"
|
|
200
|
+
cleanup_stage() {
|
|
201
|
+
rm -rf "$STAGE_DIR"
|
|
202
|
+
}
|
|
203
|
+
trap cleanup_stage EXIT
|
|
204
|
+
|
|
205
|
+
STAGE_PROJECT="${STAGE_DIR}/$(basename "$ROOT_DIR")"
|
|
206
|
+
python3 - "$ROOT_DIR" "$STAGE_PROJECT" <<'PY'
|
|
207
|
+
import shutil
|
|
208
|
+
import sys
|
|
209
|
+
from pathlib import Path
|
|
210
|
+
|
|
211
|
+
source, target = map(Path, sys.argv[1:])
|
|
212
|
+
shutil.copytree(source, target, symlinks=True, ignore=shutil.ignore_patterns(
|
|
213
|
+
'.git', '.venv', 'venv', 'dist', 'build', '.pybuild', '__pycache__',
|
|
214
|
+
'.pytest_cache', '.mypy_cache', '.ruff_cache', '*.egg-info', '*.pyc',
|
|
215
|
+
))
|
|
216
|
+
for path in target.rglob('*'):
|
|
217
|
+
if path.is_symlink() and not path.resolve().is_relative_to(target.resolve()):
|
|
218
|
+
raise SystemExit(f'source symlink escapes project: {path.relative_to(target)}')
|
|
219
|
+
PY
|
|
220
|
+
|
|
221
|
+
BUILD_DATE="$(date -Ru)"
|
|
222
|
+
python3 - "$SOURCE_NAME" "$VERSION" "$DISTRIBUTION" "$BUILD_DATE" "${ROOT_DIR}/debian/changelog" "${STAGE_PROJECT}/debian/changelog" <<'PY'
|
|
223
|
+
from __future__ import annotations
|
|
224
|
+
|
|
225
|
+
from email.utils import parsedate_to_datetime
|
|
226
|
+
import sys
|
|
227
|
+
from pathlib import Path
|
|
228
|
+
|
|
229
|
+
source_name, version, distribution, build_date, source_path, target_path = sys.argv[1:]
|
|
230
|
+
lines = Path(source_path).read_text(encoding="utf-8").splitlines()
|
|
231
|
+
if not lines:
|
|
232
|
+
raise SystemExit("debian/changelog is empty")
|
|
233
|
+
lines[0] = f"{source_name} ({version}) {distribution}; urgency=medium"
|
|
234
|
+
for index, line in enumerate(lines):
|
|
235
|
+
if line.startswith(" -- "):
|
|
236
|
+
selected_date = build_date
|
|
237
|
+
try:
|
|
238
|
+
existing_date = line.rsplit(" ", 1)[1]
|
|
239
|
+
if parsedate_to_datetime(existing_date) > parsedate_to_datetime(build_date):
|
|
240
|
+
selected_date = existing_date
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
lines[index] = f" -- Michele Renda <michele@example.invalid> {selected_date}"
|
|
244
|
+
break
|
|
245
|
+
else:
|
|
246
|
+
raise SystemExit("debian/changelog: maintainer trailer not found")
|
|
247
|
+
Path(target_path).write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
248
|
+
PY
|
|
249
|
+
|
|
250
|
+
(
|
|
251
|
+
cd "${STAGE_PROJECT}"
|
|
252
|
+
dpkg-buildpackage -us -uc -b
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
shopt -s nullglob
|
|
256
|
+
for artifact in "${STAGE_DIR}/"*_"${VERSION}"_all.deb \
|
|
257
|
+
"${STAGE_DIR}/"*_"${VERSION}"_"${ARCH}".deb \
|
|
258
|
+
"${STAGE_DIR}/${SOURCE_NAME}_${VERSION}_${ARCH}.buildinfo" \
|
|
259
|
+
"${STAGE_DIR}/${SOURCE_NAME}_${VERSION}_${ARCH}.changes"; do
|
|
260
|
+
if [[ -f "${artifact}" ]]; then
|
|
261
|
+
mv -n "${artifact}" "${OUTPUT_DIR}/"
|
|
262
|
+
fi
|
|
263
|
+
done
|
|
264
|
+
shopt -u nullglob
|
|
265
|
+
|
|
266
|
+
if ! command -v lintian >/dev/null 2>&1; then
|
|
267
|
+
echo "Missing packaging check command: lintian" >&2
|
|
268
|
+
echo "Install with: sudo apt install lintian" >&2
|
|
269
|
+
exit 127
|
|
270
|
+
fi
|
|
271
|
+
|
|
272
|
+
(
|
|
273
|
+
cd "${OUTPUT_DIR}"
|
|
274
|
+
lintian "${SOURCE_NAME}_${VERSION}_${ARCH}.changes"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
ls -1 "${OUTPUT_DIR}"
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kuzybuild
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Local Debian, Fedora RPM and Python build helpers for kuzys.
|
|
5
|
+
Author: Michele Renda
|
|
6
|
+
License-Expression: AGPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/kuzys/kuzybuild
|
|
8
|
+
Project-URL: Repository, https://gitlab.com/kuzys/kuzybuild
|
|
9
|
+
Project-URL: Issues, https://gitlab.com/kuzys/kuzybuild/-/issues
|
|
10
|
+
Keywords: kuzys,debian,packaging,build
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: build>=1.2.2
|
|
20
|
+
Requires-Dist: packaging>=24.2
|
|
21
|
+
Requires-Dist: twine>=6.1
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# kuzybuild
|
|
27
|
+
|
|
28
|
+
Build Debian, Fedora RPM and Python distributions with shared checks and version rules.
|
|
29
|
+
|
|
30
|
+
kuzybuild provides `kuzybuild-deb`, `kuzybuild-rpm` and `kuzybuild-pypi` for
|
|
31
|
+
maintainers. They build locally, without publishing, installing packages,
|
|
32
|
+
creating tags or creating repositories. You do not need kuzybuild to use
|
|
33
|
+
another kuzys application.
|
|
34
|
+
|
|
35
|
+
It is part of [kuzys](https://www.kuzys.com/tools.html), a suite of tools for long-lived AI agents.
|
|
36
|
+
|
|
37
|
+
## What is included
|
|
38
|
+
|
|
39
|
+
- `bin/kuzybuild-deb`: the shared Bash build command.
|
|
40
|
+
- `src/kuzybuild/`: Python distribution and native RPM frontends.
|
|
41
|
+
- `debian/`: the recipe for packaging kuzybuild itself.
|
|
42
|
+
- `pyproject.toml`: package metadata and the upstream version.
|
|
43
|
+
|
|
44
|
+
The helper checks that the project version and Debian changelog agree before
|
|
45
|
+
building. It calls `dpkg-buildpackage`; each project's `debian/rules` decides
|
|
46
|
+
how to build, install and test that project. It does not replace those recipes.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
Choose one method; do not install Python packages into the system Python with
|
|
51
|
+
pip. Source-build instructions and the Python/native boundary are documented
|
|
52
|
+
in [Distribution packaging](docs/DISTRIBUTION_PACKAGING.md).
|
|
53
|
+
|
|
54
|
+
### Python / PyPI
|
|
55
|
+
|
|
56
|
+
Use Python 3.11 or newer with virtual-environment support, without sudo:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
python3 -m venv ~/.venvs/kuzybuild
|
|
60
|
+
. ~/.venvs/kuzybuild/bin/activate
|
|
61
|
+
python -m pip install 'kuzybuild'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
This installs all three build frontends, not the native toolchains.
|
|
65
|
+
Debian builds also need dpkg-dev, debhelper, dh-python,
|
|
66
|
+
pybuild-plugin-pyproject and lintian. Fedora builds need rpm-build,
|
|
67
|
+
python3-devel, pyproject-rpm-macros and the project's BuildRequires.
|
|
68
|
+
|
|
69
|
+
### Debian 13 / APT
|
|
70
|
+
|
|
71
|
+
Configure the kuzys APT repository using [Get started](https://www.kuzys.com/install.html),
|
|
72
|
+
then install on Debian 13 (trixie), amd64:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
sudo apt install kuzybuild
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Install each target project's build dependencies before building it; kuzybuild
|
|
79
|
+
does not replace the project's native recipe.
|
|
80
|
+
|
|
81
|
+
### Fedora 45 / RPM
|
|
82
|
+
|
|
83
|
+
Configure the [kuzys RPM repository](https://rpm.kuzys.com/) using its
|
|
84
|
+
repository and signing-key instructions. The coordinated release targets
|
|
85
|
+
Fedora 45 on x86_64, from the kuzys testing repository. Once the repository
|
|
86
|
+
and the package release are available:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
sudo dnf install kuzybuild
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The package supplies native RPM build prerequisites. Each target project's
|
|
93
|
+
BuildRequires must also be installed, and RPM builds must run on Fedora 45.
|
|
94
|
+
|
|
95
|
+
## First build
|
|
96
|
+
|
|
97
|
+
Run from a project containing `pyproject.toml` and `debian/changelog`, after
|
|
98
|
+
installing that project's build dependencies:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
kuzybuild-deb --help
|
|
102
|
+
kuzybuild-deb --release --target-codename trixie
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The result goes into `dist/debian/`: `.deb` packages, a `.buildinfo` file and a
|
|
106
|
+
`.changes` file. lintian checks the changes file. The original checkout is
|
|
107
|
+
copied to a temporary staging directory; its changelog is not rewritten.
|
|
108
|
+
|
|
109
|
+
The Debian output directory must be empty. No frontend removes old artifacts:
|
|
110
|
+
choose a fresh `--out-dir` for each build. Staging excludes Git metadata,
|
|
111
|
+
virtual environments, caches and previous build outputs.
|
|
112
|
+
|
|
113
|
+
## Python and Fedora builds
|
|
114
|
+
|
|
115
|
+
From a project root:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
kuzybuild-pypi --release
|
|
119
|
+
kuzybuild-rpm --release --target-fedora 45
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The first builds an sdist and then builds its wheel from that sdist, and runs
|
|
123
|
+
`twine check --strict`. It writes to `dist/pypi/`. Build isolation is enabled
|
|
124
|
+
by default; `--no-isolation` is only for an already prepared build environment.
|
|
125
|
+
All kuzys projects use `setuptools>=77.0.3`, including PEP 639 license metadata.
|
|
126
|
+
|
|
127
|
+
The RPM frontend must run inside Fedora 45, with the project's BuildRequires
|
|
128
|
+
installed. It uses `packaging/rpm/NAME.spec`, overrides Version from the Python
|
|
129
|
+
metadata and Release from `--revision` in the staging copy only, and executes
|
|
130
|
+
`rpmbuild -ba` including `%check`. It writes binary and source RPMs to
|
|
131
|
+
`dist/rpm/`; it does not cross-build on Debian or pass `--nodeps`.
|
|
132
|
+
|
|
133
|
+
When a Python distribution needs a distinct PyPI name, set
|
|
134
|
+
`[tool.kuzybuild] native-name = "NAME"` in pyproject.toml (as a normal TOML
|
|
135
|
+
table and key). The native source/spec name can then remain unchanged;
|
|
136
|
+
Python import names and command names are independent of distribution names.
|
|
137
|
+
|
|
138
|
+
Both new frontends record artifact SHA-256 hashes in a `.build.json` file.
|
|
139
|
+
For PyPI releases there is deliberately no upload command: inspect the local
|
|
140
|
+
artifacts, verify name ownership and obtain publication approval separately.
|
|
141
|
+
|
|
142
|
+
A project excluded from the PyPI flow sets this in `pyproject.toml`:
|
|
143
|
+
|
|
144
|
+
```toml
|
|
145
|
+
[tool.kuzybuild]
|
|
146
|
+
pypi = false
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`kuzybuild-pypi` then refuses before building or creating output. This is a
|
|
150
|
+
workflow safeguard, not a PyPI access-control mechanism. Native Deb/RPM builds
|
|
151
|
+
and their internal Python wheel builds remain available.
|
|
152
|
+
|
|
153
|
+
Independently, `rpm = false` in the same table disables `kuzybuild-rpm` before
|
|
154
|
+
native tools are invoked. Python and Debian targets are unaffected. This is
|
|
155
|
+
used when a required Fedora dependency is unavailable rather than emitting
|
|
156
|
+
an RPM with an incomplete dependency declaration.
|
|
157
|
+
|
|
158
|
+
## Version options
|
|
159
|
+
|
|
160
|
+
| Option | Result |
|
|
161
|
+
|---|---|
|
|
162
|
+
| No version option | Timestamped development version. |
|
|
163
|
+
| `--release` | Upstream version with a Debian revision. |
|
|
164
|
+
| `--revision 2` | Change the Debian revision. |
|
|
165
|
+
| `--target-codename trixie` | Add a distribution suffix and set the changelog distribution. |
|
|
166
|
+
| `--target-revision 2` | Change the revision within that distribution suffix. |
|
|
167
|
+
| `--version VERSION` | Use an exact Debian version; incompatible with `--target-codename`. |
|
|
168
|
+
| `--out-dir PATH` | Collect artifacts in a different, disposable directory. |
|
|
169
|
+
|
|
170
|
+
For example, upstream `0.2.0` with `--release --target-codename trixie` becomes
|
|
171
|
+
`0.2.0-1~trixie1`. Use `--help` for environment variables and all options.
|
|
172
|
+
|
|
173
|
+
## Development and bootstrap
|
|
174
|
+
|
|
175
|
+
The in-tree command runs before the kuzybuild package itself is installed.
|
|
176
|
+
On a Debian development machine with Python 3.11 or newer:
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
sudo apt install dpkg-dev debhelper dh-python pybuild-plugin-pyproject python3-all python3-setuptools python3-build python3-packaging python3-pytest twine lintian
|
|
180
|
+
bash -n bin/kuzybuild-deb
|
|
181
|
+
bin/kuzybuild-deb --help
|
|
182
|
+
bin/kuzybuild-deb --release
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The last command builds kuzybuild itself. Other repositories need their own
|
|
186
|
+
build dependencies too. Projects using the helper declare it in
|
|
187
|
+
`Build-Depends`. For the initial Fedora bootstrap, install kuzybuild's spec
|
|
188
|
+
BuildRequires and run `PYTHONPATH=src python3 -m kuzybuild.rpm --release`.
|
|
189
|
+
|
|
190
|
+
## Status and limits
|
|
191
|
+
|
|
192
|
+
- The Fedora frontend currently supports numeric dotted release versions.
|
|
193
|
+
- A distribution suffix is a label, not isolation. Build and test in an
|
|
194
|
+
environment with the target distribution's dependencies.
|
|
195
|
+
- This command does not publish packages, sign repositories or install packages.
|
|
196
|
+
- Builds include local changes. Use a reviewed, clean revision for a release.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
AGPL-3.0-or-later. See [LICENSE](LICENSE).
|
|
201
|
+
|
|
202
|
+
## Distribution packaging
|
|
203
|
+
|
|
204
|
+
See [Distribution packaging](docs/DISTRIBUTION_PACKAGING.md) for the common
|
|
205
|
+
build commands, the Python/native boundary and external dependencies.
|