diff --git a/README.md b/README.md index 9e167eb..fcc7dc5 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ See examples folder for code examples # Load poolset. - # zfs properties can be queried here with: zfs_prop=['prop1','prop2',...] + # zfs properties can be queried here with: zfs_props=['prop1','prop2',...] # zpool properties can be queried here with: zpool_props=['prop1','prop2',...] # Default properties: name, creation # If get_mounts=True, mountpoint and mounted are also retrieved automatically @@ -133,6 +133,9 @@ See examples folder for code examples # - + The path has been created # - M The path has been modified # - R The path has been renamed + # - V The path has moved + # get_move - Derrive the V flag for paths that have moved. + # By default zfs returns R for renamed and moved paths. ``` ### `.snap_path` @@ -161,4 +164,4 @@ See examples folder for code examples See `test.py` for more sample code -Credits: This code is based heavily on [zfs-tools by Rudd-O](https://github.com/Rudd-O/zfs-tools). \ No newline at end of file +Credits: This code is based heavily on [zfs-tools by Rudd-O](https://github.com/Rudd-O/zfs-tools). diff --git a/pyproject.toml b/pyproject.toml index 31eec81..22c6d03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zfslib" -version = "0.12.0" +version = "0.13.2" description = "ZFS Utilities For Python3" license = "MIT" authors = ["Timothy C. Quinn"] diff --git a/src/zfslib/zfslib.py b/src/zfslib/zfslib.py index 685cb79..9ec502f 100644 --- a/src/zfslib/zfslib.py +++ b/src/zfslib/zfslib.py @@ -13,6 +13,7 @@ import subprocess import os +import re import fnmatch import pathlib import inspect @@ -578,7 +579,9 @@ def __init__(self, pool, name, parent=None): # - + The path has been created # - M The path has been modified # - R The path has been renamed - def get_diffs(self, snap_from, snap_to=None, include=None, exclude=None, file_type=None, chg_type=None): + # - V The path has been moved + # ign_xattrdir - Filter out entries + def get_diffs(self, snap_from, snap_to=None, include=None, exclude=None, file_type=None, chg_type=None, get_move:bool=False, ign_xattrdir:bool=False): self.assertHaveMounts() assert self.mounted, "Cannot get diffs for Unmounted Dataset. Verify mounted flag on Dataset before calling" @@ -597,6 +600,7 @@ def __tv(k, v): if isinstance(v, list): return v raise AssertionError(f"{k} can only be a str or list. Got: {type(v)}") + if chg_type == 'V': get_move = True file_type = __tv('file_type', file_type) chg_type = __tv('chg_type', chg_type) @@ -629,9 +633,13 @@ def __row(s): rows = list(map(lambda s: __row(s), stdout.splitlines())) diffs = [] for i, row in enumerate(rows): + + if ign_xattrdir and row[3].find('/') > -1: + continue + # if i == 429: # print("HERE") - d = Diff(row, snap_left, snap_right) + d = Diff(row, snap_left, snap_right, get_move=get_move) if d.path_full.find('(on_delete_queue)') > 0: # It looks to be an artefact of ZFS that does not actually exist in FS # https://github.com/openzfs/zfs/blob/master/lib/libzfs/libzfs_diff.c @@ -734,7 +742,8 @@ def _get_snap_path(self): assert isinstance(self.parent, Dataset), \ "This function is only available for Snapshots of Datasets not Pools" self.parent.assertHaveMounts() - return f"{self.parent.mountpoint}/.zfs/snapshot/{self.name}" + return str(pathlib.Path(self.parent.mountpoint) / f".zfs/snapshot/{self.name}") + snap_path = property(_get_snap_path) @@ -798,8 +807,9 @@ class Diff(): ,'+': 'The path has been created' ,'M': 'The path has been modified' ,'R': 'The path has been renamed' + ,'V': 'The path has been moved' } - def __init__(self, row, snap_left, snap_right): + def __init__(self, row, snap_left, snap_right, get_move:bool=False): self.no_from_snap=False self.to_present=False if isinstance(snap_left, str) and snap_left == '(na-first)': @@ -829,6 +839,18 @@ def __init__(self, row, snap_left, snap_right): else: raise Exception(f"Unexpected len: {len(row)}. Row = {row}") + # Fix issue related to https://github.com/openzfs/zfs/issues/6318 + # Octal escapes in paths to get around issues with special characters + # a particular issue when compiling an app using pyinstaller + path = decode_octal_escapes(path) + if path_new: + path_new = decode_octal_escapes(path_new) + + # Derrive Move change type + if get_move and file_type == 'F' and chg_type == 'R': + if splitPath(path)[1] != splitPath(path_new)[1]: + chg_type = 'V' + chg_time = datetime.fromtimestamp(int(inode_ts[:inode_ts.find('.')])) self.chg_ts = inode_ts self.chg_time = chg_time @@ -871,7 +893,7 @@ def _get_snap_path_right(self): if self.to_present: return self.path_full snap_path = self.snap_right.snap_path - path_full = self.path_full_new if self.chg_type == 'R' else self.path_full + path_full = self.path_full_new if self.chg_type in ('R','V') else self.path_full return "{}{}".format(snap_path, path_full.replace(self.snap_left.dataset.mountpoint, '')) snap_path_right = property(_get_snap_path_right) @@ -1069,6 +1091,47 @@ def f(*popenargs, **kwargs): ''' END LEGACY DUCK PUNCHING ''' + +def decode_octal_escapes(s): + """ + Decode ZFS octal escape sequences to UTF-8 characters. + Handles multi-byte UTF-8 sequences like \0342\0200\0231 -> ' + """ + if not s: + return s + + result = [] + byte_buffer = [] + i = 0 + + while i < len(s): + # Check for octal escape sequence \#### (4 digits) + if i + 4 < len(s) and s[i:i+1] == '\\' and s[i+1:i+5].isdigit(): + octal_val = int(s[i+1:i+5], 8) + byte_buffer.append(octal_val) + i += 5 + else: + # Not an octal sequence - flush byte buffer if any + if byte_buffer: + try: + result.append(bytes(byte_buffer).decode('utf-8')) + except UnicodeDecodeError: + result.append(bytes(byte_buffer).decode('latin-1', errors='replace')) + byte_buffer = [] + result.append(s[i]) + i += 1 + + # Flush remaining bytes + if byte_buffer: + try: + result.append(bytes(byte_buffer).decode('utf-8')) + except UnicodeDecodeError: + result.append(bytes(byte_buffer).decode('latin-1', errors='replace')) + + return ''.join(result) + + + # No operation lambda dropin or breakpoint marker def noop(*args, **kwargs): if len(args): return args[0]