diff --git a/.github/workflows/build_exe.yml b/.github/workflows/build_exe.yml new file mode 100644 index 0000000..d712952 --- /dev/null +++ b/.github/workflows/build_exe.yml @@ -0,0 +1,51 @@ +name: Build patch exe for Windows + +on: + push: + tags: + - '*' + +jobs: + windows_exe: + name: Create windows executable + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.12 + architecture: x64 + - uses: Nuitka/Nuitka-Action@main + with: + nuitka-version: main + script-name: patch_ng.py + mode: onefile + output-dir: build + output-file: patch.exe + - name: Create release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + body: | + Automatic build of patch.exe + draft: true + prerelease: false + - name: Upload release asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./build/patch.exe + asset_name: patch.exe + asset_content_type: application/vnd.microsoft.portable-executable + - name: Publish release + uses: StuYarrow/publish-release@v1.1.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + id: ${{ steps.create_release.outputs.id }} diff --git a/LICENSE b/LICENSE index 54060ca..fa28b26 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,42 @@ +Copyright (c) 2025 Tim Felgentreff + +The Universal Permissive License (UPL), Version 1.0 + +Subject to the condition set forth below, permission is hereby granted to any +person obtaining a copy of this software, associated documentation and/or data +(collectively the "Software"), free of charge and under any and all copyright +rights in the Software, and any and all patent rights owned or freely +licensable by each licensor hereunder covering either (i) the unmodified +Software as contributed to or provided by such licensor, or (ii) the Larger +Works (as defined below), to deal in both + +(a) the Software, and +(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +one is included with the Software (each a "Larger Work" to which the Software +is contributed by such licensors), + +without restriction, including without limitation the rights to copy, create +derivative works of, display, perform, and distribute the Software and make, +use, sell, offer for sale, import, export, have made, and have sold the +Software and the Larger Work(s), and to sublicense the foregoing rights on +either these or other terms. + +This license is subject to the following condition: +The above copyright notice and either this complete permission notice or at +a minimum a reference to the UPL must be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This file incorporates prior work covered by the following copyright and +permission notice: + MIT License ----------- diff --git a/patch_ng.py b/patch_ng.py index e1114fb..e6a8b66 100755 --- a/patch_ng.py +++ b/patch_ng.py @@ -4,6 +4,47 @@ Brute-force line-by-line non-recursive parsing + Copyright (c) 2025 Tim Felgentreff + + The Universal Permissive License (UPL), Version 1.0 + + Subject to the condition set forth below, permission is hereby granted to any + person obtaining a copy of this software, associated documentation and/or data + (collectively the "Software"), free of charge and under any and all copyright + rights in the Software, and any and all patent rights owned or freely + licensable by each licensor hereunder covering either (i) the unmodified + Software as contributed to or provided by such licensor, or (ii) the Larger + Works (as defined below), to deal in both + + (a) the Software, and + (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + one is included with the Software (each a "Larger Work" to which the Software + is contributed by such licensors), + + without restriction, including without limitation the rights to copy, create + derivative works of, display, perform, and distribute the Software and make, + use, sell, offer for sale, import, export, have made, and have sold the + Software and the Larger Work(s), and to sublicense the foregoing rights on + either these or other terms. + + This license is subject to the following condition: + The above copyright notice and either this complete permission notice or at + a minimum a reference to the UPL must be included in all copies or + substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + This file incorporates prior work covered by the following copyright and + permission notice: + +--- + Copyright (c) 2008-2016 anatoly techtonik Available under the terms of MIT license @@ -31,7 +72,7 @@ from __future__ import print_function __author__ = "Conan.io " -__version__ = "1.18.0" +__version__ = "1.19.0" __license__ = "MIT" __url__ = "https://github.com/conan-io/python-patch" @@ -109,20 +150,18 @@ def createLock(self): debugmode = False -def setdebug(): - global debugmode, streamhandler +def setdebug(): + global debugmode debugmode = True - loglevel = logging.DEBUG - logformat = "%(levelname)8s %(message)s" - logger.setLevel(loglevel) + logger.setLevel(logging.DEBUG) + streamhandler.setFormatter(logging.Formatter("%(levelname)8s %(message)s")) - if streamhandler not in logger.handlers: - # when used as a library, streamhandler is not added - # by default - logger.addHandler(streamhandler) - streamhandler.setFormatter(logging.Formatter(logformat)) +if streamhandler not in logger.handlers: + # when used as a library, streamhandler is not added + # by default + logger.addHandler(streamhandler) #------------------------------------------------ @@ -761,8 +800,10 @@ def _detect_type(self, p): if p.header[idx].startswith(b"diff --git"): break if p.header[idx].startswith(b'diff --git a/'): + # git-format-patch with --full-index generates full blob index, which is 40 symbols length + # shortcut index length may vary, seems to depend on the project size if (idx+1 < len(p.header) - and re.match(b'(?:index \\w{7}..\\w{7} \\d{6}|new file mode \\d*)', p.header[idx+1])): + and re.match(b'(?:index \\w{7,40}..\\w{7,40} \\d{6}|new file mode \\d*)', p.header[idx+1])): if DVCS: return GIT @@ -791,12 +832,14 @@ def _detect_type(self, p): def _normalize_filenames(self): """ sanitize filenames, normalizing paths, i.e.: - 1. strip a/ and b/ prefixes from GIT and HG style patches - 2. remove all references to parent directories (with warning) - 3. translate any absolute paths to relative (with warning) + 1. remove all references to parent directories (with warning) + 2. translate any absolute paths to relative (with warning) [x] always use forward slashes to be crossplatform (diff/patch were born as a unix utility after all) + [x] Do *not* strip a/ and b/ prefixes from GIT and HG style + patches, GNU patch would not do so, instead the user + *must* account for this in the -p/--strip option return None """ @@ -807,18 +850,6 @@ def _normalize_filenames(self): debug(" patch type = %s" % p.type) debug(" source = %s" % p.source) debug(" target = %s" % p.target) - if p.type in (HG, GIT): - debug("stripping a/ and b/ prefixes") - if p.source != b'/dev/null': - if not p.source.startswith(b"a/"): - warning("invalid source filename") - else: - p.source = p.source[2:] - if p.target != b'/dev/null': - if not p.target.startswith(b"b/"): - warning("invalid target filename") - else: - p.target = p.target[2:] p.source = xnormpath(p.source) p.target = xnormpath(p.target) @@ -981,9 +1012,11 @@ def apply(self, strip=0, root=None, fuzz=False): hunks = [s.decode("utf-8") for s in item.hunks[0].text] new_file = "".join(hunk[1:] for hunk in hunks) save(target, new_file) + info(f"successfully created {target}") elif "dev/null" in target: source = self.strip_path(source, root, strip) safe_unlink(source) + info(f"successfully deleted {target}") else: items.append(item) self.items = items @@ -1039,9 +1072,13 @@ def apply(self, strip=0, root=None, fuzz=False): validhunks = 0 canpatch = False for lineno, line in enumerate(f2fp): - if lineno+1 < hunk.startsrc: + last_line = f2fp.peek(1) == b'' + if lineno + 1 < hunk.startsrc and not last_line: continue - elif lineno+1 == hunk.startsrc: + elif lineno + 1 == hunk.startsrc or (last_line and lineno + 1 < hunk.startsrc): + # If the patch just appends without context, be gracious + if last_line and lineno + 1 < hunk.startsrc: + lineno = hunk.startsrc - 1 hunkfind = [x[1:].rstrip(b"\r\n") for x in hunk.text if x[0] in b" -"] hunkreplace = [x[1:].rstrip(b"\r\n") for x in hunk.text if x[0] in b" +"] #pprint(hunkreplace) @@ -1303,7 +1340,8 @@ def main(): opt = OptionParser(usage="1. %prog [options] unified.diff\n" " 2. %prog [options] http://host/patch\n" - " 3. %prog [options] -- < unified.diff", + " 3. %prog [options] -- < unified.diff" + " 4. %prog [options] -i unified.diff", version="python-patch %s" % __version__) opt.add_option("-q", "--quiet", action="store_const", dest="verbosity", const=0, help="print only warnings and errors", default=1) @@ -1319,13 +1357,15 @@ def main(): opt.add_option("--revert", action="store_true", help="apply patch in reverse order (unpatch)") opt.add_option("-f", "--fuzz", action="store_true", dest="fuzz", help="Accept fuuzzy patches") + opt.add_option("-i", "--input", metavar='PATCHFILE', + help="Read patch from PATCHFILE instead of stdin.") (options, args) = opt.parse_args() - if not args and sys.argv[-1:] != ['--']: + if not args and not options.input and sys.argv[-1:] != ['--']: opt.print_version() opt.print_help() sys.exit() - readstdin = (sys.argv[-1:] == ['--'] and not args) + readstdin = (sys.argv[-1:] == ['--'] and not args and not options.input) verbosity_levels = {0:logging.WARNING, 1:logging.INFO, 2:logging.DEBUG} loglevel = verbosity_levels[options.verbosity] @@ -1337,9 +1377,11 @@ def main(): setdebug() # this sets global debugmode variable if readstdin: - patch = PatchSet(sys.stdin) + patch = PatchSet(sys.stdin.buffer) else: - patchfile = args[0] + patchfile = options.input if options.input else args[0] + if options.directory and not os.path.isabs(patchfile): + patchfile = os.path.join(options.directory, patchfile) urltest = patchfile.split(':')[0] if (':' in patchfile and urltest.isalpha() and len(urltest) > 1): # one char before : is a windows drive letter diff --git a/pyproject.toml b/pyproject.toml index 9787c3b..dcfeac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,5 @@ [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" +[tool.coverage.run] +parallel = true diff --git a/setup.py b/setup.py index c3f6cd4..15170c8 100644 --- a/setup.py +++ b/setup.py @@ -120,9 +120,9 @@ def load_version(): # To provide executable scripts, use entry points in preference to the # "scripts" keyword. Entry points provide cross-platform support and allow # pip to create the appropriate form of executable for the target platform. - #entry_points={ - # 'console_scripts': [ - # 'patch_ng.py=patch', - # ], - #}, + entry_points={ + 'console_scripts': [ + 'patch = patch_ng:main', + ], + }, ) diff --git a/tests/12appendonly/12appendonly.patch b/tests/12appendonly/12appendonly.patch new file mode 100644 index 0000000..ff9e662 --- /dev/null +++ b/tests/12appendonly/12appendonly.patch @@ -0,0 +1,8 @@ +diff --git a/Jamroot b/Jamroot +index a6981dd..0c08f09 100644 +--- a/Jamroot ++++ b/Jamroot +@@ -4,0 +4,3 @@ ++X ++Y ++Z diff --git a/tests/12appendonly/Jamroot b/tests/12appendonly/Jamroot new file mode 100644 index 0000000..186401d --- /dev/null +++ b/tests/12appendonly/Jamroot @@ -0,0 +1,3 @@ +X +Y +Z diff --git a/tests/12appendonly/[result]/Jamroot b/tests/12appendonly/[result]/Jamroot new file mode 100644 index 0000000..9ad6882 --- /dev/null +++ b/tests/12appendonly/[result]/Jamroot @@ -0,0 +1,6 @@ +X +Y +Z +X +Y +Z diff --git a/tests/recoverage.bat b/tests/recoverage.bat index 2792a93..91f1af5 100644 --- a/tests/recoverage.bat +++ b/tests/recoverage.bat @@ -1,4 +1,5 @@ cd .. python -m coverage run tests/run_tests.py +python -m coverage combine python -m coverage html -d tests/coverage python -m coverage report -m diff --git a/tests/run_tests.py b/tests/run_tests.py index 6c16ef2..4fa5db3 100755 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -24,10 +24,11 @@ http://pypi.python.org/pypi/coverage/ and run this file with: coverage run run_tests.py + coverage combine coverage html -d coverage On Windows it may be more convenient instead of `coverage` call -`python -m coverage.__main__` +`python -m coverage` """ from __future__ import print_function @@ -38,6 +39,7 @@ import unittest import copy import stat +import subprocess from os import listdir, chmod from os.path import abspath, dirname, exists, join, isdir, isfile from tempfile import mkdtemp @@ -113,7 +115,7 @@ def _assert_dirs_equal(self, dir1, dir2, ignore=[]): self.fail("extra file or directory: %s" % e2) - def _run_test(self, testname): + def _run_test(self, testname, inputarg): """ boilerplate for running *.patch file tests """ @@ -126,6 +128,16 @@ def _run_test(self, testname): tmpdir = mkdtemp(prefix="%s."%testname) + # Ensure we collect coverage for subprocesses into the parent + if 'coverage' in sys.modules.keys(): + with open(join(tmpdir, ".coveragerc"), "w") as f: + f.write("[run]\n") + f.write("parallel = true\n") + f.write("data_file = " + join(dirname(TESTS), ".coverage") + "\n") + exe = [sys.executable, "-m", "coverage", "run"] + else: + exe = [sys.executable] + basepath = join(TESTS, testname) basetmp = join(tmpdir, testname) @@ -153,13 +165,20 @@ def _run_test(self, testname): save_cwd = getcwdu() os.chdir(tmpdir) extra = "-f" if "10fuzzy" in testname else "" + if not verbose: + extra += " -q " + extra += " " + inputarg + " " + if "--" in inputarg: + cmd = '%s %s %s' % (" ".join(exe), patch_tool, extra) + with open(patch_file, "rb") as f: + input = f.read() + else: + cmd = '%s %s %s "%s"' % (" ".join(exe), patch_tool, extra, patch_file) + input = None if verbose: - cmd = '%s %s %s "%s"' % (sys.executable, patch_tool, extra, patch_file) print("\n"+cmd) - else: - cmd = '%s %s -q %s "%s"' % (sys.executable, patch_tool, extra, patch_file) - ret = os.system(cmd) - assert ret == 0, "Error %d running test %s" % (ret, testname) + proc = subprocess.run(cmd, shell=True, input=input) + assert proc.returncode == 0, "Error %d running test %s" % (proc.returncode, testname) os.chdir(save_cwd) @@ -171,7 +190,7 @@ def _run_test(self, testname): # recursive comparison self._assert_dirs_equal(join(basepath, "[result]"), tmpdir, - ignore=["%s.patch" % testname, ".svn", ".gitkeep", "[result]"]) + ignore=["%s.patch" % testname, ".svn", ".gitkeep", "[result]", ".coveragerc"]) remove_tree_force(tmpdir) return 0 @@ -189,11 +208,14 @@ def add_test_methods(cls): testset = [testptn.match(e).group('name') for e in listdir(TESTS) if testptn.match(e)] testset = sorted(set(testset)) - for filename in testset: + for idx, filename in enumerate(testset): methname = 'test_' + filename def create_closure(): name = filename - return lambda self: self._run_test(name) + inputarg = ["", "-i", "--"][idx % 3] + def test(self): + self._run_test(name, inputarg) + return test test = create_closure() setattr(cls, methname, test) if verbose: