# # Keypirinha: a fast launcher for Windows (keypirinha.com) # Copyright 2013-2018 Jean-Charles Lefebvre # import fnmatch import glob import os import re import stat import sys __all__ = ['create_filter'] PY36 = sys.version_info >= (3, 6) IS_WINDOWS = os.name == 'nt' EXPRESSION_REGEX = re.compile( r""" ^\s* (?:([+-])\s+)? (?:\:?([a-z_\:]{2,})\:\s+)? (.+) (? 0: path = path[pos:] # keep the front sep if not self.case_sensitive: path = self.norm_case(path) # Stay consistent with __init__ if path[0] != os.sep: path = os.sep + path delta = len(path) - len(self.pattern) if delta > 0: return path.endswith(self.pattern) elif delta == 0: return path == self.pattern else: return False class PathShellFilter(_PathFilter_Base): def __init__(self, pattern, case_sensitive, nodrive, inclusive): pattern = self.norm_path(pattern, ispattern=True) assert glob.has_magic(pattern) if not case_sensitive: pattern = self.norm_case(pattern) super().__init__(pattern, case_sensitive, inclusive) self.patmatch = re.compile(fnmatch.translate(pattern)).match self.nodrive = nodrive def __hash__(self): # CAUTION: for this to be consistent, object must be immutable if self.hash_cache is None: self.hash_cache = hash((self.__class__.__name__, self.inclusive, self.case_sensitive, self.pattern, self.nodrive)) return self.hash_cache def __str__(self): sign = '+ ' if self.inclusive else '- ' props = '' if self.case_sensitive: props += 'case:' if self.nodrive: props += 'nodrive:' if props: props += ' ' return sign + props + self.pattern def match(self, path): path = self.norm_path(path, ispattern=False) if not self.case_sensitive: path = self.norm_case(path) return bool(self.patmatch(path)) class ExtensionsFilter(Filter): def __init__(self, pattern, case_sensitive, inclusive): super().__init__(inclusive) if not case_sensitive: pattern = self.norm_case(pattern) if os.sep in pattern: raise ValueError('invalid or empty ext filter') self.case_sensitive = case_sensitive self.ext = frozenset(filter(None, re.split(r'[\s\;]+', pattern))) def __hash__(self): # CAUTION: for this to be consistent, object must be immutable if self.hash_cache is None: self.hash_cache = hash((self.inclusive, self.ext)) return self.hash_cache def __eq__(self, other): if isinstance(other, self.__class__): return hash(self) == hash(other) return NotImplemented def __str__(self): sign = '+ ' if self.inclusive else '- ' props = 'ext:' if self.case_sensitive: props += 'case:' props += ' ' return sign + props + ' '.join(self.ext) def match(self, path_or_entry): if not self.ext: return False # Assume first that *path_or_entry* is `globex.GlobExEntry` object or # any other `os.DirEntry`-compatible try: basename = path_or_entry.name except AttributeError: basename = os.path.basename(path_or_entry) # os.PathLike supported if isinstance(basename, bytes): basename = os.fsdecode(basename) if not self.case_sensitive: basename = self.norm_case(basename) for ext in self.ext: if len(basename) > len(ext) and basename.endswith(ext): return True return False if IS_WINDOWS: class WinAttrFilter(Filter): def __init__(self, pattern, match_all, inclusive): super().__init__(inclusive) self.match_all = match_all self.desired_attr = 0 self.not_desired_attr = 0 pattern = pattern.lower().split() for attr_str in pattern: if attr_str: desired = True if attr_str[0] == '!': desired = False attr_str = attr_str.lstrip('!') try: attr_flag = WIN_FILE_ATTRIBUTE[attr_str] except KeyError: raise ValueError('unknown file attribute "{}"'.format( attr_str)) if desired: self.desired_attr |= attr_flag else: self.not_desired_attr |= attr_flag if not self.desired_attr and not self.not_desired_attr: raise ValueError('empty attr or attr_all filter') if (self.desired_attr & self.not_desired_attr) != 0: raise ValueError( 'colliding attribute(s) found in attr or attr_all filter') def __hash__(self): # CAUTION: for this to be consistent, object must be immutable if self.hash_cache is None: self.hash_cache = hash((self.inclusive, self.match_all, self.desired_attr, self.not_desired_attr)) return self.hash_cache def __eq__(self, other): if isinstance(other, self.__class__): return hash(self) == hash(other) return NotImplemented def __str__(self): s = '+ ' if self.inclusive else '- ' s += 'attr_all:' if self.match_all else 'attr:' desired_attr = self.desired_attr not_desired_attr = self.not_desired_attr for attr_name, attr_flag in WIN_FILE_ATTRIBUTE.items(): if desired_attr & attr_flag: s += ' ' + attr_name desired_attr &= ~attr_flag elif not_desired_attr & attr_flag: s += ' !' + attr_name not_desired_attr &= ~attr_flag return s def match(self, path_or_entry): # Assume first that *path_or_entry* is `globex.GlobExEntry` object # or any other `os.DirEntry`-compatible try: file_attr = path_or_entry.stat( follow_symlinks=False).st_file_attributes path_or_entry = path_or_entry.path except OSError: return None except Exception: # AttributeError, TypeError, ... We are really blind here if PY36: path_or_entry = os.fspath(path_or_entry) if isinstance(path_or_entry, bytes): path_or_entry = os.fsdecode(path_or_entry) file_attr = ctypes.windll.kernel32.GetFileAttributesW( path_or_entry) if file_attr == 0xffffffff: # INVALID_FILE_ATTRIBUTES return None assert isinstance(path_or_entry, (str, bytes)) # A file is considered *hidden* if it has the *hidden* attribute or # if its name starts with a '.' character. if not (file_attr & stat.FILE_ATTRIBUTE_HIDDEN): if isinstance(path_or_entry, bytes): if path_or_entry[0] == b'.'[0]: file_attr |= stat.FILE_ATTRIBUTE_HIDDEN elif path_or_entry[0] == '.': file_attr |= stat.FILE_ATTRIBUTE_HIDDEN match = file_attr & self.desired_attr not_match = file_attr & self.not_desired_attr if self.match_all: return match == self.desired_attr and not_match == 0 else: return match or (self.not_desired_attr != 0 and not_match != self.not_desired_attr) def create_filter(expression): rem = EXPRESSION_REGEX.match(expression) if not rem: raise ValueError('invalid filter expression') inclusive = False if rem.group(1) == '-' else True props = rem.group(2) if not props: props_orig = () props = () else: props_orig = props[:] props = props.lower().split(':') pattern = rem.group(3) is_regex = False is_attr_or = False is_attr_and = False is_ext = False is_nodrive = False is_case_sensitive = False assert pattern == pattern.strip() for prop in props: if not prop: pass elif prop in ('re', 'regex'): is_regex = True elif prop == 'attr': is_attr_or = True elif prop == 'attr_all': is_attr_and = True elif prop == 'case': is_case_sensitive = True elif prop == 'ext': is_ext = True elif prop == 'nodrive': is_nodrive = True else: raise ValueError('invalid property "{}"'.format(prop)) if sum((is_regex, is_attr_or, is_attr_and, is_ext)) > 1: raise ValueError('invalid mix of filter properties "{}"'.format( props_orig)) if is_ext: return ExtensionsFilter(pattern, case_sensitive=is_case_sensitive, inclusive=inclusive) elif is_attr_or or is_attr_and: if IS_WINDOWS: return WinAttrFilter(pattern, match_all=is_attr_and, inclusive=inclusive) else: raise NotImplementedError # TODO elif is_regex: return PathRegexFilter(pattern, case_sensitive=is_case_sensitive, nodrive=is_nodrive, inclusive=inclusive) else: tail = os.path.splitdrive(pattern)[1] if tail and glob.has_magic(tail): return PathShellFilter(pattern, case_sensitive=is_case_sensitive, nodrive=is_nodrive, inclusive=inclusive) else: # Note: PathTailFilter is "nodrive" by design return PathTailFilter(pattern, case_sensitive=is_case_sensitive, inclusive=inclusive) if __name__ == '__main__': # Keep this __debug__ test constant so it can be stripped by the compiler in # non-debug mode if __debug__: print('DEBUG mode', flush=True) pf = create_filter('test') assert isinstance(pf, PathTailFilter) assert pf.inclusive assert not pf.case_sensitive assert pf.match('test') assert pf.match('TeSt') assert pf.match('/test') assert pf.match('/test/') assert pf.match(r'c:\test') assert pf.match('c:/test/') assert pf.match('D:/test') assert pf.match('testt') == False pf = create_filter('dir/test') assert isinstance(pf, PathTailFilter) assert pf.inclusive assert not pf.case_sensitive assert pf.match(r'c:\foo\dir\test\\') assert pf.match(r'c:\foo\_dir\test') == False # absolute path filters are not supported for s in ('/test', 'c:/test', r'\\?\c:\test', r'\\server\share\dir'): try: pf = create_filter(s) assert isinstance(pf, PathTailFilter) except ValueError as exc: assert 'absolute' in str(exc) continue # ok assert 0 # we should never get here pf = create_filter('t?st') assert isinstance(pf, PathShellFilter) assert pf.inclusive assert not pf.case_sensitive assert not pf.nodrive assert pf.match('test') assert pf.match('c:/dir/test') == False assert pf.match('dir/test') == False assert pf.match('tst') == False pf = create_filter('*/dir/t?st') assert isinstance(pf, PathShellFilter) assert pf.inclusive assert not pf.case_sensitive assert not pf.nodrive assert pf.match(r'c:\foo\dir\test') assert pf.match(r'c:\foo\dir\test\\') assert pf.match('dir/test') == False assert pf.match(r'c:\foo\_dir\test') == False assert pf.match('dir') == False assert pf.match('test') == False pf = create_filter('dir/*') assert isinstance(pf, PathShellFilter) assert pf.inclusive assert not pf.case_sensitive assert not pf.nodrive assert pf.match('dir/test') assert pf.match(r'\dir\test') == False assert pf.match(r'c:\foo\dir\test') == False assert pf.match(r'c:\foo\_dir\test') == False assert pf.match('dir') == False assert pf.match('test') == False pf = create_filter('*/dir/*') assert isinstance(pf, PathShellFilter) assert pf.inclusive assert not pf.case_sensitive assert not pf.nodrive assert pf.match('dir/test') == False assert pf.match('\\dir\\test') assert pf.match(r'any\dir\test') assert pf.match(r'any\dir') == False assert pf.match(r'c:\foo\dir\test') assert pf.match(r'c:\foo\dir\test\\') assert pf.match(r'test\any_dir\test') == False assert pf.match('dir') == False assert pf.match('test') == False pf = create_filter('regex: .*t.st.*') assert isinstance(pf, PathRegexFilter) assert pf.inclusive assert not pf.case_sensitive assert not pf.nodrive assert pf.match('test') assert pf.match('test world') assert pf.match('hello test') assert pf.match('hello test world') assert pf.match(r'c:\foo\dir\test') assert pf.match(r'c:\foo\dir\test\\') assert pf.match(r'c:\foo\test\bar') pf = create_filter('regex: .*t.st\\.doc') assert isinstance(pf, PathRegexFilter) assert pf.inclusive assert not pf.case_sensitive assert not pf.nodrive assert pf.match('test') == False assert pf.match('test.doc') assert pf.match('tEst.doc') assert pf.match('tOst.doc') assert pf.match('hello tost.doc')