makepatch 0.2.7__tar.gz → 0.3.0__tar.gz

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.
Files changed (51) hide show
  1. makepatch-0.3.0/.gitignore +7 -0
  2. makepatch-0.3.0/PKG-INFO +148 -0
  3. makepatch-0.3.0/README.md +137 -0
  4. makepatch-0.3.0/pyproject.toml +53 -0
  5. makepatch-0.3.0/src/makepatch/__init__.py +3 -0
  6. makepatch-0.3.0/src/makepatch/__main__.py +3 -0
  7. makepatch-0.3.0/src/makepatch/_startup.py +90 -0
  8. makepatch-0.3.0/src/makepatch/cli.py +238 -0
  9. makepatch-0.3.0/src/makepatch/config.py +130 -0
  10. makepatch-0.3.0/src/makepatch/errors.py +2 -0
  11. makepatch-0.3.0/src/makepatch/git.py +133 -0
  12. makepatch-0.3.0/src/makepatch/hatch/__init__.py +0 -0
  13. makepatch-0.3.0/src/makepatch/hatch/hook.py +82 -0
  14. makepatch-0.2.7/src/makepatch/__hooks__.py → makepatch-0.3.0/src/makepatch/hatch/hooks.py +8 -7
  15. makepatch-0.3.0/src/makepatch/package/__init__.py +0 -0
  16. makepatch-0.3.0/src/makepatch/package/apply.py +263 -0
  17. makepatch-0.3.0/src/makepatch/package/dist.py +198 -0
  18. makepatch-0.3.0/src/makepatch/package/edit.py +98 -0
  19. makepatch-0.3.0/src/makepatch/source/__init__.py +0 -0
  20. makepatch-0.3.0/src/makepatch/source/upstream.py +50 -0
  21. makepatch-0.3.0/src/makepatch/source/workspace.py +281 -0
  22. makepatch-0.3.0/src/makepatch/term.py +53 -0
  23. makepatch-0.3.0/src/makepatch-startup.pth +1 -0
  24. makepatch-0.3.0/tests/conftest.py +85 -0
  25. makepatch-0.3.0/tests/test_hatch.py +53 -0
  26. makepatch-0.3.0/tests/test_package.py +195 -0
  27. makepatch-0.3.0/tests/test_source.py +200 -0
  28. makepatch-0.3.0/tests/test_term.py +29 -0
  29. makepatch-0.3.0/uv.lock +432 -0
  30. makepatch-0.2.7/.editorconfig +0 -1
  31. makepatch-0.2.7/.gitignore +0 -6
  32. makepatch-0.2.7/.idea/inspectionProfiles/Project_Default.xml +0 -407
  33. makepatch-0.2.7/.idea/inspectionProfiles/profiles_settings.xml +0 -6
  34. makepatch-0.2.7/.idea/makepatch.iml +0 -16
  35. makepatch-0.2.7/.idea/modules.xml +0 -8
  36. makepatch-0.2.7/.idea/workspace.xml +0 -9
  37. makepatch-0.2.7/CHANGELOG.md +0 -11
  38. makepatch-0.2.7/PKG-INFO +0 -74
  39. makepatch-0.2.7/README.md +0 -48
  40. makepatch-0.2.7/pyproject.toml +0 -56
  41. makepatch-0.2.7/src/makepatch/__init__.py +0 -1
  42. makepatch-0.2.7/src/makepatch/_config.py +0 -59
  43. makepatch-0.2.7/src/makepatch/_types/__init__.pyi +0 -1
  44. makepatch-0.2.7/src/makepatch/_types/hatch.pyi +0 -57
  45. makepatch-0.2.7/src/makepatch/core/__init__.py +0 -1
  46. makepatch-0.2.7/src/makepatch/core/_patcher.py +0 -127
  47. makepatch-0.2.7/src/makepatch/hooks/__init__.py +0 -1
  48. makepatch-0.2.7/src/makepatch/hooks/_patch.py +0 -54
  49. makepatch-0.2.7/src/makepatch/scripts/__init__.py +0 -32
  50. makepatch-0.2.7/src/makepatch/scripts/_rebuild.py +0 -78
  51. makepatch-0.2.7/uv.lock +0 -194
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .makepatch/
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.5
2
+ Name: makepatch
3
+ Version: 0.3.0
4
+ Summary: Git-based patch tool for Python packages: source forks (paperweight-style) and installed-package patches (pnpm-style)
5
+ Project-URL: source, https://github.com/MadeByAlpha/makepatch.git
6
+ Project-URL: issues, https://github.com/MadeByAlpha/makepatch/issues
7
+ Classifier: Framework :: Hatch
8
+ Classifier: Programming Language :: Python :: 3
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+
12
+ # makepatch
13
+
14
+ A Git-based patch tool for Python packages that integrates with Hatchling.
15
+
16
+ *Inspired by [paperweight](https://github.com/PaperMC/paperweight) and `pnpm patch`.*
17
+
18
+ | Mode | Approach | Output |
19
+ | --- | --- | --- |
20
+ | Source patch (`makepatch src`) | Fetch an upstream Git repository at a pinned ref and patch it | sdist/wheel of a new package (fork) |
21
+ | Package patch (`makepatch pkg`) | Patch installed packages in site-packages | patched virtual environment |
22
+
23
+ There are two kinds of patches:
24
+
25
+ - **Source patch**: created with `git diff` and applied with `git apply`. One patch per file.
26
+ - **Feature patch**: created with `git format-patch -p --minimal --zero-commit --no-numbered` and applied with `git am --3way`. One patch per commit; available in source patch mode only.
27
+
28
+ All patches are applied statically; there is no runtime monkey-patching.
29
+
30
+ ## Requirements
31
+
32
+ - Python ≥ 3.13
33
+ - Git ≥ 2.32
34
+
35
+ ## Source patch mode
36
+
37
+ A fork repository needs only `pyproject.toml` and `patches/`.
38
+
39
+ ```toml
40
+ [build-system]
41
+ requires = ["hatchling", "makepatch"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [project] # Define the fork's metadata yourself.
45
+ name = "requests-fork"
46
+ version = "2.32.3.post1"
47
+ dependencies = ["urllib3>=1.21.1,<3", "idna>=2.5,<4", "charset_normalizer>=2,<4", "certifi>=2017.4.17"]
48
+
49
+ [tool.makepatch.source]
50
+ upstream = "https://github.com/psf/requests.git"
51
+ ref = "v2.32.3" # a 40-digit commit SHA is recommended; tags and branches work too
52
+ include = { "src/requests" = "requests" } # upstream path → wheel path
53
+ exclude = ["**/*.pyi"] # excluded from sdist/wheel (relative to include paths)
54
+ work-exclude = ["docs/", "tests/"] # excluded from work/ and the build tree (relative to the upstream root)
55
+ # work-dir = "work" # default
56
+ # patches-dir = "patches" # default
57
+
58
+ [tool.hatch.build.hooks.makepatch] # enables the build hook (settings live in the table above)
59
+
60
+ [tool.hatch.build.targets.wheel]
61
+ bypass-selection = true
62
+ ```
63
+
64
+ Add `work/` and `.makepatch/` to `.gitignore` so that they are excluded from the sdist.
65
+
66
+ ### Excluding files
67
+
68
+ Both options use gitignore syntax (including `!` negation), but differ in base path and effect.
69
+
70
+ | Option | Base path | Effect |
71
+ | --- | --- | --- |
72
+ | `exclude` | each `include` path (e.g. `src/requests`) | Excluded from the sdist and wheel. Still present in `work/`. |
73
+ | `work-exclude` | upstream root | Not checked out into `work/` or the build tree, via `git sparse-checkout`. Meant to save time and disk space. |
74
+
75
+ - If a patch touches a file excluded by `work-exclude`, `makepatch src apply` and the build stop with an error.
76
+ - After changing `work-exclude`, run `makepatch src apply` again.
77
+
78
+ ### Workflow
79
+
80
+ ```sh
81
+ makepatch src apply # fetch upstream → create work/ → apply source patches → git am feature patches
82
+ # edit files in work/
83
+ makepatch src fixup # fold working tree changes into the source patch commit
84
+ # or make regular commits in work/ → feature patches
85
+ makepatch src rebuild # regenerate patches/sources/** and patches/features/*.patch
86
+ makepatch src status
87
+ ```
88
+
89
+ The `work/` repository is laid out as follows:
90
+
91
+ ```
92
+ <upstream ref> tag makepatch/base
93
+ makepatch: source patches tag makepatch/sources ← patches/sources/<path>.patch
94
+ <feature commits> ... ← patches/features/NNNN-*.patch
95
+ ```
96
+
97
+ If a feature patch conflicts, the `git am` session is left in place. Resolve the conflict in `work/`, run `git am --continue`, then run `makepatch src rebuild`.
98
+
99
+ ### Building
100
+
101
+ ```sh
102
+ uv build
103
+ ```
104
+
105
+ - The build hook does not use `work/`; it applies the patches afresh in `.makepatch/build/tree`. Uncommitted work therefore never leaks into the output.
106
+ - The sdist contains the patched sources (`_makepatch/tree/`). Building a wheel from the sdist needs neither git nor network access.
107
+ - Set `MAKEPATCH_OFFLINE=1` to use only the `.makepatch/upstream.git` cache.
108
+ - Upstream's own build steps (C extensions, etc.) are not run. Pure Python sources are the target.
109
+
110
+ ## Package patch mode
111
+
112
+ Install makepatch as a development dependency of the project.
113
+
114
+ ```sh
115
+ uv add --dev makepatch
116
+ ```
117
+
118
+ ```sh
119
+ uv run makepatch pkg edit requests # create an editable copy in .makepatch/edit/requests@2.32.3/
120
+ # edit files in the copy
121
+ uv run makepatch pkg commit requests # save patches/packages/requests@2.32.3.patch and apply it
122
+ uv run makepatch pkg apply # apply every patch (--check: only check that they apply)
123
+ uv run makepatch pkg status
124
+ uv run makepatch pkg revert requests # restore the original files
125
+ ```
126
+
127
+ - Patch files are named `<normalized name>@<version>.patch`, with paths relative to site-packages. An error is raised if the installed version differs.
128
+ - Deleting a patch file and running `pkg apply` restores that package to its original state.
129
+ - The patch directory can be changed with `[tool.makepatch.packages] patches-dir`.
130
+ - `--python <interpreter>` targets a different environment.
131
+ - Editable installs are not supported; edit their sources directly.
132
+
133
+ ### Compatibility with uv
134
+
135
+ - **Cache protection**: on Linux, uv installs files from its cache as hardlinks by default (`--link-mode` selects clone, copy or symlink instead). makepatch never modifies files in place: it runs `git apply` on the affected files in a temporary directory and swaps them in with `os.replace`. Only the replaced paths get new inodes, so the uv cache stays untouched. The same holds in symlink mode.
136
+ - **Records**: the hashes in `RECORD` are updated, and the applied state (`makepatch.json`, `makepatch.patch`) is written into the dist-info and registered in `RECORD`, so it is removed together with the package.
137
+ - **Recovery after reinstall**: `uv sync` does not inspect the contents of installed files, so patches persist. If a reinstall or version change drops a patch, the `makepatch-startup.pth` installed by makepatch detects this at interpreter start-up and re-applies it.
138
+ - Normally it only compares the hashes of the patch files and markers.
139
+ - Only on a mismatch does it take the environment lock and do the same work as `pkg apply`.
140
+ - Disable it with `MAKEPATCH_DISABLE_STARTUP=1`.
141
+ - It is skipped when the interpreter runs with `-I` (isolated), as when uv queries an interpreter.
142
+
143
+ ## Development
144
+
145
+ ```sh
146
+ uv sync
147
+ uv run pytest
148
+ ```
@@ -0,0 +1,137 @@
1
+ # makepatch
2
+
3
+ A Git-based patch tool for Python packages that integrates with Hatchling.
4
+
5
+ *Inspired by [paperweight](https://github.com/PaperMC/paperweight) and `pnpm patch`.*
6
+
7
+ | Mode | Approach | Output |
8
+ | --- | --- | --- |
9
+ | Source patch (`makepatch src`) | Fetch an upstream Git repository at a pinned ref and patch it | sdist/wheel of a new package (fork) |
10
+ | Package patch (`makepatch pkg`) | Patch installed packages in site-packages | patched virtual environment |
11
+
12
+ There are two kinds of patches:
13
+
14
+ - **Source patch**: created with `git diff` and applied with `git apply`. One patch per file.
15
+ - **Feature patch**: created with `git format-patch -p --minimal --zero-commit --no-numbered` and applied with `git am --3way`. One patch per commit; available in source patch mode only.
16
+
17
+ All patches are applied statically; there is no runtime monkey-patching.
18
+
19
+ ## Requirements
20
+
21
+ - Python &ge; 3.13
22
+ - Git &ge; 2.32
23
+
24
+ ## Source patch mode
25
+
26
+ A fork repository needs only `pyproject.toml` and `patches/`.
27
+
28
+ ```toml
29
+ [build-system]
30
+ requires = ["hatchling", "makepatch"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [project] # Define the fork's metadata yourself.
34
+ name = "requests-fork"
35
+ version = "2.32.3.post1"
36
+ dependencies = ["urllib3>=1.21.1,<3", "idna>=2.5,<4", "charset_normalizer>=2,<4", "certifi>=2017.4.17"]
37
+
38
+ [tool.makepatch.source]
39
+ upstream = "https://github.com/psf/requests.git"
40
+ ref = "v2.32.3" # a 40-digit commit SHA is recommended; tags and branches work too
41
+ include = { "src/requests" = "requests" } # upstream path → wheel path
42
+ exclude = ["**/*.pyi"] # excluded from sdist/wheel (relative to include paths)
43
+ work-exclude = ["docs/", "tests/"] # excluded from work/ and the build tree (relative to the upstream root)
44
+ # work-dir = "work" # default
45
+ # patches-dir = "patches" # default
46
+
47
+ [tool.hatch.build.hooks.makepatch] # enables the build hook (settings live in the table above)
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ bypass-selection = true
51
+ ```
52
+
53
+ Add `work/` and `.makepatch/` to `.gitignore` so that they are excluded from the sdist.
54
+
55
+ ### Excluding files
56
+
57
+ Both options use gitignore syntax (including `!` negation), but differ in base path and effect.
58
+
59
+ | Option | Base path | Effect |
60
+ | --- | --- | --- |
61
+ | `exclude` | each `include` path (e.g. `src/requests`) | Excluded from the sdist and wheel. Still present in `work/`. |
62
+ | `work-exclude` | upstream root | Not checked out into `work/` or the build tree, via `git sparse-checkout`. Meant to save time and disk space. |
63
+
64
+ - If a patch touches a file excluded by `work-exclude`, `makepatch src apply` and the build stop with an error.
65
+ - After changing `work-exclude`, run `makepatch src apply` again.
66
+
67
+ ### Workflow
68
+
69
+ ```sh
70
+ makepatch src apply # fetch upstream → create work/ → apply source patches → git am feature patches
71
+ # edit files in work/
72
+ makepatch src fixup # fold working tree changes into the source patch commit
73
+ # or make regular commits in work/ → feature patches
74
+ makepatch src rebuild # regenerate patches/sources/** and patches/features/*.patch
75
+ makepatch src status
76
+ ```
77
+
78
+ The `work/` repository is laid out as follows:
79
+
80
+ ```
81
+ <upstream ref> tag makepatch/base
82
+ makepatch: source patches tag makepatch/sources ← patches/sources/<path>.patch
83
+ <feature commits> ... ← patches/features/NNNN-*.patch
84
+ ```
85
+
86
+ If a feature patch conflicts, the `git am` session is left in place. Resolve the conflict in `work/`, run `git am --continue`, then run `makepatch src rebuild`.
87
+
88
+ ### Building
89
+
90
+ ```sh
91
+ uv build
92
+ ```
93
+
94
+ - The build hook does not use `work/`; it applies the patches afresh in `.makepatch/build/tree`. Uncommitted work therefore never leaks into the output.
95
+ - The sdist contains the patched sources (`_makepatch/tree/`). Building a wheel from the sdist needs neither git nor network access.
96
+ - Set `MAKEPATCH_OFFLINE=1` to use only the `.makepatch/upstream.git` cache.
97
+ - Upstream's own build steps (C extensions, etc.) are not run. Pure Python sources are the target.
98
+
99
+ ## Package patch mode
100
+
101
+ Install makepatch as a development dependency of the project.
102
+
103
+ ```sh
104
+ uv add --dev makepatch
105
+ ```
106
+
107
+ ```sh
108
+ uv run makepatch pkg edit requests # create an editable copy in .makepatch/edit/requests@2.32.3/
109
+ # edit files in the copy
110
+ uv run makepatch pkg commit requests # save patches/packages/requests@2.32.3.patch and apply it
111
+ uv run makepatch pkg apply # apply every patch (--check: only check that they apply)
112
+ uv run makepatch pkg status
113
+ uv run makepatch pkg revert requests # restore the original files
114
+ ```
115
+
116
+ - Patch files are named `<normalized name>@<version>.patch`, with paths relative to site-packages. An error is raised if the installed version differs.
117
+ - Deleting a patch file and running `pkg apply` restores that package to its original state.
118
+ - The patch directory can be changed with `[tool.makepatch.packages] patches-dir`.
119
+ - `--python <interpreter>` targets a different environment.
120
+ - Editable installs are not supported; edit their sources directly.
121
+
122
+ ### Compatibility with uv
123
+
124
+ - **Cache protection**: on Linux, uv installs files from its cache as hardlinks by default (`--link-mode` selects clone, copy or symlink instead). makepatch never modifies files in place: it runs `git apply` on the affected files in a temporary directory and swaps them in with `os.replace`. Only the replaced paths get new inodes, so the uv cache stays untouched. The same holds in symlink mode.
125
+ - **Records**: the hashes in `RECORD` are updated, and the applied state (`makepatch.json`, `makepatch.patch`) is written into the dist-info and registered in `RECORD`, so it is removed together with the package.
126
+ - **Recovery after reinstall**: `uv sync` does not inspect the contents of installed files, so patches persist. If a reinstall or version change drops a patch, the `makepatch-startup.pth` installed by makepatch detects this at interpreter start-up and re-applies it.
127
+ - Normally it only compares the hashes of the patch files and markers.
128
+ - Only on a mismatch does it take the environment lock and do the same work as `pkg apply`.
129
+ - Disable it with `MAKEPATCH_DISABLE_STARTUP=1`.
130
+ - It is skipped when the interpreter runs with `-I` (isolated), as when uv queries an interpreter.
131
+
132
+ ## Development
133
+
134
+ ```sh
135
+ uv sync
136
+ uv run pytest
137
+ ```
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "makepatch"
3
+ version = "0.3.0"
4
+ description = "Git-based patch tool for Python packages: source forks (paperweight-style) and installed-package patches (pnpm-style)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ ]
9
+ classifiers = [
10
+ "Framework :: Hatch",
11
+ "Programming Language :: Python :: 3",
12
+ ]
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "basedpyright>=1.40.1",
17
+ "hatchling>=1.21",
18
+ "mypy>=2.3.1",
19
+ "pytest>=8",
20
+ "ruff>=0.16.9",
21
+ "ty>=0.0.84",
22
+ ]
23
+
24
+
25
+ [project.urls]
26
+ source = "https://github.com/MadeByAlpha/makepatch.git"
27
+ issues = "https://github.com/MadeByAlpha/makepatch/issues"
28
+
29
+ [project.scripts]
30
+ makepatch = "makepatch.cli:main"
31
+
32
+ [project.entry-points.hatch]
33
+ makepatch = "makepatch.hatch.hooks"
34
+
35
+
36
+ [tool.uv]
37
+ # Rebuild when the sources change while makepatch is used as a path dependency
38
+ # (e.g. as a build requirement of a fork during development).
39
+ cache-keys = [{ file = "pyproject.toml" }, { file = "src/**/*.py" }, { file = "src/*.pth" }]
40
+
41
+
42
+ [build-system]
43
+ requires = ["hatchling"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/makepatch"]
48
+
49
+ [tool.hatch.build.targets.wheel.force-include]
50
+ "src/makepatch-startup.pth" = "makepatch-startup.pth"
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """makepatch: git-based patching for Python packages."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from makepatch.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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")
@@ -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())