Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ you can get stats (if it is a new, removed or modified file; the source/target
lines; etc), besides having access to each hunk (also like a list) and its
respective info.

For git diffs, the file mode is exposed through the :code:`source_mode` and
:code:`target_mode` attributes (e.g. :code:`'100644'`, :code:`'100755'`,
:code:`'120000'`), or :code:`None` when unknown. The :code:`is_symlink`
property is a shortcut to detect symbolic links (mode :code:`120000`).

At any point you can get the string representation of the current object, and
that will return the unified diff data of it.

Expand Down
8 changes: 8 additions & 0 deletions tests/samples/git_symlink.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
diff --git a/bin/check b/bin/check
new file mode 120000
index 000000000..f35a09670
--- /dev/null
+++ b/bin/check
@@ -0,0 +1 @@
+.pyenv-wrapper
\ No newline at end of file
58 changes: 58 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,64 @@ def test_deleted_file(self):
self.assertEqual(res[0].target_file, '/dev/null')
self.assertTrue(res[0].is_removed_file)

def test_added_symlink_file_mode(self):
# issue #125: expose the file mode; a new symlink has mode 120000
filename = os.path.join(self.samples_dir, 'samples/git_symlink.diff')
with open(filename) as f:
res = PatchSet(f)

self.assertEqual(len(res), 1)
self.assertTrue(res[0].is_added_file)
self.assertIsNone(res[0].source_mode)
self.assertEqual(res[0].target_mode, '120000')
self.assertTrue(res[0].is_symlink)

def test_new_file_mode(self):
# issue #125: a regular new file carries `new file mode 100644`
filename = os.path.join(self.samples_dir, 'samples/git_quoted_filename.diff')
with open(filename) as f:
res = PatchSet(f)

self.assertEqual(res[0].target_mode, '100644')
self.assertFalse(res[0].is_symlink)

def test_mode_change_file(self):
# issue #125: `old mode` / `new mode` expose a chmod
diff = (
'diff --git a/server.py b/bin/server.py\n'
'old mode 100644\n'
'new mode 100755\n'
'similarity index 100%\n'
'rename from server.py\n'
'rename to bin/server.py\n'
)
res = PatchSet(diff)

self.assertEqual(res[0].source_mode, '100644')
self.assertEqual(res[0].target_mode, '100755')
self.assertFalse(res[0].is_symlink)
self.assertTrue(res[0].is_rename)

def test_index_line_mode(self):
# issue #125: an unchanged mode on the index line applies to both sides
diff = (
'diff --git a/info.sh b/info.sh\n'
'index ddbe53c40..6c84b8acf 100755\n'
'--- a/info.sh\n'
'+++ b/info.sh\n'
'@@ -1,2 +1,2 @@\n'
' a\n'
'-b\n'
'+c\n'
)
res = PatchSet(diff)

self.assertEqual(res[0].source_mode, '100755')
self.assertEqual(res[0].target_mode, '100755')
self.assertFalse(res[0].is_symlink)
# the index line is preserved so the diff still round-trips
self.assertEqual(str(res), diff)

def test_diff_lines_linenos(self):
with open(self.sample_file, 'rb') as diff_file:
res = PatchSet(diff_file, encoding='utf-8')
Expand Down
18 changes: 15 additions & 3 deletions unidiff/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,19 @@
RE_DIFF_GIT_HEADER_NO_PREFIX = re.compile(
r'^diff --git (?P<source>[^\t\n]+) (?P<target>[^\t\n]+)')

# check diff git new file marker `deleted file mode 100644`
RE_DIFF_GIT_DELETED_FILE = re.compile(r'^deleted file mode \d+$')
# check diff git deleted file marker `deleted file mode 100644`
RE_DIFF_GIT_DELETED_FILE = re.compile(r'^deleted file mode (?P<mode>\d+)$')

# check diff git new file marker `new file mode 100644`
RE_DIFF_GIT_NEW_FILE = re.compile(r'^new file mode \d+$')
RE_DIFF_GIT_NEW_FILE = re.compile(r'^new file mode (?P<mode>\d+)$')

# check diff git file mode change markers `old mode 100644` / `new mode 100755`
RE_DIFF_GIT_OLD_MODE = re.compile(r'^old mode (?P<mode>\d+)$')
RE_DIFF_GIT_NEW_MODE = re.compile(r'^new mode (?P<mode>\d+)$')

# check diff git index line with a trailing mode `index abc..def 100644`
RE_DIFF_GIT_INDEX = re.compile(
r'^index [0-9a-f]+\.\.[0-9a-f]+ (?P<mode>\d+)$')


