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 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.