Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-patch-ng for openSUSE:Factory 
checked in at 2025-11-21 16:56:38
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-patch-ng (Old)
 and      /work/SRC/openSUSE:Factory/.python-patch-ng.new.2061 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-patch-ng"

Fri Nov 21 16:56:38 2025 rev:4 rq:1318843 version:1.19.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-patch-ng/python-patch-ng.changes  
2024-11-07 16:29:16.882376130 +0100
+++ 
/work/SRC/openSUSE:Factory/.python-patch-ng.new.2061/python-patch-ng.changes    
    2025-11-21 16:57:31.520501794 +0100
@@ -1,0 +2,11 @@
+Thu Nov 20 10:13:00 UTC 2025 - John Paul Adrian Glaubitz 
<[email protected]>
+
+- Update to 1.19.0
+  * Fix: File move/rename is not supported when applying Git patch (#24)
+  * Fix: Changing file permissions is ignored when applying Git patch (#23)
+  * Fix: Git patch full index format is not recognized (#45)
+  * Fix: Better handling for big patch files (#34)
+  * Fix: Quoted file path is not parsed correctly
+- Use Python 3.11 on SLE-15 by default
+
+-------------------------------------------------------------------

Old:
----
  python-patch-ng-1.18.1.tar.gz

New:
----
  python-patch-ng-1.19.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-patch-ng.spec ++++++
--- /var/tmp/diff_new_pack.pAEN46/_old  2025-11-21 16:57:32.876558938 +0100
+++ /var/tmp/diff_new_pack.pAEN46/_new  2025-11-21 16:57:32.880559106 +0100
@@ -15,9 +15,9 @@
 # Please submit bugfixes or comments via https://bugs.opensuse.org/
 #
 
-
+%{?sle15_python_module_pythons}
 Name:           python-patch-ng
-Version:        1.18.1
+Version:        1.19.0
 Release:        0
 Summary:        Library to parse and apply unified diffs
 License:        MIT

++++++ python-patch-ng-1.18.1.tar.gz -> python-patch-ng-1.19.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/.github/ISSUE_TEMPLATE/bug.yml 
new/python-patch-ng-1.19.0/.github/ISSUE_TEMPLATE/bug.yml
--- old/python-patch-ng-1.18.1/.github/ISSUE_TEMPLATE/bug.yml   1970-01-01 
01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/.github/ISSUE_TEMPLATE/bug.yml   2025-10-08 
16:28:08.000000000 +0200
@@ -0,0 +1,23 @@
+name: Bug Report
+description: Report a bug, something does not work as it's supposed to
+title: '[bug]'
+body:
+  - type: textarea
+    id: description
+    attributes:
+      label: Describe the bug
+      description: Include the bug description and environment details
+      placeholder: |
+        Environment details: OS, Python version, patch-ng version, etc.
+        Description: xxxx
+    validations:
+      required: false
+  - type: textarea
+    id: steps
+    attributes:
+      label: How to reproduce it
+      description: It would be great to know how to reproduce it locally
+      placeholder: |
+        Commands to reproduce it, remote repository to use it locally, etc. 
Every detail is more than welcome!
+    validations:
+      required: false
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/.github/ISSUE_TEMPLATE/feature_request.yml 
new/python-patch-ng-1.19.0/.github/ISSUE_TEMPLATE/feature_request.yml
--- old/python-patch-ng-1.18.1/.github/ISSUE_TEMPLATE/feature_request.yml       
1970-01-01 01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/.github/ISSUE_TEMPLATE/feature_request.yml       
2025-10-08 16:28:08.000000000 +0200
@@ -0,0 +1,17 @@
+name: Feature Request
+description: Request a new feature or suggest a change
+title: '[feature] SHORT DESCRIPTION'
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking the time to submit a request.
+        **Please don't forget to update the issue title.**
+  - type: textarea
+    id: suggestion
+    attributes:
+      label: What is your suggestion?
+      description: Please be as specific as possible!
+      placeholder: Hi! I would like for patch-ng to be able to ...
+    validations:
+      required: true
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/.github/ISSUE_TEMPLATE/question.yml 
new/python-patch-ng-1.19.0/.github/ISSUE_TEMPLATE/question.yml
--- old/python-patch-ng-1.18.1/.github/ISSUE_TEMPLATE/question.yml      
1970-01-01 01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/.github/ISSUE_TEMPLATE/question.yml      
2025-10-08 16:28:08.000000000 +0200
@@ -0,0 +1,17 @@
+name: Question
+description: If something needs clarification
+title: '[question] SHORT DESCRIPTION'
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking the time to fill your question.
+        **Please don't forget to update the issue title.**
+  - type: textarea
+    id: question
+    attributes:
+      label: What is your question?
+      description: Please be as specific as possible!
+      placeholder: Hi! I have a question regarding ...
+    validations:
+      required: true
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/.github/PULL_REQUEST_TEMPLATE.md 
new/python-patch-ng-1.19.0/.github/PULL_REQUEST_TEMPLATE.md
--- old/python-patch-ng-1.18.1/.github/PULL_REQUEST_TEMPLATE.md 1970-01-01 
01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/.github/PULL_REQUEST_TEMPLATE.md 2025-10-08 
16:28:08.000000000 +0200
@@ -0,0 +1,8 @@
+### Description of your Pull Request
+
+<!-- Replace this text with a description of your pull request. -->
+
+- [ ] Refer to the issue that supports this Pull Request.
+- [ ] If the issue has missing info, explain the purpose/use case/pain/need 
that covers this Pull Request.
+- [ ] I've tested locally that my code works.
+- [ ] I've added relevant tests to verify that my code works.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/.github/scripts/validate-cci-patch-ng.sh 
new/python-patch-ng-1.19.0/.github/scripts/validate-cci-patch-ng.sh
--- old/python-patch-ng-1.18.1/.github/scripts/validate-cci-patch-ng.sh 
1970-01-01 01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/.github/scripts/validate-cci-patch-ng.sh 
2025-10-08 16:28:08.000000000 +0200
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# Get TEST_ALL_RECIPES from environment variable, default to 0 (false)
+PYTHON_NG_TEST_ALL_RECIPES=${PYTHON_NG_TEST_ALL_RECIPES:-0}
+
+SAMPLE_RECIPES_NUM=30
+RECIPES_BUILD_NUM=10
+RECIPES_BUILT_COUNT=0
+
+# Ensure required tools are installed
+COMMANDS=("conan" "yq" "jq")
+for cmd in "${COMMANDS[@]}"; do
+    if ! which $cmd &> /dev/null; then
+        echo "ERROR: $cmd is not installed. Please install $cmd to proceed."
+        exit 1
+    fi
+done
+
+# Find all conanfile.py files that use apply_conandata_patches
+RECIPES=$(find . -type f -name "conanfile.py" -exec grep -l 
"apply_conandata_patches(self)" {} + | sort | uniq)
+# And does not need system requirement
+RECIPES=$(grep -L "/system" $RECIPES)
+# And does not contain Conan 1 imports
+RECIPES=$(grep -L "from conans" $RECIPES)
+
+echo "Found $(echo "$RECIPES" | wc -l) recipes using apply_conandata_patches."
+
+if [ "${PYTHON_NG_TEST_ALL_RECIPES}" -eq "1" ]; then
+    SAMPLE_RECIPES_NUM=$(echo "$RECIPES" | wc -l)
+    RECIPES_BUILD_NUM=$SAMPLE_RECIPES_NUM
+    echo "PYTHON_NG_TEST_ALL_RECIPES is set to 1, testing all 
$SAMPLE_RECIPES_NUM recipes."
+else
+    RECIPES=$(shuf -e ${RECIPES[@]} -n $SAMPLE_RECIPES_NUM)
+    echo "Pick $SAMPLE_RECIPES_NUM random recipes to test:"
+    echo "$RECIPES"
+fi
+
+# Run conan create for each sampled recipe
+for it in $RECIPES; do
+
+    if [ $RECIPES_BUILT_COUNT -ge $RECIPES_BUILD_NUM ]; then
+        echo "Reached the limit of $RECIPES_BUILD_NUM recipes built, stopping. 
All done."
+        break
+    fi
+
+    recipe_dir=$(dirname "${it}")
+    pushd "$recipe_dir" > /dev/null
+    echo "Testing recipe in directory: ${recipe_dir}"
+    # Get a version from conandata.yml that uses a patch
+    version=$(yq '.patches | keys | .[0]' conandata.yml 2>/dev/null)
+    if [ -z "$version" ]; then
+        echo "ERROR: No patches found in conandata.yml for $recipe_dir, 
skipping."
+        popd > /dev/null
+        continue
+    fi
+    version=$(echo ${version} | tr -d '"')
+    # Replace apply_conandata_patches to exit just after applying patches
+    sed -i -e 's/apply_conandata_patches(self)/apply_conandata_patches(self); 
import sys; sys.exit(0)/g' conanfile.py
+
+    # Allow conan create to fail without stopping the script, we will handle 
errors manually
+    set +e
+
+    # Create the package with the specified version
+    output=$(conan create . --version=${version} 2>&1)
+    # Accept some errors as non-fatal
+    if [ $? -ne 0 ]; then
+        echo "WARNING: conan create failed for $recipe_dir"
+        allowed_errors=(
+            "ERROR: There are invalid packages"
+            "ERROR: Version conflict"
+            "ERROR: Missing binary"
+            "Failed to establish a new connection"
+            "ConanException: sha256 signature failed"
+            "ConanException: Error downloading file"
+            "ConanException: Cannot find"
+            "certificate verify failed: certificate has expired"
+            "NotFoundException: Not found"
+        )
+        # check if any allowed error is in the output
+        if printf '%s\n' "${allowed_errors[@]}" | grep -q -f - <(echo 
"$output"); then
+            echo "WARNING: Could not apply patches, skipping build:"
+            echo "$output" | tail -n 10
+            echo "-------------------------------------------------------"
+        else
+            echo "ERROR: Fatal error during conan create command execution:"
+            echo "$output"
+            popd > /dev/null
+            exit 1
+        fi
+    else
+        echo "INFO: Successfully patched $recipe_dir."
+        echo "$output" | tail -n 10
+        echo "-------------------------------------------------------"
+        RECIPES_BUILT_COUNT=$((RECIPES_BUILT_COUNT + 1))
+    fi
+    popd > /dev/null
+done
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/.github/workflows/conan-center-index.yml 
new/python-patch-ng-1.19.0/.github/workflows/conan-center-index.yml
--- old/python-patch-ng-1.18.1/.github/workflows/conan-center-index.yml 
1970-01-01 01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/.github/workflows/conan-center-index.yml 
2025-10-08 16:28:08.000000000 +0200
@@ -0,0 +1,47 @@
+name: Validate Applying Patch in Conan Center Index
+
+on:
+  push:
+    paths-ignore:
+      - 'doc/**'
+      - '**/*.md'
+      - 'LICENSE'
+      - 'example/**'
+      - '.gitignore'
+      - 'tests/**'
+  workflow_dispatch:
+  pull_request:
+    paths-ignore:
+      - 'doc/**'
+      - '**/*.md'
+      - 'LICENSE'
+      - 'example/**'
+      - '.gitignore'
+      - 'tests/**'
+
+jobs:
+  conan-center-index-validate:
+    name: "Validate Patche-NG in Conan Center Index"
+    runs-on: ubuntu-22.04
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v5
+        with:
+          path: python-ng
+
+      - name: Setup Conan client
+        uses: conan-io/setup-conan@v1
+
+      - name: Setup Python NG
+        run: pip install -e ./python-ng
+
+      - name: Checkout conan-center-index
+        uses: actions/checkout@v5
+        with:
+          repository: conan-io/conan-center-index
+          path: conan-center-index
+          ref: 'd8efbb6f3c51b134205f01d3f8d90bdad1a67fe6'
+
+      - name: Validate Python-NG patch application
+        working-directory: conan-center-index/recipes
+        run: bash 
"${GITHUB_WORKSPACE}/python-ng/.github/scripts/validate-cci-patch-ng.sh"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-patch-ng-1.18.1/.github/workflows/conan.yml 
new/python-patch-ng-1.19.0/.github/workflows/conan.yml
--- old/python-patch-ng-1.18.1/.github/workflows/conan.yml      1970-01-01 
01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/.github/workflows/conan.yml      2025-10-08 
16:28:08.000000000 +0200
@@ -0,0 +1,48 @@
+name: Validate Conan client
+
+on:
+  push:
+    paths-ignore:
+      - 'doc/**'
+      - '**/*.md'
+      - 'LICENSE'
+      - 'example/**'
+      - '.gitignore'
+      - 'tests/**'
+  workflow_dispatch:
+  pull_request:
+    paths-ignore:
+      - 'doc/**'
+      - '**/*.md'
+      - 'LICENSE'
+      - 'example/**'
+      - '.gitignore'
+      - 'tests/**'
+
+jobs:
+  conan-client-validate:
+    name: "Validate Conan client"
+    runs-on: ubuntu-22.04
+    steps:
+      - uses: actions/checkout@v5
+
+      - name: Setup python
+        uses: actions/setup-python@v6
+        with:
+          python-version: 3.12
+          architecture: x64
+
+      - name: Clone conan client
+        run: | 
+            git clone --depth 1 https://github.com/conan-io/conan.git
+            cd conan/
+            pip install -r conans/requirements_dev.txt
+            pip install -r conans/requirements.txt
+
+      - name: Run Conan client tests involving patch-ng
+        run: |
+            pip install -e .
+            cd conan/
+            pytest -v test/functional/test_third_party_patch_flow.py
+            pytest -v test/functional/tools/test_files.py
+            pytest -v test/unittests/tools/files/test_patches.py
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/.github/workflows/workflow.yml 
new/python-patch-ng-1.19.0/.github/workflows/workflow.yml
--- old/python-patch-ng-1.18.1/.github/workflows/workflow.yml   2024-10-25 
14:05:56.000000000 +0200
+++ new/python-patch-ng-1.19.0/.github/workflows/workflow.yml   2025-10-08 
16:28:08.000000000 +0200
@@ -1,4 +1,4 @@
-name: Main workflow
+name: Python Patch-NG Tests
 
 on:
   push:
@@ -21,15 +21,15 @@
 jobs:
   linux-validate:
     name: Validate on Linux - Python ${{ matrix.python }}
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-22.04
     strategy:
       matrix:
-        python: [ '3.6', '3.8', '3.12' ]
+        python: [ '3.7', '3.8', '3.12' ]
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
 
       - name: Setup python
-        uses: actions/setup-python@v5
+        uses: actions/setup-python@v6
         with:
           python-version: ${{ matrix.python }}
           architecture: x64
@@ -42,12 +42,12 @@
     runs-on: windows-latest
     strategy:
       matrix:
-        python: [ '3.6', '3.8', '3.12' ]
+        python: [ '3.7', '3.8', '3.12' ]
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
 
       - name: Setup python
-        uses: actions/setup-python@v5
+        uses: actions/setup-python@v6
         with:
           python-version: ${{ matrix.python }}
           architecture: x64
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-patch-ng-1.18.1/.gitignore 
new/python-patch-ng-1.19.0/.gitignore
--- old/python-patch-ng-1.18.1/.gitignore       2024-10-25 14:05:56.000000000 
+0200
+++ new/python-patch-ng-1.19.0/.gitignore       2025-10-08 16:28:08.000000000 
+0200
@@ -1,5 +1,113 @@
-*.pyc
-build
-dist
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+venv/
+.venv/
+*.egg-info/
+.installed.cfg
+*.egg
+pip-wheel-metadata/
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+
+# OS generated files #
+######################
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+Icon?
+ehthumbs.db
+Thumbs.db
+
+.pydev*
+.project
+
+# IDEs #
+########
+.metadata
 .idea
-patch_ng.egg-info
+.history/
+
+conan.conf
+*default_package_folder
+
+#Eclipse folder
+.settings
+
+#VScode folder
+.vscode
+
+#Generated certificate file
+cacert.pem
+
+#linux backup and vim files
+*~
+.*.sw?
+Session.vim
+
+#Pyinstaller generated binaries
+/pyinstaller
+
+# Run tests in docker in current dir
+.bash_history
+.conan_server/
+.sudo_as_admin_successful
+.noseids
+
+# add excluded
+!conans/client/build
+!conan/tools/build
+!test/unittests/client/build
+!test/unittests/tools/build
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-patch-ng-1.18.1/example/example.py 
new/python-patch-ng-1.19.0/example/example.py
--- old/python-patch-ng-1.18.1/example/example.py       2024-10-25 
14:05:56.000000000 +0200
+++ new/python-patch-ng-1.19.0/example/example.py       2025-10-08 
16:28:08.000000000 +0200
@@ -8,7 +8,7 @@
 
     def emit(self, record):
         logstr = self.format(record)
-        print logstr
+        print(logstr)
 
 patchlog = logging.getLogger("patch")
 patchlog.handlers = []
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-patch-ng-1.18.1/patch_ng.py 
new/python-patch-ng-1.19.0/patch_ng.py
--- old/python-patch-ng-1.18.1/patch_ng.py      2024-10-25 14:05:56.000000000 
+0200
+++ new/python-patch-ng-1.19.0/patch_ng.py      2025-10-08 16:28:08.000000000 
+0200
@@ -28,56 +28,23 @@
     ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 
OTHER DEALINGS IN THE
     SOFTWARE.
 """
-from __future__ import print_function
-
 __author__ = "Conan.io <[email protected]>"
-__version__ = "1.18.1"
+__version__ = "1.19.0"
 __license__ = "MIT"
 __url__ = "https://github.com/conan-io/python-patch";
 
+import codecs
 import copy
+import io
 import logging
-import re
-import tempfile
-import codecs
-
-# cStringIO doesn't support unicode in 2.5
-try:
-  from StringIO import StringIO
-except ImportError:
-  from io import BytesIO as StringIO # python 3
-try:
-  import urllib2 as urllib_request
-except ImportError:
-  import urllib.request as urllib_request
-
-from os.path import exists, isfile, abspath
 import os
 import posixpath
+import re
 import shutil
-import sys
 import stat
-
-
-PY3K = sys.version_info >= (3, 0)
-
-# PEP 3114
-if not PY3K:
-  compat_next = lambda gen: gen.next()
-else:
-  compat_next = lambda gen: gen.__next__()
-
-def tostr(b):
-  """ Python 3 bytes encoder. Used to print filename in
-      diffstat output. Assumes that filenames are in utf-8.
-  """
-  if not PY3K:
-    return b
-
-  # [ ] figure out how to print non-utf-8 filenames without
-  #     information loss
-  return b.decode('utf-8')
-
+import tempfile
+import urllib.request
+from os.path import exists, isfile, abspath
 
 #------------------------------------------------
 # Logging is controlled by logger named after the
@@ -90,22 +57,10 @@
 warning = logger.warning
 error = logger.error
 
-class NullHandler(logging.Handler):
-  """ Copied from Python 2.7 to avoid getting
-      `No handlers could be found for logger "patch"`
-      http://bugs.python.org/issue16539
-  """
-  def handle(self, record):
-    pass
-  def emit(self, record):
-    pass
-  def createLock(self):
-    self.lock = None
-
 streamhandler = logging.StreamHandler()
 
 # initialize logger itself
-logger.addHandler(NullHandler())
+logger.addHandler(logging.NullHandler())
 
 debugmode = False
 
@@ -194,11 +149,10 @@
   """
   patchset = PatchSet()
   debug("reading %s" % filename)
-  fp = open(filename, "rb")
-  res = patchset.parse(fp)
-  fp.close()
-  if res == True:
-    return patchset
+  with open(filename, "rb") as fp:
+    res = patchset.parse(fp)
+    if res == True:
+      return patchset
   return False
 
 
@@ -206,7 +160,7 @@
   """ Parse text string and return PatchSet()
       object (or False if parsing fails)
   """
-  ps = PatchSet( StringIO(s) )
+  ps = PatchSet( io.BytesIO(s) )
   if ps.errors == 0:
     return ps
   return False
@@ -217,7 +171,7 @@
       if an error occured. Note that this also
       can throw urlopen() exceptions.
   """
-  ps = PatchSet( urllib_request.urlopen(url) )
+  ps = PatchSet( urllib.request.urlopen(url) )
   if ps.errors == 0:
     return ps
   return False
@@ -261,15 +215,6 @@
   return text.decode("utf-8", "ignore")  # Ignore not compatible characters
 
 
-def to_file_bytes(content):
-  if PY3K:
-    if not isinstance(content, bytes):
-      content = bytes(content, "utf-8")
-  elif isinstance(content, unicode):
-    content = content.encode("utf-8")
-  return content
-
-
 def load(path, binary=False):
   """ Loads a file content """
   with open(path, 'rb') as handle:
@@ -290,7 +235,9 @@
   except Exception:
     pass
 
-  new_content = to_file_bytes(content)
+  new_content = content
+  if not isinstance(content, bytes):
+      new_content = bytes(content, "utf-8")
 
   if only_if_modified and os.path.exists(path):
     old_content = load(path, binary=True)
@@ -326,10 +273,11 @@
     self.header = []
 
     self.type = None
+    self.filemode = None
+    self.mode = None
 
   def __iter__(self):
-    for h in self.hunks:
-      yield h
+    return iter(self.hunks)
 
 
 class PatchSet(object):
@@ -344,6 +292,7 @@
     self.name = None
     # patch set type - one of constants
     self.type = None
+    self.filemode = None
 
     # list of Patch objects
     self.items = []
@@ -359,8 +308,7 @@
     return len(self.items)
 
   def __iter__(self):
-    for i in self.items:
-      yield i
+    return iter(self.items)
 
   def parse(self, stream):
     """ parse unified diff
@@ -394,7 +342,7 @@
           return False
 
         try:
-          self._lineno, self._line = compat_next(super(wrapumerate, self))
+          self._lineno, self._line = super(wrapumerate, self).__next__()
         except StopIteration:
           self._exhausted = True
           self._line = False
@@ -431,6 +379,7 @@
     header = []
     srcname = None
     tgtname = None
+    rename = False
 
     # start of main cycle
     # each parsing block already has line available in fe.line
@@ -441,19 +390,26 @@
       # --           line fetched at the start of this cycle
       if hunkparsed:
         hunkparsed = False
+        rename = False
         if re_hunk_start.match(fe.line):
             hunkhead = True
         elif fe.line.startswith(b"--- "):
             filenames = True
+        elif fe.line.startswith(b"rename from "):
+            filenames = True
         else:
             headscan = True
       # -- ------------------------------------
 
       # read out header
       if headscan:
-        while not fe.is_empty and not fe.line.startswith(b"--- "):
-            header.append(fe.line)
-            fe.next()
+        while not fe.is_empty and not fe.line.startswith(b"--- ") and not 
fe.line.startswith(b"rename from "):
+              header.append(fe.line)
+              fe.next()
+        if not fe.is_empty and fe.line.startswith(b"rename from "):
+          rename = True
+          hunkskip = True
+          hunkbody = False
         if fe.is_empty:
             if p is None:
               debug("no patch data found")  # error is shown later
@@ -548,7 +504,7 @@
           # switch to hunkhead state
           hunkskip = False
           hunkhead = True
-        elif line.startswith(b"--- "):
+        elif line.startswith(b"--- ") or line.startswith(b"rename from "):
           # switch to filenames state
           hunkskip = False
           filenames = True
@@ -591,6 +547,50 @@
             # switch back to headscan state
             filenames = False
             headscan = True
+        elif rename:
+          if line.startswith(b"rename from "):
+            re_rename_from = br"^rename from (.+)"
+            match = re.match(re_rename_from, line)
+            if match:
+              srcname = match.group(1).strip()
+            else:
+              warning("skipping invalid rename from at line %d" % (lineno+1))
+              self.errors += 1
+              # XXX p.header += line
+              # switch back to headscan state
+              filenames = False
+              headscan = True
+            if not fe.is_empty:
+              fe.next()
+              line = fe.line
+              lineno = fe.lineno
+              re_rename_to = br"^rename to (.+)"
+              match = re.match(re_rename_to, line)
+              if match:
+                tgtname = match.group(1).strip()
+              else:
+                warning("skipping invalid rename from at line %d" % (lineno + 
1))
+                self.errors += 1
+                # XXX p.header += line
+                # switch back to headscan state
+                filenames = False
+                headscan = True
+            if p:  # for the first run p is None
+              self.items.append(p)
+            p = Patch()
+            p.source = srcname
+            srcname = None
+            p.target = tgtname
+            tgtname = None
+            p.header = header
+            header = []
+            # switch to hunkhead state
+            filenames = False
+            hunkhead = False
+            nexthunkno = 0
+            p.hunkends = lineends.copy()
+            hunkparsed = True
+            continue
         elif not line.startswith(b"+++ "):
           if srcname != None:
             warning("skipping invalid patch with no target for %s" % srcname)
@@ -715,6 +715,9 @@
     # ---- detect patch and patchset types ----
     for idx, p in enumerate(self.items):
       self.items[idx].type = self._detect_type(p)
+      if self.items[idx].type == GIT:
+        self.items[idx].filemode = self._detect_file_mode(p)
+        self.items[idx].mode = self._detect_patch_mode(p)
 
     types = set([p.type for p in self.items])
     if len(types) > 1:
@@ -761,9 +764,34 @@
         if p.header[idx].startswith(b"diff --git"):
           break
       if p.header[idx].startswith(b'diff --git a/'):
-        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])):
-          if DVCS:
+        git_indicators = []
+        for i in range(idx + 1, len(p.header)):
+          git_indicators.append(p.header[i])
+        for line in git_indicators:
+          if re.match(
+                  b'(?:index \\w{4,40}\\.\\.\\w{4,40}(?: \\d{6})?|new file 
mode \\d+|deleted file mode \\d+|old mode \\d+|new mode \\d+)',
+                  line):
+            if DVCS:
+              return GIT
+
+        # Additional check: look for mode change patterns
+        # "old mode XXXXX" followed by "new mode XXXXX"
+        has_old_mode = False
+        has_new_mode = False
+
+        for line in git_indicators:
+          if re.match(b'old mode \\d+', line):
+            has_old_mode = True
+          elif re.match(b'new mode \\d+', line):
+            has_new_mode = True
+
+        # If we have both old and new mode, it's definitely Git
+        if has_old_mode and has_new_mode and DVCS:
+          return GIT
+
+        # Check for similarity index (Git renames/copies)
+        for line in git_indicators:
+          if re.match(b'similarity index \\d+%', line):
             return GIT
 
     # HG check