# @@ (source offset, length) (target offset, length) @@ (section header)
Expand All @@ -74,6 +82,10 @@
DEFAULT_ENCODING = 'UTF-8'

DEV_NULL = '/dev/null'

# git file mode for a symbolic link
SYMLINK_FILE_MODE = '120000'

LINE_TYPE_ADDED = '+'
LINE_TYPE_REMOVED = '-'
LINE_TYPE_CONTEXT = ' '
Expand Down
47 changes: 46 additions & 1 deletion unidiff/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,18 @@
RE_DIFF_GIT_HEADER,
RE_DIFF_GIT_HEADER_URI_LIKE,
RE_DIFF_GIT_HEADER_NO_PREFIX,
RE_DIFF_GIT_INDEX,
RE_DIFF_GIT_NEW_FILE,
RE_DIFF_GIT_NEW_MODE,
RE_DIFF_GIT_OLD_MODE,
RE_HUNK_BODY_LINE,
RE_HUNK_EMPTY_BODY_LINE,
RE_HUNK_HEADER,
RE_SOURCE_FILENAME,
RE_TARGET_FILENAME,
RE_NO_NEWLINE_MARKER,
RE_BINARY_DIFF,
SYMLINK_FILE_MODE,
)
from unidiff.errors import UnidiffParseError

Expand Down Expand Up @@ -202,14 +206,19 @@ def __init__(self, patch_info: Optional[PatchInfo] = None,
source: str = '', target: str = '',
source_timestamp: Optional[str] = None,
target_timestamp: Optional[str] = None,
is_binary_file: bool = False) -> None:
is_binary_file: bool = False,
source_mode: Optional[str] = None,
target_mode: Optional[str] = None) -> None:
super(PatchedFile, self).__init__()
self.patch_info = patch_info
self.source_file = source
self.source_timestamp = source_timestamp
self.target_file = target
self.target_timestamp = target_timestamp
self.is_binary_file = is_binary_file
# git file modes (e.g. '100644', '100755', '120000'); None if unknown
self.source_mode = source_mode
self.target_mode = target_mode

def __repr__(self) -> str:
return "<PatchedFile: %s>" % self.path
Expand Down Expand Up @@ -402,6 +411,14 @@ def is_modified_file(self) -> bool:
"""Return True if this patch modifies the file."""
return not (self.is_added_file or self.is_removed_file)

@property
def is_symlink(self) -> bool:
"""Return True if the patched file is a symbolic link."""
# prefer the target mode; fall back to the source mode (e.g. a
# removed symlink only carries the old mode)
mode = self.target_mode if self.target_mode is not None else self.source_mode
return mode == SYMLINK_FILE_MODE


class PatchSet(list[PatchedFile]):
"""A list of PatchedFiles."""
Expand Down Expand Up @@ -459,6 +476,7 @@ def _parse(self, diff: Iterable, encoding: Optional[str],
if current_file is None or patch_info is None:
raise UnidiffParseError('Unexpected new file found: %s' % line)
current_file.source_file = DEV_NULL
current_file.target_mode = is_diff_git_new_file.group('mode')
patch_info.append(line)
continue

Expand All @@ -468,9 +486,36 @@ def _parse(self, diff: Iterable, encoding: Optional[str],
if current_file is None or patch_info is None:
raise UnidiffParseError('Unexpected deleted file found: %s' % line)
current_file.target_file = DEV_NULL
current_file.source_mode = is_diff_git_deleted_file.group('mode')
patch_info.append(line)
continue

# check for git file mode change / index lines (extract the mode
# but keep the line as patch info so the diff still round-trips)
if current_file is not None and patch_info is not None:
is_diff_git_old_mode = RE_DIFF_GIT_OLD_MODE.match(line)
if is_diff_git_old_mode:
current_file.source_mode = is_diff_git_old_mode.group('mode')
patch_info.append(line)
continue

is_diff_git_new_mode = RE_DIFF_GIT_NEW_MODE.match(line)
if is_diff_git_new_mode:
current_file.target_mode = is_diff_git_new_mode.group('mode')
patch_info.append(line)
continue

is_diff_git_index = RE_DIFF_GIT_INDEX.match(line)
if is_diff_git_index:
# an unchanged index mode applies to both source and target
mode = is_diff_git_index.group('mode')
if current_file.source_mode is None:
current_file.source_mode = mode
if current_file.target_mode is None:
current_file.target_mode = mode
patch_info.append(line)
continue

# check for source file header
is_source_filename = RE_SOURCE_FILENAME.match(line)
if is_source_filename:
Expand Down
Loading