├── setup.cfg ├── requirements.txt ├── MAINTAINERS ├── README.rst ├── clickclick ├── __init__.py └── console.py ├── MANIFEST.in ├── .gitignore ├── .travis.yml ├── tox.ini ├── LICENSE ├── contributing.rst ├── release.sh ├── example.sh ├── setup.py ├── example.py └── tests └── test_console.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click>=4.0 2 | PyYAML>=3.11 3 | -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | Henning Jacobs 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Moved to https://codeberg.org/hjacobs/python-clickclick 2 | -------------------------------------------------------------------------------- /clickclick/__init__.py: -------------------------------------------------------------------------------- 1 | from clickclick.console import * # noqa 2 | 3 | __version__ = '1.2.2' 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.rst 3 | include LICENSE 4 | recursive-include clickclick *.py 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg* 3 | build/ 4 | dist/ 5 | junit.xml 6 | coverage.xml 7 | .coverage 8 | htmlcov 9 | .cache 10 | .tox/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "pypy" 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | install: 8 | - pip install tox tox-travis coveralls 9 | script: 10 | - tox 11 | after_success: 12 | - coveralls 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | 4 | [tox] 5 | envlist=pypy,py27,py34,py35,isort-check,flake8 6 | 7 | [tox:travis] 8 | pypy=pypy 9 | 2.7=py27 10 | 3.4=py34 11 | 3.5=py35,isort-check,flake8 12 | 13 | [testenv] 14 | commands=python setup.py test 15 | 16 | [testenv:flake8] 17 | deps=flake8 18 | commands=python setup.py flake8 19 | 20 | [testenv:isort-check] 21 | basepython=python3 22 | deps=isort 23 | commands=isort -ns __init__.py -rc -c -df {toxinidir}/clickclick {toxinidir}/tests 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Zalando SE 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing Guidelines 2 | ========================= 3 | 4 | Wanna contribute to this project? Yay, here is how! 5 | 6 | Filing Issues 7 | ================ 8 | 9 | If you have a question about python-clickclick, or have problems using it, please read the README before filing an issue. Also, double-check with the current issues on our `Issues Tracker`_. 10 | 11 | Contributing a Pull Request 12 | ============================== 13 | 14 | 1. Submit a comment to the relevant issue or create a new issue describing your proposed change. 15 | 2. Do a fork, develop and test your code changes. 16 | 3. Include documentation. 17 | 4. Submit a pull request. 18 | 19 | You'll get feedback about your pull request as soon as possible. 20 | 21 | Happy hacking ;-) 22 | 23 | .. _Issues Tracker: https://github.com/zalando/python-clickclick/issues 24 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ $# -ne 1 ]; then 4 | >&2 echo "usage: $0 " 5 | exit 1 6 | fi 7 | 8 | set -xe 9 | 10 | python3 --version 11 | git --version 12 | 13 | version=$1 14 | 15 | if [[ "$OSTYPE" == "darwin"* ]]; then 16 | sed -i "" "s/__version__ = .*/__version__ = '${version}'/" */__init__.py 17 | else 18 | sed -i "s/__version__ = .*/__version__ = '${version}'/" */__init__.py 19 | fi 20 | 21 | # Do not tag/push on Go CD 22 | if [ -z "$GO_PIPELINE_LABEL" ]; then 23 | python3 setup.py clean 24 | python3 setup.py test 25 | python3 setup.py flake8 26 | 27 | git add */__init__.py 28 | 29 | git commit -m "Bumped version to $version" 30 | git push 31 | fi 32 | 33 | python3 setup.py sdist bdist_wheel upload 34 | 35 | if [ -z "$GO_PIPELINE_LABEL" ]; then 36 | git tag ${version} 37 | git push --tags 38 | fi 39 | -------------------------------------------------------------------------------- /example.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | 4 | # Print Usage 5 | ./example.py 6 | 7 | # Print Message 'too many matches' 8 | ./example.py l 9 | 10 | # Print Localtime 11 | ./example.py lo 12 | 13 | # Print listing with multiple outputformats 14 | ./example.py li 15 | ./example.py li -o tsv 16 | ./example.py li -o json 17 | ./example.py li -o yaml 18 | 19 | # Print Action messages 20 | ./example.py work- 21 | 22 | # print Choice prompt 23 | echo 2 | ./example.py work_ 15.4 24 | echo 3 | ./example.py work_ 15.4 25 | echo | ./example.py work_ 15.4 26 | 27 | # redirect output 28 | echo 2 | ./example.py work_ 15.4 >/dev/null 29 | echo 3 | ./example.py work_ 15.4 >/dev/null 30 | 31 | # Print Action messages with multiple output format 32 | ./example.py output 33 | ./example.py output -o tsv 34 | ./example.py output -o json 35 | ./example.py output -o yaml 36 | 37 | exit 0 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import os 6 | import inspect 7 | 8 | import setuptools 9 | from setuptools.command.test import test as TestCommand 10 | from setuptools import setup 11 | 12 | __location__ = os.path.join(os.getcwd(), os.path.dirname(inspect.getfile(inspect.currentframe()))) 13 | 14 | 15 | def read_version(package): 16 | with open(os.path.join(package, '__init__.py'), 'r') as fd: 17 | for line in fd: 18 | # do not use "exec" here and do manual parsing to not require deps 19 | if line.startswith('__version__ = '): 20 | return line.split()[-1].strip().strip('\'') 21 | 22 | 23 | NAME = 'clickclick' 24 | MAIN_PACKAGE = 'clickclick' 25 | VERSION = read_version(MAIN_PACKAGE) 26 | DESCRIPTION = 'Click utility functions' 27 | LICENSE = 'Apache License 2.0' 28 | URL = 'https://github.com/zalando/python-clickclick' 29 | AUTHOR = 'Henning Jacobs' 30 | EMAIL = 'henning.jacobs@zalando.de' 31 | 32 | COVERAGE_XML = True 33 | COVERAGE_HTML = False 34 | JUNIT_XML = True 35 | 36 | # Add here all kinds of additional classifiers as defined under 37 | # https://pypi.python.org/pypi?%3Aaction=list_classifiers 38 | CLASSIFIERS = [ 39 | 'Development Status :: 5 - Production/Stable', 40 | 'Environment :: Console', 41 | 'Intended Audience :: Developers', 42 | 'Intended Audience :: System Administrators', 43 | 'License :: OSI Approved :: Apache Software License', 44 | 'Operating System :: POSIX :: Linux', 45 | 'Programming Language :: Python', 46 | 'Programming Language :: Python :: 3.4', 47 | 'Programming Language :: Python :: 3.5', 48 | 'Programming Language :: Python :: Implementation :: CPython', 49 | ] 50 | 51 | CONSOLE_SCRIPTS = [] 52 | 53 | 54 | class PyTest(TestCommand): 55 | 56 | user_options = [('cov=', None, 'Run coverage'), ('cov-xml=', None, 'Generate junit xml report'), 57 | ('cov-html=', None, 'Generate junit html report'), 58 | ('junitxml=', None, 'Generate xml of test results')] 59 | 60 | def initialize_options(self): 61 | TestCommand.initialize_options(self) 62 | self.cov = None 63 | self.cov_xml = False 64 | self.cov_html = False 65 | self.junitxml = None 66 | 67 | def finalize_options(self): 68 | TestCommand.finalize_options(self) 69 | if self.cov is not None: 70 | self.cov = ['--cov', self.cov, '--cov-report', 'term-missing'] 71 | if self.cov_xml: 72 | self.cov.extend(['--cov-report', 'xml']) 73 | if self.cov_html: 74 | self.cov.extend(['--cov-report', 'html']) 75 | if self.junitxml is not None: 76 | self.junitxml = ['--junitxml', self.junitxml] 77 | 78 | def run_tests(self): 79 | try: 80 | import pytest 81 | except: 82 | raise RuntimeError('py.test is not installed, run: pip install pytest') 83 | params = {'args': self.test_args} 84 | if self.cov: 85 | params['args'] += self.cov 86 | if self.junitxml: 87 | params['args'] += self.junitxml 88 | params['args'] += ['--doctest-modules', MAIN_PACKAGE, '-s'] 89 | errno = pytest.main(**params) 90 | sys.exit(errno) 91 | 92 | 93 | def get_install_requirements(path): 94 | content = open(os.path.join(__location__, path)).read() 95 | return [req for req in content.split('\n') if req != ''] 96 | 97 | 98 | def read(fname): 99 | with open(os.path.join(__location__, fname)) as readme: 100 | return readme.read() 101 | 102 | 103 | def check_deps(deps): 104 | '''check dependency licenses''' 105 | from pkg_resources import Requirement 106 | import requests 107 | for dep in deps: 108 | dep = Requirement.parse(dep) 109 | url = 'https://pypi.python.org/pypi/{}/json'.format(dep.project_name) 110 | r = requests.get(url) 111 | data = r.json() 112 | print(data['info'].get('name'), data['info'].get('license')) 113 | 114 | 115 | def setup_package(): 116 | # Assemble additional setup commands 117 | cmdclass = {} 118 | cmdclass['test'] = PyTest 119 | 120 | install_reqs = get_install_requirements('requirements.txt') 121 | 122 | # check_deps(install_reqs) 123 | 124 | command_options = {'test': {'test_suite': ('setup.py', 'tests'), 'cov': ('setup.py', MAIN_PACKAGE)}} 125 | if JUNIT_XML: 126 | command_options['test']['junitxml'] = 'setup.py', 'junit.xml' 127 | if COVERAGE_XML: 128 | command_options['test']['cov_xml'] = 'setup.py', True 129 | if COVERAGE_HTML: 130 | command_options['test']['cov_html'] = 'setup.py', True 131 | 132 | setup( 133 | name=NAME, 134 | version=VERSION, 135 | url=URL, 136 | description=DESCRIPTION, 137 | author=AUTHOR, 138 | author_email=EMAIL, 139 | license=LICENSE, 140 | keywords='click console terminal cli', 141 | long_description=read('README.rst'), 142 | classifiers=CLASSIFIERS, 143 | test_suite='tests', 144 | packages=setuptools.find_packages(exclude=['tests', 'tests.*']), 145 | package_data={MAIN_PACKAGE: ["*.json"]}, 146 | install_requires=install_reqs, 147 | setup_requires=['six', 'flake8'], 148 | cmdclass=cmdclass, 149 | tests_require=['pytest-cov', 'pytest'], 150 | command_options=command_options, 151 | entry_points={'console_scripts': CONSOLE_SCRIPTS}, 152 | ) 153 | 154 | 155 | if __name__ == '__main__': 156 | setup_package() 157 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import click 4 | from clickclick import AliasedGroup, Action, choice, FloatRange, OutputFormat, __version__, get_now 5 | from clickclick import ok, warning, error, fatal_error, action, info 6 | from clickclick.console import print_table 7 | import time 8 | 9 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 10 | 11 | STYLES = { 12 | 'FINE': {'fg': 'green'}, 13 | 'ERROR': {'fg': 'red'}, 14 | 'WARNING': {'fg': 'yellow', 'bold': True}, 15 | } 16 | 17 | 18 | TITLES = { 19 | 'state': 'Status', 20 | 'creation_time': 'Creation Date', 21 | 'id': 'Identifier', 22 | 'desc': 'Description', 23 | 'name': 'Name', 24 | } 25 | 26 | MAX_COLUMN_WIDTHS = { 27 | 'desc': 50, 28 | 'name': 20, 29 | } 30 | 31 | output_option = click.option('-o', '--output', type=click.Choice(['text', 'json', 'tsv', 'yaml']), default='text', 32 | help='Use alternative output format') 33 | json_output_option = click.option('-o', '--output', type=click.Choice(['json', 'yaml']), default='json', 34 | help='Use alternative output format') 35 | watch_option = click.option('-w', '--watch', type=click.IntRange(1, 300), metavar='SECS', 36 | help='Auto update the screen every X seconds') 37 | 38 | 39 | def watching(watch: int): 40 | if watch: 41 | click.clear() 42 | yield 0 43 | if watch: 44 | while True: 45 | time.sleep(watch) 46 | click.clear() 47 | yield 0 48 | 49 | 50 | def print_version(ctx, param, value): 51 | if not value or ctx.resilient_parsing: 52 | return 53 | click.echo('ClickClick Example {}'.format(__version__)) 54 | ctx.exit() 55 | 56 | 57 | @click.group(cls=AliasedGroup, context_settings=CONTEXT_SETTINGS) 58 | @click.option('-V', '--version', is_flag=True, callback=print_version, expose_value=False, is_eager=True, 59 | help='Print the current version number and exit.') 60 | def cli(): 61 | pass 62 | 63 | 64 | @cli.command('list') 65 | @output_option 66 | @watch_option 67 | def list_dummy_states(output, watch): 68 | '''Example for Listings''' 69 | states = ['ERROR', 'FINE', 'WARNING'] 70 | i = 0 71 | for _ in watching(watch): 72 | i += 1 73 | rows = [] 74 | for y in (1, 2, 3): 75 | id = i * y - i 76 | rows.append({'id': id, 77 | 'name': 'Column #{}'.format(id), 78 | 'state': states[id % len(states)], 79 | 'creation_time': 1444911300, 80 | 'desc': 'this is a ve' + 'r' * 50 + 'y long description', 81 | 'without_title': 'column without title', 82 | 'missing_column': 'Column are not in output'}) 83 | 84 | with OutputFormat(output): 85 | print_table('id name state creation_time desc without_title'.split(), rows, 86 | styles=STYLES, titles=TITLES, max_column_widths=MAX_COLUMN_WIDTHS) 87 | 88 | 89 | @cli.command() 90 | @output_option 91 | def output(output): 92 | '''Example for all possible Echo Formats 93 | 94 | You see the message only, if the Output TEXT 95 | ''' 96 | with OutputFormat(output): 97 | action('This is a ok:') 98 | ok() 99 | action('This is a ok with message:') 100 | ok('all is fine') 101 | action('This is a warning:') 102 | warning('please check this') 103 | with Action('Start with working..') as act: 104 | # save_the_world() 105 | act.progress() 106 | act.progress() 107 | act.progress() 108 | act.progress() 109 | print_table('id name'.split(), [{'id': 1, 'name': 'Test #1'}, {'id': 2, 'name': 'Test #2'}]) 110 | info('Only FYI') 111 | action('This is a error:') 112 | error('this is wrong, please fix') 113 | action('This is a fatal error:') 114 | fatal_error('this is a fuckup') 115 | info('I\'am not printed, the process a dead') 116 | 117 | 118 | @cli.command() 119 | def localtime(): 120 | '''Print the localtime''' 121 | print('Localtime: {}'.format(get_now())) 122 | 123 | 124 | @cli.command('work-in-progress') 125 | def work_in_progress(): 126 | '''Work untile working is done''' 127 | 128 | with Action('do anything..'): 129 | pass 130 | 131 | try: 132 | with Action('create an excption..'): 133 | raise 134 | except: 135 | pass 136 | 137 | with Action('Start with working..') as act: 138 | # save_the_world() 139 | act.progress() 140 | act.progress() 141 | act.progress() 142 | act.progress() 143 | 144 | with Action('Calc 1 + 1..') as act: 145 | # save_the_world() 146 | act.ok(1+1) 147 | 148 | with Action('Oh, I make an error..') as act: 149 | # clear_the_oceans() 150 | act.error('work not complete done') 151 | 152 | with Action('Oh, I make a warning..') as act: 153 | # clear_the_air() 154 | act.warning('work is complicated') 155 | 156 | try: 157 | with Action('Start an exception..') as act: 158 | function_not_found() # noqa 159 | act.progress() 160 | except: 161 | pass 162 | 163 | with Action('Make a final error..') as act: 164 | act.fatal_error('this is the end..') 165 | 166 | with Action('This should not run..'): 167 | pass 168 | 169 | 170 | @cli.command() 171 | @click.argument('percentage', type=FloatRange(0, 100, clamp=True), required=True) 172 | def work_done(percentage): 173 | '''Work done in ?? %''' 174 | state = choice('Please select the state of your work', ['Done', 'In Progress', 'unknown', 'lost'], default='lost') 175 | 176 | print('Your work is {}% {}'.format(percentage, state)) 177 | 178 | 179 | if __name__ == "__main__": 180 | cli() 181 | -------------------------------------------------------------------------------- /tests/test_console.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | import time 5 | 6 | import click 7 | from click.testing import CliRunner 8 | from clickclick import (Action, AliasedGroup, FloatRange, OutputFormat, 9 | UrlType, action, choice, error, fatal_error, 10 | format_time, info, ok, print_table, warning) 11 | 12 | import pytest 13 | 14 | 15 | def test_echo(): 16 | action('Action..') 17 | ok() 18 | 19 | action('Action..') 20 | error(' some error') 21 | 22 | action('Action..') 23 | with pytest.raises(SystemExit): 24 | fatal_error(' some fatal error') # noqa 25 | 26 | action('Action..') 27 | warning(' some warning') 28 | 29 | info('Some info') 30 | 31 | 32 | def test_action(): 33 | try: 34 | with Action('Try and fail..'): 35 | raise Exception() 36 | except: 37 | pass 38 | 39 | with Action('Perform and progress..') as act: 40 | act.progress() 41 | act.error('failing..') 42 | 43 | with Action('Perform and progress..') as act: 44 | act.progress() 45 | act.warning('warning..') 46 | 47 | with Action('Perform and progress..') as act: 48 | act.progress() 49 | act.ok('all fine') 50 | 51 | with Action('Perform and progress..') as act: 52 | act.progress() 53 | 54 | with Action('Perform, progress and done', ok_msg='DONE') as act: 55 | act.progress() 56 | 57 | with Action('Perform action new line', nl=True): 58 | print('In new line!') 59 | 60 | with pytest.raises(SystemExit): 61 | with Action('Try and fail badly..') as act: 62 | act.fatal_error('failing..') 63 | 64 | 65 | def test_print_tables(): 66 | print_table('Name Status some_time'.split(), [{'Name': 'foobar', 'Status': True, 'some_time': 'now'}, 67 | {'some_time': time.time() - 123}, 68 | {'some_time': time.time() - 950}, 69 | {'Status': 'long output', 'some_time': 0}]) 70 | print_table('Name Status some_time'.split(), [{'Name': 'foobar', 'Status': True, 'some_time': 'now'}, 71 | {'some_time': time.time() - 123}, 72 | {'some_time': time.time() - 950}, 73 | {'Status': 'long output', 'some_time': 0}], 74 | styles='wrong format', 75 | max_column_widths={'Status': 4}) 76 | print_table('Name Status some_time'.split(), [{'Name': {'orignal': 'bla', 'other': 'foo'}, 'Status': 'ERROR'}], 77 | styles={'ERROR': {'fg': 'red', 'bold': True}}) 78 | 79 | 80 | def test_text_out(capsys): 81 | with OutputFormat('text'): 82 | warning('this is a warning') 83 | print_table('a b'.split(), [{}, {}]) 84 | out, err = capsys.readouterr() 85 | assert u'A│B\n \n \n' == out 86 | assert 'this is a warning\n' == err 87 | 88 | 89 | def test_json_out(capsys): 90 | with OutputFormat('json'): 91 | warning('this is a warning') 92 | print_table('a b'.split(), [{}, {}]) 93 | out, err = capsys.readouterr() 94 | assert '[{"a": null, "b": null}, {"a": null, "b": null}]\n' == out 95 | assert 'this is a warning\n' == err 96 | 97 | 98 | def test_yaml_out(capsys): 99 | with OutputFormat('yaml'): 100 | warning('this is a warning') 101 | print_table('a b'.split(), [{}, {}]) 102 | out, err = capsys.readouterr() 103 | assert 'a: null\nb: null\n---\na: null\nb: null\n\n' == out 104 | assert 'this is a warning\n' == err 105 | 106 | 107 | def test_tsv_out(capsys): 108 | with OutputFormat('tsv'): 109 | warning('this is a warning') 110 | print_table('a b'.split(), [{"a": 1}, {"b": 2}]) 111 | out, err = capsys.readouterr() 112 | assert 'a\tb\n1\t\n\t2\n' == out 113 | assert 'this is a warning\n' == err 114 | 115 | 116 | def test_float_range(): 117 | fr = FloatRange(1, 7.25, clamp=True) 118 | assert str(fr) == 'FloatRange(1, 7.25)' 119 | assert 7.25 == fr.convert('100', None, None) 120 | fr = FloatRange(1, 7.25, clamp=False) 121 | try: 122 | assert 7.25 == fr.convert('100', None, None) 123 | except click.exceptions.BadParameter as e: 124 | assert e.format_message() == 'Invalid value: 100.0 is not in the valid range of 1 to 7.25.' 125 | 126 | fr = FloatRange(min=10, clamp=True) 127 | assert 10 == fr.convert('7.25', None, None) 128 | 129 | fr = FloatRange(min=10, clamp=False) 130 | try: 131 | assert 10 == fr.convert('7.25', None, None) 132 | except click.exceptions.BadParameter as e: 133 | assert e.format_message() == 'Invalid value: 7.25 is smaller than the minimum valid value 10.' 134 | 135 | fr = FloatRange(max=5, clamp=True) 136 | assert 5 == fr.convert('100', None, None) 137 | 138 | fr = FloatRange(max=5, clamp=False) 139 | try: 140 | assert 5 == fr.convert('100', None, None) 141 | except click.exceptions.BadParameter as e: 142 | assert e.format_message() == 'Invalid value: 100.0 is bigger than the maximum valid value 5.' 143 | 144 | fr = FloatRange(0, 5) 145 | assert 3 == fr.convert('3', None, None) 146 | 147 | 148 | def test_url_type(): 149 | ut = UrlType() 150 | assert str(ut) == "UrlType('https', ('http', 'https'))" 151 | assert 'https://foobar' == ut.convert(' foobar ', None, None) 152 | 153 | try: 154 | ut.convert(' ', None, None) 155 | except click.exceptions.BadParameter as e: 156 | assert e.format_message() == 'Invalid value: "" is not a valid URL' 157 | 158 | try: 159 | ut.convert('ftp://test', None, None) 160 | except click.exceptions.BadParameter as e: 161 | assert e.format_message() == 'Invalid value: "ftp" is not one of the allowed URL schemes (http, https)' 162 | 163 | 164 | def test_choice(monkeypatch): 165 | def get_number(): 166 | yield 50 167 | while True: 168 | yield 1 169 | generator = get_number() 170 | 171 | def returnnumber(*args, **vargs): 172 | return next(generator) 173 | 174 | monkeypatch.setattr('click.prompt', returnnumber) 175 | assert 'a' == choice('Please choose', ['a', 'b']) 176 | assert 'a' == choice('Please choose', [('a', 'Label A')]) 177 | 178 | 179 | def test_format_time(monkeypatch): 180 | now = datetime.datetime.now() 181 | one_minute = datetime.timedelta(minutes=1) 182 | two_hours = datetime.timedelta(hours=2) 183 | two_days = datetime.timedelta(days=2) 184 | three_days = datetime.timedelta(days=3) 185 | monkeypatch.setattr('clickclick.get_now', lambda: now) 186 | assert 's ago' in format_time(time.mktime((now - one_minute).timetuple())) 187 | assert '2h ago' == format_time(time.mktime((now - two_hours).timetuple())) 188 | assert '48h ago' == format_time(time.mktime((now - two_days).timetuple())) 189 | assert '3d ago' == format_time(time.mktime((now - three_days).timetuple())) 190 | 191 | 192 | def test_cli(monkeypatch): 193 | runner = CliRunner() 194 | result = runner.invoke(cli, ['l']) 195 | assert 'Error: Too many matches: last, list' in result.output 196 | 197 | runner = CliRunner() 198 | result = runner.invoke(cli, ['li']) 199 | assert 'list\n' == result.output 200 | 201 | runner = CliRunner() 202 | result = runner.invoke(cli, ['last']) 203 | assert 'last\n' == result.output 204 | 205 | runner = CliRunner() 206 | result = runner.invoke(cli, ['notexists']) 207 | assert 'Error: No such command "notexists"' in result.output 208 | 209 | 210 | def test_choice_default(monkeypatch): 211 | runner = CliRunner() 212 | result = runner.invoke(cli, ['testchoice'], input='\n\n\n1\n') 213 | assert '3) c\nPlease select (1-3) [3]: \n>>c<<\n' in result.output 214 | assert '3) Label C\nPlease select (1-3) [2]: \n>>b<<\n' in result.output 215 | assert '3) Label C\nPlease select (1-3): \nPlease select (1-3): 1\n>>a<<\n' in result.output 216 | 217 | 218 | @click.group(cls=AliasedGroup) 219 | def cli(): 220 | pass 221 | 222 | 223 | @cli.command('list') 224 | def list(): 225 | print('list') 226 | 227 | 228 | @cli.command('last') 229 | def last(): 230 | print('last') 231 | 232 | 233 | @cli.command('testchoice') 234 | def choicetest(): 235 | print('>>{}<<'.format(choice('Please choose', ['a', 'b', 'c'], default='c'))) 236 | print('>>{}<<'.format(choice('Please choose', [('a', 'Label A'), ('b', 'Label B'), ('c', 'Label C')], default='b'))) 237 | print('>>{}<<'.format(choice('Please choose', [('a', 'Label A'), ('b', 'Label B'), ('c', 'Label C')], default='x'))) 238 | -------------------------------------------------------------------------------- /clickclick/console.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | import json 5 | import numbers 6 | import sys 7 | import time 8 | 9 | import click 10 | import yaml 11 | 12 | try: 13 | from urllib.parse import urlsplit, urlunsplit 14 | except ImportError: # NOQA 15 | from urlparse import urlsplit, urlunsplit 16 | 17 | 18 | # global state is evil! 19 | # anyway, we are using this as a convenient hack to switch output formats 20 | GLOBAL_STATE = {'output_format': 'text'} 21 | 22 | 23 | def is_json_output(): 24 | return GLOBAL_STATE.get('output_format') == 'json' 25 | 26 | 27 | def is_yaml_output(): 28 | return GLOBAL_STATE.get('output_format') == 'yaml' 29 | 30 | 31 | def is_tsv_output(): 32 | return GLOBAL_STATE.get('output_format') == 'tsv' 33 | 34 | 35 | def is_text_output(): 36 | return GLOBAL_STATE.get('output_format') == 'text' 37 | 38 | 39 | def secho(*args, **kwargs): 40 | args = list(args) 41 | if len(args) > 0: 42 | args[0] = '{}'.format(args[0]) 43 | if 'err' not in kwargs: 44 | if not sys.stdout.isatty(): 45 | kwargs['err'] = True 46 | if not is_text_output(): 47 | kwargs['err'] = True 48 | 49 | click.secho(*args, **kwargs) 50 | 51 | 52 | def action(msg, **kwargs): 53 | secho(msg.format(**kwargs), nl=False, bold=True) 54 | 55 | 56 | def ok(msg=' OK', **kwargs): 57 | secho(msg, fg='green', bold=True, **kwargs) 58 | 59 | 60 | def error(msg, **kwargs): 61 | secho(msg, fg='red', bold=True, **kwargs) 62 | 63 | 64 | def fatal_error(msg, **kwargs): 65 | error(msg, **kwargs) 66 | sys.exit(1) 67 | 68 | 69 | def warning(msg, **kwargs): 70 | secho(msg, fg='yellow', bold=True, **kwargs) 71 | 72 | 73 | def info(msg): 74 | secho(msg, fg='blue', bold=True) 75 | 76 | 77 | class OutputFormat: 78 | 79 | def __init__(self, fmt): 80 | self.fmt = fmt 81 | self._old_fmt = None 82 | 83 | def __enter__(self): 84 | self._old_fmt = GLOBAL_STATE.get('output_format') 85 | GLOBAL_STATE['output_format'] = self.fmt 86 | 87 | def __exit__(self, exc_type, exc_val, exc_tb): 88 | GLOBAL_STATE['output_format'] = self._old_fmt 89 | 90 | 91 | class Action: 92 | 93 | def __init__(self, msg, ok_msg=' OK', nl=False, **kwargs): 94 | self.msg = msg 95 | self.ok_msg = ok_msg 96 | self.msg_args = kwargs 97 | self.nl = nl 98 | self.errors = [] 99 | self._suppress_exception = False 100 | 101 | def __enter__(self): 102 | action(self.msg, **self.msg_args) 103 | if self.nl: 104 | secho('') 105 | return self 106 | 107 | def __exit__(self, exc_type, exc_val, exc_tb): 108 | if exc_type is None: 109 | if not self.errors: 110 | ok(self.ok_msg) 111 | elif not self._suppress_exception: 112 | error(' EXCEPTION OCCURRED: {}'.format(exc_val)) 113 | 114 | def fatal_error(self, msg, **kwargs): 115 | self._suppress_exception = True # Avoid printing "EXCEPTION OCCURRED: -1" on exit 116 | fatal_error(' {}'.format(msg), **kwargs) 117 | 118 | def error(self, msg, **kwargs): 119 | error(' {}'.format(msg), **kwargs) 120 | self.errors.append(msg) 121 | 122 | def progress(self): 123 | secho(' .', nl=False) 124 | 125 | def warning(self, msg, **kwargs): 126 | warning(' {}'.format(msg), **kwargs) 127 | self.errors.append(msg) 128 | 129 | def ok(self, msg): 130 | self.ok_msg = ' {}'.format(msg) 131 | 132 | 133 | def get_now(): 134 | return datetime.datetime.now() 135 | 136 | 137 | def format_time(ts): 138 | if ts == 0: 139 | return '' 140 | now = get_now() 141 | try: 142 | dt = datetime.datetime.fromtimestamp(ts) 143 | except: 144 | return ts 145 | diff = now - dt 146 | s = diff.total_seconds() 147 | if s > (3600 * 49): 148 | t = '{:.0f}d'.format(s / (3600 * 24)) 149 | elif s > 3600: 150 | t = '{:.0f}h'.format(s / 3600) 151 | elif s > 70: 152 | t = '{:.0f}m'.format(s / 60) 153 | else: 154 | t = '{:.0f}s'.format(s) 155 | return '{} ago'.format(t) 156 | 157 | 158 | def format(col, val): 159 | if val is None: 160 | val = '' 161 | elif col.endswith('_time'): 162 | val = format_time(val) 163 | elif isinstance(val, bool): 164 | val = 'yes' if val else 'no' 165 | else: 166 | val = str(val) 167 | return val 168 | 169 | 170 | def print_tsv_table(cols, rows): 171 | sys.stdout.write('\t'.join(cols)) 172 | sys.stdout.write('\n') 173 | for row in rows: 174 | first_col = True 175 | for col in cols: 176 | if not first_col: 177 | sys.stdout.write('\t') 178 | val = row.get(col) 179 | sys.stdout.write(format(col, val)) 180 | first_col = False 181 | sys.stdout.write('\n') 182 | 183 | 184 | def print_table(cols, rows, styles=None, titles=None, max_column_widths=None): 185 | if is_json_output() or is_yaml_output(): 186 | new_rows = [] 187 | for row in rows: 188 | new_row = {} 189 | for col in cols: 190 | new_row[col] = row.get(col) 191 | new_rows.append(new_row) 192 | if is_json_output(): 193 | print(json.dumps(new_rows, sort_keys=True)) 194 | else: 195 | print(yaml.safe_dump_all(new_rows, default_flow_style=False)) 196 | return 197 | elif is_tsv_output(): 198 | return print_tsv_table(cols, rows) 199 | 200 | if not styles or type(styles) != dict: 201 | styles = {} 202 | 203 | if not titles or type(titles) != dict: 204 | titles = {} 205 | 206 | if not max_column_widths or type(max_column_widths) != dict: 207 | max_column_widths = {} 208 | 209 | colwidths = {} 210 | 211 | for col in cols: 212 | colwidths[col] = len(titles.get(col, col)) 213 | 214 | for row in rows: 215 | for col in cols: 216 | val = row.get(col) 217 | colwidths[col] = min(max(colwidths[col], len(format(col, val))), max_column_widths.get(col, 1000)) 218 | 219 | for i, col in enumerate(cols): 220 | click.secho(('{:' + str(colwidths[col]) + '}').format(titles.get(col, col.title().replace('_', ' '))), 221 | nl=False, fg='black', bg='white') 222 | if i < len(cols) - 1: 223 | click.secho('│', nl=False, fg='black', bg='white') 224 | click.echo('') 225 | 226 | for row in rows: 227 | for col in cols: 228 | val = row.get(col) 229 | align = '' 230 | try: 231 | style = styles.get(val, {}) 232 | except: 233 | # val might not be hashable 234 | style = {} 235 | if val is not None and col.endswith('_time') and isinstance(val, numbers.Number): 236 | align = '>' 237 | diff = time.time() - val 238 | if diff < 900: 239 | style = {'fg': 'green', 'bold': True} 240 | elif diff < 3600: 241 | style = {'fg': 'green'} 242 | elif isinstance(val, int) or isinstance(val, float): 243 | align = '>' 244 | val = format(col, val) 245 | 246 | if len(val) > max_column_widths.get(col, 1000): 247 | val = val[:max_column_widths.get(col, 1000) - 2] + '..' 248 | click.secho(('{:' + align + str(colwidths[col]) + '}').format(val), nl=False, **style) 249 | click.echo(' ', nl=False) 250 | click.echo('') 251 | 252 | 253 | def choice(prompt, options, default=None): 254 | """ 255 | Ask to user to select one option and return it 256 | """ 257 | stderr = True 258 | if sys.stdout.isatty(): 259 | stderr = False 260 | 261 | click.secho(prompt, err=stderr) 262 | promptdefault = None 263 | for i, option in enumerate(options): 264 | if isinstance(option, tuple): 265 | value, label = option 266 | else: 267 | value = label = option 268 | if value == default: 269 | promptdefault = i + 1 270 | click.secho('{}) {}'.format(i + 1, label), err=stderr) 271 | while True: 272 | selection = click.prompt('Please select (1-{})'.format(len(options)), 273 | type=int, default=promptdefault, err=stderr) 274 | try: 275 | result = options[int(selection) - 1] 276 | if isinstance(result, tuple): 277 | value, label = result 278 | else: 279 | value = result 280 | return value 281 | except: 282 | pass 283 | 284 | 285 | class AliasedGroup(click.Group): 286 | """ 287 | Click group which allows using abbreviated commands 288 | """ 289 | 290 | def get_command(self, ctx, cmd_name): 291 | rv = click.Group.get_command(self, ctx, cmd_name) 292 | if rv is not None: 293 | return rv 294 | matches = [x for x in self.list_commands(ctx) 295 | if x.startswith(cmd_name)] 296 | if not matches: 297 | return None 298 | elif len(matches) == 1: 299 | return click.Group.get_command(self, ctx, matches[0]) 300 | ctx.fail('Too many matches: %s' % ', '.join(sorted(matches))) 301 | 302 | 303 | class FloatRange(click.types.FloatParamType): 304 | """A parameter that works similar to :data:`click.FLOAT` but restricts 305 | the value to fit into a range. The default behavior is to fail if the 306 | value falls outside the range, but it can also be silently clamped 307 | between the two edges. 308 | """ 309 | name = 'float range' 310 | 311 | def __init__(self, min=None, max=None, clamp=False): 312 | self.min = min 313 | self.max = max 314 | self.clamp = clamp 315 | 316 | def convert(self, value, param, ctx): 317 | rv = click.types.FloatParamType.convert(self, value, param, ctx) 318 | if self.clamp: 319 | if self.min is not None and rv < self.min: 320 | return self.min 321 | if self.max is not None and rv > self.max: 322 | return self.max 323 | if self.min is not None and rv < self.min or \ 324 | self.max is not None and rv > self.max: 325 | if self.min is None: 326 | self.fail('%s is bigger than the maximum valid value ' 327 | '%s.' % (rv, self.max), param, ctx) 328 | elif self.max is None: 329 | self.fail('%s is smaller than the minimum valid value ' 330 | '%s.' % (rv, self.min), param, ctx) 331 | else: 332 | self.fail('%s is not in the valid range of %s to %s.' 333 | % (rv, self.min, self.max), param, ctx) 334 | return rv 335 | 336 | def __repr__(self): 337 | return 'FloatRange(%r, %r)' % (self.min, self.max) 338 | 339 | 340 | class UrlType(click.types.ParamType): 341 | name = 'url' 342 | 343 | def __init__(self, default_scheme='https', allowed_schemes=('http', 'https')): 344 | self.default_scheme = default_scheme 345 | self.allowed_schemes = allowed_schemes 346 | 347 | def convert(self, value, param, ctx): 348 | value = value.strip() 349 | if not value: 350 | self.fail('"{}" is not a valid URL'.format(value)) 351 | if self.default_scheme and '://' not in value: 352 | value = '{}://{}'.format(self.default_scheme, value) 353 | url = urlsplit(value) 354 | if self.allowed_schemes and url.scheme not in self.allowed_schemes: 355 | self.fail('"{}" is not one of the allowed URL schemes ({})'.format( 356 | url.scheme, ', '.join(self.allowed_schemes))) 357 | return urlunsplit(url) 358 | 359 | def __repr__(self): 360 | return 'UrlType(%r, %r)' % (self.default_scheme, self.allowed_schemes) 361 | --------------------------------------------------------------------------------