@@ -788,6 +816,54 @@
 
     return PLAIN
 
+  def _detect_file_mode(self, p):
+    """ Detect the file mode listed in the patch header
+
+       INFO: Only working with Git-style patches
+    """
+    if len(p.header) > 1:
+      for idx in reversed(range(len(p.header))):
+        if p.header[idx].startswith(b"diff --git"):
+          break
+      if p.header[idx].startswith(b'diff --git a/'):
+        if idx + 1 < len(p.header):
+          # new file (e.g)
+          # diff --git a/quote.txt b/quote.txt
+          # new file mode 100755
+          match = re.match(b'new file mode (\\d+)', p.header[idx + 1])
+          if match:
+            return int(match.group(1), 8)
+          # changed mode (e.g)
+          # diff --git a/quote.txt b/quote.txt
+          # old mode 100755
+          # new mode 100644
+          if idx + 2 < len(p.header):
+            match = re.match(b'new mode (\\d+)', p.header[idx + 2])
+            if match:
+              return int(match.group(1), 8)
+    return None
+
+  def _apply_filemode(self, filepath, filemode):
+    if filemode is not None and stat.S_ISREG(filemode):
+      try:
+        only_file_permissions = filemode & 0o777
+        os.chmod(filepath, only_file_permissions)
+      except Exception as error:
+        warning(f"Could not set filemode {oct(filemode)} for {filepath}: 
{str(error)}")
+
+  def _detect_patch_mode(self, p):
+    """Detect patch mode - add, delete, rename, etc.
+    """
+    if len(p.header) > 1:
+      for idx in reversed(range(len(p.header))):
+        if p.header[idx].startswith(b"diff --git"):
+          break
+      change_pattern = re.compile(rb"^diff --git a/([^ ]+) b/(.+)")
+      match = change_pattern.match(p.header[idx])
+      if match:
+        if match.group(1) != match.group(2) and not p.hunks and p.source != 
b'/dev/null' and p.target != b'/dev/null':
+          return 'rename'
+    return None
 
   def _normalize_filenames(self):
     """ sanitize filenames, normalizing paths, i.e.:
@@ -805,6 +881,7 @@
     for i,p in enumerate(self.items):
       if debugmode:
         debug("    patch type = %s" % p.type)
+        debug("    filemode = %s" % p.filemode)
         debug("    source = %s" % p.source)
         debug("    target = %s" % p.target)
       if p.type in (HG, GIT):
@@ -823,6 +900,9 @@
       p.source = xnormpath(p.source)
       p.target = xnormpath(p.target)
 
+      p.source = p.source.strip(b'"')
+      p.target = p.target.strip(b'"')
+
       sep = b'/'  # sep value can be hardcoded, but it looks nice this way
 
       # references to parent are not allowed
@@ -902,7 +982,7 @@
         #print(iratio, dratio, iwidth, dwidth, histwidth)
         hist = "+"*int(iwidth) + "-"*int(dwidth)
       # -- /calculating +- histogram --
-      output += (format % (tostr(names[i]), str(insert[i] + delete[i]), hist))
+      output += (format % (names[i].decode('utf-8'), str(insert[i] + 
delete[i]), hist))
 
     output += (" %d files changed, %d insertions(+), %d deletions(-), %+d 
bytes"
                % (len(names), sum(insert), sum(delete), delta))
@@ -981,9 +1061,17 @@
         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)
+        self._apply_filemode(target, item.filemode)
       elif "dev/null" in target:
         source = self.strip_path(source, root, strip)
         safe_unlink(source)
+      elif item.mode == 'rename':
+        source = self.strip_path(source, root, strip)
+        target = self.strip_path(target, root, strip)
+        if exists(source):
+          os.makedirs(os.path.dirname(target), exist_ok=True)
+          shutil.move(source, target)
+          self._apply_filemode(target, item.filemode)
       else:
         items.append(item)
     self.items = items
@@ -1112,6 +1200,7 @@
         else:
           shutil.move(filenamen, backupname)
           if self.write_hunks(backupname if filenameo == filenamen else 
filenameo, filenamen, p.hunks):
+            self._apply_filemode(filenamen, p.filemode)
             info("successfully patched %d/%d:\t %s" % (i+1, total, filenamen))
             safe_unlink(backupname)
             if new == b'/dev/null':
@@ -1270,15 +1359,11 @@
 
 
   def write_hunks(self, srcname, tgtname, hunks):
-    src = open(srcname, "rb")
-    tgt = open(tgtname, "wb")
-
-    debug("processing target file %s" % tgtname)
-
-    tgt.writelines(self.patch_stream(src, hunks))
+    with open(srcname, "rb") as src, open(tgtname, "wb") as tgt:
+        debug("processing target file %s" % tgtname)
+    
+        tgt.writelines(self.patch_stream(src, hunks))
 
-    tgt.close()
-    src.close()
     # [ ] TODO: add test for permission copy
     shutil.copymode(srcname, tgtname)
     return True
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/tests/06nested/tests/app/EVENT_LOOP.py 
new/python-patch-ng-1.19.0/tests/06nested/tests/app/EVENT_LOOP.py
--- old/python-patch-ng-1.18.1/tests/06nested/tests/app/EVENT_LOOP.py   
2024-10-25 14:05:56.000000000 +0200
+++ new/python-patch-ng-1.19.0/tests/06nested/tests/app/EVENT_LOOP.py   
2025-10-08 16:28:08.000000000 +0200
@@ -21,8 +21,8 @@
 
 class EVENT_LOOP(unittest.TestCase):
     def t_scheduled(self, interval, iterations, sleep_time=0):
-        print 'Test interval=%s, iterations=%s, sleep=%s' % (interval,
-            iterations, sleep_time)
+        print('Test interval=%s, iterations=%s, sleep=%s' % (interval,
+            iterations, sleep_time))
         warmup_iterations = iterations
 
         self.last_t = 0.
@@ -76,6 +76,6 @@
 
 if __name__ == '__main__':
     if pyglet.version != '1.2dev':
-        print 'Wrong version of pyglet imported; please check your PYTHONPATH'
+        print('Wrong version of pyglet imported; please check your PYTHONPATH')
     else:
         unittest.main()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/tests/emptypatches/create755.patch 
new/python-patch-ng-1.19.0/tests/emptypatches/create755.patch
--- old/python-patch-ng-1.18.1/tests/emptypatches/create755.patch       
1970-01-01 01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/tests/emptypatches/create755.patch       
2025-10-08 16:28:08.000000000 +0200
@@ -0,0 +1,21 @@
+From 39fdfb57a112a3b00cc352b45d17aba4f0f58005 Mon Sep 17 00:00:00 2001
+From: John Doe <[email protected]>
+Date: Wed, 1 Oct 2025 12:39:25 +0200
+Subject: [PATCH] Add quotes.txt
+
+Signed-off-by: John Doe <[email protected]>
+---
+ quote.txt | 1 +
+ 1 file changed, 1 insertion(+)
+ create mode 100755 quote.txt
+
+diff --git a/quote.txt b/quote.txt
+new file mode 100755
+index 
0000000000000000000000000000000000000000..cbfafe956ec35385f5b728daa390603ff71f1933
+--- /dev/null
++++ b/quote.txt
+@@ -0,0 +1 @@
++post malam segetem, serendum est.
+-- 
+2.51.0
+
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/tests/emptypatches/update644.patch 
new/python-patch-ng-1.19.0/tests/emptypatches/update644.patch
--- old/python-patch-ng-1.18.1/tests/emptypatches/update644.patch       
1970-01-01 01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/tests/emptypatches/update644.patch       
2025-10-08 16:28:08.000000000 +0200
@@ -0,0 +1,16 @@
+From 9a6f61c8cabffc01811605577d6d276a07c8bb95 Mon Sep 17 00:00:00 2001
+From: John Doe <[email protected]>
+Date: Fri, 3 Oct 2025 08:57:43 +0200
+Subject: [PATCH] Change file permission
+
+Signed-off-by: John Doe <[email protected]>
+---
+ quote.txt | 0
+ 1 file changed, 0 insertions(+), 0 deletions(-)
+ mode change 100755 => 100644 quote.txt
+
+diff --git a/quote.txt b/quote.txt
+old mode 100755
+new mode 100644
+--
+2.51.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/tests/filepermission/create755.patch 
new/python-patch-ng-1.19.0/tests/filepermission/create755.patch
--- old/python-patch-ng-1.18.1/tests/filepermission/create755.patch     
1970-01-01 01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/tests/filepermission/create755.patch     
2025-10-08 16:28:08.000000000 +0200
@@ -0,0 +1,21 @@
+From 39fdfb57a112a3b00cc352b45d17aba4f0f58005 Mon Sep 17 00:00:00 2001
+From: John Doe <[email protected]>
+Date: Wed, 1 Oct 2025 12:39:25 +0200
+Subject: [PATCH] Add quotes.txt
+
+Signed-off-by: John Doe <[email protected]>
+---
+ quote.txt | 1 +
+ 1 file changed, 1 insertion(+)
+ create mode 100755 quote.txt
+
+diff --git a/quote.txt b/quote.txt
+new file mode 100755
+index 
0000000000000000000000000000000000000000..cbfafe956ec35385f5b728daa390603ff71f1933
+--- /dev/null
++++ b/quote.txt
+@@ -0,0 +1 @@
++post malam segetem, serendum est.
+-- 
+2.51.0
+
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/tests/filepermission/update644.patch 
new/python-patch-ng-1.19.0/tests/filepermission/update644.patch
--- old/python-patch-ng-1.18.1/tests/filepermission/update644.patch     
1970-01-01 01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/tests/filepermission/update644.patch     
2025-10-08 16:28:08.000000000 +0200
@@ -0,0 +1,23 @@
+From 5f6ac26ddfe6ad80f76a1ec982abe95c11c7e947 Mon Sep 17 00:00:00 2001
+From: John Doe <[email protected]>
+Date: Wed, 1 Oct 2025 15:56:37 +0200
+Subject: [PATCH] Read only
+
+Signed-off-by: John Doe <[email protected]>
+---
+ quote.txt | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+ mode change 100755 => 100644 quote.txt
+
+diff --git a/quote.txt b/quote.txt
+old mode 100755
+new mode 100644
+index 
cbfafe956ec35385f5b728daa390603ff71f1933..155913b0aafa16e4b37278209e772e946cecb393
+--- a/quote.txt
++++ b/quote.txt
+@@ -1 +1 @@
+-post malam segetem, serendum est.
++praestat cautela quam medela.
+-- 
+2.51.0
+
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/tests/filewithspace/0001-quote.diff 
new/python-patch-ng-1.19.0/tests/filewithspace/0001-quote.diff
--- old/python-patch-ng-1.18.1/tests/filewithspace/0001-quote.diff      
1970-01-01 01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/tests/filewithspace/0001-quote.diff      
2025-10-08 16:28:08.000000000 +0200
@@ -0,0 +1,6 @@
+diff '--color=auto' -ruN "b/Wrapper/FreeImage.NET/cs/Samples/Sample 01 - 
Loading and saving/Program.cs" "a/Wrapper/FreeImage.NET/cs/Samples/Sample 01 - 
Loading and saving/Program.cs"
+--- "b/Wrapper/FreeImage.NET/cs/Samples/Sample 01 - Loading and 
saving/Program.cs"     2025-10-08 15:56:02.302486070 +0200
++++ "a/Wrapper/FreeImage.NET/cs/Samples/Sample 01 - Loading and 
saving/Program.cs"     2025-10-08 15:21:46.283174211 +0200
+@@ -1 +1 @@
+-feriunt summos, fulmina montes.
++lux oculorum laetificat animam.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/tests/movefile/0001-quote.patch 
new/python-patch-ng-1.19.0/tests/movefile/0001-quote.patch
--- old/python-patch-ng-1.18.1/tests/movefile/0001-quote.patch  1970-01-01 
01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/tests/movefile/0001-quote.patch  2025-10-08 
16:28:08.000000000 +0200
@@ -0,0 +1,61 @@
+From 90ffe300b588f40f1409deb414498af8bc681072 Mon Sep 17 00:00:00 2001
+From: John Doe <[email protected]>
+Date: Fri, 3 Oct 2025 11:28:55 +0200
+Subject: [PATCH 1/3] Add file
+
+Signed-off-by: John Doe <[email protected]>
+---
+ quotes.txt | 1 +
+ 1 file changed, 1 insertion(+)
+ create mode 100644 quotes.txt
+
+diff --git a/quotes.txt b/quotes.txt
+new file mode 100644
+index 
0000000000000000000000000000000000000000..6da41619625400f0b6c99beccc47c328f3967366
+--- /dev/null
++++ b/quotes.txt
+@@ -0,0 +1 @@
++in herbis, salus.
+-- 
+2.51.0
+
+
+From a055d1be31d11c149bfd9ca88d9554199d57d444 Mon Sep 17 00:00:00 2001
+From: John Doe <[email protected]>
+Date: Fri, 3 Oct 2025 11:29:27 +0200
+Subject: [PATCH 2/3] Move file
+
+Signed-off-by: John Doe <[email protected]>
+---
+ quotes.txt => quote/quotes.txt | 0
+ 1 file changed, 0 insertions(+), 0 deletions(-)
+ rename quotes.txt => quote/quotes.txt (100%)
+
+diff --git a/quotes.txt b/quote/quotes.txt
+similarity index 100%
+rename from quotes.txt
+rename to quote/quotes.txt
+-- 
+2.51.0
+
+
+From c275530ce83dc6eeeae671c2082660f1b6c16c4f Mon Sep 17 00:00:00 2001
+From: John Doe <[email protected]>
+Date: Fri, 3 Oct 2025 11:30:14 +0200
+Subject: [PATCH 3/3] Update quote
+
+Signed-off-by: John Doe <[email protected]>
+---
+ quote/quotes.txt | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/quote/quotes.txt b/quote/quotes.txt
+index 
6da41619625400f0b6c99beccc47c328f3967366..928532e38b2a8e607814da280bb9e02862d2b4ea
 100644
+--- a/quote/quotes.txt
++++ b/quote/quotes.txt
+@@ -1 +1 @@
+-in herbis, salus.
++dum tempus habemus, operemur bonum.
+-- 
+2.51.0
+
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/tests/patchformat/create.patch 
new/python-patch-ng-1.19.0/tests/patchformat/create.patch
--- old/python-patch-ng-1.18.1/tests/patchformat/create.patch   1970-01-01 
01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/tests/patchformat/create.patch   2025-10-08 
16:28:08.000000000 +0200
@@ -0,0 +1,20 @@
+From 8cacee878aff9a79603c87366d3eeefa68d80cf3 Mon Sep 17 00:00:00 2001
+From: John Doe <[email protected]>
+Date: Wed, 1 Oct 2025 10:59:19 +0200
+Subject: [PATCH] Add quotes.txt
+
+Signed-off-by: John Doe <[email protected]>
+---
+ quotes.txt | 1 +
+ 1 file changed, 1 insertion(+)
+ create mode 100644 quotes.txt
+
+diff --git a/quotes.txt b/quotes.txt
+new file mode 100644
+index 
0000000000000000000000000000000000000000..edcfcc5846847fffa4986b8437f1d7a3bd8aa8cf
+--- /dev/null
++++ b/quotes.txt
+@@ -0,0 +1 @@
++habita fides ipsam plerumque obligat fidem.
+--
+2.51.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/tests/patchformat/remove.patch 
new/python-patch-ng-1.19.0/tests/patchformat/remove.patch
--- old/python-patch-ng-1.18.1/tests/patchformat/remove.patch   1970-01-01 
01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/tests/patchformat/remove.patch   2025-10-08 
16:28:08.000000000 +0200
@@ -0,0 +1,20 @@
+From 22751ffae72c0b955b55e68ec0e25a80e434e890 Mon Sep 17 00:00:00 2001
+From: John Doe <[email protected]>
+Date: Wed, 1 Oct 2025 10:59:47 +0200
+Subject: [PATCH] Remove quotes.txt
+
+Signed-off-by: John Doe <[email protected]>
+---
+ quotes.txt | 1 -
+ 1 file changed, 1 deletion(-)
+ delete mode 100644 quotes.txt
+
+diff --git a/quotes.txt b/quotes.txt
+deleted file mode 100644
+index 
22174d6b96bbdd674d38f8be446d22758f996aba..0000000000000000000000000000000000000000
+--- a/quotes.txt
++++ /dev/null
+@@ -1 +0,0 @@
+-Si vis pacem cole justitiam.
+--
+2.51.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/python-patch-ng-1.18.1/tests/patchformat/update.patch 
new/python-patch-ng-1.19.0/tests/patchformat/update.patch
--- old/python-patch-ng-1.18.1/tests/patchformat/update.patch   1970-01-01 
01:00:00.000000000 +0100
+++ new/python-patch-ng-1.19.0/tests/patchformat/update.patch   2025-10-08 
16:28:08.000000000 +0200
@@ -0,0 +1,19 @@
+From 3360e00554544de3a68a227ac9eff5bb687da723 Mon Sep 17 00:00:00 2001
+From: John Doe <[email protected]>
+Date: Wed, 1 Oct 2025 10:59:37 +0200
+Subject: [PATCH] Update quote
+
+Signed-off-by: John Doe <[email protected]>
+---
+ quotes.txt | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/quotes.txt b/quotes.txt
+index 
edcfcc5846847fffa4986b8437f1d7a3bd8aa8cf..22174d6b96bbdd674d38f8be446d22758f996aba
 100644
+--- a/quotes.txt
++++ b/quotes.txt
+@@ -1 +1 @@
+-habita fides ipsam plerumque obligat fidem.
++Si vis pacem cole justitiam.
+--
+2.51.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-patch-ng-1.18.1/tests/run_tests.py 
new/python-patch-ng-1.19.0/tests/run_tests.py
--- old/python-patch-ng-1.18.1/tests/run_tests.py       2024-10-25 
14:05:56.000000000 +0200
+++ new/python-patch-ng-1.19.0/tests/run_tests.py       2025-10-08 
16:28:08.000000000 +0200
@@ -36,15 +36,11 @@
 import re
 import shutil
 import unittest
-import copy
 import stat
+import platform
 from os import listdir, chmod
 from os.path import abspath, dirname, exists, join, isdir, isfile
 from tempfile import mkdtemp
-try:
-  getcwdu = os.getcwdu
-except AttributeError:
-  getcwdu = os.getcwd  # python 3, where getcwd always returns a unicode object
 
 verbose = False
 if "-v" in sys.argv or "--verbose" in sys.argv:
@@ -150,7 +146,7 @@
       # 3.
       # test utility as a whole
       patch_tool = join(dirname(TESTS), "patch_ng.py")
-      save_cwd = getcwdu()
+      save_cwd = os.getcwd()
       os.chdir(tmpdir)
       extra = "-f" if "10fuzzy" in testname else ""
       if verbose:
@@ -204,7 +200,7 @@
 
 class TestCheckPatched(unittest.TestCase):
     def setUp(self):
-        self.save_cwd = getcwdu()
+        self.save_cwd = os.getcwd()
         os.chdir(TESTS)
 
     def tearDown(self):
@@ -355,7 +351,7 @@
 
 class TestPatchApply(unittest.TestCase):
     def setUp(self):
-        self.save_cwd = getcwdu()
+        self.save_cwd = os.getcwd()
         self.tmpdir = mkdtemp(prefix=self.__class__.__name__)
         os.chdir(self.tmpdir)
 
@@ -457,6 +453,176 @@
         self.assertTrue(os.stat(some_file).st_mode, stat.S_IRUSR | 
stat.S_IRGRP | stat.S_IROTH)
 
 
+class TestHugePatchFile(unittest.TestCase):
+
+    def setUp(self):
+        self.save_cwd = os.getcwd()
+        self.tmpdir = mkdtemp(prefix=self.__class__.__name__)
+        os.chdir(self.tmpdir)
+        self.huge_patchfile = self._create_huge_patchfile(self.tmpdir)
+
+    def _create_huge_patchfile(self, tmpdir):
+        """ Create a patch file with ~30MB of data
+        """
+        hugefile = join(tmpdir, 'hugefile')
+        with open(hugefile, 'wb') as f:
+            for i in range(2500000):
+                f.write(b'Line %d\n' % i)
+        huge_patchfile = join(tmpdir, 'huge.patch')
+        with open(huge_patchfile, 'wb') as f:
+            f.write(b'--- a/hugefile\n')
+            f.write(b'+++ b/hugefile\n')
+            f.write(b'@@ -1,2500000 +1,2500000 @@\n')
+            for i in range(2500000):
+                f.write(b' Line %d\n' % i)
+        return huge_patchfile
+
+    def tearDown(self):
+        os.chdir(self.save_cwd)
+        remove_tree_force(self.tmpdir)
+
+    def test_apply_huge_patch(self):
+        """ Test that a huge patch file can be applied without issues
+        """
+        pto = patch_ng.fromfile(self.huge_patchfile)
+        self.assertTrue(pto.apply(root=self.tmpdir))
+
+
+class TestPreserveFilePermissions(unittest.TestCase):
+
+    def setUp(self):
+        self.save_cwd = os.getcwd()
+        self.tmpdir = mkdtemp(prefix=self.__class__.__name__)
+        shutil.copytree(join(TESTS, 'filepermission'), join(self.tmpdir, 
'filepermission'))
+
+    def tearDown(self):
+        os.chdir(self.save_cwd)
+        remove_tree_force(self.tmpdir)
+
+    @unittest.skipIf(platform.system() == "Windows", "File permission modes 
are not supported on Windows")
+    def test_handle_full_index_patch_format(self):
+        """Test that when file permission mode is listed in the patch,
+        the same should be applied to the target file after patching.
+        """
+
+        os.chdir(self.tmpdir)
+        pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 
'create755.patch'))
+        self.assertEqual(len(pto), 1)
+        self.assertEqual(pto.items[0].type, patch_ng.GIT)
+        self.assertEqual(pto.items[0].filemode, 0o100755)
+        self.assertTrue(pto.apply())
+        self.assertTrue(os.path.exists(join(self.tmpdir, 'quote.txt')))
+        self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 
0o755 | stat.S_IFREG)
+
+        pto = patch_ng.fromfile(join(self.tmpdir, 'filepermission', 
'update644.patch'))
+        self.assertEqual(len(pto), 1)
+        self.assertEqual(pto.items[0].type, patch_ng.GIT)
+        self.assertEqual(pto.items[0].filemode, 0o100644)
+        self.assertTrue(pto.apply())
+        self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 
0o644 | stat.S_IFREG)
+
+
+class TestPatchEmptyFile(unittest.TestCase):
+
+    def setUp(self):
+        self.save_cwd = os.getcwd()
+        self.tmpdir = mkdtemp(prefix=self.__class__.__name__)
+        shutil.copytree(join(TESTS, 'emptypatches'), join(self.tmpdir, 
'emptypatches'))
+
+    def tearDown(self):
+        os.chdir(self.save_cwd)
+        remove_tree_force(self.tmpdir)
+
+    @unittest.skipIf(platform.system() == "Windows", "File permission modes 
are not supported on Windows")
+    @unittest.expectedFailure # FIXME: 
https://github.com/conan-io/python-patch-ng/issues/35
+    def test_apply_patch_only_file_mode(self):
+        """Test when a patch file is empty in terms of content, but has file
+        permission mode listed in the patch, the same should be applied to
+        the target file after patching.
+        """
+
+        os.chdir(self.tmpdir)
+        pto = patch_ng.fromfile(join(self.tmpdir, 'emptypatches', 
'create755.patch'))
+        self.assertEqual(len(pto), 1)
+        self.assertEqual(pto.items[0].type, patch_ng.GIT)
+        self.assertEqual(pto.items[0].filemode, 0o100755)
+        self.assertTrue(pto.apply())
+        self.assertTrue(os.path.exists(join(self.tmpdir, 'quote.txt')))
+        self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 
0o755 | stat.S_IFREG)
+
+        pto = patch_ng.fromfile(join(self.tmpdir, 'emptypatches', 
'update644.patch'))
+        self.assertEqual(len(pto), 1)
+        self.assertEqual(pto.items[0].type, patch_ng.GIT)
+        self.assertEqual(pto.items[0].filemode, 0o100644)
+        self.assertTrue(pto.apply())
+        self.assertEqual(os.stat(join(self.tmpdir, 'quote.txt')).st_mode, 
0o644 | stat.S_IFREG)
+
+class TestMoveAndPatch(unittest.TestCase):
+
+    def setUp(self):
+        self.save_cwd = os.getcwd()
+        self.tmpdir = mkdtemp(prefix=self.__class__.__name__)
+        shutil.copytree(join(TESTS, 'movefile'), join(self.tmpdir, 'movefile'))
+
+    def tearDown(self):
+        os.chdir(self.save_cwd)
+        remove_tree_force(self.tmpdir)
+
+    def test_add_move_and_update_file(self):
+        """When a patch file contains a file move (rename) and an update to 
the file,
+           the patch should be applied correctly.
+
+           Reported by https://github.com/conan-io/python-patch-ng/issues/24
+        """
+
+        os.chdir(self.tmpdir)
+        pto = patch_ng.fromfile(join(self.tmpdir, 'movefile', 
'0001-quote.patch'))
+        self.assertEqual(len(pto), 3)
+        self.assertEqual(pto.items[0].type, patch_ng.GIT)
+        self.assertEqual(pto.items[1].type, patch_ng.GIT)
+        self.assertEqual(pto.items[2].type, patch_ng.GIT)
+        self.assertEqual(pto.items[1].mode, 'rename')
+        self.assertTrue(pto.apply())
+        self.assertFalse(os.path.exists(join(self.tmpdir, 'quotes.txt')))
+        self.assertTrue(os.path.exists(join(self.tmpdir, 'quote', 
'quotes.txt')))
+        with open(join(self.tmpdir, 'quote', 'quotes.txt'), 'rb') as f:
+            content = f.read()
+            self.assertTrue(b'dum tempus habemus, operemur bonum' in content)
+
+class TestPatchFileWithSpaces(unittest.TestCase):
+
+    def setUp(self):
+        self.save_cwd = os.getcwd()
+        self.tmpdir = mkdtemp(prefix=self.__class__.__name__)
+        shutil.copytree(join(TESTS, 'filewithspace'), join(self.tmpdir, 
'filewithspace'))
+        patch_folder = join(self.tmpdir, "a", "Wrapper", "FreeImage.NET", 
"cs", "Samples", "Sample 01 - Loading and saving")
+        os.makedirs(patch_folder, exist_ok=True)
+        self.program_cs = join(patch_folder, "Program.cs")
+        with open(self.program_cs, 'w') as fd:
+            fd.write("feriunt summos, fulmina montes.")
+
+    def tearDown(self):
+        os.chdir(self.save_cwd)
+        remove_tree_force(self.tmpdir)
+
+    def test_patch_with_white_space(self):
+        """When a patch file is generated using `diff -ruN b/ a/` command, and
+           contains white spaces in the file path, the patch should be applied 
correctly.
+
+           Reported by https://github.com/conan-io/conan/issues/16727
+        """
+
+        os.chdir(self.tmpdir)
+        print("TMPDIR:", self.tmpdir)
+        pto = patch_ng.fromfile(join(self.tmpdir, 'filewithspace', 
'0001-quote.diff'))
+        self.assertEqual(len(pto), 1)
+        self.assertEqual(pto.items[0].type, patch_ng.PLAIN)
+        self.assertTrue(pto.apply())
+        with open(self.program_cs, 'rb') as f:
+            content = f.read()
+            self.assertTrue(b'lux oculorum laetificat animam.' in content)
+
+
 class TestHelpers(unittest.TestCase):
     # unittest setting
     longMessage = True
@@ -488,6 +654,47 @@
         self.assertEqual(patch_ng.pathstrip(b'path/name.diff', 1), 
b'name.diff')
         self.assertEqual(patch_ng.pathstrip(b'path/name.diff', 0), 
b'path/name.diff')
 
+
+class TestPatchFormat(unittest.TestCase):
+    def setUp(self):
+        self.save_cwd = os.getcwd()
+        self.tmpdir = mkdtemp(prefix=self.__class__.__name__)        
+        shutil.copytree(join(TESTS, 'patchformat'), join(self.tmpdir, 
'patchformat'))
+
+    def tearDown(self):
+        os.chdir(self.save_cwd)
+        remove_tree_force(self.tmpdir)
+
+    def test_handle_full_index_patch_format(self):
+        """Test that a full index patch format is handled correctly
+
+        When parsing a git patch with a full index line (40 hex chars),
+        the patch type should be detected as GIT and the patch should be
+        applied correctly (create, update, remove a file).
+        """
+
+        os.chdir(self.tmpdir)
+        pto = patch_ng.fromfile(join(self.tmpdir, 'patchformat', 
'create.patch'))
+        self.assertEqual(len(pto), 1)
+        self.assertEqual(pto.items[0].type, patch_ng.GIT)
+        self.assertTrue(pto.apply())
+        self.assertTrue(os.path.exists(join(self.tmpdir, 'quotes.txt')))
+
+        pto = patch_ng.fromfile(join(self.tmpdir, 'patchformat', 
'update.patch'))
+        self.assertEqual(len(pto), 1)
+        self.assertEqual(pto.items[0].type, patch_ng.GIT)
+        self.assertTrue(pto.apply())
+        with open(join(self.tmpdir, 'quotes.txt'), 'rb') as f:
+            content = f.read()
+            self.assertTrue(b'Si vis pacem cole justitiam.' in content)
+
+        pto = patch_ng.fromfile(join(self.tmpdir, 'patchformat', 
'remove.patch'))
+        self.assertEqual(len(pto), 1)
+        self.assertEqual(pto.items[0].type, patch_ng.GIT)
+        self.assertTrue(pto.apply())
+        self.assertFalse(os.path.exists(join(self.tmpdir, 'quotes.txt')))
+
+
 def remove_tree_force(folder):
     for root, _, files in os.walk(folder):
         for it in files:

Reply via email to