Merge pull request #241 from untitaker/click5

Factor out a few click-related things into extensions, new threading model
This commit is contained in:
Markus Unterwaditzer 2015-08-16 20:00:50 +02:00
commit c8014e5205
10 changed files with 159 additions and 343 deletions

View file

@ -32,7 +32,9 @@ setup(
}, },
install_requires=[ install_requires=[
# https://github.com/mitsuhiko/click/issues/200 # https://github.com/mitsuhiko/click/issues/200
'click>=3.1', 'click>=5.0',
'click-log',
'click-threading',
'requests', 'requests',
'lxml>=3.0', 'lxml>=3.0',
# https://github.com/sigmavirus24/requests-toolbelt/pull/28 # https://github.com/sigmavirus24/requests-toolbelt/pull/28

View file

@ -90,6 +90,7 @@ def test_empty_storage(tmpdir, runner):
tmpdir.join('path_a/haha.txt').write('UID:haha') tmpdir.join('path_a/haha.txt').write('UID:haha')
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
assert not result.exception
tmpdir.join('path_b/haha.txt').remove() tmpdir.join('path_b/haha.txt').remove()
result = runner.invoke(['sync']) result = runner.invoke(['sync'])
lines = result.output.splitlines() lines = result.output.splitlines()

View file

@ -2,15 +2,14 @@
''' '''
General-purpose fixtures for vdirsyncer's testsuite. General-purpose fixtures for vdirsyncer's testsuite.
''' '''
import pytest import click_log
import vdirsyncer.log import pytest
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_logging(): def setup_logging():
vdirsyncer.log.set_level(vdirsyncer.log.logging.DEBUG) click_log.basic_config('vdirsyncer')
vdirsyncer.log.add_handler(vdirsyncer.log.stdout_handler)
try: try:

View file

@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
from click.testing import CliRunner
from vdirsyncer.doubleclick import _ctx_stack, click, ctx as global_ctx
def test_simple():
@click.command()
@click.pass_context
def cli(ctx):
assert global_ctx
assert ctx.obj is global_ctx.obj
assert _ctx_stack.top is ctx
click.echo('hello')
assert not global_ctx
runner = CliRunner()
result = runner.invoke(cli, catch_exceptions=False)
assert not global_ctx
assert not result.exception
assert result.output == 'hello\n'

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging
import os import os
import platform import platform
import stat import stat
@ -7,11 +8,14 @@ import stat
import click import click
from click.testing import CliRunner from click.testing import CliRunner
import click_log
import pytest import pytest
import requests import requests
from vdirsyncer import doubleclick, log, utils from vdirsyncer import utils
from vdirsyncer.cli import pass_context
# These modules might be uninitialized and unavailable if not explicitly # These modules might be uninitialized and unavailable if not explicitly
# imported # imported
@ -44,11 +48,12 @@ def empty_password_storages(monkeypatch):
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def no_debug_output(request): def no_debug_output(request):
old = log._level logger = click_log.basic_config('vdirsyncer')
log.set_level(log.logging.WARNING) logger.setLevel(logging.WARNING)
old = logger.level
def teardown(): def teardown():
log.set_level(old) logger.setLevel(old)
request.addfinalizer(teardown) request.addfinalizer(teardown)
@ -111,10 +116,10 @@ def test_get_password_from_command(tmpdir):
st = os.stat(filepath) st = os.stat(filepath)
os.chmod(filepath, st.st_mode | stat.S_IEXEC) os.chmod(filepath, st.st_mode | stat.S_IEXEC)
@doubleclick.click.command() @click.command()
@doubleclick.click.pass_context @pass_context
def fake_app(ctx): def fake_app(ctx):
ctx.obj = {'config': ({'password_command': filepath}, {}, {})} ctx.config = {'password_command': filepath}, {}, {}
_password = utils.password.get_password(username, resource) _password = utils.password.get_password(username, resource)
assert _password == password assert _password == password
@ -158,10 +163,9 @@ def test_set_keyring_password(monkeypatch):
monkeypatch.setattr(utils.password, 'keyring', KeyringMock()) monkeypatch.setattr(utils.password, 'keyring', KeyringMock())
@doubleclick.click.command() @click.command()
@doubleclick.click.pass_context @pass_context
def fake_app(ctx): def fake_app(ctx):
ctx.obj = {}
x = utils.password.get_password('foouser', 'http://example.com/a/b') x = utils.password.get_password('foouser', 'http://example.com/a/b')
click.echo('password is ' + x) click.echo('password is ' + x)
@ -179,15 +183,14 @@ def test_get_password_from_cache(monkeypatch):
user = 'my_user' user = 'my_user'
resource = 'http://example.com' resource = 'http://example.com'
@doubleclick.click.command() @click.command()
@doubleclick.click.pass_context @pass_context
def fake_app(ctx): def fake_app(ctx):
ctx.obj = {}
x = utils.password.get_password(user, resource) x = utils.password.get_password(user, resource)
click.echo('Password is {}'.format(x)) click.echo('Password is {}'.format(x))
monkeypatch.setattr(doubleclick.click, 'prompt', blow_up) monkeypatch.setattr(click, 'prompt', blow_up)
assert (user, 'example.com') in ctx.obj['passwords'] assert (user, 'example.com') in ctx.passwords
x = utils.password.get_password(user, resource) x = utils.password.get_password(user, resource)
click.echo('Password is {}'.format(x)) click.echo('Password is {}'.format(x))

View file

@ -3,13 +3,26 @@
import functools import functools
import sys import sys
import click
import click_log
from .. import __version__, log from .. import __version__, log
from ..doubleclick import click, ctx
cli_logger = log.get(__name__) cli_logger = log.get(__name__)
class AppContext(object):
def __init__(self):
self.config = None
self.passwords = {}
self.logger = None
pass_context = click.make_pass_decorator(AppContext, ensure=True)
class CliError(RuntimeError): class CliError(RuntimeError):
def __init__(self, msg, problems=None): def __init__(self, msg, problems=None):
self.msg = msg self.msg = msg
@ -52,32 +65,25 @@ def validate_verbosity(ctx, param, value):
@click.group() @click.group()
@click.option('--verbosity', '-v', default='INFO', @click_log.init('vdirsyncer')
callback=validate_verbosity, @click_log.simple_verbosity_option()
help='Either CRITICAL, ERROR, WARNING, INFO or DEBUG')
@click.version_option(version=__version__) @click.version_option(version=__version__)
@pass_context
@catch_errors @catch_errors
def app(verbosity): def app(ctx):
''' '''
vdirsyncer -- synchronize calendars and contacts vdirsyncer -- synchronize calendars and contacts
''' '''
from .utils import load_config from .utils import load_config
log.add_handler(log.stdout_handler)
log.set_level(verbosity)
if ctx.obj is None: if not ctx.config:
ctx.obj = {} ctx.config = load_config()
ctx.obj['verbosity'] = verbosity
if 'config' not in ctx.obj:
ctx.obj['config'] = load_config()
main = app main = app
def max_workers_callback(ctx, param, value): def max_workers_callback(ctx, param, value):
if value == 0 and ctx.obj['verbosity'] == log.logging.DEBUG: if value == 0 and click_log.get_level() == log.logging.DEBUG:
value = 1 value = 1
cli_logger.debug('Using {} maximal workers.'.format(value)) cli_logger.debug('Using {} maximal workers.'.format(value))
@ -99,8 +105,9 @@ max_workers_option = click.option(
help=('Do/Don\'t abort synchronization when all items are about ' help=('Do/Don\'t abort synchronization when all items are about '
'to be deleted from both sides.')) 'to be deleted from both sides.'))
@max_workers_option @max_workers_option
@pass_context
@catch_errors @catch_errors
def sync(pairs, force_delete, max_workers): def sync(ctx, pairs, force_delete, max_workers):
''' '''
Synchronize the given pairs. If no arguments are given, all will be Synchronize the given pairs. If no arguments are given, all will be
synchronized. synchronized.
@ -119,27 +126,27 @@ def sync(pairs, force_delete, max_workers):
''' '''
from .tasks import prepare_pair, sync_collection from .tasks import prepare_pair, sync_collection
from .utils import parse_pairs_args, WorkerQueue from .utils import parse_pairs_args, WorkerQueue
general, all_pairs, all_storages = ctx.obj['config'] general, all_pairs, all_storages = ctx.config
wq = WorkerQueue(max_workers) wq = WorkerQueue(max_workers)
for pair_name, collections in parse_pairs_args(pairs, all_pairs): with wq.join():
wq.put(functools.partial(prepare_pair, pair_name=pair_name, for pair_name, collections in parse_pairs_args(pairs, all_pairs):
collections=collections, wq.put(functools.partial(prepare_pair, pair_name=pair_name,
general=general, all_pairs=all_pairs, collections=collections,
all_storages=all_storages, general=general, all_pairs=all_pairs,
force_delete=force_delete, all_storages=all_storages,
callback=sync_collection)) force_delete=force_delete,
wq.spawn_worker() callback=sync_collection))
wq.spawn_worker()
wq.join()
@app.command() @app.command()
@click.argument('pairs', nargs=-1) @click.argument('pairs', nargs=-1)
@max_workers_option @max_workers_option
@pass_context
@catch_errors @catch_errors
def metasync(pairs, max_workers): def metasync(ctx, pairs, max_workers):
''' '''
Synchronize metadata of the given pairs. Synchronize metadata of the given pairs.
@ -147,58 +154,57 @@ def metasync(pairs, max_workers):
''' '''
from .tasks import prepare_pair, metasync_collection from .tasks import prepare_pair, metasync_collection
from .utils import parse_pairs_args, WorkerQueue from .utils import parse_pairs_args, WorkerQueue
general, all_pairs, all_storages = ctx.obj['config'] general, all_pairs, all_storages = ctx.config
wq = WorkerQueue(max_workers) wq = WorkerQueue(max_workers)
for pair_name, collections in parse_pairs_args(pairs, all_pairs): with wq.join():
wq.put(functools.partial(prepare_pair, pair_name=pair_name, for pair_name, collections in parse_pairs_args(pairs, all_pairs):
collections=collections, wq.put(functools.partial(prepare_pair, pair_name=pair_name,
general=general, all_pairs=all_pairs, collections=collections,
all_storages=all_storages, general=general, all_pairs=all_pairs,
callback=metasync_collection)) all_storages=all_storages,
wq.spawn_worker() callback=metasync_collection))
wq.spawn_worker()
wq.join()
@app.command() @app.command()
@click.argument('pairs', nargs=-1) @click.argument('pairs', nargs=-1)
@max_workers_option @max_workers_option
@pass_context
@catch_errors @catch_errors
def discover(pairs, max_workers): def discover(ctx, pairs, max_workers):
''' '''
Refresh collection cache for the given pairs. Refresh collection cache for the given pairs.
''' '''
from .tasks import discover_collections from .tasks import discover_collections
from .utils import WorkerQueue from .utils import WorkerQueue
general, all_pairs, all_storages = ctx.obj['config'] general, all_pairs, all_storages = ctx.config
wq = WorkerQueue(max_workers) wq = WorkerQueue(max_workers)
for pair in (pairs or all_pairs): with wq.join():
try: for pair in (pairs or all_pairs):
name_a, name_b, pair_options = all_pairs[pair] try:
except KeyError: name_a, name_b, pair_options = all_pairs[pair]
raise CliError('Pair not found: {}\n' except KeyError:
'These are the pairs found: {}' raise CliError('Pair not found: {}\n'
.format(pair, list(all_pairs))) 'These are the pairs found: {}'
.format(pair, list(all_pairs)))
wq.put(functools.partial( wq.put(functools.partial(
discover_collections, discover_collections, status_path=general['status_path'],
status_path=general['status_path'], name_a=name_a, name_b=name_b, name_a=name_a, name_b=name_b, pair_name=pair,
pair_name=pair, config_a=all_storages[name_a], config_a=all_storages[name_a], config_b=all_storages[name_b],
config_b=all_storages[name_b], pair_options=pair_options, pair_options=pair_options, skip_cache=True
skip_cache=True ))
)) wq.spawn_worker()
wq.spawn_worker()
wq.join()
@app.command() @app.command()
@click.argument('collection') @click.argument('collection')
@pass_context
@catch_errors @catch_errors
def repair(collection): def repair(ctx, collection):
''' '''
Repair a given collection. Repair a given collection.
@ -211,7 +217,7 @@ def repair(collection):
collection of the `calendars_local` storage. collection of the `calendars_local` storage.
''' '''
from .tasks import repair_collection from .tasks import repair_collection
general, all_pairs, all_storages = ctx.obj['config'] general, all_pairs, all_storages = ctx.config
cli_logger.warning('This operation will take a very long time.') cli_logger.warning('This operation will take a very long time.')
cli_logger.warning('It\'s recommended to turn off other client\'s ' cli_logger.warning('It\'s recommended to turn off other client\'s '

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import contextlib
import errno import errno
import hashlib import hashlib
import importlib import importlib
@ -7,14 +8,16 @@ import json
import os import os
import string import string
import sys import sys
import threading
from itertools import chain from itertools import chain
from atomicwrites import atomic_write from atomicwrites import atomic_write
import click
import click_threading
from . import CliError, cli_logger from . import CliError, cli_logger
from .. import DOCS_HOME, PROJECT_HOME, exceptions from .. import DOCS_HOME, PROJECT_HOME, exceptions
from ..doubleclick import click
from ..sync import IdentConflict, StorageEmpty, SyncConflict from ..sync import IdentConflict, StorageEmpty, SyncConflict
from ..utils import expand_path, get_class_init_args from ..utils import expand_path, get_class_init_args
from ..utils.compat import text_type from ..utils.compat import text_type
@ -560,46 +563,52 @@ class WorkerQueue(object):
self._workers = [] self._workers = []
self._exceptions = [] self._exceptions = []
self._max_workers = max_workers self._max_workers = max_workers
self._shutdown_handlers = []
def shutdown(self):
if not self._queue.unfinished_tasks:
for handler in self._shutdown_handlers:
try:
handler()
except Exception:
pass
def _worker(self): def _worker(self):
# This is a daemon thread. Since the global namespace is going to
# vanish on interpreter shutdown, redefine everything from the global
# namespace here.
_TypeError = TypeError
_Empty = queue.Empty
_handle_cli_error = handle_cli_error
while True: while True:
try: try:
func = self._queue.get(False) func = self._queue.get(False)
except (_TypeError, _Empty): except queue.Empty:
# Any kind of error might be raised if vdirsyncer just finished
# processing all items and the interpreter is shutting down,
# yet the workers try to get new tasks.
# https://github.com/untitaker/vdirsyncer/issues/167
# http://bugs.python.org/issue14623
break break
try: try:
func(wq=self) func(wq=self)
except Exception as e: except Exception as e:
_handle_cli_error() handle_cli_error()
self._exceptions.append(e) self._exceptions.append(e)
finally: finally:
self._queue.task_done() self._queue.task_done()
if not self._queue.unfinished_tasks:
self.shutdown()
def spawn_worker(self): def spawn_worker(self):
if self._max_workers and len(self._workers) >= self._max_workers: if self._max_workers and len(self._workers) >= self._max_workers:
return return
t = threading.Thread(target=self._worker) t = click_threading.Thread(target=self._worker)
t.daemon = True t.daemon = True
t.start() t.start()
self._workers.append(t) self._workers.append(t)
@contextlib.contextmanager
def join(self): def join(self):
assert self._workers or self._queue.empty() assert self._workers or not self._queue.unfinished_tasks
self._queue.join() ui_worker = click_threading.UiWorker()
self._shutdown_handlers.append(ui_worker.shutdown)
with ui_worker.patch_click():
yield
ui_worker.run()
self._queue.join()
if self._exceptions: if self._exceptions:
sys.exit(1) sys.exit(1)

View file

@ -1,144 +0,0 @@
# -*- coding: utf-8 -*-
'''
Utilities for writing threaded applications with click:
- There is a global ``ctx`` object to be used.
- The ``click`` object's attributes are supposed to be used instead of the
click package's content.
- It wraps some UI functions such that they don't produce overlapping
output or prompt the user at the same time.
- It wraps BaseCommand subclasses such that their invocation changes the
ctx global, and also changes the shortcut decorators to use the new
classes.
'''
import functools
import threading
class _ClickProxy(object):
def __init__(self, wrappers, click=None):
if click is None:
import click
self._click = click
self._cache = {}
self._wrappers = dict(wrappers)
def __getattr__(self, name):
if name not in self._cache:
f = getattr(self._click, name)
f = self._wrappers.get(name, lambda x: x)(f)
self._cache[name] = f
return self._cache[name]
_ui_lock = threading.Lock()
def _ui_function(f):
@functools.wraps(f)
def inner(*a, **kw):
with _ui_lock:
rv = f(*a, **kw)
return rv
return inner
class _Stack(object):
def __init__(self):
self._stack = []
@property
def top(self):
return self._stack[-1]
def push(self, value):
self._stack.append(value)
def pop(self):
return self._stack.pop()
class _StackProxy(object):
def __init__(self, stack):
object.__setattr__(self, '_doubleclick_stack', stack)
def __bool__(self):
try:
self._doubleclick_stack.top
except IndexError:
return False
else:
return True
__nonzero__ = __bool__
__getattr__ = lambda s, n: getattr(s._doubleclick_stack.top, n)
__setattr__ = lambda s, n, v: setattr(s._doubleclick_stack.top, n, v)
_ctx_stack = _Stack()
ctx = _StackProxy(_ctx_stack)
def _ctx_pushing_class(cls):
class ContextPusher(cls):
def command(self, *args, **kwargs):
# Also wrap commands created with @group.command()
def decorator(f):
cmd = click.command(*args, **kwargs)(f)
self.add_command(cmd)
return cmd
return decorator
def invoke(self, ctx):
_ctx_stack.push(ctx)
try:
cls.invoke(self, ctx)
finally:
new_ctx = _ctx_stack.pop()
if new_ctx is not ctx:
raise RuntimeError(
'While doubleclick is supposed to make writing '
'threaded applications easier, it removes thread '
'safety from click. It is therefore not recommended '
'to launch more than one doubleclick application per '
'process.'
)
return ContextPusher
def _command_class_wrapper(cls_name):
def inner(f):
def wrapper(name=None, **attrs):
attrs.setdefault('cls', getattr(click, cls_name))
return f(name, **attrs)
return wrapper
return inner
WRAPPERS = {
'echo': _ui_function,
'echo_via_pager': _ui_function,
'prompt': _ui_function,
'confirm': _ui_function,
'clear': _ui_function,
'edit': _ui_function,
'launch': _ui_function,
'getchar': _ui_function,
'pause': _ui_function,
'BaseCommand': _ctx_pushing_class,
'Command': _ctx_pushing_class,
'MultiCommand': _ctx_pushing_class,
'Group': _ctx_pushing_class,
'CommandCollection': _ctx_pushing_class,
'command': _command_class_wrapper('Command'),
'group': _command_class_wrapper('Group')
}
click = _ClickProxy(WRAPPERS)

View file

@ -1,63 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
import sys
from .doubleclick import click get = logging.getLogger
class ColorFormatter(logging.Formatter):
colors = {
'error': dict(fg='red'),
'exception': dict(fg='red'),
'critical': dict(fg='red'),
'debug': dict(fg='blue'),
'warning': dict(fg='yellow')
}
def format(self, record):
if not record.exc_info:
level = record.levelname.lower()
if level in self.colors:
prefix = click.style('{}: '.format(level),
**self.colors[level])
record.msg = '\n'.join(prefix + x
for x in str(record.msg).splitlines())
return logging.Formatter.format(self, record)
class ClickStream(object):
def write(self, string):
click.echo(string, file=sys.stderr, nl=False)
stdout_handler = logging.StreamHandler(ClickStream())
stdout_handler.formatter = ColorFormatter()
_level = logging.INFO
_handlers = []
_loggers = {}
def get(name):
assert name.startswith('vdirsyncer.')
if name not in _loggers:
_loggers[name] = x = logging.getLogger(name)
x.handlers = _handlers
x.setLevel(_level)
return _loggers[name]
def add_handler(handler):
if handler not in _handlers:
_handlers.append(handler)
def set_level(level):
global _level
_level = level
for logger in _loggers.values():
logger.setLevel(_level)

View file

@ -2,10 +2,12 @@
import threading import threading
import click
from . import expand_path from . import expand_path
from .compat import urlparse from .compat import urlparse
from .. import exceptions, log from .. import exceptions, log
from ..doubleclick import click, ctx from ..cli import AppContext
logger = log.get(__name__) logger = log.get(__name__)
password_key_prefix = 'vdirsyncer:' password_key_prefix = 'vdirsyncer:'
@ -40,28 +42,40 @@ def get_password(username, resource, _lock=threading.Lock()):
""" """
if ctx: # If no app is running, Click will automatically create an empty cache for
password_cache = ctx.obj.setdefault('passwords', {}) # us and discard it.
else: try:
password_cache = {} # discard passwords ctx = click.get_current_context().find_object(AppContext)
if ctx is None:
raise RuntimeError()
password_cache = ctx.passwords
except RuntimeError:
password_cache = {}
def _password_from_cache(username, host): def _password_from_cache(username, host):
'''internal cache''' '''internal cache'''
return password_cache.get((username, host), None) rv = password_cache.get((username, host), None)
if isinstance(rv, BaseException):
raise rv
return rv
with _lock: with _lock:
host = urlparse.urlsplit(resource).hostname try:
for func in (_password_from_cache, _password_from_command, host = urlparse.urlsplit(resource).hostname
_password_from_netrc, _password_from_keyring, for func in (_password_from_cache, _password_from_command,
_password_from_prompt): _password_from_netrc, _password_from_keyring,
password = func(username, host) _password_from_prompt):
if password is not None: password = func(username, host)
logger.debug('Got password for {} from {}' if password is not None:
.format(username, func.__doc__)) logger.debug('Got password for {} from {}'
break .format(username, func.__doc__))
break
password_cache[(username, host)] = password except (click.Abort, KeyboardInterrupt) as e:
return password password_cache[(username, host)] = e
raise
else:
password_cache[(username, host)] = password
return password
def _password_from_prompt(username, host): def _password_from_prompt(username, host):
@ -101,11 +115,17 @@ def _password_from_command(username, host):
'''command''' '''command'''
import subprocess import subprocess
if not ctx: try:
ctx = click.get_current_context()
except RuntimeError:
return None
ctx = ctx.find_object(AppContext)
if ctx is None or not ctx.config:
return None return None
try: try:
general, _, _ = ctx.obj['config'] general, _, _ = ctx.config
command = general['password_command'].split() command = general['password_command'].split()
except KeyError: except KeyError:
return None return None