makepatch 0.2.8__py3-none-any.whl → 0.3.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.
makepatch/__init__.py CHANGED
@@ -1 +1,3 @@
1
- from .hooks import PatcherBuildHook
1
+ """makepatch: git-based patching for Python packages."""
2
+
3
+ __version__ = "0.1.0"
makepatch/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from makepatch.cli import main
2
+
3
+ raise SystemExit(main())
makepatch/_startup.py ADDED
@@ -0,0 +1,90 @@
1
+ """Interpreter start-up check, imported from ``makepatch-startup.pth``.
2
+
3
+ It only does work in an environment where ``makepatch pkg apply`` (or
4
+ ``pkg commit``) has run before, i.e. where ``<sys.prefix>/makepatch-state.json``
5
+ exists. The fast path reads the patch files and the markers in the
6
+ dist-info directories; only when they disagree (a package was reinstalled
7
+ by uv/pip, or a patch was added, changed or removed) the patches are
8
+ applied again, under the environment lock. Nothing is ever patched in
9
+ memory: the result is the same as running ``makepatch pkg apply``.
10
+ """
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+
18
+
19
+ def _up_to_date(state):
20
+ applied = state.get("applied") or {}
21
+ patches_dir = state["patches_dir"]
22
+ try:
23
+ names = [n for n in os.listdir(patches_dir) if n.endswith(".patch")]
24
+ except FileNotFoundError:
25
+ names = []
26
+ if len(names) != len(applied):
27
+ return False
28
+ for name in names:
29
+ key = re.sub(r"[-_.]+", "-", name.partition("@")[0]).lower()
30
+ entry = applied.get(key)
31
+ if entry is None:
32
+ return False
33
+ with open(os.path.join(patches_dir, name), "rb") as fp:
34
+ sha = hashlib.sha256(fp.read()).hexdigest()
35
+ if entry.get("sha256") != sha:
36
+ return False
37
+ try:
38
+ with open(os.path.join(entry["dist_info"], "makepatch.json"), "rb") as fp:
39
+ marker = json.load(fp)
40
+ except (OSError, ValueError):
41
+ return False
42
+ if marker.get("sha256") != sha:
43
+ return False
44
+ return True
45
+
46
+
47
+ def _repair(state):
48
+ from pathlib import Path
49
+
50
+ from makepatch.config import PackageConfig
51
+ from makepatch.package.apply import apply_all, locked
52
+ from makepatch.package.dist import Environment
53
+
54
+ env = Environment.current()
55
+ cfg = PackageConfig(root=Path(state["project"]), patches_dir=Path(state["patches_dir"]))
56
+ with locked(env):
57
+ # Another process may have repaired the environment meanwhile.
58
+ try:
59
+ with open(env.state_file, "rb") as fp:
60
+ if _up_to_date(json.load(fp)):
61
+ return
62
+ except (OSError, ValueError, KeyError):
63
+ pass
64
+ results = apply_all(cfg, env)
65
+ for result in results:
66
+ if result.error:
67
+ sys.stderr.write(f"makepatch: warning: {result.error}\n")
68
+ elif result.action != "unchanged":
69
+ sys.stderr.write(f"makepatch: {result.key}: {result.action}\n")
70
+
71
+
72
+ def _run():
73
+ if os.environ.get("MAKEPATCH_DISABLE_STARTUP") or sys.flags.isolated:
74
+ return
75
+ try:
76
+ with open(os.path.join(sys.prefix, "makepatch-state.json"), "rb") as fp:
77
+ state = json.load(fp)
78
+ except (OSError, ValueError):
79
+ return
80
+ if not os.path.isdir(state.get("project", "")):
81
+ return
82
+ if _up_to_date(state):
83
+ return
84
+ _repair(state)
85
+
86
+
87
+ try:
88
+ _run()
89
+ except Exception as exc: # never break interpreter start-up
90
+ sys.stderr.write(f"makepatch: warning: start-up check failed: {exc}\n")
makepatch/cli.py ADDED
@@ -0,0 +1,238 @@
1
+ """Command line interface: ``makepatch src ...`` and ``makepatch pkg ...``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from makepatch import __version__
10
+ from makepatch.config import PackageConfig, SourceConfig, find_project_root
11
+ from makepatch.errors import MakepatchError
12
+ from makepatch.term import paint
13
+
14
+ # Colors for the actions reported by ``pkg`` commands.
15
+ ACTION_STYLES = {
16
+ "applied": ("green",),
17
+ "reapplied": ("green",),
18
+ "would apply": ("cyan",),
19
+ "unchanged": ("dim",),
20
+ "reverted": ("yellow",),
21
+ "would revert": ("yellow",),
22
+ "not patched": ("dim",),
23
+ }
24
+
25
+
26
+ def _root(args: argparse.Namespace) -> Path:
27
+ return find_project_root(Path(args.project) if args.project else None)
28
+
29
+
30
+ def _action(action: str) -> str:
31
+ return paint(action, *ACTION_STYLES.get(action, ()))
32
+
33
+
34
+ def _cmd(command: str) -> str:
35
+ return paint(f"`{command}`", "cyan")
36
+
37
+
38
+ def _err(text: str, *styles: str) -> str:
39
+ return paint(text, *styles, stream=sys.stderr)
40
+
41
+
42
+ def _note(message: str) -> None:
43
+ print(f"{_err('note:', 'yellow', 'bold')} {message}", file=sys.stderr)
44
+
45
+
46
+ def _env(args: argparse.Namespace):
47
+ from makepatch.package.dist import Environment
48
+
49
+ return Environment.for_python(args.python) if args.python else Environment.current()
50
+
51
+
52
+ # -- src -------------------------------------------------------------------------------
53
+
54
+
55
+ def cmd_src_apply(args: argparse.Namespace) -> int:
56
+ from makepatch.source import workspace
57
+
58
+ cfg = SourceConfig.load(_root(args))
59
+ work = workspace.setup(cfg, offline=args.offline, force=args.force)
60
+ print(f"{paint('work repository ready:', 'green')} {paint(str(work), 'bold')}")
61
+ return 0
62
+
63
+
64
+ def cmd_src_rebuild(args: argparse.Namespace) -> int:
65
+ from makepatch.source import workspace
66
+
67
+ cfg = SourceConfig.load(_root(args))
68
+ sources, features = workspace.rebuild(cfg)
69
+ counts = f"{paint(str(sources), 'bold')} source patch(es) and {paint(str(features), 'bold')} feature patch(es)"
70
+ print(f"{paint('wrote', 'green')} {counts}")
71
+ return 0
72
+
73
+
74
+ def cmd_src_fixup(args: argparse.Namespace) -> int:
75
+ from makepatch.source import workspace
76
+
77
+ cfg = SourceConfig.load(_root(args))
78
+ workspace.fixup(cfg)
79
+ print(f"{paint('folded changes into the source patch commit', 'green')}; run {_cmd('makepatch src rebuild')} to update patches")
80
+ return 0
81
+
82
+
83
+ def cmd_src_status(args: argparse.Namespace) -> int:
84
+ from makepatch.source import workspace
85
+
86
+ cfg = SourceConfig.load(_root(args))
87
+ st = workspace.status(cfg)
88
+ print(f"{paint('upstream:', 'bold')} {cfg.upstream} @ {paint(cfg.ref, 'cyan')} {paint(f'({st.base[:12]})', 'dim')}")
89
+ print(f"{paint('source files:', 'bold')} {st.source_files}")
90
+ print(f"{paint('feature commits:', 'bold')} {st.features}")
91
+ print(f"{paint('dirty:', 'bold')} {paint('yes', 'yellow') if st.dirty else paint('no', 'green')}")
92
+ if st.in_progress:
93
+ print(f"{paint('in progress:', 'bold')} {paint(st.in_progress, 'yellow', 'bold')}")
94
+ return 0
95
+
96
+
97
+ # -- pkg -------------------------------------------------------------------------------
98
+
99
+
100
+ def cmd_pkg_edit(args: argparse.Namespace) -> int:
101
+ from makepatch.package import edit
102
+
103
+ cfg = PackageConfig.load(_root(args))
104
+ path = edit.edit(cfg, _env(args), args.package, force=args.force)
105
+ print(f"edit the files in {paint(str(path), 'bold')}, then run {_cmd(f'makepatch pkg commit {args.package}')}")
106
+ return 0
107
+
108
+
109
+ def cmd_pkg_commit(args: argparse.Namespace) -> int:
110
+ from makepatch.package import edit
111
+
112
+ cfg = PackageConfig.load(_root(args))
113
+ patch, action = edit.commit(cfg, _env(args), args.package, keep=args.keep)
114
+ if patch is None:
115
+ print(f"no changes; {paint(args.package, 'bold')}: {_action(action)}")
116
+ else:
117
+ wrote = paint(str(patch.relative_to(cfg.root)), "bold")
118
+ print(f"{paint('wrote', 'green')} {wrote}; {paint(args.package, 'bold')}: {_action(action)}")
119
+ return 0
120
+
121
+
122
+ def _print_results(results) -> int:
123
+ failed = 0
124
+ for r in results:
125
+ if r.error:
126
+ failed += 1
127
+ print(f"{_err(r.key, 'bold')}: {_err('failed:', 'red', 'bold')} {r.error}", file=sys.stderr)
128
+ else:
129
+ print(f"{paint(r.key, 'bold')}: {_action(r.action)}")
130
+ return 1 if failed else 0
131
+
132
+
133
+ def cmd_pkg_apply(args: argparse.Namespace) -> int:
134
+ from makepatch.package.apply import apply_all, locked
135
+
136
+ cfg = PackageConfig.load(_root(args))
137
+ env = _env(args)
138
+ with locked(env):
139
+ return _print_results(apply_all(cfg, env, dry_run=args.check))
140
+
141
+
142
+ def cmd_pkg_revert(args: argparse.Namespace) -> int:
143
+ from makepatch.package.apply import locked, revert, write_state
144
+
145
+ cfg = PackageConfig.load(_root(args))
146
+ env = _env(args)
147
+ with locked(env):
148
+ dist = env.find(args.package)
149
+ done = revert(dist)
150
+ write_state(cfg, env)
151
+ print(f"{paint(dist.key, 'bold')}: {_action('reverted' if done else 'not patched')}")
152
+ if done:
153
+ _note("the start-up hook re-applies it unless the patch file is removed")
154
+ return 0
155
+
156
+
157
+ def cmd_pkg_status(args: argparse.Namespace) -> int:
158
+ from makepatch.package.apply import discover
159
+
160
+ cfg = PackageConfig.load(_root(args))
161
+ env = _env(args)
162
+ patches = discover(cfg)
163
+ dists = {d.key: d for d in env.distributions()}
164
+ keys = sorted(set(patches) | {k for k, d in dists.items() if d.marker()})
165
+ if not keys:
166
+ print(paint("no package patches", "dim"))
167
+ for key in keys:
168
+ patch, dist = patches.get(key), dists.get(key)
169
+ if dist is None:
170
+ state = paint("not installed", "red")
171
+ elif patch is None:
172
+ state = paint("patched, but the patch file is gone", "red")
173
+ elif dist.version != patch.version:
174
+ state = paint(f"version mismatch (installed {dist.version})", "red")
175
+ else:
176
+ marker = dist.marker()
177
+ if marker is None:
178
+ state = paint("not applied", "yellow")
179
+ elif marker.get("sha256") != patch.sha256:
180
+ state = paint("outdated", "yellow")
181
+ else:
182
+ state = paint("applied", "green")
183
+ print(f"{paint(patch.path.name if patch else key, 'bold')}: {state}")
184
+ return 0
185
+
186
+
187
+ def build_parser() -> argparse.ArgumentParser:
188
+ parser = argparse.ArgumentParser(prog="makepatch", description=__doc__)
189
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
190
+ parser.add_argument("-C", "--project", help="project directory (default: nearest pyproject.toml)")
191
+ modes = parser.add_subparsers(dest="mode", required=True)
192
+
193
+ src = modes.add_parser("src", help="source patch mode (fork an upstream git repository)")
194
+ src_cmds = src.add_subparsers(dest="command", required=True)
195
+ p = src_cmds.add_parser("apply", help="create the work repository and apply all patches")
196
+ p.add_argument("--offline", action="store_true", help="use the cached upstream only")
197
+ p.add_argument("--force", action="store_true", help="discard unsaved work in the work repository")
198
+ p.set_defaults(func=cmd_src_apply)
199
+ p = src_cmds.add_parser("rebuild", help="regenerate patches from the work repository")
200
+ p.set_defaults(func=cmd_src_rebuild)
201
+ p = src_cmds.add_parser("fixup", help="fold working tree changes into the source patches")
202
+ p.set_defaults(func=cmd_src_fixup)
203
+ p = src_cmds.add_parser("status", help="show the work repository state")
204
+ p.set_defaults(func=cmd_src_status)
205
+
206
+ pkg = modes.add_parser("pkg", help="package patch mode (patch installed distributions)")
207
+ pkg.add_argument("--python", help="interpreter of the target environment (default: the current one)")
208
+ pkg_cmds = pkg.add_subparsers(dest="command", required=True)
209
+ p = pkg_cmds.add_parser("edit", help="prepare an editable copy of an installed package")
210
+ p.add_argument("package")
211
+ p.add_argument("--force", action="store_true", help="discard an existing edit")
212
+ p.set_defaults(func=cmd_pkg_edit)
213
+ p = pkg_cmds.add_parser("commit", help="write the patch from an edit and apply it")
214
+ p.add_argument("package")
215
+ p.add_argument("--keep", action="store_true", help="keep the edit directory")
216
+ p.set_defaults(func=cmd_pkg_commit)
217
+ p = pkg_cmds.add_parser("apply", help="apply every package patch to the environment")
218
+ p.add_argument("--check", action="store_true", help="only check that the patches apply")
219
+ p.set_defaults(func=cmd_pkg_apply)
220
+ p = pkg_cmds.add_parser("revert", help="restore the original files of a package")
221
+ p.add_argument("package")
222
+ p.set_defaults(func=cmd_pkg_revert)
223
+ p = pkg_cmds.add_parser("status", help="show the state of every package patch")
224
+ p.set_defaults(func=cmd_pkg_status)
225
+ return parser
226
+
227
+
228
+ def main(argv: list[str] | None = None) -> int:
229
+ args = build_parser().parse_args(argv)
230
+ try:
231
+ return args.func(args)
232
+ except MakepatchError as exc:
233
+ print(f"{_err('makepatch: error:', 'red', 'bold')} {exc}", file=sys.stderr)
234
+ return 1
235
+
236
+
237
+ if __name__ == "__main__": # pragma: no cover
238
+ raise SystemExit(main())
makepatch/config.py ADDED
@@ -0,0 +1,130 @@
1
+ """Loading of ``[tool.makepatch]`` from ``pyproject.toml``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path, PurePosixPath
9
+ from typing import Any
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else: # pragma: no cover
14
+ import tomli as tomllib
15
+
16
+ from makepatch.errors import MakepatchError
17
+
18
+ STATE_DIR = ".makepatch"
19
+
20
+
21
+ def find_project_root(start: Path | None = None) -> Path:
22
+ start = (start or Path.cwd()).resolve()
23
+ for directory in (start, *start.parents):
24
+ if (directory / "pyproject.toml").is_file():
25
+ return directory
26
+ raise MakepatchError(f"no pyproject.toml found in {start} or any parent directory")
27
+
28
+
29
+ def load_tool_table(root: Path) -> dict[str, Any]:
30
+ path = root / "pyproject.toml"
31
+ try:
32
+ with path.open("rb") as fp:
33
+ data = tomllib.load(fp)
34
+ except FileNotFoundError:
35
+ raise MakepatchError(f"{path} does not exist") from None
36
+ except tomllib.TOMLDecodeError as exc:
37
+ raise MakepatchError(f"{path}: {exc}") from None
38
+ table = data.get("tool", {}).get("makepatch", {})
39
+ if not isinstance(table, dict):
40
+ raise MakepatchError("[tool.makepatch] must be a table")
41
+ return table
42
+
43
+
44
+ def _safe_relpath(value: str, what: str) -> str:
45
+ path = PurePosixPath(value)
46
+ if not value or path.is_absolute() or ".." in path.parts:
47
+ raise MakepatchError(f"{what} must be a relative path without '..': {value!r}")
48
+ return path.as_posix().rstrip("/") if path.as_posix() != "." else "."
49
+
50
+
51
+ def _patterns(table: dict[str, Any], key: str) -> list[str]:
52
+ value = table.get(key, [])
53
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
54
+ raise MakepatchError(f"[tool.makepatch.source] {key} must be a list of gitignore-style patterns")
55
+ return value
56
+
57
+
58
+ @dataclass
59
+ class SourceConfig:
60
+ root: Path
61
+ upstream: str
62
+ ref: str
63
+ include: dict[str, str] = field(default_factory=dict)
64
+ exclude: list[str] = field(default_factory=list)
65
+ work_exclude: list[str] = field(default_factory=list)
66
+ work_dir: Path = Path()
67
+ patches_dir: Path = Path()
68
+
69
+ @property
70
+ def state_dir(self) -> Path:
71
+ return self.root / STATE_DIR
72
+
73
+ @property
74
+ def source_patches(self) -> Path:
75
+ return self.patches_dir / "sources"
76
+
77
+ @property
78
+ def feature_patches(self) -> Path:
79
+ return self.patches_dir / "features"
80
+
81
+ @classmethod
82
+ def load(cls, root: Path, table: dict[str, Any] | None = None) -> "SourceConfig":
83
+ if table is None:
84
+ table = load_tool_table(root)
85
+ src = table.get("source")
86
+ if not isinstance(src, dict):
87
+ raise MakepatchError("missing [tool.makepatch.source] table in pyproject.toml")
88
+ upstream = src.get("upstream")
89
+ ref = src.get("ref")
90
+ if not isinstance(upstream, str) or not upstream:
91
+ raise MakepatchError("[tool.makepatch.source] upstream must be a git URL")
92
+ if not isinstance(ref, str) or not ref:
93
+ raise MakepatchError("[tool.makepatch.source] ref must be a commit, tag or branch")
94
+ include = src.get("include", {})
95
+ if not isinstance(include, dict) or not all(isinstance(v, str) for v in include.values()):
96
+ raise MakepatchError("[tool.makepatch.source] include must map upstream paths to wheel paths")
97
+ include = {_safe_relpath(k, "include key"): _safe_relpath(v, "include value") for k, v in include.items()}
98
+ exclude = _patterns(src, "exclude")
99
+ work_exclude = _patterns(src, "work-exclude")
100
+ # A local path upstream is resolved against the project root.
101
+ if not re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", upstream) and not re.match(r"^[^/]+@[^/]+:", upstream):
102
+ upstream = str((root / upstream).resolve())
103
+ return cls(
104
+ root=root,
105
+ upstream=upstream,
106
+ ref=ref,
107
+ include=include,
108
+ exclude=exclude,
109
+ work_exclude=work_exclude,
110
+ work_dir=root / _safe_relpath(src.get("work-dir", "work"), "work-dir"),
111
+ patches_dir=root / _safe_relpath(src.get("patches-dir", "patches"), "patches-dir"),
112
+ )
113
+
114
+
115
+ @dataclass
116
+ class PackageConfig:
117
+ root: Path
118
+ patches_dir: Path
119
+
120
+ @property
121
+ def edit_dir(self) -> Path:
122
+ return self.root / STATE_DIR / "edit"
123
+
124
+ @classmethod
125
+ def load(cls, root: Path) -> "PackageConfig":
126
+ table = load_tool_table(root).get("packages", {})
127
+ if not isinstance(table, dict):
128
+ raise MakepatchError("[tool.makepatch.packages] must be a table")
129
+ patches_dir = _safe_relpath(table.get("patches-dir", "patches/packages"), "patches-dir")
130
+ return cls(root=root, patches_dir=root / patches_dir)
makepatch/errors.py ADDED
@@ -0,0 +1,2 @@
1
+ class MakepatchError(Exception):
2
+ """An expected failure, reported to the user without a traceback."""
makepatch/git.py ADDED
@@ -0,0 +1,133 @@
1
+ """Thin wrapper around the ``git`` executable.
2
+
3
+ Every git invocation in makepatch goes through :func:`run` so that the
4
+ environment is pinned: stable locale, fixed identity, no signing, no
5
+ line-ending conversion and no discovery of an enclosing repository.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Sequence
14
+
15
+ from makepatch.errors import MakepatchError
16
+
17
+ IDENTITY_NAME = "makepatch"
18
+ IDENTITY_EMAIL = "makepatch@localhost"
19
+ # Fixed date for commits makepatch creates itself (base / source patches),
20
+ # so that re-running setup yields identical commit ids.
21
+ FIXED_DATE = "1970-01-01T00:00:00+0000"
22
+
23
+ _CONFIG = (
24
+ "commit.gpgsign=false",
25
+ "tag.gpgsign=false",
26
+ "core.autocrlf=false",
27
+ "core.safecrlf=false",
28
+ "core.quotepath=false",
29
+ "advice.detachedHead=false",
30
+ "init.defaultBranch=main",
31
+ )
32
+
33
+
34
+ class GitError(MakepatchError):
35
+ def __init__(self, args: Sequence[str], returncode: int, stdout: str, stderr: str):
36
+ self.args_ = list(args)
37
+ self.returncode = returncode
38
+ self.stdout = stdout
39
+ self.stderr = stderr
40
+ detail = (stderr or stdout).strip()
41
+ super().__init__(f"git {' '.join(args)} failed ({returncode})" + (f":\n{detail}" if detail else ""))
42
+
43
+
44
+ def _env(cwd: Path, extra: dict[str, str] | None, fixed_date: bool, network: bool) -> dict[str, str]:
45
+ env = dict(os.environ)
46
+ for key in list(env):
47
+ # Never let an outer repository leak in (e.g. when run from a git hook).
48
+ if key.startswith("GIT_") and key not in ("GIT_SSH", "GIT_SSH_COMMAND", "GIT_ASKPASS", "GIT_TERMINAL_PROMPT"):
49
+ del env[key]
50
+ env["LC_ALL"] = "C"
51
+ env["LANG"] = "C"
52
+ if not network:
53
+ # Local operations (diff, format-patch, apply, am, commit, rebase) must
54
+ # not be influenced by user configuration such as diff.noprefix,
55
+ # format.* or core.hooksPath. Network operations keep it for
56
+ # proxies, credential helpers and url.*.insteadOf.
57
+ env["GIT_CONFIG_NOSYSTEM"] = "1"
58
+ env["GIT_CONFIG_GLOBAL"] = os.devnull
59
+ env["GIT_AUTHOR_NAME"] = env["GIT_COMMITTER_NAME"] = IDENTITY_NAME
60
+ env["GIT_AUTHOR_EMAIL"] = env["GIT_COMMITTER_EMAIL"] = IDENTITY_EMAIL
61
+ if fixed_date:
62
+ env["GIT_AUTHOR_DATE"] = env["GIT_COMMITTER_DATE"] = FIXED_DATE
63
+ # Stop repository discovery at the parent of cwd: ``cwd`` itself may be a
64
+ # repository, but nothing above it is ever used.
65
+ env["GIT_CEILING_DIRECTORIES"] = str(Path(cwd).resolve().parent)
66
+ if extra:
67
+ env.update(extra)
68
+ return env
69
+
70
+
71
+ def run(
72
+ args: Sequence[str],
73
+ cwd: Path | str,
74
+ *,
75
+ check: bool = True,
76
+ input: bytes | str | None = None,
77
+ env: dict[str, str] | None = None,
78
+ fixed_date: bool = False,
79
+ binary: bool = False,
80
+ network: bool = False,
81
+ ) -> subprocess.CompletedProcess:
82
+ cwd = Path(cwd)
83
+ cmd = ["git"]
84
+ for item in _CONFIG:
85
+ cmd += ["-c", item]
86
+ cmd += list(args)
87
+ if isinstance(input, str):
88
+ input = input.encode()
89
+ try:
90
+ proc = subprocess.run(
91
+ cmd,
92
+ cwd=cwd,
93
+ input=input,
94
+ capture_output=True,
95
+ env=_env(cwd, env, fixed_date, network),
96
+ )
97
+ except FileNotFoundError as exc: # pragma: no cover - depends on system
98
+ raise MakepatchError("git executable not found in PATH") from exc
99
+ if not binary:
100
+ proc.stdout = proc.stdout.decode("utf-8", "surrogateescape")
101
+ proc.stderr = proc.stderr.decode("utf-8", "surrogateescape")
102
+ if check and proc.returncode != 0:
103
+ out = proc.stdout if isinstance(proc.stdout, str) else proc.stdout.decode("utf-8", "replace")
104
+ err = proc.stderr if isinstance(proc.stderr, str) else proc.stderr.decode("utf-8", "replace")
105
+ raise GitError(args, proc.returncode, out, err)
106
+ return proc
107
+
108
+
109
+ def out(args: Sequence[str], cwd: Path | str, **kwargs) -> str:
110
+ return run(args, cwd, **kwargs).stdout.strip()
111
+
112
+
113
+ def patch_paths(patch: Path, cwd: Path) -> list[str]:
114
+ """Return every path a patch touches (old and new names)."""
115
+ proc = run(["apply", "--numstat", "-z", str(patch)], cwd)
116
+ paths: list[str] = []
117
+ fields = proc.stdout.split("\0")
118
+ # ``--numstat -z`` emits "added\tdeleted\tpath\0", or for renames
119
+ # "added\tdeleted\t\0old\0new\0".
120
+ i = 0
121
+ while i < len(fields):
122
+ field = fields[i]
123
+ parts = field.split("\t")
124
+ if len(parts) == 3:
125
+ if parts[2]:
126
+ paths.append(parts[2])
127
+ i += 1
128
+ else:
129
+ paths += fields[i + 1 : i + 3]
130
+ i += 3
131
+ else:
132
+ i += 1
133
+ return list(dict.fromkeys(p for p in paths if p))
File without changes
@@ -0,0 +1,82 @@
1
+ """Hatchling build hook for source patch mode.
2
+
3
+ ``[tool.hatch.build.hooks.makepatch]`` enables it; the settings themselves
4
+ live in ``[tool.makepatch.source]``.
5
+
6
+ * sdist: the patched upstream tree is stored under ``_makepatch/tree`` so
7
+ that building a wheel from the sdist needs neither git history nor network.
8
+ * wheel: the paths of ``include`` are mapped from the patched tree into the
9
+ wheel.
10
+
11
+ Files matching ``exclude`` (gitignore syntax, relative to each ``include``
12
+ path) are left out of both.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ from pathlib import Path
20
+ from typing import Iterator
21
+
22
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
23
+
24
+ from makepatch.config import SourceConfig
25
+ from makepatch.errors import MakepatchError
26
+ from makepatch.source import workspace
27
+
28
+ SDIST_TREE = "_makepatch/tree"
29
+
30
+
31
+ class MakepatchBuildHook(BuildHookInterface):
32
+ PLUGIN_NAME = "makepatch"
33
+
34
+ def _tree(self, cfg: SourceConfig) -> Path:
35
+ root = Path(self.root)
36
+ prebuilt = root / SDIST_TREE
37
+ if (root / "PKG-INFO").is_file() and prebuilt.is_dir():
38
+ return prebuilt
39
+ offline = os.environ.get("MAKEPATCH_OFFLINE", "") not in ("", "0")
40
+ dest = cfg.state_dir / "build" / "tree"
41
+ self.app.display_info(f"makepatch: applying patches onto {cfg.upstream} @ {cfg.ref}")
42
+ return workspace.materialize(cfg, dest, offline=offline)
43
+
44
+ def initialize(self, version: str, build_data: dict) -> None:
45
+ cfg = SourceConfig.load(Path(self.root))
46
+ if not cfg.include:
47
+ raise MakepatchError("[tool.makepatch.source] include is empty: nothing would be built")
48
+ tree = self._tree(cfg)
49
+ force_include = build_data.setdefault("force_include", {})
50
+ for src, dst in cfg.include.items():
51
+ path = tree / src
52
+ if not path.exists():
53
+ raise MakepatchError(f"include path {src!r} does not exist in the patched upstream tree")
54
+ prefix = f"{SDIST_TREE}/{src}" if self.target_name == "sdist" else dst
55
+ for file, rel in _files(path, cfg.exclude):
56
+ force_include[str(file)] = f"{prefix}/{rel}" if rel else prefix
57
+
58
+ def clean(self, versions: list[str]) -> None:
59
+ build = Path(self.root) / ".makepatch" / "build"
60
+ if build.is_dir():
61
+ shutil.rmtree(build)
62
+
63
+
64
+ def _files(path: Path, exclude: list[str]) -> Iterator[tuple[Path, str]]:
65
+ """Yield (file, path relative to ``path``) for files not excluded.
66
+
67
+ For a single-file include the relative path is empty and the file name
68
+ is matched against the patterns.
69
+ """
70
+ from pathspec import GitIgnoreSpec # a dependency of hatchling
71
+
72
+ spec = GitIgnoreSpec.from_lines(exclude)
73
+ if path.is_file():
74
+ if not spec.match_file(path.name):
75
+ yield path, ""
76
+ return
77
+ for file in sorted(path.rglob("*")):
78
+ rel = file.relative_to(path).as_posix()
79
+ if ".git" in file.relative_to(path).parts or not file.is_file():
80
+ continue
81
+ if not spec.match_file(rel):
82
+ yield file, rel