diff --git a/kitty/boss.py b/kitty/boss.py index 558266778..2d30fd7b5 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1016,9 +1016,17 @@ class Boss: tab.set_active_window(window_id) def open_url(self, url: str, program: Optional[Union[str, List[str]]] = None, cwd: Optional[str] = None) -> None: - if url: - if isinstance(program, str): - program = to_cmdline(program) + if not url: + return + if isinstance(program, str): + program = to_cmdline(program) + found_action = False + if program is None: + from .open_actions import actions_for_url + for action in actions_for_url(url): + found_action = True + self.dispatch_action(action) + if not found_action: open_url(url, program or self.opts.open_url_with, cwd=cwd) def destroy(self) -> None: diff --git a/kitty/open_actions.py b/kitty/open_actions.py new file mode 100644 index 000000000..1fa1830b5 --- /dev/null +++ b/kitty/open_actions.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +import os +import posixpath +from contextlib import suppress +from functools import lru_cache +from typing import Generator, Iterable, List, NamedTuple, Optional, Tuple, cast +from urllib.parse import ParseResult, unquote, urlparse + +from .conf.utils import to_bool, to_cmdline +from .config import KeyAction, parse_key_action +from .constants import config_dir +from .typing import MatchType +from .utils import expandvars, log_error + + +class MatchCriteria(NamedTuple): + type: MatchType + value: str + + +class OpenAction(NamedTuple): + match_criteria: Tuple[MatchCriteria, ...] + actions: Tuple[KeyAction, ...] + + +def parse(lines: Iterable[str]) -> Generator[OpenAction, None, None]: + match_criteria: List[MatchCriteria] = [] + actions: List[KeyAction] = [] + + for line in lines: + line = line.strip() + if line.startswith('#'): + continue + if not line: + if match_criteria and actions: + yield OpenAction(tuple(match_criteria), tuple(actions)) + match_criteria = [] + actions = [] + continue + parts = line.split(maxsplit=1) + if len(parts) != 2: + continue + key, rest = parts + key = key.lower() + if key == 'action': + with to_cmdline.filter_env_vars('URL', 'FILE_PATH', 'FILE', 'FRAGMENT'): + x = parse_key_action(rest) + if x is not None: + actions.append(x) + elif key in ('mime', 'ext', 'protocol', 'file', 'path', 'url', 'has_fragment'): + if key != 'url': + rest = rest.lower() + match_criteria.append(MatchCriteria(cast(MatchType, key), rest)) + else: + log_error(f'Ignoring malformed open actions line: {line}') + + if match_criteria and actions: + yield OpenAction(tuple(match_criteria), tuple(actions)) + + +def url_matches_criterion(purl: 'ParseResult', url: str, mc: MatchCriteria) -> bool: + if mc.type == 'url': + import re + try: + pat = re.compile(mc.value) + except re.error: + return False + return pat.search(url) is not None + + if mc.type == 'mime': + import fnmatch + from mimetypes import guess_type + try: + mt = guess_type(purl.path)[0] + except Exception: + return False + if mt is None: + return False + mt = mt.lower() + for mpat in mc.value.split(','): + mpat = mpat.strip() + with suppress(Exception): + if fnmatch.fnmatchcase(mt, mpat): + return True + return False + + if mc.type == 'ext': + if not purl.path: + return False + path = purl.path.lower() + for ext in mc.value.split(','): + ext = ext.strip() + if path.endswith('.' + ext): + return True + return False + + if mc.type == 'protocol': + protocol = (purl.scheme or 'file').lower() + for key in mc.value.split(','): + if key.strip() == protocol: + return True + return False + + if mc.type == 'has_fragment': + return to_bool(mc.value) == bool(purl.fragment) + + if mc.type == 'path': + import fnmatch + try: + return fnmatch.fnmatchcase(purl.path.lower(), mc.value) + except Exception: + return False + + if mc.type == 'file': + import fnmatch + import posixpath + try: + fname = posixpath.basename(purl.path) + except Exception: + return False + try: + return fnmatch.fnmatchcase(fname.lower(), mc.value) + except Exception: + return False + + +def url_matches_criteria(purl: 'ParseResult', url: str, criteria: Iterable[MatchCriteria]) -> bool: + for x in criteria: + try: + if not url_matches_criterion(purl, url, x): + return False + except Exception: + return False + return True + + +def actions_for_url_from_list(url: str, actions: Iterable[OpenAction]) -> Generator[KeyAction, None, None]: + try: + purl = urlparse(url) + except Exception: + return + path = unquote(purl.path) + + env = { + 'URL': url, + 'FILE_PATH': path, + 'FILE': posixpath.basename(path), + 'FRAGMENT': purl.fragment + } + + def expand(x: str) -> str: + return expandvars(x, env, fallback_to_os_env=False) + + for action in actions: + if url_matches_criteria(purl, url, action.match_criteria): + for ac in action.actions: + yield ac._replace(args=tuple(map(expand, ac.args))) + return + + +@lru_cache(maxsize=2) +def load_open_actions() -> Tuple[OpenAction, ...]: + try: + f = open(os.path.join(config_dir, 'open-actions.conf')) + except FileNotFoundError: + return () + with f: + return tuple(parse(f)) + + +def actions_for_url(url: str, actions_spec: Optional[str] = None) -> Generator[KeyAction, None, None]: + if actions_spec is None: + actions = load_open_actions() + else: + actions = tuple(parse(actions_spec.splitlines())) + yield from actions_for_url_from_list(url, actions) diff --git a/kitty/typing.py b/kitty/typing.py index 268bba293..3207ed740 100644 --- a/kitty/typing.py +++ b/kitty/typing.py @@ -18,4 +18,5 @@ TermManagerType = LoopType = Debug = GraphicsCommandType = None CompletedProcess = Tuple TypedDict = dict EdgeLiteral = str +MatchType = str Protocol = object diff --git a/kitty/typing.pyi b/kitty/typing.pyi index 8554de9ad..b2133a16f 100644 --- a/kitty/typing.pyi +++ b/kitty/typing.pyi @@ -43,6 +43,7 @@ from .config import ( # noqa; noqa ) EdgeLiteral = Literal['left', 'top', 'right', 'bottom'] +MatchType = Literal['mime', 'ext', 'protocol', 'file', 'path', 'url', 'has_fragment'] GRT_a = Literal['t', 'T', 'q', 'p', 'd'] GRT_f = Literal[24, 32, 100] GRT_t = Literal['d', 'f', 't', 's'] diff --git a/kitty_tests/open_actions.py b/kitty_tests/open_actions.py new file mode 100644 index 000000000..234155fce --- /dev/null +++ b/kitty_tests/open_actions.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2018, Kovid Goyal + + +import os +from contextlib import contextmanager + +from . import BaseTest + + +@contextmanager +def patch_env(**kw): + orig = os.environ.copy() + for k, v in kw.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + yield + os.environ.clear() + os.environ.update(orig) + + +class TestOpenActions(BaseTest): + + def test_parsing_of_open_actions(self): + from kitty.open_actions import actions_for_url, KeyAction + spec = ''' +protocol file +mime text/* +has_fragment yes +AcTion launch $EDITOR $FILE_PATH $FRAGMENT +action + +protocol file +mime text/* +action ignored + +ext py,txt +action one +action two +''' + + def actions(url): + with patch_env(EDITOR='editor', FILE_PATH='notgood'): + return tuple(actions_for_url(url, spec)) + + def single(url, func, *args): + acts = actions(url) + self.ae(len(acts), 1) + self.ae(acts[0].func, func) + self.ae(acts[0].args, args) + + single('file://hostname/tmp/moo.txt#23', 'launch', 'editor', '/tmp/moo.txt', '23') + single('some thing.txt', 'ignored') + self.ae(actions('x:///a.txt'), (KeyAction('one', ()), KeyAction('two', ())))