Add type hints for browser.hints

This commit is contained in:
Florian Bruhin 2019-10-10 09:28:46 +02:00
parent 62dd08f6d6
commit 00c341fe6d
2 changed files with 136 additions and 97 deletions

View File

@ -58,6 +58,10 @@ disallow_subclassing_any = False
disallow_untyped_defs = True
disallow_incomplete_defs = True
[mypy-qutebrowser.browser.hints]
disallow_untyped_defs = True
disallow_incomplete_defs = True
[mypy-qutebrowser.misc.objects]
disallow_untyped_defs = True
disallow_incomplete_defs = True

View File

@ -20,6 +20,7 @@
"""A HintManager to draw hints over links."""
import collections
import typing
import functools
import os
import re
@ -37,6 +38,8 @@ from qutebrowser.browser import webelem
from qutebrowser.commands import userscripts, runners
from qutebrowser.api import cmdutils
from qutebrowser.utils import usertypes, log, qtutils, message, objreg, utils
if typing.TYPE_CHECKING:
from qutebrowser.browser import browsertab
Target = enum.Enum('Target', ['normal', 'current', 'tab', 'tab_fg', 'tab_bg',
@ -50,7 +53,7 @@ class HintingError(Exception):
"""Exception raised on errors during hinting."""
def on_mode_entered(mode, win_id):
def on_mode_entered(mode: usertypes.KeyMode, win_id: int) -> None:
"""Stop hinting when insert mode was entered."""
if mode == usertypes.KeyMode.insert:
modeman.leave(win_id, usertypes.KeyMode.hint, 'insert mode',
@ -66,7 +69,8 @@ class HintLabel(QLabel):
_context: The current hinting context.
"""
def __init__(self, elem, context):
def __init__(self, elem: webelem.AbstractWebElement,
context: 'HintContext') -> None:
super().__init__(parent=context.tab)
self._context = context
self.elem = elem
@ -81,14 +85,14 @@ class HintLabel(QLabel):
self._move_to_elem()
self.show()
def __repr__(self):
def __repr__(self) -> str:
try:
text = self.text()
except RuntimeError:
text = '<deleted>'
return utils.get_repr(self, elem=self.elem, text=text)
def update_text(self, matched, unmatched):
def update_text(self, matched: str, unmatched: str) -> None:
"""Set the text for the hint.
Args:
@ -112,7 +116,7 @@ class HintLabel(QLabel):
self.adjustSize()
@pyqtSlot()
def _move_to_elem(self):
def _move_to_elem(self) -> None:
"""Reposition the label to its element."""
if not self.elem.has_frame():
# This sometimes happens for some reason...
@ -123,7 +127,7 @@ class HintLabel(QLabel):
rect = self.elem.rect_on_view(no_js=no_js)
self.move(rect.x(), rect.y())
def cleanup(self):
def cleanup(self) -> None:
"""Clean up this element and hide it."""
self.hide()
self.deleteLater()
@ -159,22 +163,22 @@ class HintContext:
group: The group of web elements to hint.
"""
all_labels = attr.ib(attr.Factory(list))
labels = attr.ib(attr.Factory(dict))
target = attr.ib(None)
baseurl = attr.ib(None)
to_follow = attr.ib(None)
rapid = attr.ib(False)
first_run = attr.ib(True)
add_history = attr.ib(False)
filterstr = attr.ib(None)
args = attr.ib(attr.Factory(list))
tab = attr.ib(None)
group = attr.ib(None)
hint_mode = attr.ib(None)
first = attr.ib(False)
all_labels = attr.ib(attr.Factory(list)) # type: typing.List[HintLabel]
labels = attr.ib(attr.Factory(dict)) # type: typing.Dict[str, HintLabel]
target = attr.ib(None) # type: Target
baseurl = attr.ib(None) # type: QUrl
to_follow = attr.ib(None) # type: str
rapid = attr.ib(False) # type: bool
first_run = attr.ib(True) # type: bool
add_history = attr.ib(False) # type: bool
filterstr = attr.ib(None) # type: str
args = attr.ib(attr.Factory(list)) # type: typing.List[str]
tab = attr.ib(None) # type: browsertab.AbstractTab
group = attr.ib(None) # type: str
hint_mode = attr.ib(None) # type: str
first = attr.ib(False) # type: bool
def get_args(self, urlstr):
def get_args(self, urlstr: str) -> typing.Sequence[str]:
"""Get the arguments, with {hint-url} replaced by the given URL."""
args = []
for arg in self.args:
@ -187,16 +191,12 @@ class HintActions:
"""Actions which can be done after selecting a hint."""
def __init__(self, win_id):
def __init__(self, win_id: int) -> None:
self._win_id = win_id
def click(self, elem, context):
"""Click an element.
Args:
elem: The QWebElement to click.
context: The HintContext to use.
"""
def click(self, elem: webelem.AbstractWebElement,
context: HintContext) -> None:
"""Click an element."""
target_mapping = {
Target.normal: usertypes.ClickTarget.normal,
Target.current: usertypes.ClickTarget.normal,
@ -225,20 +225,15 @@ class HintActions:
except webelem.Error as e:
raise HintingError(str(e))
def yank(self, url, context):
"""Yank an element to the clipboard or primary selection.
Args:
url: The URL to open as a QUrl.
context: The HintContext to use.
"""
def yank(self, url: QUrl, context: HintContext) -> None:
"""Yank an element to the clipboard or primary selection."""
sel = (context.target == Target.yank_primary and
utils.supports_selection())
flags = QUrl.FullyEncoded | QUrl.RemovePassword
if url.scheme() == 'mailto':
flags |= QUrl.RemoveScheme
urlstr = url.toString(flags)
urlstr = url.toString(flags) # type: ignore
new_content = urlstr
@ -257,26 +252,16 @@ class HintActions:
urlstr)
message.info(msg)
def run_cmd(self, url, context):
"""Run the command based on a hint URL.
Args:
url: The URL to open as a QUrl.
context: The HintContext to use.
"""
urlstr = url.toString(QUrl.FullyEncoded)
def run_cmd(self, url: QUrl, context: HintContext) -> None:
"""Run the command based on a hint URL."""
urlstr = url.toString(QUrl.FullyEncoded) # type: ignore
args = context.get_args(urlstr)
commandrunner = runners.CommandRunner(self._win_id)
commandrunner.run_safely(' '.join(args))
def preset_cmd_text(self, url, context):
"""Preset a commandline text based on a hint URL.
Args:
url: The URL to open as a QUrl.
context: The HintContext to use.
"""
urlstr = url.toDisplayString(QUrl.FullyEncoded)
def preset_cmd_text(self, url: QUrl, context: HintContext) -> None:
"""Preset a commandline text based on a hint URL."""
urlstr = url.toDisplayString(QUrl.FullyEncoded) # type: ignore
args = context.get_args(urlstr)
text = ' '.join(args)
if text[0] not in modeparsers.STARTCHARS:
@ -285,7 +270,8 @@ class HintActions:
cmd = objreg.get('status-command', scope='window', window=self._win_id)
cmd.set_cmd_text(text)
def download(self, elem, context):
def download(self, elem: webelem.AbstractWebElement,
context: HintContext) -> None:
"""Download a hint URL.
Args:
@ -305,7 +291,8 @@ class HintActions:
download_manager.get(url, qnam=qnam, user_agent=user_agent,
prompt_download_directory=prompt)
def call_userscript(self, elem, context):
def call_userscript(self, elem: webelem.AbstractWebElement,
context: HintContext) -> None:
"""Call a userscript from a hint.
Args:
@ -321,7 +308,7 @@ class HintActions:
}
url = elem.resolve_url(context.baseurl)
if url is not None:
env['QUTE_URL'] = url.toString(QUrl.FullyEncoded)
env['QUTE_URL'] = url.toString(QUrl.FullyEncoded) # type: ignore
try:
userscripts.run_async(context.tab, cmd, *args, win_id=self._win_id,
@ -329,22 +316,28 @@ class HintActions:
except userscripts.Error as e:
raise HintingError(str(e))
def delete(self, elem, _context):
def delete(self, elem: webelem.AbstractWebElement,
_context: HintContext) -> None:
elem.delete()
def spawn(self, url, context):
def spawn(self, url: QUrl, context: HintContext) -> None:
"""Spawn a simple command from a hint.
Args:
url: The URL to open as a QUrl.
context: The HintContext to use.
"""
urlstr = url.toString(QUrl.FullyEncoded | QUrl.RemovePassword)
urlstr = url.toString(
QUrl.FullyEncoded | QUrl.RemovePassword) # type: ignore
args = context.get_args(urlstr)
commandrunner = runners.CommandRunner(self._win_id)
commandrunner.run_safely('spawn ' + ' '.join(args))
_ElemsType = typing.Sequence[webelem.AbstractWebElement]
_HintStringsType = typing.MutableSequence[str]
class HintManager(QObject):
"""Manage drawing hints over links or other elements.
@ -379,11 +372,11 @@ class HintManager(QObject):
Target.delete: "Delete an element",
}
def __init__(self, win_id, parent=None):
def __init__(self, win_id: int, parent: QObject = None) -> None:
"""Constructor."""
super().__init__(parent)
self._win_id = win_id
self._context = None
self._context = None # type: typing.Optional[HintContext]
self._word_hinter = WordHinter()
self._actions = HintActions(win_id)
@ -392,16 +385,18 @@ class HintManager(QObject):
window=win_id)
mode_manager.left.connect(self.on_mode_left)
def _get_text(self):
def _get_text(self) -> str:
"""Get a hint text based on the current context."""
assert self._context is not None
text = self.HINT_TEXTS[self._context.target]
if self._context.rapid:
text += ' (rapid mode)'
text += '...'
return text
def _cleanup(self):
def _cleanup(self) -> None:
"""Clean up after hinting."""
assert self._context is not None
for label in self._context.all_labels:
label.cleanup()
@ -411,7 +406,7 @@ class HintManager(QObject):
message_bridge.maybe_reset_text(text)
self._context = None
def _hint_strings(self, elems):
def _hint_strings(self, elems: _ElemsType) -> _HintStringsType:
"""Calculate the hint strings for elems.
Inspired by Vimium.
@ -424,6 +419,8 @@ class HintManager(QObject):
"""
if not elems:
return []
assert self._context is not None
hint_mode = self._context.hint_mode
if hint_mode == 'word':
try:
@ -441,7 +438,9 @@ class HintManager(QObject):
else:
return self._hint_linear(min_chars, chars, elems)
def _hint_scattered(self, min_chars, chars, elems):
def _hint_scattered(self, min_chars: int,
chars: str,
elems: _ElemsType) -> _HintStringsType:
"""Produce scattered hint labels with variable length (like Vimium).
Args:
@ -478,7 +477,9 @@ class HintManager(QObject):
return self._shuffle_hints(strings, len(chars))
def _hint_linear(self, min_chars, chars, elems):
def _hint_linear(self, min_chars: int,
chars: str,
elems: _ElemsType) -> _HintStringsType:
"""Produce linear hint labels with constant length (like dwb).
Args:
@ -492,7 +493,8 @@ class HintManager(QObject):
strings.append(self._number_to_hint_str(i, chars, needed))
return strings
def _shuffle_hints(self, hints, length):
def _shuffle_hints(self, hints: _HintStringsType,
length: int) -> _HintStringsType:
"""Shuffle the given set of hints so that they're scattered.
Hints starting with the same character will be spread evenly throughout
@ -507,15 +509,19 @@ class HintManager(QObject):
Return:
A list of shuffled hint strings.
"""
buckets = [[] for i in range(length)]
buckets = [
[] for i in range(length)
] # type: typing.Sequence[_HintStringsType]
for i, hint in enumerate(hints):
buckets[i % len(buckets)].append(hint)
result = []
result = [] # type: _HintStringsType
for bucket in buckets:
result += bucket
return result
def _number_to_hint_str(self, number, chars, digits=0):
def _number_to_hint_str(self, number: int,
chars: str,
digits: int = 0) -> str:
"""Convert a number like "8" into a hint string like "JK".
This is used to sequentially generate all of the hint text.
@ -533,7 +539,7 @@ class HintManager(QObject):
A hint string.
"""
base = len(chars)
hintstr = []
hintstr = [] # type: typing.MutableSequence[str]
remainder = 0
while True:
remainder = number % base
@ -547,7 +553,7 @@ class HintManager(QObject):
hintstr.insert(0, chars[0])
return ''.join(hintstr)
def _check_args(self, target, *args):
def _check_args(self, target: Target, *args: str) -> None:
"""Check the arguments passed to start() and raise if they're wrong.
Args:
@ -567,7 +573,7 @@ class HintManager(QObject):
raise cmdutils.CommandError(
"'args' is only allowed with target userscript/spawn.")
def _filter_matches(self, filterstr, elemstr):
def _filter_matches(self, filterstr: str, elemstr: str) -> bool:
"""Return True if `filterstr` matches `elemstr`."""
# Empty string and None always match
if not filterstr:
@ -577,7 +583,7 @@ class HintManager(QObject):
# Do multi-word matching
return all(word in elemstr for word in filterstr.split())
def _filter_matches_exactly(self, filterstr, elemstr):
def _filter_matches_exactly(self, filterstr: str, elemstr: str) -> bool:
"""Return True if `filterstr` exactly matches `elemstr`."""
# Empty string and None never match
if not filterstr:
@ -586,7 +592,7 @@ class HintManager(QObject):
elemstr = elemstr.casefold()
return filterstr == elemstr
def _start_cb(self, elems):
def _start_cb(self, elems: _ElemsType) -> None:
"""Initialize the elements and labels based on the context set."""
if self._context is None:
log.hints.debug("In _start_cb without context!")
@ -637,8 +643,13 @@ class HintManager(QObject):
@cmdutils.register(instance='hintmanager', scope='window', name='hint',
star_args_optional=True, maxsplit=2)
def start(self, # pylint: disable=keyword-arg-before-vararg
group='all', target=Target.normal, *args, mode=None,
add_history=False, rapid=False, first=False):
group: str = 'all',
target: Target = Target.normal,
*args: str,
mode: str = None,
add_history: bool = False,
rapid: bool = False,
first: bool = False) -> None:
"""Start hinting.
Args:
@ -754,7 +765,7 @@ class HintManager(QObject):
error_cb=lambda err: message.error(str(err)),
only_visible=True)
def _get_hint_mode(self, mode):
def _get_hint_mode(self, mode: typing.Optional[str]) -> str:
"""Get the hinting mode to use based on a mode argument."""
if mode is None:
return config.val.hints.mode
@ -766,15 +777,22 @@ class HintManager(QObject):
raise cmdutils.CommandError("Invalid mode: {}".format(e))
return mode
def current_mode(self):
def current_mode(self) -> typing.Optional[str]:
"""Return the currently active hinting mode (or None otherwise)."""
if self._context is None:
return None
return self._context.hint_mode
def _handle_auto_follow(self, keystr="", filterstr="", visible=None):
def _handle_auto_follow(
self,
keystr: str = "",
filterstr: str = "",
visible: typing.Mapping[str, HintLabel] = None
) -> None:
"""Handle the auto_follow option."""
assert self._context is not None
if visible is None:
visible = {string: label
for string, label in self._context.labels.items()
@ -788,7 +806,7 @@ class HintManager(QObject):
if auto_follow == "always":
follow = True
elif auto_follow == "unique-match":
follow = keystr or filterstr
follow = bool(keystr or filterstr)
elif auto_follow == "full-match":
elemstr = str(list(visible.values())[0].elem)
filter_match = self._filter_matches_exactly(filterstr, elemstr)
@ -810,7 +828,7 @@ class HintManager(QObject):
self._fire(*visible)
@pyqtSlot(str)
def handle_partial_key(self, keystr):
def handle_partial_key(self, keystr: str) -> None:
"""Handle a new partial keypress."""
if self._context is None:
log.hints.debug("Got key without context!")
@ -834,7 +852,7 @@ class HintManager(QObject):
pass
self._handle_auto_follow(keystr=keystr)
def filter_hints(self, filterstr):
def filter_hints(self, filterstr: typing.Optional[str]) -> None:
"""Filter displayed hints according to a text.
Args:
@ -844,6 +862,8 @@ class HintManager(QObject):
and `self._context.filterstr` are None, all hints are
shown.
"""
assert self._context is not None
if filterstr is None:
filterstr = self._context.filterstr
else:
@ -889,12 +909,13 @@ class HintManager(QObject):
self._handle_auto_follow(filterstr=filterstr,
visible=self._context.labels)
def _fire(self, keystr):
def _fire(self, keystr: str) -> None:
"""Fire a completed hint.
Args:
keystr: The keychain string to follow.
"""
assert self._context is not None
# Handlers which take a QWebElement
elem_handlers = {
Target.normal: self._actions.click,
@ -958,13 +979,14 @@ class HintManager(QObject):
@cmdutils.register(instance='hintmanager', scope='window',
modes=[usertypes.KeyMode.hint])
def follow_hint(self, select=False, keystring=None):
def follow_hint(self, select: bool = False, keystring: str = None) -> None:
"""Follow a hint.
Args:
select: Only select the given hint, don't necessarily follow it.
keystring: The hint to follow, or None.
"""
assert self._context is not None
if keystring is None:
if self._context.to_follow is None:
raise cmdutils.CommandError("No hint to follow")
@ -980,7 +1002,7 @@ class HintManager(QObject):
self._fire(keystring)
@pyqtSlot(usertypes.KeyMode)
def on_mode_left(self, mode):
def on_mode_left(self, mode: usertypes.KeyMode) -> None:
"""Stop hinting when hinting mode was left."""
if mode != usertypes.KeyMode.hint or self._context is None:
# We have one HintManager per tab, so when this gets called,
@ -999,12 +1021,12 @@ class WordHinter:
derived from the hinted element.
"""
def __init__(self):
def __init__(self) -> None:
# will be initialized on first use.
self.words = set()
self.words = set() # type: typing.Set[str]
self.dictionary = None
def ensure_initialized(self):
def ensure_initialized(self) -> None:
"""Generate the used words if yet uninitialized."""
dictionary = config.val.hints.dictionary
if not self.words or self.dictionary != dictionary:
@ -1031,8 +1053,11 @@ class WordHinter:
error = "Word hints requires reading the file at {}: {}"
raise HintingError(error.format(dictionary, str(e)))
def extract_tag_words(self, elem):
def extract_tag_words(
self, elem: webelem.AbstractWebElement
) -> typing.Iterator[str]:
"""Extract tag words form the given element."""
_extractor_type = typing.Callable[[webelem.AbstractWebElement], str]
attr_extractors = {
"alt": lambda elem: elem["alt"],
"name": lambda elem: elem["name"],
@ -1041,7 +1066,7 @@ class WordHinter:
"src": lambda elem: elem["src"].split('/')[-1],
"href": lambda elem: elem["href"].split('/')[-1],
"text": str,
}
} # type: typing.Mapping[str, _extractor_type]
extractable_attrs = collections.defaultdict(list, {
"img": ["alt", "title", "src"],
@ -1055,7 +1080,10 @@ class WordHinter:
for attr in extractable_attrs[elem.tag_name()]
if attr in elem or attr == "text")
def tag_words_to_hints(self, words):
def tag_words_to_hints(
self,
words: typing.Iterable[str]
) -> typing.Iterator[str]:
"""Take words and transform them to proper hints if possible."""
for candidate in words:
if not candidate:
@ -1066,13 +1094,20 @@ class WordHinter:
if 4 < match.end() - match.start() < 8:
yield candidate[match.start():match.end()].lower()
def any_prefix(self, hint, existing):
def any_prefix(self, hint: str, existing: typing.Iterable[str]) -> bool:
return any(hint.startswith(e) or e.startswith(hint) for e in existing)
def filter_prefixes(self, hints, existing):
def filter_prefixes(
self,
hints: typing.Iterable[str],
existing: typing.Iterable[str]
) -> typing.Iterator[str]:
"""Filter hints which don't start with the given prefix."""
return (h for h in hints if not self.any_prefix(h, existing))
def new_hint_for(self, elem, existing, fallback):
def new_hint_for(self, elem: webelem.AbstractWebElement,
existing: typing.Iterable[str],
fallback: typing.Iterable[str]) -> typing.Optional[str]:
"""Return a hint for elem, not conflicting with the existing."""
new = self.tag_words_to_hints(self.extract_tag_words(elem))
new_no_prefixes = self.filter_prefixes(new, existing)
@ -1081,7 +1116,7 @@ class WordHinter:
return (next(new_no_prefixes, None) or
next(fallback_no_prefixes, None))
def hint(self, elems):
def hint(self, elems: _ElemsType) -> _HintStringsType:
"""Produce hint labels based on the html tags.
Produce hint words based on the link text and random words
@ -1096,7 +1131,7 @@ class WordHinter:
"""
self.ensure_initialized()
hints = []
used_hints = set()
used_hints = set() # type: typing.Set[str]
words = iter(self.words)
for elem in elems:
hint = self.new_hint_for(elem, used_hints, words)