# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Shared pytest fixtures and cross-file test helpers."""
from __future__ import annotations
import ast
import base64
import json
import re
import struct
from contextlib import contextmanager
from http.client import IncompleteRead
from io import BytesIO
from pathlib import Path
from subprocess import CompletedProcess, run
from textwrap import dedent
from unittest.mock import Mock, patch
from urllib.error import URLError
import pytest
import yaml
from repomatic import pypi, pyproject
from repomatic.bundle import get_data_content
from repomatic.checks import CheckResult
from repomatic.config import config_key_paths, publish_config
from repomatic.github.event import get_github_event
from repomatic.github.token import PatPermissionResults
from repomatic.github.workflow_sync import _canonical
from repomatic.metadata.core import (
METADATA_SUBCOMMAND,
Metadata,
_metadata_invocations,
_positional_keys,
)
from repomatic.release.binary import MACHINE_IDS, BinaryFormat
from repomatic.release.verify import SHT_GNU_VERNEED
from repomatic.tooling.binaries import ensure_binary
from repomatic.tooling.registry import ToolInstallError
from repomatic.tooling.runner import run_tool
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Any, Self
from extra_platforms import Architecture
PROJECT_ROOT = Path(__file__).parent.parent
"""Root of the repository."""
IN_GIT_CHECKOUT = (PROJECT_ROOT / ".git").exists()
"""Whether the suite runs from a git checkout, not from an sdist or a tag tarball.
A packager's source carries no git history, holds a release's frozen workflows,
and, for an sdist, a `pyproject.toml` rewritten and symlinks dereferenced by the
build backend. The tests marked `repo_maintenance` check those properties of a
development checkout, so {func}`pytest_collection_modifyitems` skips them
everywhere else.
"""
PACKAGE_DIR = PROJECT_ROOT / "repomatic"
"""Root of the package whose sources the conformance tests scan."""
PACKAGE_FILES = sorted(PACKAGE_DIR.rglob("*.py"))
"""Every Python module shipped in the package.
Sorted because `@pytest.mark.parametrize` derives test IDs from iteration
order, and `pytest-xdist` aborts a run whose workers disagree on them.
"""
SITE_ANCHOR_RE = re.compile(
r"https://repomatic\.net/(?P<page>[\w-]+)#(?P<anchor>[\w-]+)"
)
"""A link into one section of the published documentation."""
WORKFLOWS_DIR = PROJECT_ROOT / ".github" / "workflows"
"""Directory holding this repository's own workflow files."""
REAL_HTTP_MARKER = "real_http"
"""Marks a test that runs a registry tool for real, downloads included.
The `_no_real_http` fixture leaves such a test alone, so the probe of
{func}`skip_unless_tool_runs` can download the pinned binary on a cold cache.
`tests/test_suite_hygiene.py` holds every caller of that probe to the marker.
"""
[docs]
def docs_heading_slug(title: str) -> str:
"""The anchor the published Sphinx site derives from a heading.
Follows the docutils section-id algorithm (lowercase, every non-alphanumeric
run collapsed to one hyphen, trimmed), which is what the published page
exposes as `id=` attributes.
:param title: Heading text, without its leading `#` marks.
"""
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
[docs]
def docs_heading_anchors(page: str) -> set[str]:
"""Anchors a page of `docs/` exposes: heading slugs and explicit targets.
Heading slugs follow {func}`docs_heading_slug`.
:param page: Name of the page, without its `.md` extension.
"""
text = (PROJECT_ROOT / "docs" / f"{page}.md").read_text(encoding="UTF-8")
anchors = set()
for line in text.splitlines():
if re.match(r"#{1,6} ", line):
title = line.lstrip("#").strip()
# Keep the text of markdown links, drop their targets.
title = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", title)
anchors.add(docs_heading_slug(title))
else:
explicit = re.fullmatch(r"\(([\w-]+)\)=", line.strip())
if explicit:
anchors.add(explicit.group(1))
return anchors
[docs]
def assert_shipped_text_reads_true_downstream(origin: str, text: str) -> None:
"""Check a text that travels downstream cites nothing a repository lacks.
A bundled configuration and a generated workflow comment both land in
repositories that hold no copy of this project's `AGENTS.md`: the `agent`
component that shipped its sections retired in `7.14.0`. A pointer to that
file is a dead end there. So such a text states its rationale inline, or
cites a section of the published documentation, whose anchor is held here
to a heading that exists.
:param origin: Name of the text under test, for the failure message.
:param text: Content as it ships.
"""
assert not re.search(r"(?:agents|claude)\.md", text, re.IGNORECASE), (
f"{origin} sends its reader to this project's instructions file, "
"which no downstream repository holds. State the rationale inline, or "
"cite a page of "
"https://repomatic.net instead."
)
for match in SITE_ANCHOR_RE.finditer(text):
page, anchor = match.group("page", "anchor")
assert anchor in docs_heading_anchors(page), (
f"{origin} cites {match.group()}, but docs/{page}.md declares no "
f"heading that renders to #{anchor}."
)
[docs]
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
"""Skip the `repo_maintenance` tests outside a git checkout.
See {data}`IN_GIT_CHECKOUT`.
"""
if IN_GIT_CHECKOUT:
return
skip = pytest.mark.skip(reason="repo-maintenance test: not a git checkout")
for item in items:
if item.get_closest_marker("repo_maintenance"):
item.add_marker(skip)
def _declares_concurrency(path: Path) -> bool:
"""Whether a workflow file carries a top-level `concurrency:` block."""
return any(
line.startswith("concurrency:")
for line in path.read_text(encoding="UTF-8").splitlines()
)
WORKFLOWS_WITH_CONCURRENCY_BLOCK = tuple(
sorted(p.name for p in WORKFLOWS_DIR.glob("*.yaml") if _declares_concurrency(p))
)
"""Workflows that actually define a concurrency block, read off disk.
Derived rather than hand-listed because two test modules assert against it from
opposite directions, and a hand-listed pair drifted apart once already: a
workflow can be *exempt* from the requirement and still carry a block, which no
list of intentions can express.
"""
WORKFLOWS_WITHOUT_CONCURRENCY_BLOCK = tuple(
sorted(p.name for p in WORKFLOWS_DIR.glob("*.yaml") if not _declares_concurrency(p))
)
"""The complement of {data}`WORKFLOWS_WITH_CONCURRENCY_BLOCK`."""
[docs]
def load_workflow(workflow_name: str) -> dict[str, Any]:
"""Load and parse one of this repository's workflow YAML files.
The one loader behind every test that reads a workflow off disk, so they
all parse the same bytes the same way.
"""
workflow_path = WORKFLOWS_DIR / workflow_name
with workflow_path.open(encoding="UTF-8") as f:
result: dict[str, Any] = yaml.safe_load(f)
return result
[docs]
@pytest.fixture
def hermetic_git(monkeypatch):
"""Shield temporary git repos from the developer's own git configuration.
Commit signing, hooks, templates and identity from the global or system
config would otherwise leak into repos a test builds under `tmp_path` and
break hermeticity in machine-specific ways.
"""
monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/dev/null")
monkeypatch.setenv("GIT_CONFIG_SYSTEM", "/dev/null")
[docs]
@pytest.fixture
def scrub_workflow_event(monkeypatch):
"""Detach a test from the CI run's own workflow event.
In CI the suite inherits the real run's `GITHUB_EVENT_NAME`, event payload
and `GITHUB_SHA`, which event-derived properties (`event_type`,
`commit_range`, the canary gating of `nuitka_matrix`) read, so expectations
would differ between CI and a local run. Deleting the event markers pins a
test to the deterministic local behavior; a test of the CI context sets the
variables back with `monkeypatch.setenv`.
"""
for envvar in (
"GITHUB_BASE_REF",
"GITHUB_EVENT_NAME",
"GITHUB_EVENT_PATH",
"GITHUB_HEAD_REF",
"GITHUB_SHA",
):
monkeypatch.delenv(envvar, raising=False)
[docs]
def run_git(*args: str, cwd) -> str:
"""Run a git command in *cwd* and return its stdout."""
result = run(
["git", *args],
cwd=cwd,
check=True,
capture_output=True,
text=True,
encoding="UTF-8",
)
return result.stdout
[docs]
def seed_commit(workdir, filename: str, content: str, message: str) -> None:
"""Create a file and commit it with a throwaway identity."""
(workdir / filename).write_text(content, encoding="UTF-8")
run_git("add", "--", filename, cwd=workdir)
run_git(
"-c",
"user.name=Seeder",
"-c",
"[email protected]",
"commit",
"--message",
message,
cwd=workdir,
)
SHALLOW_HISTORY = ("apple", "banana", "cherry", "date", "elderberry")
"""Content of the successive commits {func}`make_shallow_clone` pushes."""
[docs]
def make_shallow_clone(tmp_path: Path) -> tuple[Path, list[str]]:
"""Clone a remote holding one commit per {data}`SHALLOW_HISTORY` entry.
The clone is one commit deep. The remote serves any commit it can reach by
SHA, as GitHub does, so a commit outside the clone can be fetched alone.
:return: The clone, and the SHAs of the remote's commits, oldest first.
"""
remote = tmp_path / "remote.git"
run_git("init", "--bare", "--initial-branch=main", str(remote), cwd=tmp_path)
run_git("config", "uploadpack.allowReachableSHA1InWant", "true", cwd=remote)
seed = tmp_path / "seed"
run_git("clone", str(remote), str(seed), cwd=tmp_path)
run_git("checkout", "-B", "main", cwd=seed)
shas = []
for fruit in SHALLOW_HISTORY:
seed_commit(seed, "fruits.txt", f"{fruit}\n", f"Add {fruit}")
shas.append(run_git("rev-parse", "HEAD", cwd=seed).strip())
run_git("push", "origin", "main", cwd=seed)
clone = tmp_path / "clone"
run_git("clone", "--depth=1", remote.as_uri(), str(clone), cwd=tmp_path)
return clone, shas
def _reset_process_caches() -> None:
"""Clear every process-level memo a test could have poisoned.
The production caches assume their inputs are immutable for the life of
the process, which is true of a CLI run and false of a test session: a
monkeypatched `get_data_content` or a rewritten fixture project would
otherwise leak one test's world into the next through them.
"""
Metadata.reset()
get_github_event.cache_clear()
pypi._FETCH_MEMO.clear()
get_data_content.cache_clear()
_canonical.cache_clear()
config_key_paths.cache_clear()
pyproject._TOML_CACHE.clear()
publish_config(None)
@pytest.fixture(autouse=True)
def _reset_metadata():
"""Ensure each test gets fresh singleton, event and content caches.
Resets before and after every test so that ``@cached_property`` values
computed with one test's monkeypatched env vars never leak into another,
and so the process-level memos never serve another test's stubs.
"""
_reset_process_caches()
yield
_reset_process_caches()
@pytest.fixture(autouse=True)
def _no_real_gh_subprocess():
"""Refuse to shell out to a real `gh` from the suite.
A test that patches no `gh` seam at all used to run the actual binary
against whatever credentials the machine carries, hitting the network once
per unmocked check: `tests/test_lint_repo.py` spent seconds per test that
way, and its results depended on the developer's `gh auth` state. Failing
the call instead makes every such check report "could not run", which is
the outcome an offline CI runner would see anyway.
Tests wanting specific `gh` behavior patch a higher layer
(`run_gh_command`, `gh_json`) or re-patch this one; either shadows the
stub for their duration.
"""
refused = CompletedProcess(
args=["gh"],
returncode=1,
stdout="",
stderr="gh is not invoked for real in the test suite.",
)
with patch("repomatic.github.gh.run", return_value=refused):
yield
@pytest.fixture(autouse=True)
def _no_real_http(request: pytest.FixtureRequest):
"""Refuse every unmocked fetch made through {mod}`repomatic.http`.
The suite runs offline. A fetch that no test stubbed fails here the way it
does on a machine with no network: at once, with a `FetchError`. Left to
reach a live host, it makes a result depend on that host's state and rate
limit. A test of the whole `lint-repo` is the usual way in: one check reads
the `setup-uv` checksum table of this repository's own pin from the GitHub
API.
A test wanting a response patches `repomatic.http.urlopen` itself, or a
higher layer. Either shadows this stub for its duration. A test marked
{data}`REAL_HTTP_MARKER` runs a tool for real and gets no stub at all.
"""
if request.node.get_closest_marker(REAL_HTTP_MARKER):
yield
return
refused = URLError("HTTP is not reached for real in the test suite.")
with patch("repomatic.http.urlopen", side_effect=refused):
yield
@pytest.fixture(autouse=True)
def _no_status_probe():
"""Keep a failed `gh` call from probing githubstatus.com.
{func}`_no_real_gh_subprocess` fails every unmocked `gh` call, and the
failure path of `run_gh_command` annotates its error with GitHub's live
status. Without this stub, each such test sends a request to a live host,
waits on its timeout when offline, and asserts on a message that gains a
sentence during an incident.
A test of the annotation patches
`repomatic.github.status.status_annotation` itself, which shadows this stub
for its duration. A test of the probe calls it by its imported name, which
this patch does not reach.
"""
with patch("repomatic.github.status.status_annotation", return_value=""):
yield
@pytest.fixture(autouse=True)
def _stub_gh_executable():
"""Keep the suite off the network when resolving the `gh` binary.
`run_gh_command` resolves a registry-pinned `gh` through
{func}`~repomatic.tooling.binaries.ensure_binary`, which downloads and
checksum-verifies an archive. Every test that exercises a `gh` path
already patches the subprocess call, so the resolution is pure overhead
and would make the suite depend on the network. Tests covering the
resolver itself patch `ensure_binary` and call it directly.
"""
with patch("repomatic.github.gh.gh_executable", return_value="gh"):
yield
@pytest.fixture(autouse=True)
def _reset_binary_memo():
"""Drop `ensure_binary`'s per-process memo so tests stay independent.
The memo is keyed on tool name only, so without the reset one test's
installed path (often under its private `tmp_path`) would be replayed to
every later test asking for the same tool.
"""
yield
ensure_binary.cache_clear()
@pytest.fixture(autouse=True)
def _isolate_user_config(isolated_app_dir):
"""Hide the developer's real `repomatic` configuration from the suite.
Any config file in the host application folder (`~/.config/repomatic` on
Unix, `~/Library/Application Support/repomatic` on macOS) is discovered by
every in-process `CliRunner().invoke(repomatic, ...)`, so assertions would
depend on the machine they run on: a local setting can pass or fail a test
CI cannot reproduce.
Autouse alias of click-extra's `isolated_app_dir` fixture (registered by
its `pytest11` entry point), which repoints `click.get_app_dir`-based
config discovery at a fresh per-test directory. `HOME` stays intact and
the override does not propagate to subprocesses; tests exercising config
loading pass an explicit path, which bypasses the default search and is
unaffected.
```{caution}
The isolation stops at the application folder: nothing hides this
repository's own `[tool.repomatic]`. Discovery is CWD-first, walking up to
the VCS root, so a call that resolves config itself (`run_init(config=None)`,
anything reaching `load_repomatic_config()` with no argument) reads this
checkout's `pyproject.toml` under pytest exactly as it does in a shell.
Enabling a feature here can therefore fail a test elsewhere: a component's
config gate switched on makes `test_init_only_workflows` see a workflow it
asserts absent. A test asserting on default behaviour passes an explicit
`Config()`.
```
"""
return isolated_app_dir
@pytest.fixture(autouse=True)
def _neutral_terminal(monkeypatch):
"""Hide the developer's terminal from every table the suite renders.
click-extra reads `$TERM_PROGRAM` to decide how wide the running terminal
paints an emoji-presentation sequence, and pads the cell to match. Apple
Terminal advances one column where the Unicode rule says two, so a
`⁉️ unstable` cell gains a space there and nowhere else: an assertion on
the rendered string passes on Linux CI and fails on the maintainer's Mac.
Clearing the variable picks the standards-conforming branch, which is what
every runner in the matrix already takes. A test asserting on the padded
rendering sets the variable back for its own duration.
"""
monkeypatch.delenv("TERM_PROGRAM", raising=False)
[docs]
class FakeResponse:
"""Minimal `urlopen` response double: a byte body behind a context manager.
Stands in for the object {func}`repomatic.http.request` reads, so network
tests patch `repomatic.http.urlopen` with `return_value=FakeResponse(...)`.
"""
def __init__(
self, data: bytes, *, status: int = 200, headers: dict[str, str] | None = None
) -> None:
self._data = BytesIO(data)
self.status = status
self.headers = dict(headers or {})
[docs]
def read(self) -> bytes:
return self._data.read()
def __enter__(self) -> Self:
return self
def __exit__(self, *args: object) -> None:
pass
[docs]
class CutShortBody(BytesIO):
"""The body of an error answer whose connection drops mid-read."""
[docs]
def read(self, *args: object) -> bytes:
raise IncompleteRead(b"<Error>")
[docs]
def pat_results(**overrides: tuple[bool, str]) -> PatPermissionResults:
"""Build a `PatPermissionResults` where every check passes by default.
Each keyword names a `PatPermissionResults` field and replaces its
`(passed, message)` pair, so a test spells out only the checks whose
outcome it cares about and the rest read as the happy path.
:param overrides: Field name to its `(passed, message)` result.
:return: The assembled results object.
"""
results = {
"administration": (True, "Administration: token has access"),
"contents": (True, "Contents: token has access"),
"issues": (True, "Issues: token has access"),
"pull_requests": (True, "Pull requests: token has access"),
"vulnerability_alerts": (
True,
"Dependabot alerts: token has access, alerts enabled",
),
"workflows": (True, "Workflows: token has access"),
**overrides,
}
return PatPermissionResults(**{
name: CheckResult(*result) for name, result in results.items()
})
[docs]
@pytest.fixture
def cache_env(monkeypatch, tmp_path):
"""Point the repomatic cache at a per-test directory, auto-purge disabled.
Returns the cache root path so tests can assert on its contents.
"""
cache_dir = tmp_path / "cache"
monkeypatch.setenv("REPOMATIC_CACHE_DIR", str(cache_dir))
monkeypatch.setenv("REPOMATIC_CACHE_MAX_AGE", "0")
return cache_dir
[docs]
@contextmanager
def patch_gh(module: str, **mock_kwargs):
"""Patch a module's `gh` dispatch points with one shared mock.
Read paths go through `gh_json` (which calls the `gh` module's
`run_gh_command`) while write paths call the name imported into *module*
directly; patching both with the same mock keeps a test's single call
sequence intact across the two layers. Bind the module with
`functools.partial` for a per-file shorthand.
"""
mock_gh = Mock(**mock_kwargs)
with (
patch(f"{module}.run_gh_command", mock_gh),
patch("repomatic.github.gh.run_gh_command", mock_gh),
):
yield mock_gh
[docs]
def attribute_docstrings(body: list[ast.stmt]):
"""Yield `(line, owner, text)` for each attribute docstring in *body*.
An attribute docstring is the bare string literal following an assignment.
`ast.get_docstring` cannot see one, yet it is this project's convention for
documenting a dataclass field or a module constant, and
`automodule ... :undoc-members:` renders it.
"""
owner = ""
for statement in body:
if (
owner
and isinstance(statement, ast.Expr)
and isinstance(statement.value, ast.Constant)
and isinstance(statement.value.value, str)
):
yield statement.lineno, owner, statement.value.value
if isinstance(statement, ast.Assign):
targets = [t for t in statement.targets if isinstance(t, ast.Name)]
owner = targets[0].id if targets else ""
elif isinstance(statement, ast.AnnAssign) and isinstance(
statement.target, ast.Name
):
owner = statement.target.id
else:
owner = ""
[docs]
def write_bundle(path: Path, *subjects: str) -> Path:
"""Write a sigstore bundle attesting *subjects*.
Mirrors the shape `actions/attest` emits: a DSSE envelope whose base64
payload is an in-toto statement listing one entry per attested file.
"""
statement = {
"_type": "https://in-toto.io/Statement/v1",
"predicateType": "https://slsa.dev/provenance/v1",
"subject": [
{"name": name, "digest": {"sha256": f"{index:064x}"}}
for index, name in enumerate(subjects)
],
}
payload = base64.b64encode(json.dumps(statement).encode("UTF-8")).decode("ascii")
path.write_text(
json.dumps({
"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
"dsseEnvelope": {
"payload": payload,
"payloadType": "application/vnd.in-toto+json",
},
}),
encoding="UTF-8",
)
return path
MULTI_RELEASE_CHANGELOG = dedent(
"""\
# Changelog
## [2.0.0 (unreleased)](https://github.com/user/repo/compare/v1.1.0...main)
> [!WARNING]
> This version is **not released yet** and is under active development.
## [1.1.0 (2026-02-10)](https://github.com/user/repo/compare/v1.0.0...v1.1.0)
- Second release.
## [1.0.0 (2025-12-01)](https://github.com/user/repo/compare/v0.9.0...v1.0.0)
- Initial release.
"""
)
"""Changelog with multiple released versions and one unreleased."""
EM_AARCH64 = 183
EM_X86_64 = 62
SHT_STRTAB = 3
"""ELF section type of a string table."""
[docs]
def make_elf(
machine: int,
versions: tuple[str, ...] = (),
*,
wide: bool = True,
big_endian: bool = False,
extended_numbering: bool = False,
) -> bytes:
"""Craft a minimal ELF file, requiring *versions* of libc when given.
Without *versions* the file holds a bare header and no section table. With
them it carries a `.dynstr` string table and a `.gnu.version_r` section
naming each one under a single `libc.so.6` requirement, the way the linker
writes them. *wide* picks ELF64 over ELF32, and *big_endian* the byte
order, as the identification bytes declare them. *extended_numbering*
moves the section count out of `e_shnum` into the `sh_size` of entry `0`,
the encoding a file with `0xff00` sections or more has to use.
"""
order = ">" if big_endian else "<"
ident = b"\x7fELF" + bytes([2 if wide else 1, 2 if big_endian else 1, 1, 0])
ident += bytes(8)
header_layout = f"{order}HHIQQQIHHHHHH" if wide else f"{order}HHIIIIIHHHHHH"
section_layout = f"{order}IIQQQQIIQQ" if wide else f"{order}IIIIIIIIII"
header_size = len(ident) + struct.calcsize(header_layout)
section_size = struct.calcsize(section_layout)
if not versions:
header = struct.pack(
header_layout,
2,
machine,
1,
0,
0,
0,
0,
header_size,
0,
0,
section_size,
0,
0,
)
return ident + header
strings = b"\0"
offsets: dict[str, int] = {}
for name in ("libc.so.6", *versions):
offsets[name] = len(strings)
strings += name.encode() + b"\0"
verneed = struct.pack(
f"{order}HHIII", 1, len(versions), offsets["libc.so.6"], 16, 0
)
for index, name in enumerate(versions):
next_aux = 0 if index == len(versions) - 1 else 16
verneed += struct.pack(
f"{order}IHHII", 0, 0, 2 + index, offsets[name], next_aux
)
strtab_offset = header_size
verneed_offset = strtab_offset + len(strings)
shoff = verneed_offset + len(verneed)
section_count = 3
sections = struct.pack(
section_layout,
0,
0,
0,
0,
0,
section_count if extended_numbering else 0,
0,
0,
0,
0,
)
sections += struct.pack(
section_layout, 0, SHT_STRTAB, 0, 0, strtab_offset, len(strings), 0, 0, 1, 0
)
sections += struct.pack(
section_layout,
0,
SHT_GNU_VERNEED,
0,
0,
verneed_offset,
len(verneed),
1,
1,
4,
0,
)
header = struct.pack(
header_layout,
2,
machine,
1,
0,
0,
shoff,
0,
header_size,
0,
0,
section_size,
0 if extended_numbering else section_count,
0,
)
return ident + header + strings + verneed + sections
[docs]
def pack_macho_version(version: str) -> int:
"""Pack a `major.minor` string into a Mach-O version integer."""
major, _, minor = version.partition(".")
return (int(major) << 16) | (int(minor or 0) << 8)
[docs]
def make_macho(cpu_type: int, min_os: str = "11.0") -> bytes:
"""Craft a minimal 64-bit Mach-O header with one LC_BUILD_VERSION command."""
build_version = struct.pack(
"<IIIIII", 0x32, 24, 1, pack_macho_version(min_os), 0, 0
)
header = struct.pack(
"<IIIIIIII", 0xFEEDFACF, cpu_type, 0, 2, 1, len(build_version), 0, 0
)
return header + build_version
[docs]
def make_fat(slices: list[bytes]) -> bytes:
"""Wrap Mach-O slices into a universal (fat) container."""
header = struct.pack(">II", 0xCAFEBABE, len(slices))
offset = 8 + 20 * len(slices)
entries = b""
for chunk in slices:
entries += struct.pack(">iiIII", 0, 0, offset, len(chunk), 0)
offset += len(chunk)
return header + entries + b"".join(slices)
[docs]
def make_pe(machine: int) -> bytes:
"""Craft a minimal PE header exposing only the COFF machine type."""
dos_header = b"MZ" + bytes(58) + struct.pack("<I", 64)
return dos_header + b"PE\0\0" + struct.pack("<H", machine) + bytes(18)
[docs]
def macho_cpu_type(arch: Architecture) -> int:
"""The Mach-O `cputype` for an architecture, read for the byte builders.
{data}`~repomatic.release.binary.MACHINE_IDS` spans every format. These two
accessors name the format for the helpers below, which write real header
bytes.
"""
return MACHINE_IDS[BinaryFormat.MACHO, arch]
[docs]
def pe_machine_id(arch: Architecture) -> int:
"""The PE COFF `Machine` value for an architecture."""
return MACHINE_IDS[BinaryFormat.PE, arch]