ghostbuild 0.1.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.
- ghostbuild/__init__.py +3 -0
- ghostbuild/__main__.py +6 -0
- ghostbuild/adapters/__init__.py +19 -0
- ghostbuild/adapters/base.py +68 -0
- ghostbuild/adapters/python.py +232 -0
- ghostbuild/cli.py +341 -0
- ghostbuild/config.py +229 -0
- ghostbuild/detection.py +27 -0
- ghostbuild/engine/__init__.py +1 -0
- ghostbuild/engine/archives.py +158 -0
- ghostbuild/engine/artifacts.py +56 -0
- ghostbuild/engine/comparator.py +395 -0
- ghostbuild/engine/executor.py +152 -0
- ghostbuild/engine/isolation.py +99 -0
- ghostbuild/engine/verifier.py +310 -0
- ghostbuild/engine/workspace.py +116 -0
- ghostbuild/errors.py +47 -0
- ghostbuild/models.py +216 -0
- ghostbuild/py.typed +0 -0
- ghostbuild/reports/__init__.py +29 -0
- ghostbuild/reports/html_report.py +223 -0
- ghostbuild/reports/json_report.py +46 -0
- ghostbuild/reports/terminal.py +186 -0
- ghostbuild/utils/__init__.py +1 -0
- ghostbuild/utils/environment.py +78 -0
- ghostbuild/utils/filesystem.py +35 -0
- ghostbuild/utils/hashing.py +44 -0
- ghostbuild-0.1.0.dist-info/METADATA +198 -0
- ghostbuild-0.1.0.dist-info/RECORD +33 -0
- ghostbuild-0.1.0.dist-info/WHEEL +5 -0
- ghostbuild-0.1.0.dist-info/entry_points.txt +2 -0
- ghostbuild-0.1.0.dist-info/licenses/LICENSE +21 -0
- ghostbuild-0.1.0.dist-info/top_level.txt +1 -0
ghostbuild/__init__.py
ADDED
ghostbuild/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Registry of ecosystem adapters.
|
|
2
|
+
|
|
3
|
+
To add an ecosystem, implement ``BuildAdapter`` and append an instance here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ghostbuild.adapters.base import BuildAdapter
|
|
9
|
+
from ghostbuild.adapters.python import PythonAdapter
|
|
10
|
+
|
|
11
|
+
ADAPTERS: tuple[BuildAdapter, ...] = (PythonAdapter(),)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_adapters() -> tuple[BuildAdapter, ...]:
|
|
15
|
+
"""Return all registered adapters, in detection order."""
|
|
16
|
+
return ADAPTERS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["ADAPTERS", "BuildAdapter", "PythonAdapter", "get_adapters"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The interface an ecosystem adapter implements.
|
|
2
|
+
|
|
3
|
+
The engine only talks to this interface. To add an ecosystem (Node.js, Cargo,
|
|
4
|
+
Go, ...), subclass ``BuildAdapter`` and register it in ``adapters/__init__.py``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ghostbuild.config import Config
|
|
13
|
+
from ghostbuild.models import ProjectInfo
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BuildAdapter(ABC):
|
|
17
|
+
"""Knows how to recognise, inspect and build one kind of project."""
|
|
18
|
+
|
|
19
|
+
#: Short identifier used in reports and configuration, e.g. ``"python"``.
|
|
20
|
+
id: str
|
|
21
|
+
#: Human-readable language name.
|
|
22
|
+
language: str
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def detect(self, project_dir: Path) -> bool:
|
|
26
|
+
"""Return True if this adapter can handle the project. Must not run project code."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def inspect(self, project_dir: Path) -> ProjectInfo:
|
|
30
|
+
"""Read project metadata from files. Must not run project code."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def available_tools(self) -> dict[str, str | None]:
|
|
34
|
+
"""Map tool name to a version/path, or ``None`` if it is not available."""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def missing_prerequisites(self, config: Config) -> list[str]:
|
|
38
|
+
"""Return human-readable problems that would stop a local build."""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def build_command(self, source_dir: Path, output_dir: Path, config: Config) -> list[str]:
|
|
42
|
+
"""Return the argument list (never a shell string) for a local build."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def has_custom_build_command(self, config: Config) -> bool:
|
|
46
|
+
"""Return True if the user overrode the default build command."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def container_image(self, config: Config) -> str:
|
|
50
|
+
"""Return the container image used for an independent rebuild."""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def container_script(self, config: Config) -> str:
|
|
54
|
+
"""Return the ``sh`` script run inside the container.
|
|
55
|
+
|
|
56
|
+
The container mounts the prepared source read-only at ``/src`` and a
|
|
57
|
+
writable output directory at ``/out``. The script must write artifacts
|
|
58
|
+
to ``/out`` and print one ``GHOSTBUILD_ENV <json>`` line describing the
|
|
59
|
+
container environment.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def describe_build_tool(self) -> str | None:
|
|
63
|
+
"""Return the local build tool and version, if known."""
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def collect_warnings(self, project: ProjectInfo) -> list[str]:
|
|
67
|
+
"""Return reproducibility risks visible from project metadata."""
|
|
68
|
+
return []
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Python build adapter: PEP 517 projects built with ``python -m build``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import importlib.metadata
|
|
7
|
+
import importlib.util
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ghostbuild.adapters.base import BuildAdapter
|
|
16
|
+
from ghostbuild.config import Config, load_toml
|
|
17
|
+
from ghostbuild.errors import ConfigError
|
|
18
|
+
from ghostbuild.models import ProjectInfo
|
|
19
|
+
|
|
20
|
+
# build-backend -> friendly build system name
|
|
21
|
+
_BACKENDS = {
|
|
22
|
+
"setuptools.build_meta": "setuptools",
|
|
23
|
+
"setuptools.build_meta:__legacy__": "setuptools",
|
|
24
|
+
"hatchling.build": "hatch",
|
|
25
|
+
"poetry.core.masonry.api": "poetry",
|
|
26
|
+
"pdm.backend": "pdm",
|
|
27
|
+
"pdm.pep517.api": "pdm",
|
|
28
|
+
"flit_core.buildapi": "flit",
|
|
29
|
+
"maturin": "maturin",
|
|
30
|
+
"scikit_build_core.build": "scikit-build-core",
|
|
31
|
+
}
|
|
32
|
+
_LEGACY_BACKEND = "setuptools.build_meta:__legacy__"
|
|
33
|
+
_LEGACY_REQUIRES = ["setuptools>=40.8.0", "wheel"]
|
|
34
|
+
_CONFIG_FILES = (
|
|
35
|
+
"pyproject.toml", "setup.py", "setup.cfg", "MANIFEST.in", "hatch.toml",
|
|
36
|
+
"poetry.lock", "pdm.lock", "uv.lock", "Pipfile.lock", "tox.ini", "ghostbuild.toml",
|
|
37
|
+
) # fmt: skip
|
|
38
|
+
_LOCK_FILES = ("poetry.lock", "pdm.lock", "uv.lock", "Pipfile.lock")
|
|
39
|
+
_TOOLS_ON_PATH = ("hatch", "poetry", "pdm", "uv")
|
|
40
|
+
|
|
41
|
+
_CONTAINER_SCRIPT = """\
|
|
42
|
+
set -eu
|
|
43
|
+
export HOME=/tmp/ghostbuild-home
|
|
44
|
+
mkdir -p "$HOME" /tmp/work
|
|
45
|
+
cp -a /src/. /tmp/work/
|
|
46
|
+
cd /tmp/work
|
|
47
|
+
python -m pip install --quiet --disable-pip-version-check --user '{requirement}'
|
|
48
|
+
python -c 'import importlib.metadata as m, json, os, platform, time; print("GHOSTBUILD_ENV " + json.dumps({"python_version": platform.python_version(), "python_implementation": platform.python_implementation(), "os": platform.platform(), "architecture": platform.machine(), "build_tool": "build " + m.version("build"), "locale": os.environ.get("LC_ALL") or os.environ.get("LANG"), "timezone": os.environ.get("TZ") or "/".join(time.tzname)}))'
|
|
49
|
+
python -m build --sdist --wheel --outdir /out .
|
|
50
|
+
""" # noqa: E501
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_pinned(requirement: str) -> bool:
|
|
54
|
+
"""Return True if a PEP 508 requirement pins an exact version or URL."""
|
|
55
|
+
return bool(re.search(r"(===?|@)", requirement))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PythonAdapter(BuildAdapter):
|
|
59
|
+
"""Builds Python projects through the standard ``build`` frontend."""
|
|
60
|
+
|
|
61
|
+
id = "python"
|
|
62
|
+
language = "Python"
|
|
63
|
+
|
|
64
|
+
def detect(self, project_dir: Path) -> bool:
|
|
65
|
+
if (project_dir / "setup.py").is_file() or (project_dir / "setup.cfg").is_file():
|
|
66
|
+
return True
|
|
67
|
+
pyproject = project_dir / "pyproject.toml"
|
|
68
|
+
if not pyproject.is_file():
|
|
69
|
+
return False
|
|
70
|
+
data = load_toml(pyproject)
|
|
71
|
+
return any(k in data for k in ("build-system", "project")) or "poetry" in data.get(
|
|
72
|
+
"tool", {}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def inspect(self, project_dir: Path) -> ProjectInfo:
|
|
76
|
+
data: dict[str, Any] = {}
|
|
77
|
+
pyproject = project_dir / "pyproject.toml"
|
|
78
|
+
if pyproject.is_file():
|
|
79
|
+
data = load_toml(pyproject)
|
|
80
|
+
project = data.get("project", {})
|
|
81
|
+
poetry = data.get("tool", {}).get("poetry", {})
|
|
82
|
+
notes: list[str] = []
|
|
83
|
+
|
|
84
|
+
name, version = self._name_and_version(project_dir, project, poetry, notes)
|
|
85
|
+
|
|
86
|
+
build_system_table = data.get("build-system")
|
|
87
|
+
if build_system_table is not None:
|
|
88
|
+
backend = build_system_table.get("build-backend")
|
|
89
|
+
requires = list(build_system_table.get("requires", []))
|
|
90
|
+
if backend is None:
|
|
91
|
+
backend = _LEGACY_BACKEND
|
|
92
|
+
notes.append("[build-system] has no build-backend; pip would use setuptools.")
|
|
93
|
+
else:
|
|
94
|
+
backend, requires = _LEGACY_BACKEND, list(_LEGACY_REQUIRES)
|
|
95
|
+
notes.append("No [build-system] table; the default setuptools backend would be used.")
|
|
96
|
+
if poetry:
|
|
97
|
+
notes.append(
|
|
98
|
+
"Found [tool.poetry] without [build-system]; add one (poetry-core) so the "
|
|
99
|
+
"project builds with Poetry's backend."
|
|
100
|
+
)
|
|
101
|
+
build_system = _BACKENDS.get(backend, backend)
|
|
102
|
+
if build_system_table is None and not pyproject.is_file():
|
|
103
|
+
build_system = "setuptools (legacy setup.py/setup.cfg)"
|
|
104
|
+
|
|
105
|
+
dependencies = list(project.get("dependencies", []))
|
|
106
|
+
if not dependencies and poetry:
|
|
107
|
+
dependencies = [
|
|
108
|
+
f"{dep} {spec if isinstance(spec, str) else ''}".strip()
|
|
109
|
+
for dep, spec in poetry.get("dependencies", {}).items()
|
|
110
|
+
if dep.lower() != "python"
|
|
111
|
+
]
|
|
112
|
+
requires_python = project.get("requires-python")
|
|
113
|
+
if requires_python is None and isinstance(poetry.get("dependencies", {}).get("python"), str):
|
|
114
|
+
requires_python = poetry["dependencies"]["python"]
|
|
115
|
+
|
|
116
|
+
config_files = [f for f in _CONFIG_FILES if (project_dir / f).is_file()]
|
|
117
|
+
config_files += sorted(p.name for p in project_dir.glob("requirements*.txt") if p.is_file())
|
|
118
|
+
|
|
119
|
+
return ProjectInfo(
|
|
120
|
+
path=str(project_dir),
|
|
121
|
+
name=name,
|
|
122
|
+
version=version,
|
|
123
|
+
language=self.language,
|
|
124
|
+
build_system=build_system,
|
|
125
|
+
build_backend=backend,
|
|
126
|
+
adapter=self.id,
|
|
127
|
+
config_files=config_files,
|
|
128
|
+
build_requires=requires,
|
|
129
|
+
dependencies=dependencies,
|
|
130
|
+
requires_python=requires_python,
|
|
131
|
+
lock_files=[f for f in _LOCK_FILES if (project_dir / f).is_file()],
|
|
132
|
+
notes=notes,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _name_and_version(
|
|
137
|
+
project_dir: Path, project: dict[str, Any], poetry: dict[str, Any], notes: list[str]
|
|
138
|
+
) -> tuple[str | None, str | None]:
|
|
139
|
+
name = project.get("name") or poetry.get("name")
|
|
140
|
+
version = project.get("version") or poetry.get("version")
|
|
141
|
+
if version is None and "version" in project.get("dynamic", []):
|
|
142
|
+
version = "dynamic"
|
|
143
|
+
setup_cfg = project_dir / "setup.cfg"
|
|
144
|
+
if (name is None or version is None) and setup_cfg.is_file():
|
|
145
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
146
|
+
try:
|
|
147
|
+
parser.read(setup_cfg, encoding="utf-8")
|
|
148
|
+
except configparser.Error as exc:
|
|
149
|
+
notes.append(f"setup.cfg could not be parsed: {exc}")
|
|
150
|
+
else:
|
|
151
|
+
name = name or parser.get("metadata", "name", fallback=None)
|
|
152
|
+
cfg_version = parser.get("metadata", "version", fallback=None)
|
|
153
|
+
if version is None and cfg_version:
|
|
154
|
+
version = "dynamic" if cfg_version.startswith(("attr:", "file:")) else cfg_version
|
|
155
|
+
if name is None and (project_dir / "setup.py").is_file():
|
|
156
|
+
notes.append("Name/version are defined in setup.py, which GhostBuild does not execute.")
|
|
157
|
+
if version == "dynamic":
|
|
158
|
+
notes.append("The version is computed at build time (dynamic).")
|
|
159
|
+
return name, version
|
|
160
|
+
|
|
161
|
+
def available_tools(self) -> dict[str, str | None]:
|
|
162
|
+
tools: dict[str, str | None] = {"python": f"{platform.python_version()} ({sys.executable})"}
|
|
163
|
+
for package in ("pip", "build", "setuptools", "wheel"):
|
|
164
|
+
tools[package] = self._package_version(package)
|
|
165
|
+
for executable in _TOOLS_ON_PATH:
|
|
166
|
+
tools[executable] = shutil.which(executable)
|
|
167
|
+
return tools
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _package_version(package: str) -> str | None:
|
|
171
|
+
try:
|
|
172
|
+
return importlib.metadata.version(package)
|
|
173
|
+
except importlib.metadata.PackageNotFoundError:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def describe_build_tool(self) -> str | None:
|
|
177
|
+
version = self._package_version("build")
|
|
178
|
+
return f"build {version}" if version else None
|
|
179
|
+
|
|
180
|
+
def missing_prerequisites(self, config: Config) -> list[str]:
|
|
181
|
+
if config.python.build_command:
|
|
182
|
+
return []
|
|
183
|
+
if importlib.util.find_spec("build") is None:
|
|
184
|
+
return [
|
|
185
|
+
"The 'build' package is not installed for this Python interpreter "
|
|
186
|
+
f"({sys.executable}). Install it with: python -m pip install build"
|
|
187
|
+
]
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
def has_custom_build_command(self, config: Config) -> bool:
|
|
191
|
+
return bool(config.python.build_command)
|
|
192
|
+
|
|
193
|
+
def build_command(self, source_dir: Path, output_dir: Path, config: Config) -> list[str]:
|
|
194
|
+
if config.python.build_command:
|
|
195
|
+
replacements = {
|
|
196
|
+
"{python}": sys.executable,
|
|
197
|
+
"{source_dir}": str(source_dir),
|
|
198
|
+
"{outdir}": str(output_dir),
|
|
199
|
+
}
|
|
200
|
+
command = []
|
|
201
|
+
for part in config.python.build_command:
|
|
202
|
+
for placeholder, value in replacements.items():
|
|
203
|
+
part = part.replace(placeholder, value)
|
|
204
|
+
command.append(part)
|
|
205
|
+
return command
|
|
206
|
+
return [
|
|
207
|
+
sys.executable, "-m", "build", "--sdist", "--wheel",
|
|
208
|
+
"--outdir", str(output_dir), str(source_dir),
|
|
209
|
+
] # fmt: skip
|
|
210
|
+
|
|
211
|
+
def container_image(self, config: Config) -> str:
|
|
212
|
+
if config.python.docker_image:
|
|
213
|
+
return config.python.docker_image
|
|
214
|
+
return f"python:{sys.version_info.major}.{sys.version_info.minor}-slim"
|
|
215
|
+
|
|
216
|
+
def container_script(self, config: Config) -> str:
|
|
217
|
+
requirement = config.python.build_requirement
|
|
218
|
+
if "'" in requirement:
|
|
219
|
+
raise ConfigError("[python] build_requirement must not contain a single quote.")
|
|
220
|
+
# str.replace, not str.format: the script contains literal braces.
|
|
221
|
+
return _CONTAINER_SCRIPT.replace("{requirement}", requirement)
|
|
222
|
+
|
|
223
|
+
def collect_warnings(self, project: ProjectInfo) -> list[str]:
|
|
224
|
+
warnings = []
|
|
225
|
+
unpinned = [r for r in project.build_requires if not _is_pinned(r)]
|
|
226
|
+
if unpinned:
|
|
227
|
+
warnings.append(
|
|
228
|
+
"Build requirements are not pinned to exact versions "
|
|
229
|
+
f"({', '.join(unpinned)}). Builds in different environments may resolve "
|
|
230
|
+
"different versions, which is a common cause of differing artifacts."
|
|
231
|
+
)
|
|
232
|
+
return warnings
|
ghostbuild/cli.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Command-line interface (Typer + Rich)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Optional
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from ghostbuild import __version__
|
|
17
|
+
from ghostbuild.config import (
|
|
18
|
+
CONFIG_FILENAME,
|
|
19
|
+
EXAMPLE_CONFIG,
|
|
20
|
+
REPORT_FORMATS,
|
|
21
|
+
load_config,
|
|
22
|
+
)
|
|
23
|
+
from ghostbuild.detection import detect_project
|
|
24
|
+
from ghostbuild.engine.isolation import (
|
|
25
|
+
ISOLATION_CHOICES,
|
|
26
|
+
DockerStatus,
|
|
27
|
+
detect_docker,
|
|
28
|
+
resolve_isolation,
|
|
29
|
+
)
|
|
30
|
+
from ghostbuild.engine.verifier import compare_directories, verify_project
|
|
31
|
+
from ghostbuild.errors import GhostBuildError, LocalFallbackNotApproved, PathError
|
|
32
|
+
from ghostbuild.models import VerificationResult, to_plain
|
|
33
|
+
from ghostbuild.reports import json_report
|
|
34
|
+
from ghostbuild.reports.html_report import write_html
|
|
35
|
+
from ghostbuild.reports.terminal import render_banner, render_result, render_scan
|
|
36
|
+
from ghostbuild.utils.filesystem import validate_directory
|
|
37
|
+
|
|
38
|
+
# Exit codes: 2 is reserved for usage errors (Typer/Click).
|
|
39
|
+
EXIT_REPRODUCIBLE = 0
|
|
40
|
+
EXIT_NON_REPRODUCIBLE = 1
|
|
41
|
+
EXIT_INCONCLUSIVE = 3
|
|
42
|
+
EXIT_ERROR = 4
|
|
43
|
+
EXIT_DECLINED = 5
|
|
44
|
+
_VERDICT_EXIT = {
|
|
45
|
+
"reproducible": EXIT_REPRODUCIBLE,
|
|
46
|
+
"non-reproducible": EXIT_NON_REPRODUCIBLE,
|
|
47
|
+
"inconclusive": EXIT_INCONCLUSIVE,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
app = typer.Typer(
|
|
51
|
+
name="ghostbuild",
|
|
52
|
+
help="Build it twice. Know what changed. Reproducible build verification.",
|
|
53
|
+
add_completion=False,
|
|
54
|
+
no_args_is_help=False,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
NoColorOption = Annotated[bool, typer.Option("--no-color", help="Disable coloured output.")]
|
|
58
|
+
VerboseOption = Annotated[bool, typer.Option("--verbose", help="Show full details and build logs.")]
|
|
59
|
+
ConfigOption = Annotated[
|
|
60
|
+
Optional[Path],
|
|
61
|
+
typer.Option("--config", "-c", help="Path to a ghostbuild.toml (default: <project>/ghostbuild.toml)."),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _consoles(no_color: bool) -> tuple[Console, Console]:
|
|
66
|
+
"""Return (stdout console, stderr console). Progress and errors go to stderr."""
|
|
67
|
+
return Console(no_color=no_color), Console(stderr=True, no_color=no_color)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@contextmanager
|
|
71
|
+
def _handle_errors(err: Console) -> Iterator[None]:
|
|
72
|
+
"""Turn expected GhostBuild errors into a clean message and exit code 4."""
|
|
73
|
+
try:
|
|
74
|
+
yield
|
|
75
|
+
except GhostBuildError as exc:
|
|
76
|
+
err.print(Text(f"Error: {exc}", style="bold red"))
|
|
77
|
+
raise typer.Exit(EXIT_ERROR) from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check_format(value: str | None) -> str | None:
|
|
81
|
+
if value is not None and value not in REPORT_FORMATS:
|
|
82
|
+
raise typer.BadParameter(f"must be one of: {', '.join(REPORT_FORMATS)}", param_hint="--format")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _version_callback(value: bool) -> None:
|
|
87
|
+
if value:
|
|
88
|
+
typer.echo(f"ghostbuild {__version__}")
|
|
89
|
+
raise typer.Exit()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.callback(invoke_without_command=True)
|
|
93
|
+
def main(
|
|
94
|
+
ctx: typer.Context,
|
|
95
|
+
version: Annotated[
|
|
96
|
+
bool,
|
|
97
|
+
typer.Option("--version", "-V", callback=_version_callback, is_eager=True,
|
|
98
|
+
help="Show the version and exit."),
|
|
99
|
+
] = False,
|
|
100
|
+
) -> None: # fmt: skip
|
|
101
|
+
"""Build it twice. Know what changed."""
|
|
102
|
+
if ctx.invoked_subcommand is None:
|
|
103
|
+
render_banner(Console())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def version() -> None:
|
|
108
|
+
"""Show the installed GhostBuild version."""
|
|
109
|
+
typer.echo(f"ghostbuild {__version__}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command()
|
|
113
|
+
def init(
|
|
114
|
+
path: Annotated[Path, typer.Argument(help="Where to write the example configuration.")] = Path(
|
|
115
|
+
CONFIG_FILENAME
|
|
116
|
+
),
|
|
117
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Write an example ghostbuild.toml containing all defaults."""
|
|
120
|
+
out, err = _consoles(False)
|
|
121
|
+
if path.exists() and not force:
|
|
122
|
+
err.print(Text(f"Error: {path} already exists. Use --force to overwrite it.", style="bold red"))
|
|
123
|
+
raise typer.Exit(EXIT_ERROR)
|
|
124
|
+
path.write_text(EXAMPLE_CONFIG, encoding="utf-8")
|
|
125
|
+
out.print(Text(f"Wrote {path}"))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def scan(
|
|
130
|
+
project_dir: Annotated[Path, typer.Argument(help="Project directory to inspect.")] = Path("."),
|
|
131
|
+
config_path: ConfigOption = None,
|
|
132
|
+
no_color: NoColorOption = False,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Inspect a project: name, build system, requirements and available tools.
|
|
135
|
+
|
|
136
|
+
Reads project files only. No build is run.
|
|
137
|
+
"""
|
|
138
|
+
out, err = _consoles(no_color)
|
|
139
|
+
with _handle_errors(err):
|
|
140
|
+
load_config(config_path, validate_directory(project_dir, "project directory"))
|
|
141
|
+
adapter, project = detect_project(project_dir)
|
|
142
|
+
docker = detect_docker()
|
|
143
|
+
render_scan(out, to_plain(project), adapter.available_tools(), to_plain(docker))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _interactive() -> bool:
|
|
147
|
+
return sys.stdin.isatty()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _choose_isolation(requested: str, docker: DockerStatus, err: Console) -> str:
|
|
151
|
+
"""Resolve the isolation mode, asking before any local fallback."""
|
|
152
|
+
try:
|
|
153
|
+
return resolve_isolation(requested, docker, local_approved=False)
|
|
154
|
+
except LocalFallbackNotApproved as exc:
|
|
155
|
+
if not _interactive():
|
|
156
|
+
raise
|
|
157
|
+
err.print(Text(f"Docker is unavailable: {docker.reason}.", style="bold yellow"))
|
|
158
|
+
err.print(
|
|
159
|
+
"A local rebuild uses the same interpreter and tools as the original build, so it "
|
|
160
|
+
"is NOT equivalent to an independent clean-container build. It can only show "
|
|
161
|
+
"repeatability on this machine."
|
|
162
|
+
)
|
|
163
|
+
if not typer.confirm("Use the weaker local rebuild instead?", default=False):
|
|
164
|
+
raise typer.Exit(EXIT_DECLINED) from exc
|
|
165
|
+
return resolve_isolation(requested, docker, local_approved=True)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _present_plan(err: Console, project_dir: Path, command: list[str], mode: str, image: str | None) -> None:
|
|
169
|
+
err.print(Text("\nBuild plan", style="bold"))
|
|
170
|
+
err.print(Text(f" Project: {project_dir}"))
|
|
171
|
+
err.print(Text(f" Original build: runs on this host in a temporary copy: {shlex.join(command)}"))
|
|
172
|
+
if mode == "docker":
|
|
173
|
+
err.print(Text(f" Independent rebuild: runs inside a Docker container ({image}), source mounted read-only"))
|
|
174
|
+
else:
|
|
175
|
+
err.print(Text(" Independent rebuild: runs on this host in a second temporary copy (LOCAL, weaker isolation)",
|
|
176
|
+
style="yellow")) # fmt: skip
|
|
177
|
+
err.print(Text(" Your project directory is only read, never modified.", style="dim"))
|
|
178
|
+
err.print(Text(" WARNING: building runs code from this project (build backend, setup.py, hooks).",
|
|
179
|
+
style="bold yellow")) # fmt: skip
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command()
|
|
183
|
+
def verify(
|
|
184
|
+
project_dir: Annotated[Path, typer.Argument(help="Project directory to verify.")] = Path("."),
|
|
185
|
+
output: Annotated[Optional[Path], typer.Option("--output", "-o", help="Directory for reports.")] = None,
|
|
186
|
+
fmt: Annotated[
|
|
187
|
+
Optional[str],
|
|
188
|
+
typer.Option("--format", "-f", help="Output format: terminal, json or html."),
|
|
189
|
+
] = None,
|
|
190
|
+
keep_artifacts: Annotated[
|
|
191
|
+
bool, typer.Option("--keep-artifacts", help="Keep both builds' artifacts in the output directory.")
|
|
192
|
+
] = False,
|
|
193
|
+
isolation: Annotated[
|
|
194
|
+
str,
|
|
195
|
+
typer.Option(help="Rebuild isolation: auto (Docker if available), docker, or local."),
|
|
196
|
+
] = "auto",
|
|
197
|
+
yes: Annotated[
|
|
198
|
+
bool, typer.Option("--yes", "-y", help="Approve running the project's build without prompting.")
|
|
199
|
+
] = False,
|
|
200
|
+
timeout: Annotated[Optional[int], typer.Option(help="Per-build timeout in seconds.", min=1)] = None,
|
|
201
|
+
config_path: ConfigOption = None,
|
|
202
|
+
verbose: VerboseOption = False,
|
|
203
|
+
no_color: NoColorOption = False,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Build the project twice and compare the artifacts.
|
|
206
|
+
|
|
207
|
+
This executes the project's build scripts. GhostBuild asks for consent first
|
|
208
|
+
(or use --yes) and never silently weakens isolation.
|
|
209
|
+
"""
|
|
210
|
+
out, err = _consoles(no_color)
|
|
211
|
+
if isolation not in ISOLATION_CHOICES:
|
|
212
|
+
raise typer.BadParameter(f"must be one of: {', '.join(ISOLATION_CHOICES)}", param_hint="--isolation")
|
|
213
|
+
_check_format(fmt)
|
|
214
|
+
result: VerificationResult
|
|
215
|
+
with _handle_errors(err):
|
|
216
|
+
root = validate_directory(project_dir, "project directory")
|
|
217
|
+
config = load_config(config_path, root)
|
|
218
|
+
fmt = fmt or config.report.format
|
|
219
|
+
output_dir = (output or Path(config.report.output_dir)).resolve()
|
|
220
|
+
_check_output_dir(output_dir, keep_artifacts)
|
|
221
|
+
adapter, project = detect_project(root)
|
|
222
|
+
err.print(Text(f"GhostBuild v{__version__}", style="bold"))
|
|
223
|
+
err.print(Text(f"Detected: {project.language} Build system: {project.build_system}"))
|
|
224
|
+
|
|
225
|
+
mode = _choose_isolation(isolation, detect_docker(), err)
|
|
226
|
+
image = adapter.container_image(config) if mode == "docker" else None
|
|
227
|
+
command = adapter.build_command(Path("<source>"), Path("<output>"), config)
|
|
228
|
+
_present_plan(err, root, command, mode, image)
|
|
229
|
+
if not yes:
|
|
230
|
+
if not _interactive():
|
|
231
|
+
raise GhostBuildError(
|
|
232
|
+
"Refusing to run build commands without consent in a non-interactive session. "
|
|
233
|
+
"Re-run with --yes to approve."
|
|
234
|
+
)
|
|
235
|
+
if not typer.confirm("Run these build commands now?", default=False):
|
|
236
|
+
err.print("Aborted. No build commands were run.")
|
|
237
|
+
raise typer.Exit(EXIT_DECLINED)
|
|
238
|
+
|
|
239
|
+
err.print()
|
|
240
|
+
with err.status("Starting...") as status:
|
|
241
|
+
def on_stage(index: int, total: int, label: str) -> None:
|
|
242
|
+
err.print(Text(f"[{index}/{total}] {label}", style="bold cyan"))
|
|
243
|
+
status.update(f"{label}...")
|
|
244
|
+
|
|
245
|
+
result = verify_project(
|
|
246
|
+
root, adapter, project, config,
|
|
247
|
+
mode=mode,
|
|
248
|
+
timeout=timeout or config.build.timeout_seconds,
|
|
249
|
+
keep_artifacts_dir=(output_dir / "artifacts") if keep_artifacts else None,
|
|
250
|
+
skip_paths=[output_dir],
|
|
251
|
+
on_stage=on_stage,
|
|
252
|
+
) # fmt: skip
|
|
253
|
+
_emit(result, output_dir, fmt, out, err, verbose)
|
|
254
|
+
raise typer.Exit(_VERDICT_EXIT[result.verdict.value])
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _check_output_dir(output_dir: Path, keep_artifacts: bool) -> None:
|
|
258
|
+
if output_dir.exists() and not output_dir.is_dir():
|
|
259
|
+
raise PathError(f"The output path exists and is not a directory: {output_dir}")
|
|
260
|
+
kept = output_dir / "artifacts"
|
|
261
|
+
if keep_artifacts and kept.exists() and any(kept.iterdir()):
|
|
262
|
+
raise PathError(
|
|
263
|
+
f"{kept} already contains files. GhostBuild will not overwrite or delete them; "
|
|
264
|
+
"choose another --output directory or remove it yourself."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _emit(
|
|
269
|
+
result: VerificationResult, output_dir: Path, fmt: str, out: Console, err: Console, verbose: bool
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Write result.json (always) and print/write the requested format."""
|
|
272
|
+
saved = json_report.write_json(result, output_dir)
|
|
273
|
+
data = result.to_dict()
|
|
274
|
+
if fmt == "json":
|
|
275
|
+
typer.echo(json_report.to_json(data), nl=False)
|
|
276
|
+
else:
|
|
277
|
+
render_result(out, data, verbose)
|
|
278
|
+
if fmt == "html":
|
|
279
|
+
err.print(Text(f"HTML report saved to: {write_html(data, output_dir)}"))
|
|
280
|
+
err.print(Text(f"Report saved to: {saved.parent}/"))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@app.command()
|
|
284
|
+
def compare(
|
|
285
|
+
dir_a: Annotated[Path, typer.Argument(help="First (original) artifact directory.")],
|
|
286
|
+
dir_b: Annotated[Path, typer.Argument(help="Second (rebuilt) artifact directory.")],
|
|
287
|
+
pattern: Annotated[
|
|
288
|
+
Optional[list[str]], typer.Option("--pattern", "-p", help="Artifact glob (repeatable).")
|
|
289
|
+
] = None,
|
|
290
|
+
output: Annotated[Optional[Path], typer.Option("--output", "-o", help="Write reports to this directory.")] = None,
|
|
291
|
+
fmt: Annotated[Optional[str], typer.Option("--format", "-f", help="terminal, json or html.")] = None,
|
|
292
|
+
config_path: ConfigOption = None,
|
|
293
|
+
verbose: VerboseOption = False,
|
|
294
|
+
no_color: NoColorOption = False,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Compare two existing artifact directories. No builds are run."""
|
|
297
|
+
out, err = _consoles(no_color)
|
|
298
|
+
_check_format(fmt)
|
|
299
|
+
with _handle_errors(err):
|
|
300
|
+
first = validate_directory(dir_a, "first directory")
|
|
301
|
+
second = validate_directory(dir_b, "second directory")
|
|
302
|
+
config = load_config(config_path, None)
|
|
303
|
+
fmt = fmt or "terminal"
|
|
304
|
+
if fmt == "html" and output is None:
|
|
305
|
+
raise PathError("--format html needs --output to choose where to write the report.")
|
|
306
|
+
result = compare_directories(first, second, config, pattern or None)
|
|
307
|
+
if output is not None:
|
|
308
|
+
_emit(result, output.resolve(), fmt, out, err, verbose)
|
|
309
|
+
else:
|
|
310
|
+
data = result.to_dict()
|
|
311
|
+
if fmt == "json":
|
|
312
|
+
typer.echo(json_report.to_json(data), nl=False)
|
|
313
|
+
else:
|
|
314
|
+
render_result(out, data, verbose)
|
|
315
|
+
raise typer.Exit(_VERDICT_EXIT[result.verdict.value])
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.command()
|
|
319
|
+
def report(
|
|
320
|
+
path: Annotated[Path, typer.Argument(help="A result.json file or a directory containing one.")] = Path(
|
|
321
|
+
"ghostbuild-results"
|
|
322
|
+
),
|
|
323
|
+
fmt: Annotated[str, typer.Option("--format", "-f", help="terminal, json or html.")] = "terminal",
|
|
324
|
+
output: Annotated[
|
|
325
|
+
Optional[Path], typer.Option("--output", "-o", help="Directory to write an HTML report to.")
|
|
326
|
+
] = None,
|
|
327
|
+
verbose: VerboseOption = False,
|
|
328
|
+
no_color: NoColorOption = False,
|
|
329
|
+
) -> None:
|
|
330
|
+
"""Display or regenerate a report from a stored result.json."""
|
|
331
|
+
out, err = _consoles(no_color)
|
|
332
|
+
_check_format(fmt)
|
|
333
|
+
with _handle_errors(err):
|
|
334
|
+
data = json_report.load_result(path)
|
|
335
|
+
if fmt == "json":
|
|
336
|
+
typer.echo(json_report.to_json(data), nl=False)
|
|
337
|
+
elif fmt == "html":
|
|
338
|
+
target = output or (path if path.is_dir() else path.parent)
|
|
339
|
+
err.print(Text(f"HTML report saved to: {write_html(data, target.resolve())}"))
|
|
340
|
+
else:
|
|
341
|
+
render_result(out, data, verbose)
|