1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net> 4 5 6import os 7import posixpath 8from contextlib import suppress 9from typing import ( 10 Any, Generator, Iterable, List, NamedTuple, Optional, Tuple, cast 11) 12from urllib.parse import ParseResult, unquote, urlparse 13 14from .conf.utils import KeyAction, to_cmdline_implementation 15from .constants import config_dir 16from .guess_mime_type import guess_type 17from .options.utils import parse_key_action 18from .types import run_once 19from .typing import MatchType 20from .utils import expandvars, log_error 21 22 23class MatchCriteria(NamedTuple): 24 type: MatchType 25 value: str 26 27 28class OpenAction(NamedTuple): 29 match_criteria: Tuple[MatchCriteria, ...] 30 actions: Tuple[KeyAction, ...] 31 32 33def parse(lines: Iterable[str]) -> Generator[OpenAction, None, None]: 34 match_criteria: List[MatchCriteria] = [] 35 actions: List[KeyAction] = [] 36 37 for line in lines: 38 line = line.strip() 39 if line.startswith('#'): 40 continue 41 if not line: 42 if match_criteria and actions: 43 yield OpenAction(tuple(match_criteria), tuple(actions)) 44 match_criteria = [] 45 actions = [] 46 continue 47 parts = line.split(maxsplit=1) 48 if len(parts) != 2: 49 continue 50 key, rest = parts 51 key = key.lower() 52 if key == 'action': 53 with to_cmdline_implementation.filter_env_vars('URL', 'FILE_PATH', 'FILE', 'FRAGMENT'): 54 x = parse_key_action(rest) 55 if x is not None: 56 actions.append(x) 57 elif key in ('mime', 'ext', 'protocol', 'file', 'path', 'url', 'fragment_matches'): 58 if key != 'url': 59 rest = rest.lower() 60 match_criteria.append(MatchCriteria(cast(MatchType, key), rest)) 61 else: 62 log_error(f'Ignoring malformed open actions line: {line}') 63 64 if match_criteria and actions: 65 yield OpenAction(tuple(match_criteria), tuple(actions)) 66 67 68def url_matches_criterion(purl: 'ParseResult', url: str, unquoted_path: str, mc: MatchCriteria) -> bool: 69 if mc.type == 'url': 70 import re 71 try: 72 pat = re.compile(mc.value) 73 except re.error: 74 return False 75 return pat.search(unquote(url)) is not None 76 77 if mc.type == 'mime': 78 import fnmatch 79 mt = guess_type(unquoted_path, allow_filesystem_access=True) 80 if not mt: 81 return False 82 mt = mt.lower() 83 for mpat in mc.value.split(','): 84 mpat = mpat.strip() 85 with suppress(Exception): 86 if fnmatch.fnmatchcase(mt, mpat): 87 return True 88 return False 89 90 if mc.type == 'ext': 91 if not purl.path: 92 return False 93 path = unquoted_path.lower() 94 for ext in mc.value.split(','): 95 ext = ext.strip() 96 if path.endswith('.' + ext): 97 return True 98 return False 99 100 if mc.type == 'protocol': 101 protocol = (purl.scheme or 'file').lower() 102 for key in mc.value.split(','): 103 if key.strip() == protocol: 104 return True 105 return False 106 107 if mc.type == 'fragment_matches': 108 import re 109 try: 110 pat = re.compile(mc.value) 111 except re.error: 112 return False 113 114 return pat.search(unquote(purl.fragment)) is not None 115 116 if mc.type == 'path': 117 import fnmatch 118 try: 119 return fnmatch.fnmatchcase(unquoted_path.lower(), mc.value) 120 except Exception: 121 return False 122 123 if mc.type == 'file': 124 import fnmatch 125 import posixpath 126 try: 127 fname = posixpath.basename(unquoted_path) 128 except Exception: 129 return False 130 try: 131 return fnmatch.fnmatchcase(fname.lower(), mc.value) 132 except Exception: 133 return False 134 135 136def url_matches_criteria(purl: 'ParseResult', url: str, unquoted_path: str, criteria: Iterable[MatchCriteria]) -> bool: 137 for x in criteria: 138 try: 139 if not url_matches_criterion(purl, url, unquoted_path, x): 140 return False 141 except Exception: 142 return False 143 return True 144 145 146def actions_for_url_from_list(url: str, actions: Iterable[OpenAction]) -> Generator[KeyAction, None, None]: 147 try: 148 purl = urlparse(url) 149 except Exception: 150 return 151 path = unquote(purl.path) 152 153 env = { 154 'URL': url, 155 'FILE_PATH': path, 156 'FILE': posixpath.basename(path), 157 'FRAGMENT': unquote(purl.fragment) 158 } 159 160 def expand(x: Any) -> Any: 161 as_bytes = isinstance(x, bytes) 162 if as_bytes: 163 x = x.decode('utf-8') 164 if isinstance(x, str): 165 ans = expandvars(x, env, fallback_to_os_env=False) 166 if as_bytes: 167 return ans.encode('utf-8') 168 return ans 169 return x 170 171 for action in actions: 172 if url_matches_criteria(purl, url, path, action.match_criteria): 173 for ac in action.actions: 174 yield ac._replace(args=tuple(map(expand, ac.args))) 175 return 176 177 178@run_once 179def load_open_actions() -> Tuple[OpenAction, ...]: 180 try: 181 f = open(os.path.join(config_dir, 'open-actions.conf')) 182 except FileNotFoundError: 183 return () 184 with f: 185 return tuple(parse(f)) 186 187 188def actions_for_url(url: str, actions_spec: Optional[str] = None) -> Generator[KeyAction, None, None]: 189 if actions_spec is None: 190 actions = load_open_actions() 191 else: 192 actions = tuple(parse(actions_spec.splitlines())) 193 yield from actions_for_url_from_list(url, actions) 194