makepatch 0.2.8__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.
- makepatch-0.3.0/.gitignore +7 -0
- makepatch-0.3.0/PKG-INFO +148 -0
- makepatch-0.3.0/README.md +137 -0
- makepatch-0.3.0/pyproject.toml +53 -0
- makepatch-0.3.0/src/makepatch/__init__.py +3 -0
- makepatch-0.3.0/src/makepatch/__main__.py +3 -0
- makepatch-0.3.0/src/makepatch/_startup.py +90 -0
- makepatch-0.3.0/src/makepatch/cli.py +238 -0
- makepatch-0.3.0/src/makepatch/config.py +130 -0
- makepatch-0.3.0/src/makepatch/errors.py +2 -0
- makepatch-0.3.0/src/makepatch/git.py +133 -0
- makepatch-0.3.0/src/makepatch/hatch/__init__.py +0 -0
- makepatch-0.3.0/src/makepatch/hatch/hook.py +82 -0
- makepatch-0.2.8/src/makepatch/__hooks__.py → makepatch-0.3.0/src/makepatch/hatch/hooks.py +8 -7
- makepatch-0.3.0/src/makepatch/package/__init__.py +0 -0
- makepatch-0.3.0/src/makepatch/package/apply.py +263 -0
- makepatch-0.3.0/src/makepatch/package/dist.py +198 -0
- makepatch-0.3.0/src/makepatch/package/edit.py +98 -0
- makepatch-0.3.0/src/makepatch/source/__init__.py +0 -0
- makepatch-0.3.0/src/makepatch/source/upstream.py +50 -0
- makepatch-0.3.0/src/makepatch/source/workspace.py +281 -0
- makepatch-0.3.0/src/makepatch/term.py +53 -0
- makepatch-0.3.0/src/makepatch-startup.pth +1 -0
- makepatch-0.3.0/tests/conftest.py +85 -0
- makepatch-0.3.0/tests/test_hatch.py +53 -0
- makepatch-0.3.0/tests/test_package.py +195 -0
- makepatch-0.3.0/tests/test_source.py +200 -0
- makepatch-0.3.0/tests/test_term.py +29 -0
- makepatch-0.3.0/uv.lock +432 -0
- makepatch-0.2.8/.editorconfig +0 -1
- makepatch-0.2.8/.gitignore +0 -6
- makepatch-0.2.8/.idea/inspectionProfiles/Project_Default.xml +0 -407
- makepatch-0.2.8/.idea/inspectionProfiles/profiles_settings.xml +0 -6
- makepatch-0.2.8/.idea/makepatch.iml +0 -16
- makepatch-0.2.8/.idea/modules.xml +0 -8
- makepatch-0.2.8/.idea/workspace.xml +0 -9
- makepatch-0.2.8/CHANGELOG.md +0 -15
- makepatch-0.2.8/PKG-INFO +0 -74
- makepatch-0.2.8/README.md +0 -48
- makepatch-0.2.8/pyproject.toml +0 -56
- makepatch-0.2.8/src/makepatch/__init__.py +0 -1
- makepatch-0.2.8/src/makepatch/_config.py +0 -59
- makepatch-0.2.8/src/makepatch/_types/__init__.pyi +0 -1
- makepatch-0.2.8/src/makepatch/_types/hatch.pyi +0 -57
- makepatch-0.2.8/src/makepatch/core/__init__.py +0 -1
- makepatch-0.2.8/src/makepatch/core/_patcher.py +0 -127
- makepatch-0.2.8/src/makepatch/hooks/__init__.py +0 -1
- makepatch-0.2.8/src/makepatch/hooks/_patch.py +0 -51
- makepatch-0.2.8/src/makepatch/scripts/__init__.py +0 -32
- makepatch-0.2.8/src/makepatch/scripts/_rebuild.py +0 -78
- makepatch-0.2.8/uv.lock +0 -194
makepatch-0.3.0/PKG-INFO
ADDED
|
@@ -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 ≥ 3.13
|
|
22
|
+
- Git ≥ 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,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())
|