makepatch 0.2.7__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 +3 -1
- makepatch/__main__.py +3 -0
- makepatch/_startup.py +90 -0
- makepatch/cli.py +238 -0
- makepatch/config.py +130 -0
- makepatch/errors.py +2 -0
- makepatch/git.py +133 -0
- makepatch/hatch/__init__.py +0 -0
- makepatch/hatch/hook.py +82 -0
- makepatch/{__hooks__.py → hatch/hooks.py} +8 -7
- makepatch/package/__init__.py +0 -0
- makepatch/package/apply.py +263 -0
- makepatch/package/dist.py +198 -0
- makepatch/package/edit.py +98 -0
- makepatch/source/__init__.py +0 -0
- makepatch/source/upstream.py +50 -0
- makepatch/source/workspace.py +281 -0
- makepatch/term.py +53 -0
- makepatch-0.3.0.dist-info/METADATA +148 -0
- makepatch-0.3.0.dist-info/RECORD +23 -0
- {makepatch-0.2.7.dist-info → makepatch-0.3.0.dist-info}/WHEEL +1 -1
- makepatch-0.3.0.dist-info/entry_points.txt +5 -0
- makepatch-startup.pth +1 -0
- makepatch/_config.py +0 -59
- makepatch/_types/__init__.pyi +0 -1
- makepatch/_types/hatch.pyi +0 -57
- makepatch/core/__init__.py +0 -1
- makepatch/core/_patcher.py +0 -127
- makepatch/hooks/__init__.py +0 -1
- makepatch/hooks/_patch.py +0 -54
- makepatch/scripts/__init__.py +0 -32
- makepatch/scripts/_rebuild.py +0 -78
- makepatch-0.2.7.dist-info/METADATA +0 -74
- makepatch-0.2.7.dist-info/RECORD +0 -15
- makepatch-0.2.7.dist-info/entry_points.txt +0 -6
makepatch/__init__.py
CHANGED
makepatch/__main__.py
ADDED
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
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
|
makepatch/hatch/hook.py
ADDED
|
@@ -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
|