├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── appveyor.yml ├── changelog.rst ├── release.py ├── requirements-dev.txt ├── requirements-win.txt ├── run_code_checks.sh ├── screenshots ├── icon_PyCharm.png ├── ps-containers.png ├── rm-all-stopped.png ├── rm-containers.png ├── rmi-all-dangling.png ├── rmi-images.png └── wharfee-demo.gif ├── scripts ├── __init__.py └── optionizer.py ├── setup.cfg ├── setup.py ├── tests ├── conftest.py ├── data │ └── pull.output ├── features │ ├── 1_basic_commands.feature │ ├── 2_image_commands.feature │ ├── 3_container_commands.feature │ ├── 4_info_commands.feature │ ├── 5_volume_commands.feature │ ├── docker_utils.py │ ├── environment.py │ ├── fixture_data │ │ ├── Dockerfile │ │ └── help.txt │ ├── fixture_utils.py │ └── steps │ │ ├── 1_basic_commands.py │ │ ├── 2_image_commands.py │ │ ├── 3_container_commands.py │ │ ├── 4_info_commands.py │ │ ├── 5_volume_commands.py │ │ ├── __init__.py │ │ └── wrappers.py ├── pytest.ini ├── test_client.py ├── test_completer.py ├── test_formatter.py ├── test_helpers.py └── test_options.py ├── tox.ini └── wharfee ├── __init__.py ├── client.py ├── completer.py ├── config.py ├── decorators.py ├── formatter.py ├── helpers.py ├── keys.py ├── lexer.py ├── logger.py ├── main.py ├── option.py ├── options.py ├── style.py ├── toolbar.py ├── utils.py └── wharfeerc /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | 6 | ## Checklist 7 | 8 | - [ ] I've added this contribution to the `changelog.rst`. 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.pyc 3 | *.egg-info/ 4 | *env/ 5 | .docker* 6 | .tox/ 7 | build/ 8 | dist/ 9 | .DS_Store 10 | .coverage 11 | .python-version 12 | .cache/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | dist: xenial 4 | 5 | sudo: required 6 | 7 | services: 8 | - docker 9 | 10 | python: 11 | - "2.7" 12 | - "3.4" 13 | - "3.5" 14 | - "3.6" 15 | - "3.7" 16 | 17 | before_install: 18 | # show docker version 19 | - docker --version 20 | 21 | install: 22 | - pip install pip==9.0.1 23 | - pip install -r requirements-dev.txt 24 | - pip install codecov 25 | - pip install -e . 26 | 27 | script: 28 | - coverage run --source wharfee -m py.test 29 | - cd tests 30 | - behave --tags ~@slow 31 | 32 | after_success: 33 | - codecov 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Irina Truong 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of wharfee nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stories in Ready](https://badge.waffle.io/j-bennet/wharfee.png?label=ready&title=Ready)](https://waffle.io/j-bennet/wharfee) 2 | [![PyPI version](https://badge.fury.io/py/wharfee.svg)](http://badge.fury.io/py/wharfee) 3 | [![Join the chat at https://gitter.im/j-bennet/wharfee](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/j-bennet/wharfee?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | ## Is this still maintained? 5 | 6 | I love wharfee, and I don't want it to die, that's why it's still on github. But the project didn't pick up any new contributors, and I don't currently have the time to maintain it. If you're interested in taking over and maintaining wharfee, let me know. If you want to sponsor maintaining wharfee, let me know as well. 7 | 8 | # wharfee 9 | 10 | A shell for Docker that can do autocompletion and syntax highlighting. 11 | 12 | ![ps](screenshots/wharfee-demo.gif) 13 | 14 | ## Why? 15 | 16 | Docker commands have tons of options. They are hard to remember. 17 | 18 | ![ps](screenshots/ps-containers.png) 19 | 20 | Container names are hard to remember and type. 21 | 22 | ![rm](screenshots/rm-containers.png) 23 | 24 | Same goes for image names. 25 | 26 | ![rmi](screenshots/rmi-images.png) 27 | 28 | There are some handy shortcuts too. What was that command to remove all dangling images? OMG, what was it? docker rmi $(docker ps --all --quiet)? Oh, there you go: 29 | 30 | ![rmi-dangling](screenshots/rmi-all-dangling.png) 31 | 32 | Boom! How about removing all stopped containers? 33 | 34 | ![rm-stopped](screenshots/rm-all-stopped.png) 35 | 36 | ## Installation 37 | 38 | Wharfee is a Python package hosted on pypi and installed with: 39 | 40 | $ pip install wharfee 41 | 42 | Alternatively, you can install the latest from github and get all the bugfixes that didn't make it into pypi release yet: 43 | 44 | $ pip install git+https://github.com/j-bennet/wharfee.git 45 | 46 | ## Running 47 | 48 | Wharfee is a console application. You run it from terminal by typing the program name into 49 | the command line: 50 | 51 | $ wharfee 52 | 53 | If you're on Windows, you may be not so familiar with using the terminal. But if you installed 54 | Docker (Docker Toolbox), you'll have Docker Quickstart Terminal as part of you installation. So, 55 | just as above, you'll run Docker Quickstart Terminal and type `wharfee` into your command prompt. 56 | After you hit `Enter`, you'll see wharfee prompt: 57 | 58 | wharfee> 59 | 60 | ## What are you using? 61 | 62 | * To talk to Docker: [docker-py](https://github.com/docker/docker-py). 63 | * To power the CLI: [Python Prompt Toolkit](http://github.com/jonathanslenders/python-prompt-toolkit). 64 | * To format the output: [tabulate](https://pypi.python.org/pypi/tabulate). 65 | * To print out the output: [Click](http://click.pocoo.org/3/). 66 | 67 | ## Can I contribute? 68 | 69 | Yes! Pull request or [issues](https://github.com/j-bennet/wharfee/issues) are welcome. 70 | 71 | ## How do you test it? 72 | 73 | First, install the requirements for testing: 74 | 75 | $ pip install -r requirements-dev.txt 76 | 77 | There are unit tests under *tests*. The command to run them is: 78 | 79 | $ py.test 80 | 81 | Additionally, there are integration tests, that can be run with: 82 | 83 | $ cd tests 84 | $ behave 85 | 86 | To see stdout/stderr, use the following command: 87 | 88 | $ behave --no-capture 89 | 90 | To enter debugger on error, use the following command: 91 | 92 | $ behave -D DEBUG_ON_ERROR 93 | 94 | ## Thanks 95 | 96 | [![I develop with PyCharm](screenshots/icon_PyCharm.png)](https://www.jetbrains.com/pycharm/) 97 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | build: false 2 | 3 | environment: 4 | matrix: 5 | - PYTHON: "C:\\Python27" 6 | PYTHON_VERSION: "2.7.8" 7 | PYTHON_ARCH: "32" 8 | 9 | - PYTHON: "C:\\Python34" 10 | PYTHON_VERSION: "3.4.1" 11 | PYTHON_ARCH: "32" 12 | 13 | - PYTHON: "C:\\Python35" 14 | PYTHON_VERSION: "3.5.4" 15 | PYTHON_ARCH: "32" 16 | 17 | - PYTHON: "C:\\Python36" 18 | PYTHON_VERSION: "3.6.3" 19 | PYTHON_ARCH: "32" 20 | 21 | - PYTHON: "C:\\Python37" 22 | PYTHON_VERSION: "3.7.0" 23 | PYTHON_ARCH: "32" 24 | 25 | init: 26 | - "ECHO %PYTHON% %PYTHON_VERSION% %PYTHON_ARCH%" 27 | 28 | install: 29 | - "%PYTHON%/Scripts/pip.exe install -r requirements-win.txt" 30 | - "%PYTHON%/Scripts/pip.exe install ." 31 | 32 | test_script: 33 | - "%PYTHON%/Scripts/py.test" 34 | -------------------------------------------------------------------------------- /changelog.rst: -------------------------------------------------------------------------------- 1 | Upcoming 2 | ======== 3 | 4 | * Use ``ruamel.yaml`` to format hierarchical info. 5 | 6 | 0.10 7 | ==== 8 | 9 | * Add "kill" command. (Thanks: `r-m-n`_). 10 | * Fix output when using ``rm`` with multiple containers. 11 | 12 | 13 | 0.9.1 14 | ===== 15 | 16 | * Fixed a bug in ``images`` that would cause wharfee to crash on 17 | start if docker API returned an image without repo tags. 18 | * Fixed a typo in requirements. 19 | 20 | 0.9 21 | === 22 | 23 | A lot of fixes, thanks to finally added integration tests. 24 | 25 | * Fix exception in py3 when printing out "pull" output. 26 | * Fix exception in py3 when printing out "logs" output. 27 | * Fix exception in py2.6 when printing out "rm" output. 28 | * Fix bug in "rm --all" shortcut, which did not really remove stopped containers. 29 | * Fix bug in "start", which was not called unless interactive flag was set. 30 | * Fix output of "port" command with no port mappings. 31 | * Handle exception in "inspect" when argument is not a container or image name. 32 | * "run" now uses pexpect to call external cli, because new version of docker-py was throwing "jack is incompatible with use of CloseNotifier in same ServeHTTP call". 33 | * Add "-f/--force" flag to "rm" command. 34 | * Add "--detach-keys" option to "attach" command. 35 | 36 | 37 | 0.8.1 38 | ===== 39 | 40 | * Fix a bug in ``volume ls``. 41 | * Fix a bug in ``pull`` output. 42 | 43 | 0.8 44 | === 45 | 46 | * Updated `Python Prompt Toolkit`_ to 1.0.0. 47 | 48 | 0.7 49 | === 50 | 51 | Features: 52 | --------- 53 | 54 | * Added "volume" commands: 55 | 56 | :: 57 | 58 | volume create 59 | volume rm 60 | volume ls 61 | volume inspect 62 | 63 | * Added ``--all`` shortcut option to ``rm`` and ``rmi`` commands. 64 | * Internal fixes and updates (thanks: `Anthony DiSanti`_, `Arthur Skowronek`_). 65 | * Updated `Python Prompt Toolkit`_ to 0.57. 66 | 67 | 0.6.8 68 | ===== 69 | 70 | * Fix for "port" command not returning anything (#100). 71 | * Fix for "--publish" not publishing the ports (#90). 72 | 73 | 0.6.7 74 | ===== 75 | 76 | Fixes and travis updates. 77 | 78 | 0.6.6 79 | ===== 80 | 81 | * Fixes to support python 2.6. 82 | * Added logging (finally). 83 | 84 | 0.6.5 85 | ===== 86 | 87 | Features: 88 | --------- 89 | 90 | * Updated `Python Prompt Toolkit`_ to 0.46. This adds the following features: 91 | 92 | * Ctrl + Z puts the application into background (suspends it). Use "fg" command to bring it back. 93 | * Meta + ! brings up "system prompt". 94 | 95 | * Support for using TLS and DOCKER_* variables with Swarm (thanks `achied`_). 96 | * Colorized output of "inspect". 97 | 98 | Bug fixes: 99 | ---------- 100 | 101 | * Fixed completer crashing when trying to autocomplete Unicode characters. 102 | * Fixed external CLI call when environment variable contains spaces. 103 | 104 | 0.6.1-0.6.4 105 | =========== 106 | 107 | Features: 108 | --------- 109 | 110 | * Added "refresh" command to force refresh of autocompletions. 111 | 112 | Bug fixes: 113 | ---------- 114 | 115 | * Fix for the crash on image names with ':' (thanks `Sean`_). 116 | * Fix for incorrect handling of "attach" in external CLI call. 117 | * Fix for an error when running with --publish port:port and --detach (#80). 118 | * Fix for "exec" failing because of "interactive" parameter passed in erroneously (#92). 119 | 120 | 0.5-0.6 121 | ======= 122 | 123 | Version bumped up because of erroneous releases to PyPi. 124 | 125 | 0.4 126 | === 127 | 128 | Bug fixes: 129 | ---------- 130 | 131 | * Fix for missing file on startup (thanks `Amjith`_). 132 | 133 | 0.3 134 | === 135 | 136 | Features: 137 | --------- 138 | 139 | * More supported commands: 140 | 141 | :: 142 | 143 | attach 144 | build 145 | clear 146 | create 147 | exec 148 | login 149 | logs 150 | pause 151 | port 152 | push 153 | restart 154 | shell (shortcat for "exec ") 155 | tag 156 | top 157 | unpause 158 | 159 | * Implemented interactive terminal mode for "start", "run" and "exec". 160 | * Added fuzzy matching option to completion suggestions. 161 | * Completer can suggest either short or long option names. 162 | * Added more options to "run", including volumes, ports and and links. 163 | * Non-standard options are moved into a separate group in command help. 164 | * Prettier formatting of "images" and "ps" output. 165 | 166 | Bug fixes: 167 | ---------- 168 | 169 | * Completer crashing on unexpected characters. 170 | * Completer crashing inside an unfinished quoted string. 171 | 172 | 0.2 173 | ==== 174 | 175 | Features: 176 | --------- 177 | 178 | * Configuration file .dockerclirc, where timeout and visual style can be 179 | specified. 180 | 181 | Bug fixes: 182 | ---------- 183 | 184 | * Catch-all clause for exceptions to avoid an ugly stack trace. 185 | * Timeout for attaching to a Docker service. 186 | 187 | 0.1 188 | ==== 189 | 190 | Features: 191 | --------- 192 | 193 | * Syntax highlighting for implemented commands and options. 194 | * Autocomplete for commands, container names, image names. 195 | * Help for available commands. 196 | * Supported commands (with basic options):: 197 | 198 | version 199 | ps 200 | pull 201 | images 202 | info 203 | inspect 204 | run 205 | rm 206 | rmi 207 | search 208 | start 209 | stop 210 | top 211 | 212 | Not supported: 213 | -------------- 214 | 215 | * "run" in tty/interactive mode. 216 | 217 | .. _`Amjith`: https://github.com/amjith 218 | .. _`Anthony DiSanti`: https://github.com/AnthonyDiSanti 219 | .. _`Arthur Skowronek`: https://github.com/eisensheng 220 | .. _`Sean`: https://github.com/seanch87 221 | .. _`achied`: https://github.com/achied 222 | .. _`r-m-n`: https://github.com/r-m-n 223 | .. _`Python Prompt Toolkit`: http://github.com/jonathanslenders/python-prompt-toolkit 224 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | import re 4 | import ast 5 | import subprocess 6 | import sys 7 | from optparse import OptionParser 8 | 9 | DEBUG = False 10 | CONFIRM_STEPS = False 11 | DRY_RUN = False 12 | 13 | 14 | def skip_step(): 15 | """ 16 | Asks for user's response whether to run a step. Default is yes. 17 | :return: boolean 18 | """ 19 | global CONFIRM_STEPS 20 | 21 | if CONFIRM_STEPS: 22 | choice = raw_input("--- Confirm step? (y/N) [y] ") 23 | if choice.lower() == 'n': 24 | return True 25 | return False 26 | 27 | 28 | def run_step(*args): 29 | """ 30 | Prints out the command and asks if it should be run. 31 | If yes (default), runs it. 32 | :param args: list of strings (command and args) 33 | """ 34 | global DRY_RUN 35 | 36 | cmd = args 37 | print(' '.join(cmd)) 38 | if skip_step(): 39 | print('--- Skipping...') 40 | elif DRY_RUN: 41 | print('--- Pretending to run...') 42 | else: 43 | subprocess.check_output(cmd) 44 | 45 | 46 | def version(version_file): 47 | _version_re = re.compile(r'__version__\s+=\s+(.*)') 48 | 49 | with open(version_file, 'rb') as f: 50 | ver = str(ast.literal_eval(_version_re.search( 51 | f.read().decode('utf-8')).group(1))) 52 | 53 | return ver 54 | 55 | 56 | def commit_for_release(version_file, ver): 57 | run_step('git', 'reset') 58 | run_step('git', 'add', version_file) 59 | run_step('git', 'commit', '--message', 'Releasing version %s' % ver) 60 | 61 | 62 | def create_git_tag(tag_name): 63 | run_step('git', 'tag', tag_name) 64 | 65 | 66 | def register_with_pypi(): 67 | run_step('python', 'setup.py', 'register') 68 | 69 | 70 | def create_source_tarball(): 71 | run_step('python', 'setup.py', 'sdist') 72 | 73 | 74 | def upload_source_tarball(): 75 | run_step('python', 'setup.py', 'sdist', 'upload') 76 | 77 | 78 | def push_to_github(): 79 | run_step('git', 'push', 'origin', 'master') 80 | 81 | 82 | def push_tags_to_github(): 83 | run_step('git', 'push', '--tags', 'origin') 84 | 85 | 86 | if __name__ == '__main__': 87 | if DEBUG: 88 | subprocess.check_output = lambda x: x 89 | 90 | ver = version('wharfee/__init__.py') 91 | print('Releasing Version:', ver) 92 | 93 | parser = OptionParser() 94 | parser.add_option( 95 | "-c", "--confirm-steps", action="store_true", dest="confirm_steps", 96 | default=False, help=("Confirm every step. If the step is not " 97 | "confirmed, it will be skipped.") 98 | ) 99 | parser.add_option( 100 | "-d", "--dry-run", action="store_true", dest="dry_run", 101 | default=False, help="Print out, but not actually run any steps." 102 | ) 103 | 104 | popts, pargs = parser.parse_args() 105 | CONFIRM_STEPS = popts.confirm_steps 106 | DRY_RUN = popts.dry_run 107 | 108 | choice = raw_input('Are you sure? (y/N) [n] ') 109 | if choice.lower() != 'y': 110 | sys.exit(1) 111 | 112 | commit_for_release('wharfee/__init__.py', ver) 113 | create_git_tag('v%s' % ver) 114 | register_with_pypi() 115 | create_source_tarball() 116 | push_to_github() 117 | push_tags_to_github() 118 | upload_source_tarball() 119 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest>=2.7.0 2 | mock>=1.0.1 3 | tox>=1.9.2 4 | behave>=1.2.4 5 | flake8==2.4.1 6 | pexpect==3.3 7 | docopt==0.6.2 8 | Jinja2==2.8 9 | -------------------------------------------------------------------------------- /requirements-win.txt: -------------------------------------------------------------------------------- 1 | pytest>=2.7.0 2 | mock>=1.0.1 3 | behave>=1.2.4 4 | flake8==2.4.1 5 | pexpect>=3.3 6 | -------------------------------------------------------------------------------- /run_code_checks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | flake8 --ignore=F811 --max-line-length=99 --exclude=build . 4 | -------------------------------------------------------------------------------- /screenshots/icon_PyCharm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-bennet/wharfee/a48536cba5d3830a29c63bb440b3e74a8a23431d/screenshots/icon_PyCharm.png -------------------------------------------------------------------------------- /screenshots/ps-containers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-bennet/wharfee/a48536cba5d3830a29c63bb440b3e74a8a23431d/screenshots/ps-containers.png -------------------------------------------------------------------------------- /screenshots/rm-all-stopped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-bennet/wharfee/a48536cba5d3830a29c63bb440b3e74a8a23431d/screenshots/rm-all-stopped.png -------------------------------------------------------------------------------- /screenshots/rm-containers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-bennet/wharfee/a48536cba5d3830a29c63bb440b3e74a8a23431d/screenshots/rm-containers.png -------------------------------------------------------------------------------- /screenshots/rmi-all-dangling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-bennet/wharfee/a48536cba5d3830a29c63bb440b3e74a8a23431d/screenshots/rmi-all-dangling.png -------------------------------------------------------------------------------- /screenshots/rmi-images.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-bennet/wharfee/a48536cba5d3830a29c63bb440b3e74a8a23431d/screenshots/rmi-images.png -------------------------------------------------------------------------------- /screenshots/wharfee-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-bennet/wharfee/a48536cba5d3830a29c63bb440b3e74a8a23431d/screenshots/wharfee-demo.gif -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | -------------------------------------------------------------------------------- /scripts/optionizer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | """ 3 | Docker option checker / helper. 4 | 5 | Usage: 6 | optionizer.py [] [--implemented|--unimplemented] 7 | 8 | -h --help Show this help 9 | -i --implemented Show implemented commands only 10 | -u --unimplemented Show unimplemented commands only 11 | Specify command to review 12 | """ 13 | from __future__ import unicode_literals 14 | from __future__ import print_function 15 | 16 | import os 17 | import re 18 | import pexpect 19 | import textwrap 20 | import six 21 | import wharfee.options as opts 22 | 23 | from docopt import docopt 24 | from tabulate import tabulate 25 | from jinja2 import Template 26 | from collections import namedtuple 27 | 28 | 29 | usage = __doc__ 30 | 31 | 32 | OptInfo = namedtuple( 33 | 'OptInto', 34 | [ 35 | 'type_str', 36 | 'short_name', 37 | 'long_name', 38 | 'action', 39 | 'dest', 40 | 'help', 41 | 'default', 42 | 'nargs', 43 | ] 44 | ) 45 | 46 | 47 | def is_in_files(dir_name, file_ext, search_str): 48 | """ 49 | If any of the given strings present in files. 50 | :param dir_name: str 51 | :param file_ext: str 52 | :param search_str: list 53 | :return: boolean 54 | """ 55 | for file_name in os.listdir(dir_name): 56 | if file_name.endswith(file_ext): 57 | with open(os.path.join(dir_name, file_name), 'r') as f: 58 | for line in f: 59 | if any([s in line for s in search_str]): 60 | return True 61 | return False 62 | 63 | 64 | def is_in_steps(command): 65 | """See if command is mentioned in step files. 66 | :return: boolean 67 | """ 68 | current_dir = os.path.dirname(__file__) 69 | step_dir = os.path.abspath(os.path.join(current_dir, '../tests/features/steps/')) 70 | feature_dir = os.path.abspath(os.path.join(current_dir, '../tests/features/')) 71 | return is_in_files(step_dir, '.py', ['sendline("{0}'.format(command), "sendline('{0}".format(command)]) or \ 72 | is_in_files(feature_dir, '.feature', ['docker {0}'.format(command)]) 73 | 74 | 75 | def get_all_commands(): 76 | """Retrieve all docker commands. 77 | :return: set of str 78 | """ 79 | txt = pexpect.run('docker').strip().splitlines(False) 80 | all_commands = set() 81 | in_commands = False 82 | 83 | for line in txt: 84 | if in_commands: 85 | if line: 86 | all_commands.add(line.strip().split(' ', 1)[0]) 87 | else: 88 | break 89 | if line.lower() == 'commands:': 90 | in_commands = True 91 | 92 | return all_commands 93 | 94 | 95 | def get_option_info(name, desc): 96 | """ 97 | Create an instance of OptInfo out of option name and description. 98 | :param name: str 99 | :param desc: str 100 | :return: OptInfo 101 | """ 102 | short_name = long_name = arg = None 103 | for token in name.split(): 104 | if token.startswith('--'): 105 | long_name = token.strip(',') 106 | elif token.startswith('-'): 107 | short_name = token.strip(',') 108 | else: 109 | arg = token 110 | 111 | default = default_value(desc) 112 | nargs = None 113 | 114 | if not arg: 115 | action = 'store_true' 116 | elif default == '[]': 117 | action = 'append' 118 | nargs = '*' 119 | else: 120 | action = 'store' 121 | 122 | const_type = 'TYPE_STRING' if arg else 'TYPE_BOOLEAN' 123 | dest = clean_name(long_name) 124 | return OptInfo( 125 | type_str=const_type, 126 | short_name=short_name, 127 | long_name=long_name, 128 | action=action, 129 | dest=dest, 130 | default=default, 131 | help=desc, 132 | nargs=nargs 133 | ) 134 | 135 | 136 | def tokenize_usage(command, usage_str): 137 | """ 138 | Split usage string into groups of arguments. 139 | :param command: str 140 | :param usage_str: str 141 | :return: list 142 | """ 143 | i_command_end = usage_str.find(command) 144 | arg_str = usage_str[i_command_end + len(command) + 1:] 145 | tokens = arg_str.split() 146 | for i, token in enumerate(tokens): 147 | if token == '|': 148 | # next token is an OR of previous one. Skip it. 149 | if i < len(tokens) - 1: 150 | tokens[i + 1] = '|' 151 | return [t for t in tokens if t != '|'] 152 | 153 | 154 | def get_command_arguments(command, usage_str): 155 | """ 156 | Get command arguments out of usage string. 157 | :param command: str 158 | :param usage_str: str 159 | :return: list 160 | """ 161 | # Usage: docker run [OPTIONS] IMAGE [COMMAND] [ARG...] 162 | ars = tokenize_usage(command, usage_str) 163 | arg_dict = {} 164 | result = [] 165 | for arg in ars: 166 | if arg == '[OPTIONS]': 167 | continue 168 | long_name = clean_name(arg).lower() 169 | is_optional = '[' in arg 170 | is_multiple = '.' in arg 171 | if long_name in arg_dict: 172 | arg_dict[long_name]['mul'] = True 173 | else: 174 | arg_dict[long_name] = { 175 | 'mul': is_multiple, 176 | 'opt': is_optional 177 | } 178 | for long_name, props in arg_dict.items(): 179 | action = 'append' if props['mul'] else 'store' 180 | if props['mul'] and props['opt']: 181 | nargs = '*' 182 | elif props['mul']: 183 | nargs = '+' 184 | else: 185 | nargs = None 186 | result.append(OptInfo( 187 | type_str='TYPE_STRING', 188 | short_name=None, 189 | long_name=long_name, 190 | action=action, 191 | dest=long_name, 192 | default=None, 193 | help='', 194 | nargs=nargs 195 | )) 196 | return result 197 | 198 | 199 | def get_command_details(command): 200 | """ 201 | Parse arguments, options and subcommands out of command docstring. 202 | :param command: str main command 203 | :return: tuple of (usage, help, commands, options, arguments) 204 | """ 205 | txt = pexpect.run('docker {} --help'.format(command)).strip().splitlines(False) 206 | in_commands = False 207 | in_options = False 208 | 209 | commands = set() 210 | options = set() 211 | usage_str = txt[0] 212 | descr = txt[2] 213 | arguments = get_command_arguments(command, txt[0]) 214 | 215 | for line in txt: 216 | line = line.strip() 217 | 218 | if not line: 219 | in_commands, in_options = False, False 220 | elif in_commands: 221 | cmd, _ = re.split('\s{2,}', line, 1) 222 | commands.add(cmd) 223 | elif in_options: 224 | opt, desc = re.split('\s{2,}', line, 1) 225 | options.add((opt, desc)) 226 | 227 | if line.lower() == 'commands:': 228 | in_commands = True 229 | 230 | if line.lower() == 'options:': 231 | in_options = True 232 | 233 | return usage_str, descr, commands, options, arguments 234 | 235 | 236 | def get_implemented_commands(): 237 | """Get all implemented command names. 238 | :return: set of str 239 | """ 240 | return set([c.split(' ', 1)[0] for c in opts.COMMAND_NAMES]) 241 | 242 | 243 | def check_commands(args): 244 | """ 245 | Display information about implemented and unimplemented commands. 246 | """ 247 | is_impl = args['--implemented'] 248 | is_unimpl = args['--unimplemented'] 249 | all_commands = get_all_commands() 250 | implemented = get_implemented_commands() 251 | 252 | if is_impl: 253 | result = implemented 254 | elif is_unimpl: 255 | result = all_commands - implemented 256 | else: 257 | result = all_commands 258 | 259 | info = [(c, 'Y' if c in implemented else 'N', 'Y' if is_in_steps(c) else 'N') 260 | for c in sorted(result)] 261 | print(tabulate(info, headers=('Command', 'Implemented', 'Tested'))) 262 | 263 | 264 | def format_subcommands(commands): 265 | """Format subcommands for display. 266 | :param commands: list of str 267 | :return: str 268 | """ 269 | return '\n'.join(commands) 270 | 271 | 272 | def default_value(desc): 273 | """ 274 | Parse default out of description. 275 | :param desc: str 276 | :return: str 277 | """ 278 | subs = { 279 | 'true': True, 280 | 'false': False 281 | } 282 | if '(default' in desc: 283 | _, result = desc.split('(default ') 284 | result = result.rstrip(')') 285 | result = subs.get(result, result) 286 | return result 287 | return None 288 | 289 | 290 | def clean_name(opt_name): 291 | """ 292 | Turn long option name into valid python identifier: "--all" -> "all". 293 | :param opt_name: str 294 | :return: str 295 | """ 296 | if not re.match('[^A-Za-z_]', opt_name): 297 | return opt_name 298 | result = opt_name.strip('[]') 299 | result = result.rstrip('.') 300 | result = result.lstrip('-') 301 | result = re.sub('[^A-Za-z_]', '_', result) 302 | return result 303 | 304 | 305 | def maybe_quote(x): 306 | """ 307 | Quote if it looks like string 308 | :param x: object 309 | :return: object 310 | """ 311 | if not isinstance(x, six.string_types): 312 | return x 313 | if x.lower() in ['true', 'false', 'none']: 314 | return x 315 | if re.match('^[0-9]+\.?[0-9]*$', x): 316 | return x 317 | return "'{}'".format(x) 318 | 319 | 320 | def format_option(info): 321 | """ 322 | Format code to create CommandOption. 323 | :param info: OptInfo 324 | :return: str 325 | """ 326 | tmpl = Template(""" 327 | CommandOption( 328 | CommandOption.{{ const_type }}, 329 | {{ short_name }}, 330 | {{ long_name }}, 331 | action='{{ action }}', 332 | dest='{{ dest }}',{% if default is not none %} 333 | default={{ default }},{% endif %} 334 | help='{{ help }}.' 335 | ), 336 | """) 337 | result = tmpl.render( 338 | const_type=info.type_str, 339 | short_name=maybe_quote(info.short_name), 340 | long_name=maybe_quote(info.long_name), 341 | action=info.action, 342 | dest=info.dest, 343 | default=maybe_quote(info.default), 344 | help=info.help.rstrip('.') 345 | ) 346 | return textwrap.dedent(result).strip() 347 | 348 | 349 | def get_implemented_arguments(command): 350 | """ 351 | Get all implemented argument names for the command. 352 | :param command: str 353 | :return: set of str 354 | """ 355 | if command not in opts.COMMAND_NAMES: 356 | return [] 357 | 358 | result = set(o.name 359 | for o in opts.COMMAND_OPTIONS.get(command, []) 360 | if not o.name.startswith('-')) 361 | return result 362 | 363 | 364 | def get_implemented_options(command): 365 | """ 366 | Get all implemented option names for the command. 367 | :param command: str 368 | :return: set of tuples (short_name, long_name) 369 | """ 370 | if command not in opts.COMMAND_NAMES: 371 | return [] 372 | 373 | result = set([(o.short_name, o.long_name) 374 | for o in opts.COMMAND_OPTIONS.get(command, []) 375 | if o.name.startswith('-')]) 376 | result.add((None, '--help')) 377 | result.add(('-h', '--help')) 378 | return result 379 | 380 | 381 | def format_options(command, options, is_impl, is_unimpl, header=True): 382 | """ 383 | Format options for display. 384 | :param command: str 385 | :param options: list of (name, description) 386 | :param is_impl: boolean only show implemented 387 | :param is_unimpl: boolean only show unimplemented 388 | :param header: boolean add header 389 | :return: str 390 | """ 391 | implemented_opts = get_implemented_options(command) 392 | infos = [get_option_info(name, desc) for name, desc in options] 393 | if is_impl: 394 | infos = [i for i in infos if (i.short_name, i.long_name) in implemented_opts] 395 | elif is_unimpl: 396 | infos = [i for i in infos if (i.short_name, i.long_name) not in implemented_opts] 397 | result = '\n'.join([format_option(info) 398 | for info in infos]) 399 | if header: 400 | result = format_header('Options', len(infos), len(options), is_impl, is_unimpl) \ 401 | + '\n' \ 402 | + result 403 | return result 404 | 405 | 406 | def format_arguments(command, arguments, is_impl, is_unimpl, header=True): 407 | """ 408 | Format arguments for display. 409 | :param command: str 410 | :param arguments: list 411 | :param is_impl: boolean only show implemented 412 | :param is_unimpl: boolean only show unimplemented 413 | :param header: boolean add header 414 | :return: str 415 | """ 416 | total_args = len(arguments) 417 | implemented_args = get_implemented_arguments(command) 418 | if is_impl: 419 | arguments = [arg for arg in arguments if arg.long_name in implemented_args] 420 | elif is_unimpl: 421 | arguments = [arg for arg in arguments if arg.long_name not in implemented_args] 422 | result = '\n'.join([ 423 | format_option(arg) 424 | for arg in arguments]) 425 | if header: 426 | result = format_header('Arguments', len(arguments), total_args, is_impl, is_unimpl) \ 427 | + '\n' \ 428 | + result 429 | return result 430 | 431 | 432 | def format_header(what, length, total_length, is_impl, is_unimpl): 433 | """ 434 | Format header string 435 | :param what: str 436 | :param length: int 437 | :param total_length: int 438 | :param is_impl: boolean 439 | :param is_unimpl: boolean 440 | :return: str 441 | """ 442 | mode = 'all' 443 | if is_impl: 444 | mode = 'implemented' 445 | elif is_unimpl: 446 | mode = 'unimplemented' 447 | return textwrap.dedent(''' 448 | {} ({}): {}/{} 449 | ------------------------------''').format( 450 | what, 451 | mode, 452 | length, 453 | total_length 454 | ) 455 | 456 | 457 | def check_command(command, args): 458 | """ 459 | Display information about implemented and unimplemented options. 460 | :param command: str 461 | :param args: dict 462 | """ 463 | implemented = get_implemented_commands() 464 | is_impl = args['--implemented'] 465 | is_unimpl = args['--unimplemented'] 466 | usage_str, help_str, commands, options, arguments = get_command_details(command) 467 | print(textwrap.dedent(''' 468 | Command: [docker] {command} 469 | Help: {help} 470 | {usage} 471 | Subcommands: {subs} 472 | Implemented: {implemented}'''.format( 473 | command=command, 474 | implemented='Yes' if command in implemented else 'No', 475 | subs=len(commands) if commands else 'No', 476 | help=help_str, 477 | usage=usage_str 478 | ))) 479 | 480 | if commands: 481 | print(''' 482 | Subcommands: 483 | ------------------------------''') 484 | print(format_subcommands(commands)) 485 | print() 486 | 487 | print(format_options(command, options, is_impl, is_unimpl)) 488 | print(format_arguments(command, arguments, is_impl, is_unimpl)) 489 | 490 | 491 | def main(): 492 | """ 493 | Display information on implemented commands and options. 494 | :param command: str command name 495 | """ 496 | global usage 497 | args = docopt(usage) 498 | command = args[''] 499 | if command: 500 | check_command(command, args) 501 | else: 502 | check_commands(args) 503 | 504 | 505 | if __name__ == '__main__': 506 | main() 507 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from wharfee.__init__ import __version__ 2 | 3 | try: 4 | from setuptools import setup 5 | except ImportError: 6 | from distutils.core import setup 7 | 8 | setup( 9 | description='Wharfee: a shell for Docker', 10 | author='Irina Truong', 11 | url='http://wharfee.com', 12 | download_url='http://github.com/j-bennet/wharfee', 13 | author_email='i[dot]chernyavska[at]gmail[dot]com', 14 | version=__version__, 15 | license='LICENSE.txt', 16 | install_requires=[ 17 | 'six>=1.9.0', 18 | 'pygments>=2.0.2', 19 | 'prompt_toolkit>=1.0.0,<1.1.0', 20 | 'docker-py>=1.6.0', 21 | 'tabulate>=0.7.5', 22 | 'click>=4.0', 23 | 'py-pretty>=0.1', 24 | 'configobj>=5.0.6', 25 | 'pexpect>=3.3', 26 | 'fuzzyfinder>=1.0.0', 27 | 'ruamel.yaml>=0.15.72', 28 | ], 29 | extras_require={ 30 | 'testing': [ 31 | 'pytest>=2.7.0', 32 | 'mock>=1.0.1', 33 | 'tox>=1.9.2' 34 | ], 35 | }, 36 | entry_points={ 37 | 'console_scripts': [ 38 | 'wharfee = wharfee.main:cli', 39 | 'wharfee-ops = scripts.optionizer:main', 40 | ] 41 | }, 42 | packages=['wharfee'], 43 | package_data={'wharfee': ['wharfeerc']}, 44 | scripts=[], 45 | name='wharfee', 46 | classifiers=[ 47 | 'Intended Audience :: Developers', 48 | 'License :: OSI Approved :: BSD License', 49 | 'Operating System :: Unix', 50 | 'Programming Language :: Python', 51 | 'Programming Language :: Python :: 2.7', 52 | 'Programming Language :: Python :: 3', 53 | 'Programming Language :: Python :: 3.3', 54 | 'Programming Language :: Python :: 3.4', 55 | 'Programming Language :: Python :: 3.5', 56 | 'Programming Language :: Python :: 3.6', 57 | 'Programming Language :: Python :: 3.7', 58 | 'Topic :: Software Development', 59 | 'Topic :: Software Development :: Libraries :: Python Modules', 60 | ], 61 | ) 62 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/j-bennet/wharfee/a48536cba5d3830a29c63bb440b3e74a8a23431d/tests/conftest.py -------------------------------------------------------------------------------- /tests/features/1_basic_commands.feature: -------------------------------------------------------------------------------- 1 | Feature: run the cli, 2 | call the help command, 3 | exit the cli 4 | 5 | Scenario: run the cli 6 | Given we have wharfee installed 7 | when we run wharfee 8 | then we see wharfee prompt 9 | 10 | Scenario: run "help" command 11 | Given we have wharfee installed 12 | when we run wharfee 13 | and we wait for prompt 14 | and we send "help" command 15 | then we see help output 16 | 17 | Scenario: run "clear" command 18 | Given we have wharfee installed 19 | when we run wharfee 20 | and we wait for prompt 21 | and we clear screen 22 | then we see wharfee prompt 23 | 24 | Scenario: run "refresh" command 25 | Given we have wharfee installed 26 | when we run wharfee 27 | and we wait for prompt 28 | and we refresh completions 29 | then we see "Refreshed completions" printed out 30 | 31 | Scenario: run the cli and exit 32 | Given we have wharfee installed 33 | when we run wharfee 34 | and we wait for prompt 35 | and we send "ctrl + d" 36 | then wharfee exits 37 | -------------------------------------------------------------------------------- /tests/features/2_image_commands.feature: -------------------------------------------------------------------------------- 1 | Feature: call image-related commands 2 | 3 | Scenario: build image from Dockerfile 4 | Given we have wharfee installed 5 | when we run wharfee 6 | and we wait for prompt 7 | and we build test-image from Dockerfile 8 | then we see image built 9 | 10 | Scenario: pull hello-world image 11 | Given we have wharfee installed 12 | when we run wharfee 13 | and we wait for prompt 14 | and we pull hello-world:latest image 15 | then we see hello-world pulled 16 | and we see wharfee prompt 17 | 18 | Scenario: list images 19 | Given we have wharfee installed 20 | when we run wharfee 21 | and we wait for prompt 22 | when we list images 23 | then we see image hello-world listed 24 | 25 | Scenario: inspect image 26 | Given we have wharfee installed 27 | when we run wharfee 28 | and we wait for prompt 29 | when we inspect image hello-world 30 | then we see "/hello" printed out 31 | 32 | Scenario: log in as user wharfee 33 | Given we have wharfee installed 34 | when we run wharfee 35 | and we wait for prompt 36 | and we log in as wharfee, wharfee.cli@gmail.com with docker.me 37 | then we see login success 38 | 39 | Scenario: tag an image 40 | Given we have wharfee installed 41 | when we run wharfee 42 | and we wait for prompt 43 | when we tag test-image into wharfee/test-image:version1.0 44 | then we see test-image tagged into wharfee/test-image:version1.0 45 | 46 | Scenario: remove an image 47 | Given we have wharfee installed 48 | when we run wharfee 49 | and we wait for prompt 50 | when we remove image wharfee/test-image:version1.0 51 | then we see image wharfee/test-image:version1.0 removed 52 | when we remove image test-image 53 | then we see image test-image removed 54 | 55 | Scenario: search images 56 | Given we have wharfee installed 57 | when we run wharfee 58 | and we wait for prompt 59 | when we search for nginx 60 | then we see "Official build of Nginx" printed out 61 | -------------------------------------------------------------------------------- /tests/features/3_container_commands.feature: -------------------------------------------------------------------------------- 1 | Feature: call container-related commands 2 | 3 | Scenario: run container 4 | Given we have wharfee installed 5 | when we run wharfee 6 | and we wait for prompt 7 | when we run container hello with image hello-world 8 | then we see "Hello from Docker!" printed out 9 | 10 | Scenario: create and start container 11 | Given we have wharfee installed 12 | when we run wharfee 13 | and we wait for prompt 14 | when we create container hello with image hello-world 15 | then we see id string 16 | when we start container hello 17 | then we see hello at line end 18 | 19 | Scenario: remove container 20 | Given we have wharfee installed 21 | when we run wharfee 22 | then we see wharfee prompt 23 | when we run container hello with image hello-world 24 | then we see "Hello from Docker!" printed out 25 | when we remove container hello 26 | then we see hello at line end 27 | 28 | Scenario: check ports 29 | Given we have wharfee installed 30 | when we run wharfee 31 | and we wait for prompt 32 | when we run container foo with image busybox and command /bin/sh and options -d -i -t 33 | and we wait for prompt 34 | then we see "Interactive terminal is closed" printed out 35 | when we check ports for container foo 36 | then we see "There are no port mappings" printed out 37 | 38 | Scenario: pause and unpause 39 | Given we have wharfee installed 40 | when we run wharfee 41 | and we wait for prompt 42 | when we run container foo with image busybox and command /bin/sh and options -d -i -t 43 | and we wait for prompt 44 | then we see "Interactive terminal is closed" printed out 45 | when we pause container foo 46 | then we see container foo paused 47 | when we unpause container foo 48 | then we see container foo unpaused 49 | 50 | Scenario: run, exec, stop 51 | Given we have wharfee installed 52 | when we run wharfee 53 | and we wait for prompt 54 | when we run container foo with image busybox and command /bin/sh and options -d -i -t 55 | and we wait for prompt 56 | then we see "Interactive terminal is closed" printed out 57 | when we execute ls -l / in container foo 58 | then we see total 36 at line end 59 | when we stop container foo 60 | then we see foo at line end 61 | 62 | Scenario: run, exec, kill 63 | Given we have wharfee installed 64 | when we run wharfee 65 | and we wait for prompt 66 | when we run container foo with image busybox and command /bin/sh and options -d -i -t 67 | and we wait for prompt 68 | then we see "Interactive terminal is closed" printed out 69 | when we execute ls -l / in container foo 70 | then we see total 36 at line end 71 | when we kill container foo 72 | then we see foo at line end 73 | 74 | Scenario: shell to container 75 | Given we have wharfee installed 76 | when we run wharfee 77 | and we wait for prompt 78 | when we run container foo with image busybox and command /bin/sh and options -d -i -t 79 | and we wait for prompt 80 | then we see "Interactive terminal is closed" printed out 81 | when we open shell to container foo and /bin/sh 82 | then we see # printed out 83 | when we send "ctrl + d" 84 | then we see "Shell to foo is closed" printed out 85 | 86 | Scenario: see top processes 87 | Given we have wharfee installed 88 | when we run wharfee 89 | and we wait for prompt 90 | when we run container foo with image busybox and command /bin/sh and options -d -i -t 91 | and we wait for prompt 92 | then we see "Interactive terminal is closed" printed out 93 | when we view top for container foo 94 | then we see top processes 95 | 96 | @slow 97 | Scenario: restart container 98 | Given we have wharfee installed 99 | when we run wharfee 100 | and we wait for prompt 101 | when we run container foo with image busybox and command /bin/sh and options -d -i -t 102 | and we wait for prompt 103 | then we see "Interactive terminal is closed" printed out 104 | when we restart container foo 105 | then we see foo restarted 106 | 107 | Scenario: list containers with nothing running 108 | Given we have wharfee installed 109 | when we run wharfee 110 | and we wait for prompt 111 | when we list containers 112 | then we see "There are no containers to list" printed out 113 | 114 | Scenario: list containers, force remove container 115 | Given we have wharfee installed 116 | when we run wharfee 117 | and we wait for prompt 118 | when we run container foo with image busybox and command /bin/sh and options -d -i -t 119 | and we wait for prompt 120 | then we see "Interactive terminal is closed" printed out 121 | when we list containers 122 | then we see Status printed out 123 | when we force remove container foo 124 | then we see foo at line end 125 | 126 | Scenario: see container logs 127 | Given we have wharfee installed 128 | when we run wharfee 129 | and we wait for prompt 130 | when we run container hello with image hello-world 131 | then we see "Hello from Docker!" printed out 132 | when we wait for prompt 133 | and we see logs for container hello 134 | then we see "Hello from Docker!" printed out 135 | 136 | Scenario: attach and detach 137 | Given we have wharfee installed 138 | when we run wharfee 139 | and we wait for prompt 140 | when we run container foo with image busybox and command /bin/sh and options -d -i -t 141 | and we wait for prompt 142 | then we see "Interactive terminal is closed" printed out 143 | when we attach to container foo 144 | then we see # printed out 145 | when we detach from container foo 146 | then we see "Detached from foo" printed out 147 | 148 | Scenario: remove stopped containers 149 | Given we have wharfee installed 150 | when we run wharfee 151 | and we wait for prompt 152 | when we run container hello with image hello-world 153 | then we see "Hello from Docker!" printed out 154 | when we wait for prompt 155 | and we remove stopped containers 156 | then we see id string 157 | 158 | Scenario: rename container 159 | Given we have wharfee installed 160 | when we run wharfee 161 | and we wait for prompt 162 | when we run container foo with image busybox and command /bin/sh and options -d -i -t 163 | and we wait for prompt 164 | then we see "Interactive terminal is closed" printed out 165 | when we rename container foo to bar 166 | and we wait for prompt 167 | then we see container bar running 168 | -------------------------------------------------------------------------------- /tests/features/4_info_commands.feature: -------------------------------------------------------------------------------- 1 | Feature: call info commands 2 | 3 | Scenario: check info 4 | Given we have wharfee installed 5 | when we run wharfee 6 | and we wait for prompt 7 | when we ask for docker info 8 | then we see "SystemTime:" printed out 9 | 10 | Scenario: check version 11 | Given we have wharfee installed 12 | when we run wharfee 13 | and we wait for prompt 14 | when we ask for docker version 15 | then we see "ApiVersion" printed out 16 | -------------------------------------------------------------------------------- /tests/features/5_volume_commands.feature: -------------------------------------------------------------------------------- 1 | Feature: call volume commands 2 | 3 | Scenario: list volumes 4 | Given we have wharfee installed 5 | when we run wharfee 6 | and we wait for prompt 7 | when we list volumes 8 | then we see "There are no volumes to list" printed out 9 | 10 | Scenario: create, inspect, remove volume 11 | Given we have wharfee installed 12 | when we run wharfee 13 | and we wait for prompt 14 | when we create volume foo 15 | then we see foo at line end 16 | when we list volumes 17 | then we see local printed out 18 | when we inspect volume foo 19 | then we see Driver printed out 20 | when we remove volume foo 21 | then we see foo at line end 22 | -------------------------------------------------------------------------------- /tests/features/docker_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from __future__ import print_function 4 | 5 | """ 6 | Helpers to connect to docker. 7 | """ 8 | 9 | import sys 10 | 11 | # make sure docker-py client API class according to docker-py version 12 | from docker import version_info as docker_version_info 13 | if docker_version_info >= (2, 0, 0): 14 | from docker.api import APIClient as DockerAPIClient 15 | else: 16 | from docker import AutoVersionClient as DockerAPIClient 17 | from docker.utils import kwargs_from_env 18 | 19 | 20 | def init_docker_client(timeout=2): 21 | """ 22 | Init docker-py client. 23 | """ 24 | if sys.platform.startswith('darwin') \ 25 | or sys.platform.startswith('win32'): 26 | # mac or win 27 | kwargs = kwargs_from_env() 28 | if 'tls' in kwargs: 29 | kwargs['tls'].assert_hostname = False 30 | kwargs['timeout'] = timeout 31 | client = DockerAPIClient(**kwargs) 32 | else: 33 | # unix-based 34 | client = DockerAPIClient( 35 | timeout=timeout, 36 | base_url='unix://var/run/docker.sock') 37 | return client 38 | 39 | 40 | def pull_required_images(client): 41 | """ 42 | Make sure we have busybox image pulled. 43 | :param client: AutoVersionClient 44 | """ 45 | for line in client.pull('busybox:latest', stream=True): 46 | print(line) 47 | -------------------------------------------------------------------------------- /tests/features/environment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from __future__ import print_function 4 | 5 | import os 6 | import fixture_utils as fixutils 7 | import docker_utils as dutils 8 | import steps.wrappers as wrappers 9 | 10 | 11 | DEBUG_ON_ERROR = False 12 | 13 | 14 | def before_all(context): 15 | """ 16 | Set env parameters. 17 | """ 18 | global DEBUG_ON_ERROR 19 | DEBUG_ON_ERROR = context.config.userdata.getbool('DEBUG_ON_ERROR') 20 | 21 | os.environ['LINES'] = "50" 22 | os.environ['COLUMNS'] = "120" 23 | os.environ['PAGER'] = 'cat' 24 | 25 | context.data_dir = fixutils.get_data_dir() 26 | context.fixture_lines = fixutils.read_fixture_files() 27 | context.client = dutils.init_docker_client(timeout=10) 28 | dutils.pull_required_images(context.client) 29 | context.exit_sent = False 30 | context.has_containers = False 31 | 32 | 33 | def after_scenario(context, _): 34 | """ 35 | Cleans up after each test complete. 36 | """ 37 | if hasattr(context, 'cli'): 38 | if context.has_containers: 39 | # force remove all containers that are still running. 40 | wrappers.expect_exact(context, 'wharfee> ') 41 | print('\nCleaning up containers...',) 42 | context.cli.sendline('rm -f --all') 43 | wrappers.expect_exact(context, ['Removed: ', 'There are no']) 44 | print('Cleaned up.') 45 | context.has_containers = False 46 | 47 | if not context.exit_sent: 48 | # Terminate the cli nicely. 49 | context.cli.terminate() 50 | 51 | 52 | def after_step(_, step): 53 | if DEBUG_ON_ERROR and step.status == 'failed': 54 | import ipdb 55 | ipdb.post_mortem(step.exc_traceback) -------------------------------------------------------------------------------- /tests/features/fixture_data/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM busybox 2 | -------------------------------------------------------------------------------- /tests/features/fixture_data/help.txt: -------------------------------------------------------------------------------- 1 | -------------- ---------------------------------------------------------------------------------------------------------- 2 | attach Attach to a running container. 3 | build Build a new image from the source code 4 | clear Clear the window. 5 | create Create a new container. 6 | exec Run a command in a running container. 7 | help Help on available commands. 8 | images List images. 9 | info Display system-wide information. 10 | inspect Return low-level information on a container or image. 11 | login Register or log in to a Docker registry server (defaut "https://index.docker.io/v1/"). 12 | logs Fetch the logs of a container. 13 | ps List containers. 14 | pull Pull an image or a repository from the registry. 15 | pause Pause all processes within a container. 16 | port List port mappings for the container, or lookup the public-facing port that is NAT-ed to the private_port. 17 | push Push an image or a repository to the registry. 18 | refresh Refresh autocompletions. 19 | restart Restart a running container. 20 | run Run a command in a new container. 21 | rm Remove one or more containers. 22 | rmi Remove one or more images. 23 | search Search the Docker Hub for images. 24 | shell Get shell into a running container. 25 | start Restart a stopped container. 26 | stop Stop a running container. 27 | tag Tag an image into a repository. 28 | top Display the running processes of a container. 29 | unpause Unpause all processes within a container. 30 | version Show the Docker version information. 31 | volume create Create a new volume. 32 | volume inspect Inspect one or more volumes. 33 | volume ls List volumes. 34 | volume rm Remove a volume. 35 | -------------- ---------------------------------------------------------------------------------------------------------- 36 | -------------------------------------------------------------------------------- /tests/features/fixture_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from __future__ import print_function 4 | 5 | """ 6 | Helpers to read fixture data. 7 | """ 8 | 9 | import os 10 | import codecs 11 | 12 | 13 | def read_fixture_lines(filename): 14 | """ 15 | Read lines of text from file. 16 | :param filename: string name 17 | :return: list of strings 18 | """ 19 | lines = [] 20 | for line in codecs.open(filename, 'r', encoding='utf-8'): 21 | lines.append(line.strip()) 22 | return lines 23 | 24 | 25 | def get_data_dir(): 26 | """ 27 | Return absolute path to fixture_data directory. 28 | """ 29 | current_dir = os.path.dirname(__file__) 30 | fixture_dir = os.path.abspath(os.path.join(current_dir, 'fixture_data/')) 31 | return fixture_dir 32 | 33 | 34 | def read_fixture_files(): 35 | """ 36 | Read all files inside fixture_data directory. 37 | """ 38 | fixture_dict = {} 39 | 40 | current_dir = os.path.dirname(__file__) 41 | fixture_dir = os.path.join(current_dir, 'fixture_data/') 42 | for filename in os.listdir(fixture_dir): 43 | if filename not in ['.', '..']: 44 | fullname = os.path.join(fixture_dir, filename) 45 | fixture_dict[filename] = read_fixture_lines(fullname) 46 | 47 | return fixture_dict 48 | -------------------------------------------------------------------------------- /tests/features/steps/1_basic_commands.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import pip 5 | import pexpect 6 | import wrappers 7 | 8 | from behave import given, when, then 9 | 10 | 11 | @given('we have wharfee installed') 12 | def step_cli_installed(context): 13 | """ 14 | Make sure wharfee is in installed packages. 15 | """ 16 | dists = set([di.key for di in pip.get_installed_distributions()]) 17 | assert 'wharfee' in dists 18 | 19 | 20 | @when('we run wharfee') 21 | def step_run_cli(context): 22 | """ 23 | Run the process using pexpect. 24 | """ 25 | context.cli = pexpect.spawnu('wharfee --no-completion') 26 | 27 | 28 | @when('we wait for prompt') 29 | def step_expect_prompt(context): 30 | """ 31 | Expect to see prompt. 32 | """ 33 | context.cli.expect_exact('wharfee> ') 34 | 35 | 36 | @when('we send "help" command') 37 | def step_send_help(context): 38 | """ 39 | Send "help". 40 | """ 41 | context.cli.sendline('help') 42 | 43 | 44 | @when('we send "ctrl + d"') 45 | def step_send_ctrld(context): 46 | """ 47 | Send Ctrl + D to exit. 48 | """ 49 | context.cli.sendcontrol('d') 50 | context.exit_sent = True 51 | 52 | 53 | @when('we clear screen') 54 | def step_send_clear(context): 55 | """ 56 | Send clear. 57 | """ 58 | context.cli.sendline('clear') 59 | 60 | 61 | @when('we refresh completions') 62 | def step_refresh(context): 63 | """ 64 | Send refresh. 65 | """ 66 | context.cli.sendline('refresh') 67 | 68 | 69 | @then('we see {text} printed out') 70 | def step_see_output(context, text): 71 | """ 72 | Expect to see exact text. 73 | """ 74 | patterns = list(set([text, text.strip('"')])) 75 | wrappers.expect_exact(context, patterns) 76 | 77 | 78 | @then('we see {text} at line end') 79 | def step_see_line_end(context, text): 80 | """ 81 | Expect to see text and line end. 82 | """ 83 | wrappers.expect_exact(context, text + '\r\n') 84 | 85 | 86 | @then('wharfee exits') 87 | def step_expect_exit(context): 88 | """ 89 | Expect cli to exit. 90 | """ 91 | context.cli.expect(pexpect.EOF) 92 | 93 | 94 | @then('we see wharfee prompt') 95 | def step_see_prompt(context): 96 | """ 97 | Expect to see prompt. 98 | """ 99 | wrappers.expect_exact(context, 'wharfee> ') 100 | 101 | 102 | @then('we see help output') 103 | def step_see_help(context): 104 | """ 105 | Expect to see help lines. 106 | """ 107 | for expected_line in context.fixture_lines['help.txt']: 108 | try: 109 | context.cli.expect_exact(expected_line, timeout=1) 110 | except Exception: 111 | raise Exception('Expected: ' + expected_line) 112 | -------------------------------------------------------------------------------- /tests/features/steps/2_image_commands.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | 4 | import wrappers 5 | from behave import when, then 6 | 7 | 8 | @when('we build {image_name} from Dockerfile') 9 | def step_build_dockerfile(context, image_name): 10 | """ 11 | Send "build -t test-image ./tests/features/fixture_data/". 12 | """ 13 | context.cli.sendline('build -t {0} {1}'.format(image_name, context.data_dir)) 14 | 15 | 16 | @then('we see image built') 17 | def step_see_image_built(context): 18 | """ 19 | Expect to see image built. 20 | """ 21 | wrappers.expect_exact(context, 'Successfully built') 22 | 23 | 24 | @when('we pull {image_name} image') 25 | def step_pull_image(context, image_name): 26 | """ 27 | Send "pull {image_name}". 28 | """ 29 | context.cli.sendline('pull ' + image_name) 30 | 31 | 32 | @then('we see {image_name} pulled') 33 | def step_see_image_pulled(context, image_name): 34 | """ 35 | Expect to see image pulled. 36 | """ 37 | wrappers.expect_exact(context, [ 38 | 'Downloaded newer image for ' + image_name, 39 | 'Image is up to date for ' + image_name, 40 | 'Pull complete', 41 | 'Download complete'], 42 | timeout=180) 43 | 44 | 45 | @when('we log in as {user}, {email} with {password}') 46 | def step_log_in(context, user, email, password): 47 | """ 48 | Send "login" command. 49 | """ 50 | context.cli.sendline('login -u {0} -p {1}'.format(user, password)) 51 | 52 | 53 | @then('we see login success') 54 | def step_see_log_in_success(context): 55 | """ 56 | Expect to see login succeeded. 57 | """ 58 | wrappers.expect_exact(context, 'Login Succeeded', 30) 59 | 60 | 61 | @when('we tag {image_name} into {repo_name}') 62 | def step_tag_image(context, image_name, repo_name): 63 | """ 64 | Send "tag" command. 65 | """ 66 | context.cli.sendline('tag {0} {1}'.format(image_name, repo_name)) 67 | 68 | 69 | @then('we see {image_name} tagged into {repo_name}') 70 | def step_see_image_tagged(context, image_name, repo_name): 71 | """ 72 | Expect to see image tagged. 73 | """ 74 | wrappers.expect_exact(context, 'Tagged {0} into {1}'.format(image_name, repo_name)) 75 | 76 | 77 | @when('we remove image {image_name}') 78 | def step_remove_image(context, image_name): 79 | """ 80 | Send "rmi" command. 81 | """ 82 | context.cli.sendline('rmi {0}'.format(image_name)) 83 | 84 | 85 | @then('we see image {image_name} removed') 86 | def step_see_image_removed(context, image_name): 87 | """ 88 | Expect to see image removed. 89 | """ 90 | wrappers.expect_exact(context, image_name) 91 | 92 | 93 | @when('we list images') 94 | def step_list_images(context): 95 | """ 96 | Send "images" command. 97 | """ 98 | context.cli.sendline('images') 99 | 100 | 101 | @then('we see image {image_name} listed') 102 | def step_see_image_listed(context, image_name): 103 | """ 104 | Expect to see image listed. 105 | """ 106 | wrappers.expect_exact(context, 'hello-world') 107 | 108 | 109 | @when('we inspect image {name}') 110 | def step_inspect_image(context, name): 111 | """ 112 | Send "inspect" command. 113 | """ 114 | context.cli.sendline('inspect {0}'.format(name)) 115 | 116 | 117 | @when('we search for {name}') 118 | def step_search(context, name): 119 | """ 120 | Send "search" command. 121 | """ 122 | context.cli.sendline('search {0}'.format(name)) 123 | -------------------------------------------------------------------------------- /tests/features/steps/3_container_commands.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | 4 | import wrappers 5 | from behave import when, then 6 | 7 | 8 | @when('we run container {container_name} with image {image_name} and command {command} and options {options}') 9 | def step_run_container_with_command(context, container_name, image_name, command, options): 10 | """ 11 | Send "run" with command. 12 | """ 13 | context.cli.sendline('run {3} --name {0} {1} {2}'.format( 14 | container_name, 15 | image_name, 16 | command, 17 | options)) 18 | context.has_containers = True 19 | 20 | 21 | @when('we create container {container_name} with image {image_name}') 22 | def step_create_container(context, container_name, image_name): 23 | """ 24 | Send "create". 25 | """ 26 | context.cli.sendline('create --name {0} {1}'.format( 27 | container_name, 28 | image_name)) 29 | context.has_containers = True 30 | 31 | 32 | @when('we start container {container_name}') 33 | def step_start_container(context, container_name): 34 | """ 35 | Send "start". 36 | """ 37 | context.cli.sendline('start {0}'.format(container_name)) 38 | 39 | 40 | @when('we pause container {container_name}') 41 | def step_pause_container(context, container_name): 42 | """ 43 | Send "pause". 44 | """ 45 | context.cli.sendline('pause {0}'.format(container_name)) 46 | 47 | 48 | @when('we open shell to container {name} and {path_to_shell}') 49 | def step_shell_to_container(context, name, path_to_shell): 50 | """ 51 | Send "shell". 52 | """ 53 | context.cli.sendline('shell {0} {1}'.format(name, path_to_shell)) 54 | 55 | 56 | @then('we see container {container_name} paused') 57 | def step_see_container_paused(context, container_name): 58 | """ 59 | Check container is paused. 60 | """ 61 | wrappers.expect_exact(context, container_name + '\r\n') 62 | context.cli.sendline('ps') 63 | wrappers.expect_exact(context, ' (Paused)') 64 | 65 | 66 | @when('we unpause container {container_name}') 67 | def step_unpause_container(context, container_name): 68 | """ 69 | Send "unpause". 70 | """ 71 | context.cli.sendline('unpause {0}'.format(container_name)) 72 | 73 | 74 | @then('we see container {container_name} unpaused') 75 | def step_see_container_unpaused(context, container_name): 76 | """ 77 | Check container is running. 78 | """ 79 | wrappers.expect_exact(context, container_name + '\r\n') 80 | context.cli.sendline('ps') 81 | wrappers.expect(context, r'Up [a-zA-Z0-9\s]+\s{2,}') 82 | 83 | 84 | @then('we see container {container_name} running') 85 | def step_see_container_running(context, container_name): 86 | """ 87 | Check container is running. 88 | """ 89 | context.cli.sendline('ps') 90 | wrappers.expect(context, 91 | r'({0}[\w \t/]*Up|Up[\w \t/]*{0})'.format(container_name)) 92 | 93 | 94 | @when('we check ports for container {container_name}') 95 | def step_ports_container(context, container_name): 96 | """ 97 | Send "port". 98 | """ 99 | context.cli.sendline('port {0}'.format(container_name)) 100 | 101 | 102 | @when('we run container {container_name} with image {image_name}') 103 | def step_run_container(context, container_name, image_name): 104 | """ 105 | Send "run" with command. 106 | """ 107 | context.cli.sendline('run --name {0} {1}'.format( 108 | container_name, 109 | image_name)) 110 | context.has_containers = True 111 | 112 | 113 | @when('we execute {command_name} in container {container_name}') 114 | def step_exec_command(context, command_name, container_name): 115 | """ 116 | Send "run" command. 117 | """ 118 | context.cli.sendline('exec {0} {1}'.format(container_name, command_name)) 119 | 120 | 121 | @when('we stop container {container_name}') 122 | def step_stop_container(context, container_name): 123 | """ 124 | Send "stop" command. 125 | """ 126 | context.cli.sendline('stop {0}'.format(container_name)) 127 | 128 | 129 | @when('we kill container {container_name}') 130 | def step_kill_container(context, container_name): 131 | """ 132 | Send "kill" command. 133 | """ 134 | context.cli.sendline('kill {0}'.format(container_name)) 135 | 136 | 137 | @when('we remove container {name}') 138 | def step_remove_container(context, name): 139 | """ 140 | Send "rm" command. 141 | """ 142 | context.cli.sendline('rm {0}'.format(name)) 143 | 144 | 145 | @when('we force remove container {name}') 146 | def step_force_remove_container(context, name): 147 | """ 148 | Send "rm -f" command. 149 | """ 150 | context.cli.sendline('rm -f {0}'.format(name)) 151 | 152 | 153 | @when('we attach to container {name}') 154 | def step_attach_container(context, name): 155 | """ 156 | Send "attach" command. 157 | """ 158 | context.cli.sendline('attach --detach-keys=ctrl-q {0}'.format(name)) 159 | 160 | 161 | @when('we detach from container {name}') 162 | def step_detach_container(context, name): 163 | """ 164 | Send detach keys command. 165 | """ 166 | context.cli.sendcontrol('q') 167 | 168 | 169 | @when('we see logs for container {name}') 170 | def step_see_logs(context, name): 171 | """ 172 | Send "logs" command. 173 | """ 174 | context.cli.sendline('logs {0}'.format(name)) 175 | 176 | 177 | @when('we remove stopped containers') 178 | def step_remove_stopped(context): 179 | """ 180 | Send "rm" command. 181 | """ 182 | context.cli.sendline('rm --all-stopped') 183 | 184 | 185 | @when('we list containers') 186 | def step_list_containers(context): 187 | """ 188 | Send "ps" command. 189 | """ 190 | context.cli.sendline('ps') 191 | 192 | 193 | @then('we see id string') 194 | def step_see_id_string(context): 195 | """ 196 | Expect to see [a-zA-Z0-9]+ and line end. 197 | """ 198 | wrappers.expect(context, '[a-zA-Z0-9]+\r\n') 199 | 200 | 201 | @then('we see {name} restarted') 202 | def step_see_restarted(context, name): 203 | """ 204 | Expect to see container name and line end. 205 | """ 206 | wrappers.expect(context, '{0}\r\n'.format(name), 60) 207 | 208 | 209 | @when('we view top for container {name}') 210 | def step_see_top_for_container(context, name): 211 | """ 212 | Send "top" command. 213 | """ 214 | context.cli.sendline('top {0}'.format(name)) 215 | 216 | 217 | @then('we see top processes') 218 | def step_see_top(context): 219 | """ 220 | Expect to see [a-zA-Z0-9]+ and line end. 221 | """ 222 | wrappers.expect_exact(context, 'PID') 223 | 224 | 225 | @when('we restart container {name}') 226 | def step_restart_container(context, name): 227 | """ 228 | Send "restart" command. 229 | """ 230 | context.cli.sendline('restart {0}'.format(name)) 231 | 232 | 233 | @when('we rename container {name} to {new_name}') 234 | def step_rename_container(context, name, new_name): 235 | """ 236 | Send "rename" command. 237 | """ 238 | context.cli.sendline('rename {0} {1}'.format(name, new_name)) 239 | -------------------------------------------------------------------------------- /tests/features/steps/4_info_commands.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | from behave import when 4 | 5 | 6 | @when('we ask for docker {something}') 7 | def step_info(context, something): 8 | """ 9 | Send one-word command (info, version). 10 | """ 11 | context.cli.sendline('{0}'.format(something)) 12 | -------------------------------------------------------------------------------- /tests/features/steps/5_volume_commands.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | from behave import when 4 | 5 | 6 | @when('we list volumes') 7 | def step_volume_ls(context): 8 | """ 9 | Send volume ls. 10 | """ 11 | context.cli.sendline('volume ls') 12 | 13 | 14 | @when('we create volume {name}') 15 | def step_volume_create(context, name): 16 | """ 17 | Send volume create. 18 | """ 19 | context.cli.sendline('volume create --name {0}'.format(name)) 20 | 21 | 22 | @when('we inspect volume {name}') 23 | def step_volume_inspect(context, name): 24 | """ 25 | Send volume inspect. 26 | """ 27 | context.cli.sendline('volume inspect {0}'.format(name)) 28 | 29 | 30 | @when('we remove volume {name}') 31 | def step_volume_rm(context, name): 32 | """ 33 | Send volume create. 34 | """ 35 | context.cli.sendline('volume rm {0}'.format(name)) 36 | -------------------------------------------------------------------------------- /tests/features/steps/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | -------------------------------------------------------------------------------- /tests/features/steps/wrappers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | import re 3 | 4 | RE_ANSI = re.compile(r'(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]') 5 | 6 | 7 | def expect(context, expected, timeout=30): 8 | """ 9 | Wrapper for pexpect's expect with clear output logging. 10 | :param context: 11 | :param expected: 12 | :param timeout: 13 | """ 14 | try: 15 | context.cli.expect(expected, timeout=timeout) 16 | except: 17 | handle_exception(context, expected) 18 | 19 | 20 | def expect_exact(context, expected, timeout=30): 21 | """ 22 | Wrapper for pexpect's expect exact with clear output logging. 23 | :param context: 24 | :param expected: 25 | :param timeout: 26 | """ 27 | try: 28 | context.cli.expect_exact(expected, timeout=timeout) 29 | except: 30 | handle_exception(context, expected) 31 | 32 | 33 | def handle_exception(context, expected): 34 | # Strip color codes out of the output. 35 | lines = context.cli.before.split('\r\n') 36 | actual_lines = [RE_ANSI.sub('', l).rstrip() for l in lines] 37 | actual = '\r\n'.join([l for l in actual_lines if l != '']) 38 | raise Exception('Expected:\n---\n{0}\n---\n\nActual:\n---\n{1}\n---'.format( 39 | expected, 40 | actual)) 41 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts=--capture=sys --showlocals 3 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | 4 | import sys 5 | import pytest 6 | 7 | from mock import Mock, patch 8 | from docker.errors import InvalidVersion 9 | from docker.api.volume import VolumeApiMixin 10 | from wharfee.client import DockerClient 11 | 12 | 13 | @pytest.fixture 14 | def client(): 15 | clear = Mock() 16 | refresh = Mock() 17 | return DockerClient(clear_handler=clear, refresh_handler=refresh) 18 | 19 | 20 | @pytest.mark.skipif(sys.platform.startswith('win32'), reason="Not running on windows.") 21 | @patch.object(VolumeApiMixin, 'volumes', side_effect=InvalidVersion('Not supported.')) 22 | def test_invalid_version(mock_volumes, client): 23 | result = client.volume_ls() 24 | assert mock_volumes.called 25 | assert result is None 26 | 27 | -------------------------------------------------------------------------------- /tests/test_completer.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import os 4 | import pytest 5 | from prompt_toolkit.completion import Completion 6 | from prompt_toolkit.document import Document 7 | from wharfee.options import all_options 8 | from wharfee.options import COMMAND_NAMES 9 | from mock import patch 10 | 11 | 12 | @pytest.fixture 13 | def completer(): 14 | import wharfee.completer as cmp 15 | return cmp.DockerCompleter() 16 | 17 | 18 | @pytest.fixture 19 | def complete_event(): 20 | from mock import Mock 21 | return Mock() 22 | 23 | cs1 = ['newton', 'tesla', 'einstein', 'edison'] 24 | rs1 = ['einstein', 'edison'] 25 | im1 = ['ubuntu', 'hello-world', 'postgres', 'nginx'] 26 | 27 | cs2 = ['desperate_hodgkin', 'desperate_torvalds', 'silly_fermat', 'some-percona'] 28 | 29 | 30 | def test_empty_string_completion(completer, complete_event): 31 | """ 32 | In the beginning of the line, all available commands are suggested. 33 | """ 34 | text = '' 35 | position = 0 36 | result = set(completer.get_completions( 37 | Document(text=text, cursor_position=position), 38 | complete_event)) 39 | assert result == set(map(Completion, COMMAND_NAMES)) 40 | 41 | 42 | def test_build_path_completion_absolute(completer, complete_event): 43 | """ 44 | Suggest build paths from filesystem root. 45 | """ 46 | command = 'build /' 47 | 48 | position = len(command) 49 | 50 | with patch('wharfee.completer.list_dir', 51 | return_value=['etc', 'home', 'tmp', 'usr', 'var']): 52 | 53 | result = set(completer.get_completions( 54 | Document(text=command, cursor_position=position), 55 | complete_event)) 56 | 57 | expected = ['etc', 'home', 'tmp', 'usr', 'var'] 58 | 59 | expected = set(map(lambda t: Completion(t, 0), expected)) 60 | 61 | assert expected.issubset(result) 62 | 63 | 64 | def test_build_path_completion_user(completer, complete_event): 65 | """ 66 | Suggest build paths from user home directory. 67 | """ 68 | command = 'build ~' 69 | 70 | position = len(command) 71 | 72 | with patch('wharfee.completer.list_dir', 73 | return_value=['Documents', 'Downloads', 'Pictures']): 74 | 75 | result = set(completer.get_completions( 76 | Document(text=command, cursor_position=position), 77 | complete_event)) 78 | 79 | expected = ['~{0}{1}'.format(os.path.sep, d) for d in ['Documents', 'Downloads']] 80 | 81 | expected = set(map(lambda t: Completion(t, -1), expected)) 82 | 83 | assert expected.issubset(result) 84 | 85 | 86 | def test_build_path_completion_user_dir(completer, complete_event): 87 | """ 88 | Suggest build paths from user home directory. 89 | """ 90 | command = 'build ~/s' 91 | 92 | position = len(command) 93 | 94 | with patch('wharfee.completer.list_dir', 95 | return_value=['.config', 'db-dumps', 'src', 'venv']): 96 | 97 | result = set(completer.get_completions( 98 | Document(text=command, cursor_position=position), 99 | complete_event)) 100 | 101 | expected = ['src'] 102 | 103 | expected = set(map(lambda t: Completion(t, -1), expected)) 104 | 105 | assert expected.issubset(result) 106 | 107 | 108 | @pytest.mark.parametrize("command, expected", [ 109 | ("h", ['help']), 110 | ("he", ['help']), 111 | ("hel", ['help']), 112 | ("help", ['help']), 113 | ('run -d ubuntu:14.04 /bin/sh -c "w', []) # not complete in quoted string 114 | ]) 115 | def test_command_completion(completer, complete_event, command, expected): 116 | """ 117 | Test command suggestions. 118 | :param command: string: text that user started typing 119 | :param expected: list: expected completions 120 | """ 121 | position = len(command) 122 | result = set(completer.get_completions( 123 | Document(text=command, cursor_position=position), 124 | complete_event)) 125 | 126 | expected = set(map(lambda t: Completion(t, -len(command)), expected)) 127 | 128 | assert result == expected 129 | 130 | 131 | @pytest.mark.parametrize("command, expected", [ 132 | ("h", ['help', 'shell', 'push', 'attach', 'search', 'refresh']), 133 | ("he", ['help', 'shell']), 134 | ("hel", ['help', 'shell']), 135 | ("help", ['help']), 136 | ('run -d ubuntu:14.04 /bin/sh -c "w', []) # not complete in quoted string 137 | ]) 138 | def test_command_completion_fuzzy(completer, complete_event, command, expected): 139 | """ 140 | Test command suggestions. 141 | :param command: string: text that user started typing 142 | :param expected: list: expected completions 143 | """ 144 | completer.set_fuzzy_match(True) 145 | 146 | position = len(command) 147 | result = list(completer.get_completions( 148 | Document(text=command, cursor_position=position), 149 | complete_event)) 150 | 151 | expected = list(map(lambda t: Completion(t, -len(command)), expected)) 152 | 153 | assert result == expected 154 | 155 | 156 | pso = list(filter(lambda x: x.name.startswith('-'), all_options('ps'))) 157 | 158 | 159 | @pytest.mark.parametrize("command, expected, expected_pos", [ 160 | ("ps ", pso, 0), 161 | ("ps --", list(filter( 162 | lambda x: x.long_name and x.long_name.startswith('--'), pso)), -2), 163 | ("ps --h", list(filter( 164 | lambda x: x.long_name and x.long_name.startswith('--h'), pso)), -3), 165 | ("ps --all ", list(filter( 166 | lambda x: x.long_name not in ['--all'], pso)), 0), 167 | ("ps --all --quiet ", list(filter( 168 | lambda x: x.long_name not in ['--all', '--quiet'], pso)), 0), 169 | ]) 170 | def test_options_completion_long(completer, complete_event, command, expected, expected_pos): 171 | """ 172 | Test command options suggestions. 173 | :param command: string: text that user started typing 174 | :param expected: list: expected completions 175 | """ 176 | position = len(command) 177 | 178 | result = set(completer.get_completions( 179 | Document(text=command, cursor_position=position), complete_event)) 180 | 181 | expected = set(map(lambda t: Completion( 182 | t.get_name(is_long=True), expected_pos, t.display), expected)) 183 | 184 | assert result == expected 185 | 186 | 187 | def option_map(cmd, is_long): 188 | result = {} 189 | for x in all_options(cmd): 190 | if x.name.startswith('-'): 191 | result[x.get_name(is_long)] = x.display 192 | return result 193 | 194 | 195 | psm = option_map('ps', True) 196 | 197 | 198 | @pytest.mark.parametrize("command, expected, expected_pos", [ 199 | ("ps ", sorted(psm.keys()), 0), 200 | ("ps h", ['--help'], -1), 201 | ("ps i", ['--since', '--size', '--quiet'], -1), 202 | ("ps ze", ['--size'], -2), 203 | ]) 204 | def test_options_completion_long_fuzzy(completer, complete_event, command, expected, expected_pos): 205 | """ 206 | Test command options suggestions. 207 | :param command: string: text that user started typing 208 | :param expected: list: expected completions 209 | """ 210 | completer.set_fuzzy_match(True) 211 | 212 | position = len(command) 213 | 214 | result = list(completer.get_completions( 215 | Document(text=command, cursor_position=position), complete_event)) 216 | 217 | expected = list(map(lambda t: Completion( 218 | t, expected_pos, psm[t]), expected)) 219 | 220 | assert result == expected 221 | 222 | 223 | @pytest.mark.parametrize("command, expected, expected_pos", [ 224 | ("ps ", pso, 0), 225 | ("ps -", filter( 226 | lambda x: x.name.startswith('-'), pso), -1), 227 | ("ps -h", filter( 228 | lambda x: x.short_name and x.short_name.startswith('-h'), pso), -2), 229 | ]) 230 | def test_options_completion_short(completer, complete_event, command, expected, 231 | expected_pos): 232 | """ 233 | Test command options suggestions. 234 | :param command: string: text that user started typing 235 | :param expected: list: expected completions 236 | """ 237 | completer.set_long_options(False) 238 | 239 | position = len(command) 240 | 241 | result = set(completer.get_completions( 242 | Document(text=command, cursor_position=position), complete_event)) 243 | 244 | expected = set(map(lambda t: Completion( 245 | t.get_name(is_long=completer.get_long_options()), 246 | expected_pos, t.display), expected)) 247 | 248 | assert result == expected 249 | 250 | 251 | @pytest.mark.parametrize("command, expected, expected_pos", [ 252 | ("ps --before ", cs1, 0), 253 | ("ps --before e", filter(lambda x: x.startswith('e'), cs1), -1), 254 | ("ps --before ei", filter(lambda x: x.startswith('ei'), cs1), -2), 255 | ]) 256 | def test_options_container_completion(completer, complete_event, command, 257 | expected, expected_pos): 258 | """ 259 | Suggest container names in relevant options (ps --before) 260 | """ 261 | completer.set_containers(cs1) 262 | 263 | position = len(command) 264 | 265 | result = set(completer.get_completions( 266 | Document(text=command, cursor_position=position), complete_event)) 267 | 268 | expected = set(map(lambda t: Completion(t, expected_pos), expected)) 269 | 270 | assert result == expected 271 | 272 | 273 | @pytest.mark.parametrize("command, expected, expected_pos", [ 274 | ("top ", list(map( 275 | lambda x: (x, x), rs1)) + [('--help', '-h/--help')], 0), 276 | ("top e", map( 277 | lambda x: (x, x), filter(lambda x: x.startswith('e'), rs1)), -1), 278 | ]) 279 | def test_options_container_running_completion(completer, complete_event, 280 | command, expected, expected_pos): 281 | """ 282 | Suggest running container names (top [container]) 283 | """ 284 | completer.set_containers(cs1) 285 | completer.set_running(rs1) 286 | 287 | position = len(command) 288 | 289 | result = set(completer.get_completions( 290 | Document(text=command, cursor_position=position), complete_event)) 291 | 292 | expected_completions = set() 293 | for text, display in expected: 294 | if display: 295 | expected_completions.add(Completion(text, expected_pos, display)) 296 | else: 297 | expected_completions.add(Completion(text, expected_pos)) 298 | 299 | assert result == expected_completions 300 | 301 | 302 | @pytest.mark.parametrize("command, expected, expected_pos", [ 303 | ("rm ", ['--all', '--all-stopped', ('--force', '-f/--force'), ('--help', '-h/--help')] + cs2, 0), 304 | ("rm spe", ['--all-stopped', 'desperate_hodgkin', 'desperate_torvalds', 305 | 'some-percona'], -3), 306 | ]) 307 | def test_options_container_completion_fuzzy(completer, complete_event, command, 308 | expected, expected_pos): 309 | """ 310 | Suggest running container names (top [container]) 311 | """ 312 | completer.set_containers(cs2) 313 | 314 | completer.set_fuzzy_match(True) 315 | 316 | position = len(command) 317 | 318 | result = list(completer.get_completions( 319 | Document(text=command, cursor_position=position), complete_event)) 320 | 321 | expected_completions = [] 322 | for x in expected: 323 | if isinstance(x, tuple): 324 | expected_completions.append(Completion(x[0], expected_pos, x[1])) 325 | else: 326 | expected_completions.append(Completion(x, expected_pos)) 327 | 328 | assert result == expected_completions 329 | 330 | 331 | def test_options_image_completion(completer, complete_event): 332 | """ 333 | Suggest image names in relevant options (images --filter) 334 | """ 335 | command = 'images --filter ' 336 | expected = ['ubuntu', 'hello-world', 'postgres', 'nginx'] 337 | expected_pos = 0 338 | 339 | completer.set_images(expected) 340 | position = len(command) 341 | 342 | result = set(completer.get_completions( 343 | Document(text=command, cursor_position=position), complete_event)) 344 | 345 | expected = set(map(lambda t: Completion(t, expected_pos), expected)) 346 | 347 | assert result == expected 348 | 349 | 350 | @pytest.mark.parametrize("command, expected, expected_pos", [ 351 | ('images --filter ', ['hello-world', 'nginx', 'postgres', 'ubuntu'], 0), 352 | ('images --filter n', ['nginx', 'ubuntu'], -1), 353 | ('images --filter g', ['nginx', 'postgres'], -1), 354 | ('images --filter u', ['ubuntu'], -1), 355 | ]) 356 | def test_options_image_completion_fuzzy(completer, complete_event, command, 357 | expected, expected_pos): 358 | """ 359 | Suggest image names in relevant options (images --filter) 360 | """ 361 | completer.set_images(im1) 362 | 363 | completer.set_fuzzy_match(True) 364 | 365 | position = len(command) 366 | 367 | result = list(completer.get_completions( 368 | Document(text=command, cursor_position=position), complete_event)) 369 | 370 | expected = list(map(lambda t: Completion(t, expected_pos), expected)) 371 | 372 | assert result == expected 373 | 374 | 375 | @pytest.mark.parametrize("command, expected, expected_pos", [ 376 | ('volume create ', [('--name',), ('--help', '-h/--help'), 377 | ('--opt', '-o/--opt'), ('--driver', '-d/--driver')], 0), 378 | ('volume rm ', [('--help', '-h/--help'), ('abc',), ('def',)], 0), 379 | ('volume ls ', [('--help', '-h/--help'), ('--filter',), 380 | ('--quiet', '-q/--quiet')], 0), 381 | ('volume inspect ', [('--help', '-h/--help'), ('abc',), ('def',)], 0), 382 | ]) 383 | def test_options_volume_completion(completer, complete_event, command, 384 | expected, expected_pos): 385 | """ 386 | Suggest options in volume commands 387 | """ 388 | position = len(command) 389 | 390 | completer.set_volumes(['abc', 'def']) 391 | 392 | completer.set_fuzzy_match(True) 393 | 394 | result = set(completer.get_completions( 395 | Document(text=command, cursor_position=position), complete_event)) 396 | 397 | expected = set(map( 398 | lambda t: Completion(t[0], expected_pos, t[1] if len(t) > 1 else t[0]), 399 | expected)) 400 | 401 | assert result == expected 402 | -------------------------------------------------------------------------------- /tests/test_formatter.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from __future__ import print_function 3 | 4 | import os 5 | import pytest 6 | from time import sleep 7 | from wharfee.formatter import format_data 8 | from wharfee.formatter import format_struct 9 | from wharfee.formatter import format_top 10 | from wharfee.formatter import format_port_lines 11 | from wharfee.formatter import JsonStreamFormatter 12 | 13 | 14 | @pytest.mark.parametrize("data, expected", [ 15 | ([{'HostPort': '8888', 'HostIp': '0.0.0.0'}], 16 | ["0.0.0.0:8888"]), 17 | ({u'80/tcp': [{u'HostPort': u'8888', u'HostIp': u'0.0.0.0'}]}, 18 | ["80/tcp->0.0.0.0:8888"]) 19 | ]) 20 | def test_ports_formatting(data, expected): 21 | """ 22 | Test ports formatting 23 | """ 24 | result = format_port_lines(data) 25 | assert result == expected 26 | 27 | 28 | def test_top_formatting(): 29 | """ 30 | Test formatting of top output. 31 | """ 32 | data = { 33 | u'Processes': [ 34 | [u'root', u'27390', u'2347', u'0', u'Jun02', u'?', u'00:00:21', 35 | u'/bin/sh -c while true; do echo Hello boo; sleep 1; done'], 36 | [u'root', u'32694', u'27390', u'0', u'21:52', u'?', u'00:00:00', 37 | u'sleep 1']], 38 | u'Titles': [u'UID', u'PID', u'PPID', u'C', u'STIME', u'TTY', u'TIME', 39 | u'CMD'] 40 | } 41 | formatted = format_top(data) 42 | print('\n') 43 | print('\n'.join(formatted)) 44 | 45 | 46 | @pytest.mark.skipif(True, reason='Long running.') 47 | def test_json_stream_formatting(): 48 | """ 49 | Test formatting of pull output 50 | """ 51 | print("\n") 52 | fmt = JsonStreamFormatter(pull_stream()) 53 | fmt.output() 54 | 55 | 56 | def pull_stream(): 57 | """ 58 | Read pull output line by line. 59 | """ 60 | p = os.path.dirname(os.path.realpath(__file__)) 61 | f = os.path.join(p, 'data/pull.output') 62 | for line in open(f, 'r'): 63 | line = line.strip() 64 | sleep(0.1) 65 | yield line if line else '{}' 66 | 67 | 68 | def test_ps_data_formatting(): 69 | """ 70 | Test formatting for list of tuples. 71 | """ 72 | data = [ 73 | {'Id': ('9e19b1558bbcba9202c1d3c4e26d8fe6e2c6060faad9a7074487e3b210a2' 74 | '6a16')}, 75 | {'Id': ('b798acf4382421d231680d28aa62ae9b486b89711733c6acbb4cc85d8bec4' 76 | '072')}, 77 | ] 78 | formatted = format_data('ps', data) 79 | print('\n') 80 | print('\n'.join(formatted)) 81 | 82 | 83 | def test_help_data_formatting(): 84 | """ 85 | Test formatting for list of tuples. 86 | """ 87 | data = [ 88 | ('help', "Help on available commands."), 89 | ('version', "Equivalent of 'docker version'."), 90 | ('ps', "Equivalent of 'docker ps'."), 91 | ('images', "Equivalent of 'docker images'."), 92 | ('run', "Equivalent of 'docker run'."), 93 | ('stop', "Equivalent of 'docker stop'."), 94 | ('info', "Equivalent of 'docker info'.") 95 | ] 96 | formatted = format_data('help', data) 97 | print('\n') 98 | print('\n'.join(formatted)) 99 | 100 | 101 | def test_rmi_data_formatting(): 102 | """ 103 | Test formatting for list of strings. 104 | """ 105 | data = [ 106 | "busybox:latest", 107 | "busybox:ubuntu-14.04", 108 | "busybox:buildroot-2014.02", 109 | ] 110 | formatted = format_data('rmi', data) 111 | print('\n') 112 | print('\n'.join(formatted)) 113 | 114 | 115 | def test_dict_data_formatting(): 116 | """ 117 | Test hierarchical data formatting 118 | """ 119 | data = { 120 | 'name': 'John', 121 | 'profession': 'Developer', 122 | 'hobbies': { 123 | 'active': { 124 | 'indoor': ['aikido', 'swimming'], 125 | 'outdoor': ['hunting', 'fishing'] 126 | }, 127 | 'passive': ['reading', 'painting'] 128 | } 129 | } 130 | 131 | lines = format_struct(data, indent=2) 132 | 133 | print('\n') 134 | for line in lines: 135 | print(line) 136 | 137 | 138 | def test_info_data_formatting(): 139 | """ 140 | Test complex structure formatting 141 | """ 142 | data = { 143 | 'Containers': 2, 144 | 'Debug': 1, 145 | 'DockerRootDir': '/mnt/sda1/var/lib/docker', 146 | 'Driver': 'aufs', 147 | 'DriverStatus': [ 148 | ['Root Dir', '/mnt/sda1/var/lib/docker/aufs'], 149 | ['Backing Filesystem', 'extfs'], 150 | ['Dirs', '223'], 151 | ['Dirperm1 Supported', 'true'] 152 | ], 153 | 'ExecutionDriver': 'native-0.2', 154 | 'ID': '37ZW:4G34:S24K:Z6S7:PUFE:FGW2:EVXG:NFHO:AZ62:EJU5:MPJ3:XPQZ', 155 | 'IPv4Forwarding': 1, 156 | 'Images': 219, 157 | 'IndexServerAddress': 'https://index.docker.io/v1/', 158 | 'InitPath': '/usr/local/bin/docker', 159 | 'InitSha1': '9145575052383dbf64cede3bac278606472e027c', 160 | 'KernelVersion': '3.18.11-tinycore64', 161 | 'Labels': None, 162 | 'MemTotal': 2105860096, 163 | 'MemoryLimit': 1, 164 | 'NCP': 4, 165 | 'NEventsListener': 0, 166 | 'NFd': 13, 167 | 'NGoroutines': 19, 168 | 'Name': 'boot2docker', 169 | 'OperatingSystem': ('Boot2Docker 1.6.0 (TCL 5.4); master : a270c71 - ' 170 | 'Thu Apr 16 19:50:36 UTC 2015'), 171 | 'RegistryConfig': { 172 | 'IndexConfigs': { 173 | 'docker.io': { 174 | 'Mirrors': None, 175 | 'Name': 'docker.io', 176 | 'Official': True, 177 | 'Secure': True 178 | } 179 | }, 180 | 'InsecureRegistryCIDRs': ['127.0.0.0/8'] 181 | }, 182 | 'SwapLimit': 1, 183 | 'SystemTime': '2015-04-29T04:58:07.548655766Z' 184 | } 185 | 186 | lines = format_struct(data, indent=2) 187 | 188 | print('\n') 189 | for line in lines: 190 | print(line) 191 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import pytest 4 | from wharfee.helpers import (parse_port_bindings, parse_volume_bindings, 5 | parse_kv_as_dict) 6 | 7 | 8 | @pytest.mark.parametrize("ports, expected", [ 9 | (['3306'], {'3306': None}), 10 | (['9999:3306'], {'3306': '9999'}), 11 | (['0.0.0.0:9999:3306'], {'3306': ('0.0.0.0', '9999')}), 12 | (['9999:3306', '127.0.0.1::8001'], 13 | {'8001': ('127.0.0.1', None), '3306': '9999'}), 14 | ]) 15 | def test_port_parsing(ports, expected): 16 | """ 17 | Parse port mappings. 18 | """ 19 | result = parse_port_bindings(ports) 20 | 21 | assert result == expected 22 | 23 | 24 | @pytest.mark.parametrize("volumes, expected", [ 25 | (['/tmp'], {}), 26 | (['/var/www:/webapp'], {'/var/www': {'bind': '/webapp', 'ro': False}}), 27 | (['/var/www:/webapp:ro'], {'/var/www': {'bind': '/webapp', 'ro': True}}), 28 | ]) 29 | def test_volume_parsing(volumes, expected): 30 | """ 31 | Parse volume mappings. 32 | :param volumes: list of string 33 | :param expected: dict 34 | """ 35 | result = parse_volume_bindings(volumes) 36 | 37 | assert result == expected 38 | 39 | 40 | @pytest.mark.parametrize("kvalues, convert_boolean, expected", [ 41 | (['boo=foo', 'is_ok=true'], True, {'boo': 'foo', 'is_ok': True}), 42 | (['boo=foo', 'is_ok=true'], False, {'boo': 'foo', 'is_ok': 'true'}), 43 | ]) 44 | def test_kv_parsing_true(kvalues, convert_boolean, expected): 45 | """ 46 | Parse key=value mappings. 47 | :param kvalues: list of strings 48 | :param process_boolean: boolean 49 | :param expected: dict 50 | """ 51 | result = parse_kv_as_dict(kvalues, convert_boolean) 52 | 53 | assert result == expected 54 | -------------------------------------------------------------------------------- /tests/test_options.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from __future__ import print_function 3 | 4 | import pytest 5 | from optparse import OptionError 6 | from textwrap import dedent 7 | from wharfee.options import parse_command_options, format_command_help, \ 8 | format_command_line 9 | from wharfee.utils import shlex_split 10 | from wharfee.completer import DockerCompleter 11 | 12 | 13 | @pytest.mark.parametrize("help_opt_name", [ 14 | '-h', 15 | '--help' 16 | ]) 17 | def test_parse_images_help(help_opt_name): 18 | """ 19 | Test parsing of a "--help" 20 | """ 21 | parser, popts, pargs = parse_command_options('images', [help_opt_name]) 22 | assert popts['help'] is True 23 | 24 | 25 | def test_parse_run(): 26 | """ 27 | Test parsing of a simple "run" 28 | """ 29 | parser, popts, pargs = parse_command_options( 30 | 'run', ['--name', 'boo', 'ubuntu']) 31 | assert pargs == ['ubuntu'] 32 | assert popts['name'] == 'boo' 33 | 34 | 35 | def test_parse_run_command(): 36 | """ 37 | Test parsing of a "run" with command to run 38 | """ 39 | parser, popts, pargs = parse_command_options( 40 | 'run', 41 | ['--name', 'boo', 'ubuntu', 'top']) 42 | assert pargs == ['ubuntu', 'top'] 43 | assert popts['name'] == 'boo' 44 | 45 | 46 | def test_parse_run_command_string(): 47 | """ 48 | Test parsing of a "run" with command to run and parameter, quoted 49 | """ 50 | parser, popts, pargs = parse_command_options( 51 | 'run', 52 | ['--name', 'boo', 'ubuntu', 'top -b']) 53 | assert pargs == ['ubuntu', 'top -b'] 54 | assert popts['name'] == 'boo' 55 | 56 | 57 | def test_parse_run_command_with_dash_arg(): 58 | """ 59 | Test parsing of a "run" with command to run and parameter, unquoted 60 | """ 61 | parser, popts, pargs = parse_command_options( 62 | 'run', 63 | ['--name', 'boo', 'ubuntu', 'top', '-b']) 64 | assert pargs == ['ubuntu', 'top', '-b'] 65 | assert popts['name'] == 'boo' 66 | 67 | 68 | def test_parse_quoted_string(): 69 | """ 70 | Test parsing of a "complicated" run command. 71 | """ 72 | input = ('run -d ubuntu:14.04 /bin/sh -c ' 73 | '"while true; do echo hello world; sleep 1; done"') 74 | first = DockerCompleter.first_token(input) 75 | assert first == 'run' 76 | 77 | 78 | def test_parse_quoted_string_start(): 79 | """ 80 | Test parsing of a "complicated" run command. 81 | """ 82 | input = 'run -d ubuntu:14.04 /bin/sh -c "w' 83 | first = DockerCompleter.first_token(input) 84 | assert first == 'run' 85 | 86 | 87 | def test_parse_multiple_args(): 88 | """ 89 | Parsing multiple -e options to "run". 90 | :return: 91 | """ 92 | expected_opts = { 93 | 'tty': False, 94 | 'help': None, 95 | 'remove': None, 96 | 'environment': [u'FOO=1', u'BOO=2'], 97 | 'detach': None, 98 | 'name': u'boo' 99 | } 100 | 101 | expected_args = ['ubuntu'] 102 | 103 | parser, popts, pargs = parse_command_options( 104 | 'run', 105 | ['--name', 'boo', '-e', 'FOO=1', '-e', 'BOO=2', 'ubuntu']) 106 | 107 | assert pargs == expected_args 108 | for expected_key in expected_opts: 109 | assert expected_key in popts 110 | assert popts[expected_key] == expected_opts[expected_key] 111 | 112 | 113 | def test_parse_multiple_args_without_equal(): 114 | """ 115 | Parsing multiple -e options to "run". 116 | :return: 117 | """ 118 | text = 'run --name boo -e FOO 1 -e BOO 2 ubuntu' 119 | tokens = shlex_split(text) if text else [''] 120 | cmd = tokens[0] 121 | params = tokens[1:] 122 | 123 | with pytest.raises(OptionError) as ex: 124 | parse_command_options(cmd, params) 125 | assert 'KEY=VALUE' in ex.message 126 | 127 | 128 | def test_help_formatting(): 129 | """ 130 | Format and output help for the command. 131 | """ 132 | output = dedent(format_command_help('rmi')).strip() 133 | 134 | expected = dedent(""" 135 | Usage: rmi [options] image 136 | 137 | Options: 138 | -h, --help Display help for this command. 139 | 140 | Non-standard options: 141 | --all-dangling Shortcut to remove all dangling images. 142 | --all Shortcut to remove all images. 143 | """).strip() 144 | 145 | print(output) 146 | 147 | assert output == expected 148 | 149 | 150 | @pytest.mark.parametrize('text, is_long, expected', [ 151 | ('exec -it boo /usr/bin/bash', False, 'exec -i -t boo /usr/bin/bash'), 152 | ('exec -i -t boo /usr/bin/bash', False, 'exec -i -t boo /usr/bin/bash'), 153 | ('exec -i -t boo /usr/bin/bash', True, 'exec --interactive --tty boo /usr/bin/bash'), 154 | ('exec --interactive --tty boo /usr/bin/bash', False, 'exec -i -t boo /usr/bin/bash'), 155 | ('exec -i --tty boo /usr/bin/bash', False, 'exec -i -t boo /usr/bin/bash'), 156 | (('run --name some-percona --env MYSQL_ROOT_PASSWORD=masterkey ' 157 | '--publish 9999:3306 --interactive --tty percona'), 158 | False, 159 | ('run --name=some-percona -e MYSQL_ROOT_PASSWORD=masterkey ' 160 | '-p=9999:3306 -i -t percona')), 161 | ('run -e TWO_ENVS="boo hoo" -e ONE_VAR=foo -i -t some-image', 162 | False, 163 | 'run -e TWO_ENVS="boo hoo" -e ONE_VAR=foo -i -t some-image') 164 | ]) 165 | def test_external_command_line(text, is_long, expected): 166 | """ 167 | Parse and reconstruct the command line. 168 | """ 169 | cmd, params = text.split(' ', 1) 170 | params = shlex_split(params) 171 | 172 | parser, popts, pargs = parse_command_options(cmd, params) 173 | 174 | result = format_command_line(cmd, is_long, pargs, popts) 175 | 176 | result_words = set(result.split(' ')[1:]) 177 | expected_words = set(expected.split(' ')) 178 | 179 | assert result_words == expected_words 180 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py33, py34, py35, py36, py37 3 | [testenv] 4 | deps = pytest 5 | mock 6 | py-pretty 7 | fuzzyfinder 8 | commands = py.test -------------------------------------------------------------------------------- /wharfee/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.10' 2 | -------------------------------------------------------------------------------- /wharfee/completer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | 4 | import fuzzyfinder 5 | 6 | from itertools import chain 7 | from prompt_toolkit.completion import Completer, Completion 8 | from .options import COMMAND_OPTIONS, COMMAND_NAMES, all_options, find_option, \ 9 | split_command_and_args 10 | from .helpers import list_dir, parse_path, complete_path 11 | from .utils import shlex_split, shlex_first_token 12 | 13 | 14 | class DockerCompleter(Completer): 15 | """ 16 | Completer for Docker commands and parameters. 17 | """ 18 | 19 | def __init__(self, containers=None, running=None, images=None, tagged=None, 20 | volumes=None, long_option_names=True, fuzzy=False): 21 | """ 22 | Initialize the completer 23 | :return: 24 | """ 25 | self.all_completions = set(COMMAND_NAMES) 26 | self.containers = set(containers) if containers else set() 27 | self.running = set(running) if running else set() 28 | self.images = set(images) if images else set() 29 | self.tagged = set(tagged) if tagged else set() 30 | self.volumes = set(volumes) if volumes else set() 31 | self.long_option_mode = long_option_names 32 | self.fuzzy = fuzzy 33 | self.enabled = True 34 | 35 | def set_enabled(self, enabled): 36 | """ 37 | Setter for enabled/disabled property. 38 | :param enabled: boolean 39 | """ 40 | self.enabled = enabled 41 | 42 | def set_volumes(self, volumes): 43 | """ 44 | Setter for list of available volumes. 45 | :param volumes: list 46 | """ 47 | self.volumes = set(volumes) if volumes else set() 48 | 49 | def set_containers(self, containers): 50 | """ 51 | Setter for list of available containers. 52 | :param containers: list 53 | """ 54 | self.containers = set(containers) if containers else set() 55 | 56 | def set_running(self, containers): 57 | """ 58 | Setter for list of running containers. 59 | :param containers: list 60 | """ 61 | self.running = set(containers) if containers else set() 62 | 63 | def set_images(self, images): 64 | """ 65 | Setter for list of available images. 66 | :param images: list 67 | """ 68 | self.images = set(images) if images else set() 69 | 70 | def set_tagged(self, images): 71 | """ 72 | Setter for list of tagged images. 73 | :param images: list 74 | """ 75 | self.tagged = set(images) if images else set() 76 | 77 | def set_long_options(self, is_long): 78 | """ 79 | Setter for long option names. 80 | :param is_long: boolean 81 | """ 82 | self.long_option_mode = is_long 83 | 84 | def get_long_options(self): 85 | """ 86 | Getter for long option names. 87 | """ 88 | return self.long_option_mode 89 | 90 | def set_fuzzy_match(self, is_fuzzy): 91 | """ 92 | Setter for fuzzy match option. 93 | :param is_fuzzy: boolean 94 | """ 95 | self.fuzzy = is_fuzzy 96 | 97 | def get_fuzzy_match(self): 98 | """ 99 | Getter for fuzzy match option. 100 | :return: boolean 101 | """ 102 | return self.fuzzy 103 | 104 | def get_completions(self, document, _): 105 | """ 106 | Get completions for the current scope. 107 | :param document: 108 | :param _: complete_event 109 | """ 110 | if not self.enabled: 111 | return [] 112 | 113 | if DockerCompleter.in_quoted_string(document.text): 114 | return [] 115 | 116 | word_before_cursor = document.get_word_before_cursor(WORD=True) 117 | words = DockerCompleter.get_tokens(document.text) 118 | command_name = split_command_and_args(words)[0] 119 | 120 | in_command = (len(words) > 1) or \ 121 | ((not word_before_cursor) and command_name) 122 | 123 | if in_command: 124 | previous_word = '' 125 | previous_start = document.find_start_of_previous_word(WORD=True) 126 | 127 | if previous_start == -len(word_before_cursor): 128 | previous_start = document.find_start_of_previous_word( 129 | WORD=True, count=2) 130 | 131 | if previous_start: 132 | previous_word = document.text_before_cursor[previous_start:] 133 | previous_word = previous_word.strip().split()[0] 134 | 135 | params = words[1:] if (len(words) > 1) else [] 136 | completions = DockerCompleter.find_command_matches( 137 | command_name, 138 | word_before_cursor, 139 | previous_word, 140 | params, 141 | self.containers, 142 | self.running, 143 | self.images, 144 | self.tagged, 145 | self.volumes, 146 | self.long_option_mode, 147 | self.fuzzy) 148 | else: 149 | completions = DockerCompleter.find_matches( 150 | word_before_cursor, 151 | self.all_completions, 152 | self.fuzzy) 153 | 154 | return completions 155 | 156 | @staticmethod 157 | def find_command_matches(command, word='', prev='', params=None, 158 | containers=None, running=None, images=None, 159 | tagged=None, volumes=None, long_options=True, 160 | fuzzy=False): 161 | """ 162 | Find all matches in context of the given command. 163 | :param command: string: command keyword (such as "ps", "images") 164 | :param word: string: word currently being typed 165 | :param prev: string: previous word 166 | :param params: list of command parameters 167 | :param containers: list of containers 168 | :param running: list of running containers 169 | :param images: list of images 170 | :param tagged: list of tagged images 171 | :param volumes: list of volumes 172 | :param long_options: boolean 173 | :param fuzzy: boolean 174 | :return: iterable 175 | """ 176 | 177 | params = set(params) if params else set([]) 178 | current_opt = find_option(command, prev) if prev else None 179 | 180 | add_directory = False 181 | add_filepath = False 182 | 183 | if command in COMMAND_OPTIONS: 184 | opt_suggestions = [] 185 | if current_opt: 186 | if current_opt.is_type_container(): 187 | opt_suggestions = containers 188 | elif current_opt.is_type_running(): 189 | opt_suggestions = running 190 | elif current_opt.is_type_image(): 191 | opt_suggestions = images 192 | elif current_opt.is_type_tagged(): 193 | opt_suggestions = tagged 194 | elif current_opt.is_type_volume(): 195 | opt_suggestions = volumes 196 | elif current_opt.is_type_choice(): 197 | opt_suggestions = current_opt.choices 198 | elif current_opt.is_type_dirname(): 199 | add_directory = True 200 | elif current_opt.is_type_filepath(): 201 | add_filepath = True 202 | 203 | for m in DockerCompleter.find_collection_matches( 204 | word, opt_suggestions, fuzzy): 205 | yield m 206 | 207 | if not opt_suggestions: 208 | 209 | def is_unused(o): 210 | """ 211 | Do not offer options that user already set. 212 | Unless user may want to set them multiple times. 213 | Example: -e VAR1=value1 -e VAR2=value2. 214 | """ 215 | return o.long_name not in params and o.short_name not in params 216 | 217 | def is_current(o): 218 | return word in o.names 219 | 220 | def get_opt_name(t): 221 | return t.get_name(long_options) 222 | 223 | positionals = [] 224 | possible_options = [x for x in all_options(command) if is_unused(x) 225 | or is_current(x) 226 | or x.is_multiple] 227 | named_options = sorted([x for x in possible_options if x.name.startswith('-')], 228 | key=get_opt_name) 229 | positional_options = [x for x in possible_options if not x.name.startswith('-')] 230 | 231 | named_option_map = {} 232 | 233 | for x in named_options: 234 | suggestion = x.get_name(long_options) 235 | if suggestion: 236 | named_option_map[suggestion] = x.display 237 | 238 | for m in DockerCompleter.find_dictionary_matches( 239 | word, named_option_map, fuzzy): 240 | yield m 241 | 242 | for opt in positional_options: 243 | if opt.is_type_container(): 244 | positionals = chain(positionals, containers) 245 | elif opt.is_type_image(): 246 | positionals = chain(positionals, images) 247 | elif opt.is_type_running(): 248 | positionals = chain(positionals, running) 249 | elif opt.is_type_tagged(): 250 | positionals = chain(positionals, tagged) 251 | elif opt.is_type_volume(): 252 | positionals = chain(positionals, volumes) 253 | elif opt.is_type_choice(): 254 | positionals = chain(positionals, opt.choices) 255 | elif opt.is_type_dirname(): 256 | add_directory = True 257 | elif opt.is_type_filepath(): 258 | add_filepath = True 259 | 260 | # Also return completions for positional options (images, 261 | # containers, etc.) 262 | for m in DockerCompleter.find_collection_matches( 263 | word, positionals, fuzzy): 264 | yield m 265 | 266 | # Special handling for path completion 267 | if add_directory: 268 | for m in DockerCompleter.find_directory_matches(word): 269 | yield m 270 | if add_filepath: 271 | for m in DockerCompleter.find_filepath_matches(word): 272 | yield m 273 | 274 | @staticmethod 275 | def find_filepath_matches(word): 276 | """ 277 | Yield matching directory or file names. 278 | :param word: 279 | :return: iterable 280 | """ 281 | base_path, last_path, position = parse_path(word) 282 | paths = list_dir(word, dirs_only=False) 283 | for name in sorted(paths): 284 | suggestion = complete_path(name, last_path) 285 | if suggestion: 286 | yield Completion(suggestion, position) 287 | 288 | @staticmethod 289 | def find_directory_matches(word): 290 | """ 291 | Yield matching directory names. 292 | :param word: 293 | :return: iterable 294 | """ 295 | base_dir, last_dir, position = parse_path(word) 296 | dirs = list_dir(word, dirs_only=True) 297 | for name in sorted(dirs): 298 | suggestion = complete_path(name, last_dir) 299 | if suggestion: 300 | yield Completion(suggestion, position) 301 | 302 | @staticmethod 303 | def find_dictionary_matches(word, dic, fuzzy): 304 | """ 305 | Yield all matching names in dict 306 | :param dic: dict mapping name to display name 307 | :param word: string user typed 308 | :param fuzzy: boolean 309 | :return: iterable 310 | """ 311 | 312 | if fuzzy: 313 | for suggestion in fuzzyfinder.fuzzyfinder(word, dic.keys()): 314 | yield Completion(suggestion, -len(word), dic[suggestion]) 315 | else: 316 | for name in sorted(dic.keys()): 317 | if name.startswith(word) or not word: 318 | yield Completion(name, -len(word), dic[name]) 319 | 320 | @staticmethod 321 | def find_collection_matches(word, lst, fuzzy): 322 | """ 323 | Yield all matching names in list 324 | :param lst: collection 325 | :param word: string user typed 326 | :param fuzzy: boolean 327 | :return: iterable 328 | """ 329 | 330 | if fuzzy: 331 | for suggestion in fuzzyfinder.fuzzyfinder(word, lst): 332 | yield Completion(suggestion, -len(word)) 333 | else: 334 | for name in sorted(lst): 335 | if name.startswith(word) or not word: 336 | yield Completion(name, -len(word)) 337 | 338 | @staticmethod 339 | def find_matches(text, collection, fuzzy): 340 | """ 341 | Find all matches for the current text 342 | :param text: text before cursor 343 | :param collection: collection to suggest from 344 | :param fuzzy: boolean 345 | :return: iterable 346 | """ 347 | text = DockerCompleter.last_token(text).lower() 348 | 349 | for suggestion in DockerCompleter.find_collection_matches( 350 | text, collection, fuzzy): 351 | yield suggestion 352 | 353 | @staticmethod 354 | def get_tokens(text): 355 | """ 356 | Parse out all tokens. 357 | :param text: 358 | :return: list 359 | """ 360 | if text is not None: 361 | text = text.strip() 362 | words = DockerCompleter.safe_split(text) 363 | return words 364 | return [] 365 | 366 | @staticmethod 367 | def first_token(text): 368 | """ 369 | Find first word in a sentence 370 | :param text: 371 | :return: 372 | """ 373 | if text is not None: 374 | text = text.strip() 375 | if len(text) > 0: 376 | try: 377 | word = shlex_first_token(text) 378 | word = word.strip() 379 | return word 380 | except: 381 | # no error, just do not complete 382 | pass 383 | return '' 384 | 385 | @staticmethod 386 | def last_token(text): 387 | """ 388 | Find last word in a sentence 389 | :param text: 390 | :return: 391 | """ 392 | if text is not None: 393 | text = text.strip() 394 | if len(text) > 0: 395 | word = DockerCompleter.safe_split(text)[-1] 396 | word = word.strip() 397 | return word 398 | return '' 399 | 400 | @staticmethod 401 | def safe_split(text): 402 | """ 403 | Shlex can't always split. For example, "\" crashes the completer. 404 | """ 405 | try: 406 | words = shlex_split(text) 407 | return words 408 | except: 409 | return text 410 | 411 | @staticmethod 412 | def in_quoted_string(text): 413 | """ 414 | Find last word in a sentence 415 | :param text: 416 | :return: 417 | """ 418 | if text is not None: 419 | text = text.strip() 420 | if len(text) > 0 and ('"' in text or "'" in text): 421 | stack = [] 422 | for char in text: 423 | if char in ['"', "'"]: 424 | if len(stack) > 0 and stack[-1] == char: 425 | stack = stack[:-1] 426 | else: 427 | stack.append(char) 428 | return len(stack) > 0 429 | return False 430 | -------------------------------------------------------------------------------- /wharfee/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | import shutil 3 | from os.path import expanduser, exists 4 | 5 | from configobj import ConfigObj 6 | 7 | 8 | def read_config(usr_config, def_config=None): 9 | """ 10 | Read config file (if not exists, read default config). 11 | :param usr_config: string: config file name 12 | :param def_config: string: default name 13 | :return: ConfigParser 14 | """ 15 | usr_config_file = expanduser(usr_config) 16 | 17 | cfg = ConfigObj() 18 | cfg.filename = usr_config_file 19 | 20 | if def_config: 21 | cfg.merge(ConfigObj(def_config, interpolation=False)) 22 | 23 | cfg.merge(ConfigObj(usr_config_file, interpolation=False)) 24 | return cfg 25 | 26 | 27 | def write_default_config(source, destination, overwrite=False): 28 | """ 29 | Write default config (from template). 30 | :param source: string: path to template 31 | :param destination: string: path to write 32 | :param overwrite: boolean 33 | """ 34 | destination = expanduser(destination) 35 | if not overwrite and exists(destination): 36 | return 37 | 38 | shutil.copyfile(source, destination) 39 | -------------------------------------------------------------------------------- /wharfee/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | import functools 3 | 4 | 5 | def if_exception_return(ex_type, then_result): 6 | def decorator(f): 7 | @functools.wraps(f) 8 | def wrapper(self, *args, **kwargs): 9 | try: 10 | return f(self, *args, **kwargs) 11 | except ex_type as x: 12 | self.exception = x 13 | return then_result 14 | return wrapper 15 | return decorator 16 | -------------------------------------------------------------------------------- /wharfee/formatter.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Helper functions to format output for CLI. 4 | """ 5 | from __future__ import unicode_literals 6 | from __future__ import print_function 7 | 8 | import json 9 | import click 10 | import six 11 | from tabulate import tabulate 12 | from pygments import highlight 13 | from pygments.lexers.data import JsonLexer 14 | from pygments.formatters.terminal import TerminalFormatter 15 | from ruamel.yaml import YAML 16 | 17 | try: 18 | from StringIO import StringIO 19 | except ImportError: 20 | from io import StringIO 21 | 22 | # Python 3 has no 'basestring' or 'long' type we're checking for. 23 | try: 24 | unicode 25 | except NameError: 26 | # Python 3 27 | basestring = unicode = str 28 | long = int 29 | 30 | 31 | class StreamFormatter(object): 32 | 33 | def __init__(self, data): 34 | """ 35 | Initialize the formatter passing in the stream. 36 | :param data: generator 37 | """ 38 | self.stream = data 39 | self.counter = 0 40 | 41 | def output(self): 42 | """ 43 | Process and output line by line. 44 | :return: int 45 | """ 46 | for line in self.stream: 47 | self.counter += 1 48 | line = line.strip() 49 | click.echo(line) 50 | return self.counter 51 | 52 | 53 | class JsonStreamDumper(StreamFormatter): 54 | 55 | lexer = JsonLexer() 56 | term = TerminalFormatter() 57 | 58 | def output(self): 59 | """ 60 | Process and output object by object. 61 | :return: int 62 | """ 63 | for obj in self.stream: 64 | self.counter += 1 65 | if isinstance(obj, six.string_types): 66 | click.echo(obj) 67 | else: 68 | text = json.dumps(obj, indent=4) 69 | text = self.colorize(text) 70 | for line in text.split('\n'): 71 | click.echo(line) 72 | return self.counter 73 | 74 | def colorize(self, text): 75 | return highlight(text, self.lexer, self.term).rstrip('\r\n') 76 | 77 | 78 | class JsonStreamFormatter(StreamFormatter): 79 | 80 | progress = False 81 | 82 | def __init__(self, data): 83 | """ 84 | Initialize the formatter passing in the stream. 85 | :param data: generator 86 | """ 87 | StreamFormatter.__init__(self, data) 88 | 89 | def output(self): 90 | """ 91 | Process and output line by line. 92 | :return: int 93 | 94 | Lines that contain progress information in them get special handling: 95 | 96 | { 97 | "status":"Extracting", 98 | "progressDetail":{ 99 | "current":64618496, 100 | "total":65771329 101 | }, 102 | "progress":"[======================\u003e ] 64.62 MB/65.77 MB", 103 | "id":"e9e06b06e14c" 104 | } 105 | 106 | """ 107 | for line in self.stream: 108 | self.counter += 1 109 | parts = line.strip().decode('utf8').split('\r\n') 110 | for part in parts: 111 | data = json.loads(part) 112 | if self.is_progress(data): 113 | self.show_progress_line(data) 114 | else: 115 | self.show_progress_end() 116 | self.show_line(data) 117 | 118 | return self.counter 119 | 120 | def is_progress(self, data): 121 | """ 122 | If the JSON data contains progress bar information. 123 | :param data: json 124 | """ 125 | return 'progress' in data and data['progress'] 126 | 127 | def show_line(self, data): 128 | """ 129 | Format and output a JSON line. 130 | :param data: json 131 | """ 132 | if 'id' in data and data['id']: 133 | line = "{0} {1}".format(data['status'], data['id']) 134 | elif 'status' in data: 135 | line = "{0}".format(data['status']) 136 | elif 'stream' in data: 137 | line = "{0}".format(data['stream']) 138 | elif 'errorDetail' in data and data['errorDetail']: 139 | line = "{0}".format(data['errorDetail'].get( 140 | 'message', 'Unknown error')) 141 | elif 'error' in data and data['error']: 142 | line = "{0}".format(data['error']) 143 | else: 144 | line = "{0}".format(data) 145 | 146 | if line: 147 | line = line.rstrip() 148 | 149 | click.echo(line) 150 | 151 | def show_progress_end(self): 152 | """ 153 | If we were just showing progress bar, enter a newline and unset 154 | the progress flag. 155 | """ 156 | if self.progress: 157 | click.echo() 158 | self.progress = False 159 | 160 | def show_progress_line(self, data): 161 | """ 162 | Output a carriage return and new progress. 163 | :param data: json 164 | """ 165 | click.echo(b'\x0d', nl=False) 166 | 167 | self.progress = True 168 | 169 | line = "{0} {1}: {2}".format( 170 | data['status'], 171 | data['id'], 172 | data['progress']) 173 | 174 | click.echo(line, nl=False) 175 | 176 | 177 | def format_data(command, data): 178 | """ 179 | Uses tabulate to format the iterable. 180 | :return: string (multiline) 181 | """ 182 | if command and command in DATA_FORMATTERS: 183 | f = DATA_FORMATTERS[command] 184 | assert callable(f) 185 | return f(data) 186 | 187 | if command and command in DATA_FILTERS: 188 | data = DATA_FILTERS[command](data) 189 | 190 | if isinstance(data, dict): 191 | return format_struct(data) 192 | if isinstance(data, list) and len(data) > 0: 193 | if isinstance(data[0], tuple): 194 | text = tabulate(data) 195 | return text.split('\n') 196 | elif isinstance(data[0], dict): 197 | if data[0].keys() == ['Id']: 198 | # Sometimes our 'quiet' output is a list of dicts but 199 | # there's only a single "Id" key in each dict. Let's simplify 200 | # those into plain string lists. 201 | return [d['Id'] for d in data] 202 | else: 203 | data = flatten_rows(data) 204 | data = truncate_rows(data) 205 | text = tabulate(data, headers='keys') 206 | return text.split('\n') 207 | elif isinstance(data[0], six.string_types): 208 | if len(data) == 1: 209 | return data 210 | elif is_plain_list(data): 211 | return data 212 | else: 213 | data = truncate_rows(data) 214 | text = tabulate(data) 215 | return text.split('\n') 216 | return data 217 | 218 | 219 | def format_struct(data, indent=4): 220 | output = StringIO() 221 | yaml = YAML() 222 | yaml.default_flow_style = False 223 | yaml.indent = indent 224 | yaml.dump(data, stream=output) 225 | lines = output.getvalue().split('\n') 226 | return lines 227 | 228 | 229 | def is_plain_lists(lst): 230 | """ 231 | Check if all items in list of lists are strings or numbers 232 | :param lst: 233 | :return: boolean 234 | """ 235 | for x in lst: 236 | if not is_plain_list(x): 237 | return False 238 | return True 239 | 240 | 241 | def is_plain_list(lst): 242 | """ 243 | Check if all items in list are strings or numbers 244 | :param lst: 245 | :return: boolean 246 | """ 247 | for item in lst: 248 | if not isinstance(item, six.string_types) and \ 249 | not isinstance(item, (int, long, float, complex)): 250 | return False 251 | return True 252 | 253 | 254 | def flatten_list(data): 255 | """ 256 | Format and return a comma-separated string of list items. 257 | :param data: 258 | :return: 259 | """ 260 | return ', '.join(["{0}".format(x) for x in data]) 261 | 262 | 263 | def flatten_dict(data): 264 | """ 265 | Format and return a comma-separated string of dict items. 266 | :param data: 267 | :return: 268 | """ 269 | return ', '.join(["{0}: {1}".format(x, y) for x, y in data.items()]) 270 | 271 | 272 | def format_port_lines(ports): 273 | """ 274 | Return ports as list of strings 275 | :param ports: list of dicts 276 | :return: list of strings 277 | """ 278 | port_s = format_ports(ports) 279 | result = port_s.split(', ') 280 | return result 281 | 282 | 283 | def format_ports(ports): 284 | """ 285 | Ports get special treatment. 286 | 287 | They are a list that looks like this: 288 | [{u'Type': u'tcp', u'PrivatePort': 3306}] 289 | 290 | We return this: 291 | "3306/tcp" 292 | 293 | Or instead of this: 294 | [{ 295 | u'IP': u'0.0.0.0', 296 | u'Type': u'tcp', 297 | u'PublicPort': 3000, 298 | u'PrivatePort': 3306}] 299 | 300 | We return: 301 | "0.0.0.0:3000->3306/tcp" 302 | 303 | :param ports: list of dicts 304 | :return: string 305 | """ 306 | 307 | def format_port_list(l): 308 | if isinstance(l, list): 309 | return ', '.join([format_port(x) for x in l]) 310 | return '{0}'.format(l) 311 | 312 | def format_port(port): 313 | """ 314 | Format port dictionary and return string. 315 | """ 316 | if 'PublicPort' in port: 317 | return '{0}:{1}->{2}/{3}'.format( 318 | port.get('IP', '0.0.0.0'), 319 | port.get('PublicPort', '0000'), 320 | port.get('PrivatePort', '0000'), 321 | port.get('Type', 'type')) 322 | if 'HostPort' in port: 323 | return "{0}:{1}".format( 324 | port.get('HostIp', '0.0.0.0'), 325 | port.get('HostPort', '0000') 326 | ) 327 | if 'PrivatePort' in port: 328 | return '{0}/{1}'.format( 329 | port.get('PrivatePort', '0000'), 330 | port.get('Type', 'type')) 331 | # Fallback to formatting "as is" 332 | return "{0}".format(port) 333 | 334 | if isinstance(ports, dict): 335 | return ', '.join(['{0}->{1}'.format(k, format_port_list(v)) 336 | for k, v in ports.items()]) 337 | return ', '.join(format_port(x) for x in ports) 338 | 339 | 340 | def flatten_rows(rows): 341 | """ 342 | Transform all list or dict values in a dict into comma-separated strings. 343 | :param rows: iterable of dictionaries 344 | :return: 345 | """ 346 | 347 | for row in rows: 348 | for k in row.keys(): 349 | if k in ROW_FORMATTERS: 350 | row[k] = ROW_FORMATTERS[k](row[k]) 351 | elif isinstance(row[k], list): 352 | row[k] = flatten_list(row[k]) 353 | elif isinstance(row[k], dict): 354 | row[k] = flatten_dict(row[k]) 355 | return rows 356 | 357 | 358 | def truncate_rows(rows, length=30, length_id=10): 359 | """ 360 | Truncate every string value in a dictionary up to a certain length. 361 | :param rows: iterable of dictionaries 362 | :param length: int 363 | :param length_id: length for dict keys that end with "Id" 364 | :return: 365 | """ 366 | 367 | def trimto(s, l): 368 | """ 369 | Trim string to length. 370 | :param s: string to trim 371 | :param l: length 372 | """ 373 | if isinstance(s, six.string_types): 374 | return s[:l + 1] 375 | return s 376 | 377 | result = [] 378 | for row in rows: 379 | if isinstance(row, dict): 380 | updated = {} 381 | for k, v in row.items(): 382 | if k.endswith('Id'): 383 | updated[k] = trimto(v, length_id) 384 | else: 385 | updated[k] = trimto(v, length) 386 | result.append(updated) 387 | elif isinstance(row, six.string_types): 388 | result.append(trimto(row, length)) 389 | else: 390 | result.append(row) 391 | return result 392 | 393 | 394 | def format_top(data): 395 | """ 396 | Format "top" output 397 | :param data: dict 398 | :return: list 399 | """ 400 | result = [] 401 | if data: 402 | if 'Titles' in data: 403 | result.append(data['Titles']) 404 | if 'Processes' in data: 405 | for process in data['Processes']: 406 | result.append(process) 407 | result = tabulate(result, headers='firstrow').split('\n') 408 | return result 409 | 410 | 411 | def filter_dict(data, display_keys): 412 | """ 413 | Strip out some of the dictionary fields. 414 | :param display_keys: set 415 | :param data: dict 416 | :return: dict 417 | """ 418 | if data and isinstance(data, list) and isinstance(data[0], dict): 419 | result = [] 420 | for item in data: 421 | filtered = {} 422 | for k, v in item.items(): 423 | if k.lower() in display_keys: 424 | filtered[k] = v 425 | result.append(filtered) 426 | return result 427 | return data 428 | 429 | 430 | def filter_ps(data): 431 | display_keys = set([ 432 | 'status', 'created', 'image', 'id', 'command', 'names', 'ports']) 433 | return filter_dict(data, display_keys) 434 | 435 | 436 | def filter_volume_ls(data): 437 | display_keys = set(['driver', 'name']) 438 | return filter_dict(data, display_keys) 439 | 440 | 441 | DATA_FILTERS = { 442 | 'ps': filter_ps, 443 | 'volume ls': filter_volume_ls, 444 | } 445 | 446 | 447 | DATA_FORMATTERS = { 448 | 'top': format_top, 449 | 'port': format_port_lines 450 | } 451 | 452 | ROW_FORMATTERS = { 453 | 'Ports': format_ports 454 | } 455 | 456 | STREAM_FORMATTERS = { 457 | 'pull': JsonStreamFormatter, 458 | 'push': JsonStreamFormatter, 459 | 'build': JsonStreamFormatter, 460 | 'inspect': JsonStreamDumper, 461 | 'volume inspect': JsonStreamDumper, 462 | } 463 | 464 | 465 | def output_stream(command, stream, logs): 466 | """ 467 | Take the iterable and output line by line using click.echo. 468 | :param command: string command 469 | :param stream: generator 470 | :param logs: callable 471 | :return: None 472 | """ 473 | if command and command in STREAM_FORMATTERS: 474 | formatter = STREAM_FORMATTERS[command](stream) 475 | else: 476 | formatter = StreamFormatter(stream) 477 | 478 | stream_count = formatter.output() 479 | 480 | if stream_count == 0 and logs and callable(logs): 481 | # Something nasty has happened and we got an empty 482 | # output stream. But we have logs. Let's show those. 483 | lines = logs() 484 | if lines: 485 | lines = lines.split('\n') 486 | for line in lines: 487 | click.echo(line) 488 | -------------------------------------------------------------------------------- /wharfee/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | import os 3 | import math 4 | 5 | 6 | def parse_kv_as_dict(filters, convert_boolean=False): 7 | """ 8 | Parse list of "key=value" into dict 9 | :param filters: list 10 | :param convert_boolean: boolean 11 | :return: dict 12 | """ 13 | result = {} 14 | if filters: 15 | for x in filters: 16 | k, v = x.split('=', 2) 17 | if convert_boolean: 18 | if v.lower() == 'true': 19 | v = True 20 | elif v.lower() == 'false': 21 | v = False 22 | result[k] = v 23 | return result 24 | 25 | 26 | def parse_volume_bindings(volumes): 27 | """ 28 | Parse volumes into a dict. 29 | :param volumes: list of strings 30 | :return: dict 31 | """ 32 | 33 | def parse_volume(v): 34 | if ':' in v: 35 | parts = v.split(':') 36 | if len(parts) > 2: 37 | hp, cp, ro = parts[0], parts[1], parts[2] 38 | return hp, cp, ro == 'ro' 39 | else: 40 | hp, cp = parts[0], parts[1] 41 | return hp, cp, False 42 | else: 43 | return None, v, False 44 | 45 | result = {} 46 | if volumes: 47 | for vol in volumes: 48 | host_path, container_path, read_only = parse_volume(vol) 49 | if host_path: 50 | result[host_path] = { 51 | 'bind': container_path, 52 | 'ro': read_only 53 | } 54 | return result 55 | 56 | 57 | def parse_exposed_ports(ps): 58 | """ 59 | Parse array of exposed ports (not public). 60 | 61 | [1000] -> { 1000: None } 62 | [1000-1002] -> { 1000: None, 1001: None, 1002: None } 63 | 64 | :return: dict 65 | """ 66 | result = {} 67 | for p in ps: 68 | if '-' in p: 69 | # it is a range from port to port 70 | p1, p2 = p.split('-') 71 | for x in range(p1, p2 + 1): 72 | result[x] = None 73 | else: 74 | result[p] = None 75 | return result 76 | 77 | 78 | def parse_port_bindings(bindings): 79 | """ 80 | Parse array of string port bindings into a dict. For example: 81 | ['4567:1111', '2222'] 82 | becomes 83 | port_bindings={ 84 | 1111: 4567, 85 | 2222: None 86 | } 87 | and 88 | ['127.0.0.1:4567:1111'] 89 | becomes 90 | port_bindings={ 91 | 1111: ('127.0.0.1', 4567) 92 | } 93 | :param bindings: array of string 94 | :return: dict 95 | """ 96 | 97 | def parse_port_mapping(s): 98 | """ 99 | Parse single port mapping. 100 | """ 101 | if ':' in s: 102 | parts = s.split(':') 103 | if len(parts) > 2: 104 | ip, hp, cp = parts[0], parts[1], parts[2] 105 | return cp, (ip, None if hp == '' else hp) 106 | else: 107 | hp, cp = parts[0], parts[1] 108 | return cp, None if hp == '' else hp 109 | else: 110 | return s, None 111 | 112 | result = {} 113 | if bindings: 114 | for binding in bindings: 115 | container_port, mapping = parse_port_mapping(binding) 116 | result[container_port] = mapping 117 | return result 118 | 119 | 120 | def filesize(size): 121 | """ 122 | Pretty-print file size from bytes. 123 | """ 124 | size_name = ('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB') 125 | if int(size) > 0: 126 | i = int(math.floor(math.log(size, 1024))) 127 | p = math.pow(1024, i) 128 | s = round(size / p, 3) 129 | if s > 0: 130 | return '%s %s' % (s, size_name[i]) 131 | return '0 B' 132 | 133 | 134 | def complete_path(curr_dir, last_dir): 135 | """ 136 | Return the path to complete that matches the last entered component. 137 | If the last entered component is ~, expanded path would not 138 | match, so return all of the available paths. 139 | :param curr_dir: string 140 | :param last_dir: string 141 | :return: string 142 | """ 143 | if not last_dir or curr_dir.startswith(last_dir): 144 | return curr_dir 145 | elif last_dir == '~': 146 | return os.path.join(last_dir, curr_dir) 147 | 148 | 149 | def parse_path(root_dir): 150 | """ 151 | Split path into head and last component for the completer. 152 | Also return position where last component starts. 153 | :param root_dir: path 154 | :return: tuple of (string, string, int) 155 | """ 156 | base_dir, last_dir, position = '', '', 0 157 | if root_dir: 158 | base_dir, last_dir = os.path.split(root_dir) 159 | position = -len(last_dir) if last_dir else 0 160 | return base_dir, last_dir, position 161 | 162 | 163 | def list_dir(root_dir, dirs_only=False, include_special=False): 164 | """ 165 | List directory. 166 | :param root_dir: string: directory to list 167 | :param dirs_only: boolean 168 | :param include_special: boolean 169 | :return: list 170 | """ 171 | root_dir = '.' if not root_dir else root_dir 172 | res = [] 173 | 174 | if '~' in root_dir: 175 | root_dir = os.path.expanduser(root_dir) 176 | 177 | if not os.path.exists(root_dir): 178 | root_dir, _ = os.path.split(root_dir) 179 | 180 | if os.path.exists(root_dir): 181 | for name in os.listdir(root_dir): 182 | path = os.path.join(root_dir, name) 183 | if not include_special and name.startswith('.'): 184 | continue 185 | if dirs_only and not os.path.isdir(path): 186 | continue 187 | res.append(name) 188 | return res 189 | -------------------------------------------------------------------------------- /wharfee/keys.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | from __future__ import print_function 4 | 5 | from prompt_toolkit.key_binding.manager import KeyBindingManager 6 | from prompt_toolkit.keys import Keys 7 | 8 | 9 | def get_key_manager(set_long_options, get_long_options, set_fuzzy_match, get_fuzzy_match): 10 | """ 11 | Create and initialize keybinding manager 12 | :return: KeyBindingManager 13 | """ 14 | 15 | assert callable(set_long_options) 16 | assert callable(get_long_options) 17 | assert callable(set_fuzzy_match) 18 | assert callable(get_fuzzy_match) 19 | 20 | manager = KeyBindingManager( 21 | enable_search=True, 22 | enable_system_bindings=True, 23 | enable_abort_and_exit_bindings=True) 24 | 25 | @manager.registry.add_binding(Keys.F2) 26 | def _(event): 27 | """ 28 | When F2 has been pressed, fill in the "help" command. 29 | """ 30 | event.cli.current_buffer.insert_text("help") 31 | 32 | @manager.registry.add_binding(Keys.F3) 33 | def _(_): 34 | """ 35 | Enable/Disable long option name suggestion. 36 | """ 37 | set_long_options(not get_long_options()) 38 | 39 | @manager.registry.add_binding(Keys.F4) 40 | def _(_): 41 | """ 42 | Enable/Disable fuzzy matching. 43 | """ 44 | set_fuzzy_match(not get_fuzzy_match()) 45 | 46 | @manager.registry.add_binding(Keys.F10) 47 | def _(event): 48 | """ 49 | When F10 has been pressed, quit. 50 | """ 51 | # Unused parameters for linter. 52 | raise EOFError 53 | 54 | @manager.registry.add_binding(Keys.ControlSpace) 55 | def _(event): 56 | """ 57 | Initialize autocompletion at cursor. 58 | 59 | If the autocompletion menu is not showing, display it with the 60 | appropriate completions for the context. 61 | 62 | If the menu is showing, select the next completion. 63 | """ 64 | b = event.cli.current_buffer 65 | if b.complete_state: 66 | b.complete_next() 67 | else: 68 | event.cli.start_completion(select_first=False) 69 | 70 | return manager 71 | -------------------------------------------------------------------------------- /wharfee/lexer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from pygments.lexer import RegexLexer 3 | from pygments.lexer import words 4 | from pygments.token import Operator, Keyword, Text 5 | 6 | from .options import COMMAND_NAMES, all_option_names 7 | 8 | 9 | class CommandLexer(RegexLexer): 10 | name = 'Command Line' 11 | aliases = ['cli'] 12 | filenames = [] 13 | 14 | tokens = { 15 | 'root': [ 16 | (words(tuple(COMMAND_NAMES), prefix=r'^', suffix=r'\b'), 17 | Operator.Word), 18 | (words(tuple(all_option_names()), prefix=r'', suffix=r'\b'), 19 | Keyword), 20 | (r'.*\n', Text), 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /wharfee/logger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | from __future__ import print_function 4 | 5 | import os 6 | import logging 7 | 8 | 9 | def create_logger(name, log_file, log_level): 10 | """ 11 | Create and return logger for package. 12 | :param name: string logger name 13 | :param log_file: string log file name 14 | :param log_level: string 15 | :return: logger 16 | """ 17 | logger = logging.getLogger(name) 18 | 19 | level_map = { 20 | 'CRITICAL': logging.CRITICAL, 21 | 'ERROR': logging.ERROR, 22 | 'WARNING': logging.WARNING, 23 | 'INFO': logging.INFO, 24 | 'DEBUG': logging.DEBUG 25 | } 26 | 27 | handler = logging.FileHandler(os.path.expanduser(log_file)) 28 | 29 | formatter = logging.Formatter( 30 | '%(asctime)s (%(process)d/%(threadName)s) ' 31 | '%(name)s %(levelname)s - %(message)s') 32 | 33 | handler.setFormatter(formatter) 34 | 35 | root_logger = logging.getLogger('wharfee') 36 | root_logger.addHandler(handler) 37 | root_logger.setLevel(level_map[log_level.upper()]) 38 | 39 | root_logger.debug('Initializing wharfee logging.') 40 | root_logger.debug('Log file %r.', log_file) 41 | 42 | return logger 43 | -------------------------------------------------------------------------------- /wharfee/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 3 | from __future__ import unicode_literals 4 | from __future__ import print_function 5 | 6 | import os 7 | import click 8 | import traceback 9 | 10 | from types import GeneratorType 11 | from prompt_toolkit import (AbortAction, 12 | Application, 13 | CommandLineInterface) 14 | from prompt_toolkit.enums import DEFAULT_BUFFER 15 | from prompt_toolkit.filters import Always, HasFocus, IsDone 16 | from prompt_toolkit.layout.processors import ( 17 | HighlightMatchingBracketProcessor, 18 | ConditionalProcessor 19 | ) 20 | from prompt_toolkit.buffer import (Buffer, AcceptAction) 21 | from prompt_toolkit.shortcuts import (create_prompt_layout, create_eventloop) 22 | from prompt_toolkit.history import FileHistory 23 | 24 | from .client import DockerClient 25 | from .client import DockerPermissionException 26 | from .client import DockerTimeoutException 27 | from .client import DockerSslException 28 | from .completer import DockerCompleter 29 | from .lexer import CommandLexer 30 | from .formatter import format_data 31 | from .formatter import output_stream 32 | from .config import write_default_config, read_config 33 | from .style import style_factory 34 | from .keys import get_key_manager 35 | from .toolbar import create_toolbar_handler 36 | from .options import OptionError 37 | from .logger import create_logger 38 | from .__init__ import __version__ 39 | 40 | # Added this to avoid the annoying warning: http://click.pocoo.org/5/python3/ 41 | click.disable_unicode_literals_warning = True 42 | 43 | 44 | class WharfeeCli(object): 45 | """ 46 | The CLI implementation. 47 | """ 48 | 49 | dcli = None 50 | keyword_completer = None 51 | handler = None 52 | saved_less_opts = None 53 | config = None 54 | config_template = 'wharfeerc' 55 | config_name = '~/.wharfeerc' 56 | 57 | def __init__(self, no_completion=False): 58 | """ 59 | Initialize class members. 60 | Should read the config here at some point. 61 | """ 62 | 63 | self.config = self.read_configuration() 64 | self.theme = self.config['main']['theme'] 65 | 66 | log_file = self.config['main']['log_file'] 67 | log_level = self.config['main']['log_level'] 68 | self.logger = create_logger(__name__, log_file, log_level) 69 | 70 | # set_completer_options refreshes all by default 71 | self.handler = DockerClient( 72 | self.config['main'].as_int('client_timeout'), 73 | self.clear, 74 | self.refresh_completions_force, 75 | self.logger) 76 | 77 | self.completer = DockerCompleter( 78 | long_option_names=self.get_long_options(), 79 | fuzzy=self.get_fuzzy_match()) 80 | self.set_completer_options() 81 | self.completer.set_enabled(not no_completion) 82 | self.saved_less_opts = self.set_less_opts() 83 | 84 | def read_configuration(self): 85 | """ 86 | 87 | :return: 88 | """ 89 | default_config = os.path.join( 90 | self.get_package_path(), self.config_template) 91 | write_default_config(default_config, self.config_name) 92 | return read_config(self.config_name, default_config) 93 | 94 | def get_package_path(self): 95 | """ 96 | Find out pakage root path. 97 | :return: string: path 98 | """ 99 | from wharfee import __file__ as package_root 100 | return os.path.dirname(package_root) 101 | 102 | def set_less_opts(self): 103 | """ 104 | Set the "less" options and save the old settings. 105 | 106 | What we're setting: 107 | -F: 108 | --quit-if-one-screen: Quit if entire file fits on first screen. 109 | -R: 110 | --raw-control-chars: Output "raw" control characters. 111 | -X: 112 | --no-init: Don't use termcap keypad init/deinit strings. 113 | --no-keypad: Don't use termcap init/deinit strings. 114 | This also disables launching "less" in an alternate screen. 115 | 116 | :return: string with old options 117 | """ 118 | opts = os.environ.get('LESS', '') 119 | os.environ['LESS'] = '-RXF' 120 | return opts 121 | 122 | def revert_less_opts(self): 123 | """ 124 | Restore the previous "less" options. 125 | """ 126 | os.environ['LESS'] = self.saved_less_opts 127 | 128 | def write_config_file(self): 129 | """ 130 | Write config file on exit. 131 | """ 132 | self.config.write() 133 | 134 | def clear(self): 135 | """ 136 | Clear the screen. 137 | """ 138 | click.clear() 139 | 140 | def set_completer_options(self, cons=True, runs=True, imgs=True, vols=True): 141 | """ 142 | Set image and container names in Completer. 143 | Re-read if needed after a command. 144 | :param cons: boolean: need to refresh containers 145 | :param runs: boolean: need to refresh running containers 146 | :param imgs: boolean: need to refresh images 147 | :param vols: boolean: need to refresh volumes 148 | """ 149 | 150 | if cons: 151 | cs = self.handler.containers(all=True) 152 | if cs and len(cs) > 0 and isinstance(cs[0], dict): 153 | containers = [name for c in cs for name in c['Names']] 154 | self.completer.set_containers(containers) 155 | 156 | if runs: 157 | cs = self.handler.containers() 158 | if cs and len(cs) > 0 and isinstance(cs[0], dict): 159 | running = [name for c in cs for name in c['Names']] 160 | self.completer.set_running(running) 161 | 162 | if imgs: 163 | def format_tagged(tagname, img_id): 164 | if tagname == ':': 165 | return img_id[:11] 166 | return tagname 167 | 168 | def parse_image_name(tag, img_id): 169 | if ':' in tag: 170 | result = tag.split(':', 2)[0] 171 | else: 172 | result = tag 173 | if result == '': 174 | result = img_id[:11] 175 | return result 176 | 177 | ims = self.handler.images() 178 | if ims and len(ims) > 0 and isinstance(ims[0], dict): 179 | images = set([]) 180 | tagged = set([]) 181 | for im in ims: 182 | repo_tag = '{0}:{1}'.format(im['Repository'], im['Tag']) 183 | images.add(parse_image_name(repo_tag, im['Id'])) 184 | tagged.add(format_tagged(repo_tag, im['Id'])) 185 | self.completer.set_images(images) 186 | self.completer.set_tagged(tagged) 187 | 188 | if vols: 189 | vs = self.handler.volume_ls(quiet=True) 190 | self.completer.set_volumes(vs) 191 | 192 | def set_fuzzy_match(self, is_fuzzy): 193 | """ 194 | Setter for fuzzy matching mode 195 | :param is_fuzzy: boolean 196 | """ 197 | self.config['main']['fuzzy_match'] = is_fuzzy 198 | self.completer.set_fuzzy_match(is_fuzzy) 199 | 200 | def get_fuzzy_match(self): 201 | """ 202 | Getter for fuzzy matching mode 203 | :return: boolean 204 | """ 205 | return self.config['main'].as_bool('fuzzy_match') 206 | 207 | def set_long_options(self, is_long): 208 | """ 209 | Setter for long option names. 210 | :param is_long: boolean 211 | """ 212 | self.config['main']['suggest_long_option_names'] = is_long 213 | self.completer.set_long_options(is_long) 214 | 215 | def get_long_options(self): 216 | """ 217 | Getter for long option names. 218 | :return: boolean 219 | """ 220 | return self.config['main'].as_bool('suggest_long_option_names') 221 | 222 | def refresh_completions_force(self): 223 | """Force refresh and make it visible.""" 224 | self.set_completer_options() 225 | click.echo('Refreshed completions.') 226 | 227 | def refresh_completions(self): 228 | """ 229 | After processing the command, refresh the lists of 230 | containers and images as needed 231 | """ 232 | self.set_completer_options(self.handler.is_refresh_containers, 233 | self.handler.is_refresh_running, 234 | self.handler.is_refresh_images, 235 | self.handler.is_refresh_volumes) 236 | 237 | def run_cli(self): 238 | """ 239 | Run the main loop 240 | """ 241 | print('Version:', __version__) 242 | print('Home: http://wharfee.com') 243 | 244 | history = FileHistory(os.path.expanduser('~/.wharfee-history')) 245 | toolbar_handler = create_toolbar_handler(self.get_long_options, self.get_fuzzy_match) 246 | 247 | layout = create_prompt_layout( 248 | message='wharfee> ', 249 | lexer=CommandLexer, 250 | get_bottom_toolbar_tokens=toolbar_handler, 251 | extra_input_processors=[ 252 | ConditionalProcessor( 253 | processor=HighlightMatchingBracketProcessor( 254 | chars='[](){}'), 255 | filter=HasFocus(DEFAULT_BUFFER) & ~IsDone()) 256 | ] 257 | ) 258 | 259 | cli_buffer = Buffer( 260 | history=history, 261 | completer=self.completer, 262 | complete_while_typing=Always(), 263 | accept_action=AcceptAction.RETURN_DOCUMENT) 264 | 265 | manager = get_key_manager( 266 | self.set_long_options, 267 | self.get_long_options, 268 | self.set_fuzzy_match, 269 | self.get_fuzzy_match) 270 | 271 | application = Application( 272 | style=style_factory(self.theme), 273 | layout=layout, 274 | buffer=cli_buffer, 275 | key_bindings_registry=manager.registry, 276 | on_exit=AbortAction.RAISE_EXCEPTION, 277 | on_abort=AbortAction.RETRY, 278 | ignore_case=True) 279 | 280 | eventloop = create_eventloop() 281 | 282 | self.dcli = CommandLineInterface( 283 | application=application, 284 | eventloop=eventloop) 285 | 286 | while True: 287 | try: 288 | document = self.dcli.run(True) 289 | self.handler.handle_input(document.text) 290 | 291 | if isinstance(self.handler.output, GeneratorType): 292 | output_stream(self.handler.command, 293 | self.handler.output, 294 | self.handler.log) 295 | 296 | elif self.handler.output is not None: 297 | lines = format_data( 298 | self.handler.command, 299 | self.handler.output) 300 | click.echo_via_pager('\n'.join(lines)) 301 | 302 | if self.handler.after: 303 | for line in self.handler.after(): 304 | click.echo(line) 305 | 306 | if self.handler.exception: 307 | # This was handled, just log it. 308 | self.logger.warning('An error was handled: %r', 309 | self.handler.exception) 310 | 311 | self.refresh_completions() 312 | 313 | except OptionError as ex: 314 | self.logger.debug('Error: %r.', ex) 315 | self.logger.error("traceback: %r", traceback.format_exc()) 316 | click.secho(ex.msg, fg='red') 317 | 318 | except KeyboardInterrupt: 319 | # user pressed Ctrl + C 320 | if self.handler.after: 321 | click.echo('') 322 | for line in self.handler.after(): 323 | click.echo(line) 324 | 325 | self.refresh_completions() 326 | 327 | except DockerPermissionException as ex: 328 | self.logger.debug('Permission exception: %r.', ex) 329 | self.logger.error("traceback: %r", traceback.format_exc()) 330 | click.secho(str(ex), fg='red') 331 | 332 | except EOFError: 333 | # exit out of the CLI 334 | break 335 | 336 | # TODO: uncomment for release 337 | except Exception as ex: 338 | self.logger.debug('Exception: %r.', ex) 339 | self.logger.error("traceback: %r", traceback.format_exc()) 340 | click.secho(str(ex), fg='red') 341 | 342 | self.revert_less_opts() 343 | self.write_config_file() 344 | print('Goodbye!') 345 | 346 | 347 | @click.command() 348 | @click.option('--no-completion', is_flag=True, default=False, help='Disable autocompletion.') 349 | def cli(no_completion): 350 | """ 351 | Create and call the CLI 352 | """ 353 | try: 354 | dcli = WharfeeCli(no_completion) 355 | dcli.run_cli() 356 | except DockerTimeoutException as ex: 357 | click.secho(ex.message, fg='red') 358 | except DockerSslException as ex: 359 | click.secho(ex.message, fg='red') 360 | 361 | if __name__ == "__main__": 362 | cli() 363 | -------------------------------------------------------------------------------- /wharfee/option.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | 4 | 5 | class CommandOption(object): 6 | """ 7 | Wrapper for the optparse's option that adds some extra fields 8 | useful to do autocompletion in context of this option. 9 | """ 10 | 11 | choices = None 12 | 13 | OPTION_VALUES = range(18) 14 | 15 | TYPE_FILEPATH, \ 16 | TYPE_DIRPATH, \ 17 | TYPE_BOOLEAN, \ 18 | TYPE_NUMERIC, \ 19 | TYPE_CONTAINER, \ 20 | TYPE_CONTAINER_RUN, \ 21 | TYPE_IMAGE, \ 22 | TYPE_IMAGE_TAGGED, \ 23 | TYPE_IMAGE_TAG, \ 24 | TYPE_COMMAND, \ 25 | TYPE_COMMAND_ARG, \ 26 | TYPE_CHOICE, \ 27 | TYPE_KEYVALUE, \ 28 | TYPE_PORT_BINDING, \ 29 | TYPE_PORT_RANGE, \ 30 | TYPE_OBJECT, \ 31 | TYPE_STRING,\ 32 | TYPE_VOLUME = \ 33 | OPTION_VALUES 34 | 35 | def __init__(self, option_type, short_name, long_name=None, **kwargs): 36 | """ 37 | Constructor for the CommandOption 38 | :param option_type: int: one the type constants from above 39 | :param short_name: string: short name to create optparse's Option 40 | :param long_name: string: long name to create optparse's Option 41 | :param kwargs: keyword args 42 | :return: 43 | """ 44 | if option_type not in self.OPTION_VALUES: 45 | raise ValueError("Incorrect option_type.", option_type) 46 | 47 | if long_name and short_name: 48 | arguments = [short_name, long_name] 49 | self.display = '{0}/{1}'.format(short_name, long_name) 50 | elif long_name: 51 | arguments = [long_name] 52 | self.display = long_name 53 | else: 54 | arguments = [short_name] 55 | self.display = short_name 56 | 57 | if 'api_match' in kwargs and kwargs['api_match'] is not None: 58 | self.api_match = kwargs['api_match'] 59 | del kwargs['api_match'] 60 | else: 61 | self.api_match = True 62 | 63 | if 'cli_match' in kwargs and kwargs['cli_match'] is not None: 64 | self.cli_match = kwargs['cli_match'] 65 | del kwargs['cli_match'] 66 | else: 67 | self.cli_match = True 68 | 69 | if 'dest' in kwargs: 70 | self.dest = kwargs['dest'] 71 | elif long_name: 72 | self.dest = long_name.strip('-') 73 | else: 74 | self.dest = short_name.strip('-') 75 | 76 | self.default = kwargs['default'] if 'default' in kwargs else None 77 | 78 | if 'nargs' in kwargs: 79 | self.is_optional = (kwargs['nargs'] in ['?', '*']) 80 | self.is_multiple = (kwargs['nargs'] in ['+', '*']) 81 | 82 | # TODO: Optparse wants a number here... back to argparse? 83 | if kwargs['nargs'] in ['?', '*', '+']: 84 | if kwargs['nargs'] in ['*', '+']: 85 | kwargs['action'] = 'append' 86 | del kwargs['nargs'] 87 | 88 | else: 89 | self.is_optional = False 90 | self.is_multiple = False 91 | 92 | if 'choices' in kwargs: 93 | self.choices = kwargs['choices'] 94 | if option_type != CommandOption.TYPE_CHOICE: 95 | kwargs.pop('choices') 96 | 97 | self.option_type = option_type 98 | self.short_name = short_name 99 | self.long_name = long_name 100 | self.args = arguments 101 | self.kwargs = kwargs 102 | 103 | def is_type_choice(self): 104 | """ 105 | If this option is a list of choices. 106 | :return: boolean 107 | """ 108 | return self.option_type == CommandOption.TYPE_CHOICE or self.choices 109 | 110 | def is_type_container(self): 111 | """ 112 | Should this option suggest container name? 113 | :return: boolean 114 | """ 115 | return self.option_type == CommandOption.TYPE_CONTAINER 116 | 117 | def is_type_running(self): 118 | """ 119 | Should this option suggest running container name? 120 | :return: boolean 121 | """ 122 | return self.option_type == CommandOption.TYPE_CONTAINER_RUN 123 | 124 | def is_type_image(self): 125 | """ 126 | Should this option suggest image name? 127 | :return: boolean 128 | """ 129 | return self.option_type == CommandOption.TYPE_IMAGE 130 | 131 | def is_type_tagged(self): 132 | """ 133 | Should this option suggest tagged image name? 134 | :return: boolean 135 | """ 136 | return self.option_type == CommandOption.TYPE_IMAGE_TAGGED 137 | 138 | def is_type_volume(self): 139 | """ 140 | Should this option suggest volume name? 141 | :return: boolean 142 | """ 143 | return self.option_type == CommandOption.TYPE_VOLUME 144 | 145 | def is_type_filepath(self): 146 | """ 147 | Should this option suggest filename? 148 | :return: boolean 149 | """ 150 | return self.option_type == CommandOption.TYPE_FILEPATH 151 | 152 | def is_type_dirname(self): 153 | """ 154 | Should this option suggest directory name? 155 | :return: boolean 156 | """ 157 | return self.option_type == CommandOption.TYPE_DIRPATH 158 | 159 | def get_name(self, is_long): 160 | """ 161 | Return short name if we have one, and is requested. Otherwise default 162 | to long name. 163 | :param is_long: boolean 164 | :return: string 165 | """ 166 | if is_long: 167 | return self.long_name if self.long_name else self.short_name 168 | return self.short_name if self.short_name else self.long_name 169 | 170 | def is_match(self, word): 171 | """ 172 | Can this option be suggested having this word being typed? 173 | :param word: 174 | :return: 175 | """ 176 | if word: 177 | return (self.long_name and self.long_name.startswith(word)) or \ 178 | (self.short_name and self.short_name.startswith(word)) 179 | return True 180 | 181 | @property 182 | def name(self): 183 | """ 184 | Getter for short name 185 | :return: string 186 | """ 187 | return self.long_name if self.long_name else self.short_name 188 | 189 | @property 190 | def names(self): 191 | """ 192 | Getter for all possible names. 193 | :return: list 194 | """ 195 | if self.short_name and self.long_name: 196 | return [self.short_name, self.long_name] 197 | elif self.long_name: 198 | return [self.long_name] 199 | else: 200 | return [self.short_name] 201 | 202 | def __repr__(self): 203 | """ 204 | Return the printable representation. 205 | """ 206 | return 'CommandOption({0}, {1}'.format( 207 | self.short_name, 208 | self.long_name) 209 | -------------------------------------------------------------------------------- /wharfee/options.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | 4 | from optparse import OptionParser, OptionError, OptionGroup 5 | from .option import CommandOption 6 | 7 | COMMAND_NAMES = [ 8 | 'attach', 9 | 'build', 10 | 'clear', 11 | 'create', 12 | 'exec', 13 | 'help', 14 | 'images', 15 | 'info', 16 | 'inspect', 17 | 'kill', 18 | 'login', 19 | 'logs', 20 | 'ps', 21 | 'pull', 22 | 'pause', 23 | 'port', 24 | 'push', 25 | 'refresh', 26 | 'rename', 27 | 'restart', 28 | 'run', 29 | 'rm', 30 | 'rmi', 31 | 'search', 32 | 'shell', 33 | 'start', 34 | 'stop', 35 | 'tag', 36 | 'top', 37 | 'unpause', 38 | 'version', 39 | 'volume create', 40 | 'volume inspect', 41 | 'volume ls', 42 | 'volume rm', 43 | ] 44 | 45 | 46 | COMMAND_LENGTH = dict((k, len(k.split(' '))) for k in COMMAND_NAMES if ' ' in k) 47 | 48 | 49 | OPTION_HELP = CommandOption( 50 | CommandOption.TYPE_BOOLEAN, '-h', '--help', 51 | action='store_true', 52 | dest='help', 53 | help='Display help for this command.') 54 | 55 | OPTION_ATTACH_CHOICE = CommandOption( 56 | CommandOption.TYPE_CHOICE, '-a', '--attach', 57 | action='append', 58 | dest='attach', 59 | help='Attach to STDIN, STDOUT, or STDERR (can use multiple times).', 60 | nargs='*', 61 | choices=['stdin', 'stdout', 'stderr'], 62 | api_match=False) 63 | 64 | OPTION_ATTACH_BOOLEAN = CommandOption( 65 | CommandOption.TYPE_BOOLEAN, '-a', '--attach', 66 | action='store_true', 67 | dest='attach', 68 | help='Attach container\'s STDOUT and STDERR and ' + 69 | 'forward all signals to the process.', 70 | api_match=False) 71 | 72 | OPTION_ENV = CommandOption( 73 | CommandOption.TYPE_KEYVALUE, '-e', '--env', 74 | action='append', 75 | dest='environment', 76 | help='Set environment variables.', 77 | nargs='*') 78 | 79 | OPTION_EXPOSE = CommandOption( 80 | CommandOption.TYPE_PORT_RANGE, None, '--expose', 81 | action='append', 82 | dest='expose', 83 | help=('Expose a port or a range of ports (e.g. ' 84 | '--expose=3300-3310) from the container without ' 85 | 'publishing it to your host.'), 86 | nargs='*', 87 | api_match=False) 88 | 89 | OPTION_CONTAINER_HOSTNAME = CommandOption( 90 | CommandOption.TYPE_STRING, '-h', '--hostname', 91 | action='store', 92 | dest='hostname', 93 | help='Container host name.') 94 | 95 | OPTION_CONTAINER_NAME = CommandOption( 96 | CommandOption.TYPE_CONTAINER, None, '--name', 97 | action='store', 98 | dest='name', 99 | help='Specify volume name.') 100 | 101 | OPTION_VOLUME_NAME = CommandOption( 102 | CommandOption.TYPE_VOLUME, None, '--name', 103 | action='store', 104 | dest='name', 105 | help='Assign a name to the container.') 106 | 107 | OPTION_VOLUME_NAME_POS = CommandOption( 108 | CommandOption.TYPE_VOLUME, 'name', 109 | help='Volume name.', 110 | nargs='+') 111 | 112 | OPTION_LINK = CommandOption( 113 | CommandOption.TYPE_CONTAINER, None, '--link', 114 | action='append', 115 | dest='links', 116 | help=('Add link to another container in the form of ' 117 | ':alias. To add multiple links: --link ' 118 | 'name1:alias1 --link name2:alias2...'), 119 | nargs='*', 120 | api_match=False) 121 | 122 | OPTION_PUBLISH_ALL = CommandOption( 123 | CommandOption.TYPE_BOOLEAN, '-P', '--publish-all', 124 | action='store_true', 125 | dest='publish_all_ports', 126 | help=('Publish all exposed ports to the host ' 127 | 'interfaces.'), 128 | api_match=False) 129 | 130 | OPTION_PUBLISH = CommandOption( 131 | CommandOption.TYPE_PORT_BINDING, '-p', '--publish', 132 | action='append', 133 | dest='port_bindings', 134 | help=('Publish a container\'s port to the host. ' 135 | 'Format: ip:hostPort:containerPort or ' 136 | 'ip::containerPort or hostPort:containerPort or ' 137 | 'containerPort. To add multiple ports: --publish ' 138 | '1111:2222 --publish 3333:4444...'), 139 | nargs='*', 140 | api_match=False) 141 | 142 | OPTION_INTERACTIVE = CommandOption( 143 | CommandOption.TYPE_BOOLEAN, '-i', '--interactive', 144 | action='store_true', 145 | dest='interactive', 146 | default=False, 147 | help='Keep STDIN open even if not attached.', 148 | api_match=False) 149 | 150 | OPTION_TTY = CommandOption( 151 | CommandOption.TYPE_BOOLEAN, '-t', '--tty', 152 | action='store_true', 153 | dest='tty', 154 | default=False, 155 | help='Allocate a pseudo-TTY.') 156 | 157 | OPTION_RM = CommandOption( 158 | CommandOption.TYPE_BOOLEAN, None, '--rm', 159 | action='store_true', 160 | dest='remove', 161 | help=('Remove the container when it exits. ' 162 | 'Can\'t be used with --detach'), 163 | api_match=False) 164 | 165 | OPTION_VOLUME = CommandOption( 166 | CommandOption.TYPE_FILEPATH, '-v', '--volume', 167 | action='append', 168 | dest='volumes', 169 | help=('Bind mount a volume (e.g., from the host: -v ' 170 | '/host-path:/container-path, from Docker: ' 171 | '-v /container-path).'), 172 | nargs='*') 173 | 174 | OPTION_VOLUMES_FROM = CommandOption( 175 | CommandOption.TYPE_CONTAINER, None, '--volumes-from', 176 | action='append', 177 | dest='volumes_from', 178 | help=('Mount volumes from the specified containers. Can ' 179 | 'be specified multiple times. Alternatively, can ' 180 | 'accept a comma-separated string of container ' 181 | 'names.'), 182 | nargs='*', 183 | api_match=False) 184 | 185 | OPTION_NET = CommandOption( 186 | CommandOption.TYPE_STRING, None, '--net', 187 | action='store', 188 | dest='net', 189 | help='Network mode for the container. Possible values are "bridge", ' 190 | '"none", "container:", "host".', 191 | choices=['bridge', 'none', 'container:', 'host'], 192 | api_match=False) 193 | 194 | OPTION_FILTERS = CommandOption( 195 | CommandOption.TYPE_STRING, None, '--filter', 196 | action='append', 197 | dest='filters', 198 | nargs='+', 199 | help='Provide filter values (i.e. "dangling=true").') 200 | 201 | OPTION_OPT = CommandOption( 202 | CommandOption.TYPE_STRING, '-o', '--opt', 203 | action='append', 204 | dest='driver_opts', 205 | nargs='+', 206 | help='Set driver specific options (e.g. "tardis=blue").') 207 | 208 | OPTION_DRIVER = CommandOption( 209 | CommandOption.TYPE_STRING, '-d', '--driver', 210 | action='store', 211 | help='Specify volume driver name (--driver local).') 212 | 213 | OPTION_IMAGE = CommandOption( 214 | CommandOption.TYPE_IMAGE, None, 'image', 215 | action='store', 216 | help='Image ID or name to use.') 217 | 218 | OPTION_CMD = CommandOption( 219 | CommandOption.TYPE_COMMAND, None, 'cmd', 220 | action='store', 221 | help='Command to run in a container.', 222 | nargs='?') 223 | 224 | OPTION_COMMAND = CommandOption( 225 | CommandOption.TYPE_COMMAND, None, 'command', 226 | action='store', 227 | help='Command to run in a container.', 228 | nargs='?') 229 | 230 | OPTION_STDIN_OPEN = CommandOption( 231 | CommandOption.TYPE_BOOLEAN, None, 'stdin_open', 232 | action='store', 233 | dest='stdin_open', 234 | default=False) 235 | 236 | OPTION_CONTAINER = CommandOption( 237 | CommandOption.TYPE_CONTAINER, None, 'container', 238 | action='store', 239 | help='Container ID or name to use.') 240 | 241 | OPTION_CONTAINER_RUNNING = CommandOption( 242 | CommandOption.TYPE_CONTAINER_RUN, None, 'container', 243 | action='store', 244 | help='Container ID or name to use.') 245 | 246 | OPTION_HOST_CONFIG = CommandOption( 247 | CommandOption.TYPE_OBJECT, None, 'host_config', 248 | action='store', 249 | dest='host_config') 250 | 251 | OPTION_PORTS = CommandOption( 252 | CommandOption.TYPE_NUMERIC, None, 'ports', 253 | action='append', 254 | dest='ports', 255 | nargs='*') 256 | 257 | 258 | COMMAND_OPTIONS = { 259 | 'attach': [ 260 | CommandOption(CommandOption.TYPE_BOOLEAN, None, '--no-stdin', 261 | action='store_true', 262 | dest='no_stdin', 263 | help='Do not attach STDIN.', 264 | default=False, 265 | api_match=False), 266 | CommandOption(CommandOption.TYPE_CHOICE, None, '--sig-proxy', 267 | action='store', 268 | dest='sig_proxy', 269 | help='Proxy all received signals to the process.', 270 | default=True, 271 | api_match=False, 272 | choices=['true', 'false']), 273 | OPTION_CONTAINER_RUNNING, 274 | CommandOption(CommandOption.TYPE_STRING, None, '--detach-keys', 275 | action='store', 276 | dest='detach_keys', 277 | help='Override the key sequence for detaching a container.', 278 | api_match=False), 279 | ], 280 | 'build': [ 281 | CommandOption(CommandOption.TYPE_IMAGE, '-t', '--tag', 282 | dest='tag', 283 | help=('Repository name (and optionally a tag) to be ' 284 | 'applied to the resulting image in case of ' 285 | 'success.')), 286 | CommandOption(CommandOption.TYPE_BOOLEAN, '-q', '--quiet', 287 | action='store_true', 288 | dest='quiet', 289 | help=('Suppress the verbose output generated by the ' 290 | 'containers.')), 291 | CommandOption(CommandOption.TYPE_CHOICE, '--rm', 292 | action='store', 293 | dest='rm', 294 | help=('Remove intermediate containers after a ' 295 | 'successful build.'), 296 | default='true', 297 | choices=['true', 'false']), 298 | CommandOption(CommandOption.TYPE_BOOLEAN, '--no-cache', 299 | action='store_true', 300 | dest='nocache', 301 | help='Do not use cache when building the image.'), 302 | CommandOption(CommandOption.TYPE_DIRPATH, 'path', 303 | help='Path or URL where the Dockerfile is located.'), 304 | ], 305 | 'clear': [], 306 | 'create': [ 307 | OPTION_ATTACH_CHOICE, 308 | OPTION_ENV, 309 | OPTION_EXPOSE, 310 | OPTION_INTERACTIVE, 311 | OPTION_LINK, 312 | OPTION_CONTAINER_HOSTNAME, 313 | OPTION_CONTAINER_NAME, 314 | OPTION_PUBLISH_ALL, 315 | OPTION_PUBLISH, 316 | OPTION_TTY, 317 | OPTION_VOLUME, 318 | OPTION_VOLUMES_FROM, 319 | OPTION_IMAGE, 320 | OPTION_COMMAND, 321 | OPTION_NET, 322 | ], 323 | 'exec': [ 324 | CommandOption(CommandOption.TYPE_BOOLEAN, '-d', '--detach', 325 | action='store_true', 326 | dest='detach', 327 | help='Detached mode: run command in the background.'), 328 | CommandOption(CommandOption.TYPE_BOOLEAN, '-i', '--interactive', 329 | action='store_true', 330 | dest='interactive', 331 | default=False, 332 | help='Keep STDIN open even if not attached.', 333 | api_match=False), 334 | OPTION_TTY, 335 | OPTION_CONTAINER_RUNNING, 336 | OPTION_CMD, 337 | ], 338 | 'info': [ 339 | ], 340 | 'inspect': [ 341 | CommandOption(CommandOption.TYPE_IMAGE, 'image', 342 | action='store', 343 | dest="image_id", 344 | help='Image to inspect.', 345 | nargs='*'), 346 | CommandOption(CommandOption.TYPE_CONTAINER, 'container', 347 | action='store', 348 | help='Container to inspect.', 349 | nargs='*'), 350 | ], 351 | 'kill': [ 352 | CommandOption(CommandOption.TYPE_CHOICE, '-s', '--signal', 353 | action='store', 354 | dest='signal', 355 | help=('Signal to send to the container'), 356 | default='KILL', 357 | choices=['ABRT', 'ALRM', 'BUS', 'CLD', 'CONT', 'FPE', 358 | 'HUP', 'ILL', 'INT', 'KILL', 'PIPE', 'POLL', 359 | 'PROF', 'PWR', 'QUIT', 'RTMAX', 'RTMIN', 'SEGV', 360 | 'STOP', 'SYS', 'TERM', 'TRAP', 'TSTP', 'TTIN', 361 | 'TTOU', 'URG', 'USR1', 'USR2', 'VTALRM', 362 | 'WINCH', 'XCPU', 'XFSZ']), 363 | OPTION_CONTAINER_RUNNING, 364 | ], 365 | 'login': [ 366 | CommandOption(CommandOption.TYPE_STRING, '-e', '--email', 367 | help='Email.'), 368 | CommandOption(CommandOption.TYPE_STRING, '-p', '--password', 369 | help='Password.'), 370 | CommandOption(CommandOption.TYPE_STRING, '-u', '--username', 371 | help='Username.'), 372 | CommandOption(CommandOption.TYPE_STRING, None, 'server', 373 | dest='registry', 374 | help='Registry server.'), 375 | ], 376 | 'logs': [ 377 | CommandOption(CommandOption.TYPE_BOOLEAN, '-f', '--follow', 378 | action='store_true', 379 | dest='stream', 380 | help='Follow log output.'), 381 | OPTION_CONTAINER, 382 | ], 383 | 'pause': [ 384 | OPTION_CONTAINER_RUNNING, 385 | ], 386 | 'port': [ 387 | OPTION_CONTAINER, 388 | CommandOption(CommandOption.TYPE_STRING, None, 'port', 389 | action='store', 390 | dest='port', 391 | help='Port number (optionally with protocol).'), 392 | ], 393 | 'ps': [ 394 | CommandOption(CommandOption.TYPE_BOOLEAN, '-a', '--all', 395 | action='store_true', 396 | dest='all', 397 | help='Show all containers. ' 398 | 'Only running containers are shown by default.'), 399 | CommandOption(CommandOption.TYPE_CONTAINER, None, '--before', 400 | action='store', 401 | dest='before', 402 | help='Show only container created before Id or Name, ' + 403 | 'include non-running ones.'), 404 | CommandOption(CommandOption.TYPE_BOOLEAN, '-l', '--latest', 405 | action='store_true', 406 | dest='latest', 407 | help='Show only the latest created container, ' + 408 | 'include non-running ones.'), 409 | CommandOption(CommandOption.TYPE_NUMERIC, '-n', None, 410 | action='store', 411 | dest='limit', 412 | help='Show n last created containers, include ' + 413 | 'non-running ones.'), 414 | CommandOption(CommandOption.TYPE_BOOLEAN, None, '--no-trunc', 415 | action='store_false', 416 | dest='trunc', 417 | help='Don\'t truncate output.'), 418 | CommandOption(CommandOption.TYPE_BOOLEAN, '-q', '--quiet', 419 | action='store_true', 420 | dest='quiet', 421 | help='Only display numeric IDs.'), 422 | CommandOption(CommandOption.TYPE_BOOLEAN, '-s', '--size', 423 | action='store_true', 424 | dest='latest', 425 | help='Display total file sizes.'), 426 | CommandOption(CommandOption.TYPE_CONTAINER, None, '--since', 427 | action='store', 428 | dest='since', 429 | help='Show only containers created since Id or Name, ' + 430 | 'include non-running ones.') 431 | ], 432 | 'pull': [ 433 | CommandOption(CommandOption.TYPE_IMAGE, 'image', 434 | action='store', 435 | help='Image name to pull.'), 436 | ], 437 | 'push': [ 438 | CommandOption(CommandOption.TYPE_IMAGE_TAGGED, 'name', 439 | action='store', 440 | help='Image name to push (format: "name[:tag]").'), 441 | ], 442 | 'images': [ 443 | CommandOption(CommandOption.TYPE_BOOLEAN, '-a', '--all', 444 | action='store_true', 445 | dest='all', 446 | help='Show all images (by default filter out the ' + 447 | 'intermediate image layers).'), 448 | CommandOption(CommandOption.TYPE_IMAGE, '-f', '--filter', 449 | action='store', 450 | dest='name', 451 | help='Provide name to filter on.'), 452 | CommandOption(CommandOption.TYPE_BOOLEAN, '-q', '--quiet', 453 | action='store_true', 454 | dest='quiet', 455 | help='Only show numeric IDs.') 456 | ], 457 | 'refresh': [], 458 | 'rename': [ 459 | OPTION_CONTAINER 460 | ], 461 | 'run': [ 462 | CommandOption(CommandOption.TYPE_BOOLEAN, '-d', '--detach', 463 | action='store_true', 464 | dest='detach', 465 | help=('Detached mode: run the container in the ' 466 | 'background and print the new container ID')), 467 | OPTION_ATTACH_CHOICE, 468 | OPTION_ENV, 469 | OPTION_EXPOSE, 470 | OPTION_CONTAINER_HOSTNAME, 471 | OPTION_CONTAINER_NAME, 472 | OPTION_LINK, 473 | OPTION_PUBLISH_ALL, 474 | OPTION_PUBLISH, 475 | OPTION_INTERACTIVE, 476 | OPTION_TTY, 477 | OPTION_RM, 478 | OPTION_VOLUME, 479 | OPTION_VOLUMES_FROM, 480 | CommandOption(CommandOption.TYPE_IMAGE_TAGGED, None, 'image', 481 | action='store', 482 | help='Image name.'), 483 | OPTION_COMMAND, 484 | OPTION_NET, 485 | ], 486 | 'shell': [ 487 | OPTION_CONTAINER_RUNNING, 488 | CommandOption(CommandOption.TYPE_CHOICE, 'command', 489 | action='store', 490 | help='Shell command to execute.', 491 | choices=[ 492 | 'bash', 493 | 'sh', 494 | 'zsh', 495 | '/bin/sh', 496 | '/usr/bin/bash', 497 | '/usr/bin/sh', 498 | '/usr/bin/zsh', 499 | '/usr/local/bin/bash', 500 | '/usr/local/bin/sh', 501 | '/usr/local/bin/zsh', 502 | ], 503 | default='bash', 504 | nargs='?'), 505 | ], 506 | 'start': [ 507 | OPTION_ATTACH_BOOLEAN, 508 | CommandOption(CommandOption.TYPE_BOOLEAN, '-i', '--interactive', 509 | action='store_true', 510 | dest='interactive', 511 | default=False, 512 | help='Attach container\'s STDIN.', 513 | api_match=False), 514 | OPTION_CONTAINER, 515 | ], 516 | 'restart': [ 517 | CommandOption(CommandOption.TYPE_NUMERIC, '-t', '--timeout', 518 | dest='timeout', 519 | help=('Number of seconds to try to stop for before ' 520 | 'killing the container. Once killed it will then ' 521 | 'be restarted. Default is 10 seconds.')), 522 | CommandOption(CommandOption.TYPE_CONTAINER, 'container', 523 | action='store', 524 | help='Container ID or name to use.', 525 | nargs='+'), 526 | ], 527 | 'rm': [ 528 | CommandOption(CommandOption.TYPE_CONTAINER, 'container', 529 | action='store', 530 | help='Container ID or name to use.', 531 | nargs='+'), 532 | CommandOption(CommandOption.TYPE_BOOLEAN, '--all-stopped', 533 | action='store_true', 534 | dest='all_stopped', 535 | help='Shortcut to remove all stopped containers.', 536 | api_match=False, 537 | cli_match=False), 538 | CommandOption(CommandOption.TYPE_BOOLEAN, '--all', 539 | action='store_true', 540 | dest='all', 541 | help='Shortcut to remove all containers.', 542 | api_match=False, 543 | cli_match=False), 544 | CommandOption(CommandOption.TYPE_BOOLEAN, '-f', '--force', 545 | action='store_true', 546 | dest='force', 547 | help='Force the removal of a running container (uses SIGKILL).'), 548 | ], 549 | 'rmi': [ 550 | CommandOption(CommandOption.TYPE_IMAGE_TAGGED, 'image', 551 | action='store', 552 | help='Image name name to remove.', 553 | nargs='+'), 554 | CommandOption(CommandOption.TYPE_BOOLEAN, '--all-dangling', 555 | action='store_true', 556 | dest='all_dangling', 557 | help='Shortcut to remove all dangling images.', 558 | api_match=False, 559 | cli_match=False), 560 | CommandOption(CommandOption.TYPE_BOOLEAN, '--all', 561 | action='store_true', 562 | dest='all', 563 | help='Shortcut to remove all images.', 564 | api_match=False, 565 | cli_match=False), 566 | ], 567 | 'search': [ 568 | CommandOption(CommandOption.TYPE_IMAGE, 'term', 569 | action='store', 570 | help='A term to search for.'), 571 | ], 572 | 'stop': [ 573 | CommandOption(CommandOption.TYPE_NUMERIC, '-t', '--time', 574 | dest='timeout', 575 | default=10, 576 | type='int', 577 | help=('Seconds to wait for stop before killing it ' 578 | '(default 10).')), 579 | OPTION_CONTAINER_RUNNING, 580 | ], 581 | 'tag': [ 582 | CommandOption(CommandOption.TYPE_BOOLEAN, '-f', '--force', 583 | action='store_true', 584 | dest='force', 585 | help='Force.'), 586 | CommandOption(CommandOption.TYPE_IMAGE, 'image', 587 | action='store', 588 | help='The image to tag (format: "image[:tag]").'), 589 | CommandOption(CommandOption.TYPE_IMAGE_TAG, 'name', 590 | action='store', 591 | help=('The tag name (format: "[registryhost/]' 592 | '[username/]name[:tag]").')), 593 | ], 594 | 'top': [ 595 | OPTION_CONTAINER_RUNNING, 596 | ], 597 | 'unpause': [ 598 | OPTION_CONTAINER_RUNNING, 599 | ], 600 | 'volume create': [ 601 | OPTION_VOLUME_NAME, 602 | OPTION_DRIVER, 603 | OPTION_OPT 604 | ], 605 | 'volume inspect': [ 606 | OPTION_VOLUME_NAME_POS 607 | ], 608 | 'volume ls': [ 609 | CommandOption(CommandOption.TYPE_BOOLEAN, '-q', '--quiet', 610 | action='store_true', 611 | dest='quiet', 612 | help='Only display volume names.'), 613 | OPTION_FILTERS 614 | ], 615 | 'volume rm': [ 616 | OPTION_VOLUME_NAME_POS 617 | ], 618 | } 619 | 620 | 621 | # Hidden options are options that docker-py supports, but the standard docker cli doesn't have 622 | # them. Since we're emulating the standard cli as close as possible, we're not suggesting these 623 | # to the user. 624 | HIDDEN_OPTIONS = { 625 | 'start': [ 626 | OPTION_PUBLISH_ALL, 627 | ], 628 | 'run': [ 629 | OPTION_PORTS, 630 | OPTION_HOST_CONFIG, 631 | OPTION_STDIN_OPEN 632 | ], 633 | 'create': [ 634 | OPTION_PORTS, 635 | OPTION_HOST_CONFIG, 636 | OPTION_STDIN_OPEN 637 | ] 638 | } 639 | 640 | 641 | def all_option_names(): 642 | """ 643 | Helper method to go through all commands and return all option names, 644 | long or short. 645 | :return: iterable 646 | """ 647 | opts = set([OPTION_HELP.short_name, OPTION_HELP.long_name]) 648 | for command in COMMAND_OPTIONS: 649 | for opt in COMMAND_OPTIONS[command]: 650 | if opt.short_name and opt.short_name.startswith('-'): 651 | opts.add(opt.short_name) 652 | if opt.long_name and opt.long_name.startswith('--'): 653 | opts.add(opt.long_name) 654 | return sorted(list(opts)) 655 | 656 | 657 | def find_option(command, name): 658 | """ 659 | Helper method to find command option by its name. 660 | :param command: string 661 | :param name: string 662 | :return: CommandOption 663 | """ 664 | # TODO: use all_options 665 | if command in COMMAND_OPTIONS: 666 | if name == 'help': 667 | return OPTION_HELP 668 | for opt in COMMAND_OPTIONS[command]: 669 | if name in [opt.short_name, opt.long_name]: 670 | return opt 671 | return None 672 | 673 | 674 | def allowed_args(command_name, **kwargs): 675 | """ 676 | Return only arguments that the command accepts. 677 | :param command_name: string 678 | :param kwargs: dict 679 | :return: dict 680 | """ 681 | matches = {} 682 | available = all_supported(command_name) 683 | if available: 684 | for k in kwargs: 685 | if k in available: 686 | matches[k] = kwargs[k] 687 | return matches 688 | 689 | 690 | def all_options(command, include_hidden=False): 691 | """ 692 | Helper method to find all command options. 693 | :param command: string 694 | :param include_hidden: boolean 695 | :return: set of CommandOption 696 | """ 697 | result = [OPTION_HELP] 698 | if command in COMMAND_OPTIONS: 699 | result.extend(COMMAND_OPTIONS[command]) 700 | if include_hidden and command in HIDDEN_OPTIONS: 701 | result.extend(HIDDEN_OPTIONS[command]) 702 | return result 703 | 704 | 705 | def all_supported(command): 706 | """ 707 | Helper method to find all command options that docker-py supports. 708 | :param command: string 709 | :return: set of CommandOption 710 | """ 711 | result = set([OPTION_HELP]) 712 | 713 | if command in COMMAND_OPTIONS: 714 | result.update( 715 | [x.dest for x in COMMAND_OPTIONS[command] if x.api_match]) 716 | 717 | if command in HIDDEN_OPTIONS: 718 | result.update([x.dest for x in HIDDEN_OPTIONS[command]]) 719 | 720 | return result 721 | 722 | 723 | def parse_command_options(cmd, params): 724 | """ 725 | Parse options for a given command. 726 | :param cmd: string: command name 727 | :param params: list: all tokens after command name 728 | :return: parser, args, opts 729 | """ 730 | parser = OptParser( 731 | prog=cmd, add_help_option=False, conflict_handler='resolve') 732 | parser.disable_interspersed_args() 733 | for opt in all_options(cmd, include_hidden=True): 734 | if opt.name.startswith('-'): 735 | parser.add_option(*opt.args, **opt.kwargs) 736 | popts, pargs = parser.parse_args(params) 737 | parser.assert_option_format() 738 | popts = vars(popts) 739 | 740 | # Add hidden defaults 741 | if cmd in HIDDEN_OPTIONS: 742 | for opt in HIDDEN_OPTIONS[cmd]: 743 | if opt.default is not None: 744 | popts[opt.dest] = opt.default 745 | 746 | return parser, popts, pargs 747 | 748 | 749 | def format_command_line(cmd, is_long, args, kwargs): 750 | """ 751 | Reconstruct the command line for sending back to official Docker CLI. 752 | :param cmd: command 753 | :param is_long: bool use long option names 754 | :param args: positional parameters 755 | :param kwargs: named parameters 756 | :return: string 757 | """ 758 | opts = dict([(x.dest, x) for x in all_options(cmd) 759 | if x.name.startswith('-')]) 760 | comps = ['docker', cmd] 761 | 762 | def kve(o, v): 763 | v1, v2 = v.split('=', 1) 764 | if ' ' in v2: 765 | return '{0} {1}="{2}"'.format(o.get_name(is_long), v1, v2) 766 | return '{0} {1}={2}'.format(o.get_name(is_long), v1, v2) 767 | 768 | def kv(o, v): 769 | if o.dest == 'environment': 770 | return kve(o, v) 771 | elif o.dest == 'volumes' and ' ' in v: 772 | return '{0}="{1}"'.format(o.get_name(is_long), v) 773 | return '{0}={1}'.format(o.get_name(is_long), v) 774 | 775 | for opt_dest, opt_value in kwargs.items(): 776 | if opt_dest in opts and opt_value is not None: 777 | opt = opts[opt_dest] 778 | if opt.cli_match: 779 | if isinstance(opt_value, bool): 780 | # skip appending if default 781 | if opt.default is not None and opt_value == opt.default: 782 | continue 783 | comps.append(opt.get_name(is_long)) 784 | elif isinstance(opt_value, list): 785 | comps.append(' '.join([kv(opt, val) for val in opt_value])) 786 | else: 787 | comps.append(kv(opt, opt_value)) 788 | comps.extend(args) 789 | external_command = ' '.join(comps) 790 | return external_command 791 | 792 | 793 | def split_command_and_args(tokens): 794 | """ 795 | Take all tokens from command line, return command part and args part. 796 | Command can be more than 1 words. 797 | :param tokens: list 798 | :return: tuple of (string, list) 799 | """ 800 | command, args = None, None 801 | if tokens: 802 | length = 1 803 | for cmd_name in COMMAND_LENGTH: 804 | if ' '.join(tokens).startswith(cmd_name): 805 | length = COMMAND_LENGTH[cmd_name] 806 | break 807 | command = ' '.join(tokens[:length]) 808 | args = tokens[length:] if len(tokens) >= length else None 809 | return command, args 810 | 811 | 812 | def format_command_help(cmd): 813 | """ 814 | Format help string for the command. 815 | :param cmd: string: command name 816 | :return: string 817 | """ 818 | usage = [cmd, '[options]'] 819 | alls = all_options(cmd) 820 | 821 | standards = [_ for _ in alls if _.cli_match] 822 | extras = [_ for _ in alls if not _.cli_match] 823 | 824 | for opt in alls: 825 | if not opt.name.startswith('-'): 826 | optname = "[{0}]".format(opt.name) if opt.is_optional else opt.name 827 | usage.append(optname) 828 | 829 | usage = ' '.join(usage) 830 | 831 | parser = OptParser(prog=cmd, add_help_option=False, usage=usage, 832 | conflict_handler='resolve') 833 | 834 | for opt in standards: 835 | if opt.name.startswith('-'): 836 | parser.add_option(*opt.args, **opt.kwargs) 837 | 838 | if extras: 839 | g = OptionGroup(parser, "Non-standard options") 840 | for opt in extras: 841 | g.add_option(*opt.args, **opt.kwargs) 842 | parser.add_option_group(g) 843 | 844 | return parser.format_help() 845 | 846 | 847 | class OptParser(OptionParser): 848 | 849 | # TODO: Bad bad bad. There should be a better way to do this. 850 | def assert_option_format(self): 851 | """ 852 | I don't want environment vars to be provided as 853 | "-e KEY VALUE", I want "-e KEY=VALUE" instead. 854 | Would argparse help here? 855 | """ 856 | dict_values = vars(self.values) 857 | if 'environment' in dict_values and dict_values['environment']: 858 | for envar in dict_values['environment']: 859 | if '=' not in envar: 860 | raise OptionError( 861 | 'Usage: -e KEY1=VALUE1 -e KEY2=VALUE2...', 862 | '-e') 863 | 864 | """ 865 | Wrapper around OptionParser. 866 | Overrides error method to throw an exception. 867 | """ 868 | def error(self, msg): 869 | """error(msg : string) 870 | 871 | Print a usage message incorporating 'msg' to stderr and exit. 872 | If you override this in a subclass, it should not return -- it 873 | should either exit or raise an exception. 874 | """ 875 | raise Exception("Error parsing options: {0}".format(msg)) 876 | -------------------------------------------------------------------------------- /wharfee/style.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from pygments.token import Token 3 | from pygments.util import ClassNotFound 4 | from prompt_toolkit.styles import default_style_extensions, PygmentsStyle 5 | import pygments.styles 6 | 7 | 8 | def style_factory(name): 9 | try: 10 | style = pygments.styles.get_style_by_name(name) 11 | except ClassNotFound: 12 | style = pygments.styles.get_style_by_name('native') 13 | 14 | styles = {} 15 | 16 | styles.update(style.styles) 17 | styles.update(default_style_extensions) 18 | styles.update({ 19 | Token.Menu.Completions.Completion.Current: 'bg:#00aaaa #000000', 20 | Token.Menu.Completions.Completion: 'bg:#008888 #ffffff', 21 | Token.Menu.Completions.ProgressButton: 'bg:#003333', 22 | Token.Menu.Completions.ProgressBar: 'bg:#00aaaa', 23 | Token.Toolbar: 'bg:#222222 #cccccc', 24 | Token.Toolbar.Off: 'bg:#222222 #004444', 25 | Token.Toolbar.On: 'bg:#222222 #ffffff', 26 | Token.Toolbar.Search: 'noinherit bold', 27 | Token.Toolbar.Search.Text: 'nobold', 28 | Token.Toolbar.System: 'noinherit bold', 29 | Token.Toolbar.Arg: 'noinherit bold', 30 | Token.Toolbar.Arg.Text: 'nobold' 31 | }) 32 | 33 | return PygmentsStyle.from_defaults(style_dict=styles) 34 | -------------------------------------------------------------------------------- /wharfee/toolbar.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals 3 | from __future__ import print_function 4 | 5 | from pygments.token import Token 6 | 7 | 8 | def create_toolbar_handler(is_long_option, is_fuzzy): 9 | 10 | assert callable(is_long_option) 11 | assert callable(is_fuzzy) 12 | 13 | def get_toolbar_items(_): 14 | """ 15 | Return bottom menu items 16 | :param _: cli instance 17 | :return: list of Token.Toolbar 18 | """ 19 | 20 | if is_long_option(): 21 | option_mode_token = Token.Toolbar.On 22 | option_mode = 'Long' 23 | else: 24 | option_mode_token = Token.Toolbar.Off 25 | option_mode = 'Short' 26 | 27 | if is_fuzzy(): 28 | fuzzy_token = Token.Toolbar.On 29 | fuzzy = 'ON' 30 | else: 31 | fuzzy_token = Token.Toolbar.Off 32 | fuzzy = 'OFF' 33 | 34 | return [ 35 | (Token.Toolbar, ' [F2] Help '), 36 | (option_mode_token, ' [F3] Options: {0} '.format(option_mode)), 37 | (fuzzy_token, ' [F4] Fuzzy: {0} '.format(fuzzy)), 38 | (Token.Toolbar, ' [F10] Exit ') 39 | ] 40 | 41 | return get_toolbar_items 42 | -------------------------------------------------------------------------------- /wharfee/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import six 3 | import shlex 4 | 5 | 6 | def shlex_split(text): 7 | """ 8 | Wrapper for shlex, because it does not seem to handle unicode in 2.6. 9 | :param text: string 10 | :return: list 11 | """ 12 | if six.PY2: 13 | text = text.encode('utf-8') 14 | return shlex.split(text) 15 | 16 | 17 | def shlex_first_token(text): 18 | """ 19 | Wrapper for shlex, because it does not seem to handle unicode in 2.6. 20 | :param text: string 21 | :return: string 22 | """ 23 | if six.PY2: 24 | text = text.encode('utf-8') 25 | lexer = shlex.shlex(text) 26 | return lexer.get_token() 27 | -------------------------------------------------------------------------------- /wharfee/wharfeerc: -------------------------------------------------------------------------------- 1 | [main] 2 | 3 | # timeout to connect to client, in seconds 4 | client_timeout = 10 5 | 6 | # Visual theme. Possible values: manni, igor, xcode, vim, autumn, vs, rrt, 7 | # native, perldoc, borland, tango, emacs, friendly, monokai, paraiso-dark, 8 | # colorful, murphy, bw, pastie, paraiso-light, trac, default, fruity 9 | theme = default 10 | 11 | # Whether to suggest long option names. Default is 'True'. If 'False', 12 | # short option names are suggested. 13 | suggest_long_option_names = True 14 | 15 | # Use fuzzy matching mode (default is to use simple substring match). 16 | fuzzy_match = False 17 | 18 | # log_file location. 19 | log_file = ~/.wharfee.log 20 | 21 | # Default log level. Possible values: "CRITICAL", "ERROR", "WARNING", "INFO" 22 | # and "DEBUG". 23 | log_level = INFO 24 | --------------------------------------------------------------------------------