├── .gitignore ├── .travis.yml ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── coreapi_cli ├── __init__.py ├── auth.py ├── codec_plugins.py ├── compat.py ├── debug.py ├── display.py ├── history.py └── main.py ├── requirements.txt ├── runtests ├── setup.py ├── tests ├── test_example.py ├── test_history.py └── test_main.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | env/ 2 | dist/ 3 | htmlcov/ 4 | site/ 5 | .tox/ 6 | *.egg-info/ 7 | *.pyc 8 | __pycache__ 9 | .cache 10 | .coverage 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | 8 | install: 9 | - pip install -r requirements.txt 10 | 11 | script: 12 | - ./runtests 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | Copyright © 2016 Tom Christie 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | Redistributions in binary form must reproduce the above copyright notice, this 12 | list of conditions and the following disclaimer in the documentation and/or 13 | other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | global-exclude __pycache__ 2 | global-exclude *.pyc 3 | global-exclude *.pyo 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Core API command line client 2 | 3 | **An interactive command line client for Core API.** 4 | 5 | [![travis-image]][travis] 6 | [![pypi-image]][pypi] 7 | 8 | ## Installation 9 | 10 | Install using pip: 11 | 12 | $ pip install coreapi-cli 13 | 14 | 15 | [travis-image]: https://secure.travis-ci.org/core-api/coreapi-cli.svg?branch=master 16 | [travis]: http://travis-ci.org/core-api/coreapi-cli?branch=master 17 | [pypi-image]: https://img.shields.io/pypi/v/coreapi-cli.svg 18 | [pypi]: https://pypi.python.org/pypi/coreapi-cli 19 | -------------------------------------------------------------------------------- /coreapi_cli/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.9" 2 | -------------------------------------------------------------------------------- /coreapi_cli/auth.py: -------------------------------------------------------------------------------- 1 | from coreapi_cli.compat import urlparse 2 | from requests.auth import AuthBase 3 | 4 | 5 | class DomainCredentials(AuthBase): 6 | allow_cookies = False 7 | credentials = None 8 | 9 | def __init__(self, credentials=None): 10 | self.credentials = credentials 11 | 12 | def __call__(self, request): 13 | if not self.credentials: 14 | return request 15 | 16 | # Include any authorization credentials relevant to this domain. 17 | url_components = urlparse.urlparse(request.url) 18 | host = url_components.hostname 19 | if host in self.credentials: 20 | request.headers['Authorization'] = self.credentials[host] 21 | return request 22 | -------------------------------------------------------------------------------- /coreapi_cli/codec_plugins.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | import collections 3 | import coreapi 4 | import os 5 | 6 | 7 | def sorting_func(package_info): 8 | """ 9 | A sorting order for (package, codec_class) tuples. Example ordering: 10 | 11 | application/coreapi+json (highest priority) 12 | application/openapi+json (lower as not a coreapi package built-in) 13 | application/json (lower as more general subtype) 14 | text/* (lower as sub type is wildcard) 15 | */* (lowest as main type is wildcard) 16 | """ 17 | package, codec_class = package_info 18 | media_type = getattr(codec_class, 'media_type') 19 | main_type, _, sub_type = media_type.partition('/') 20 | sub_type = sub_type.split(';')[0] 21 | is_builtin = package.dist.project_name == 'coreapi' 22 | return ( 23 | main_type == '*', 24 | sub_type == '*', 25 | '+' not in sub_type, 26 | not is_builtin, 27 | media_type 28 | ) 29 | 30 | 31 | def instantiate_codec(cls): 32 | if issubclass(cls, coreapi.codecs.DownloadCodec): 33 | default_dir = os.path.join(os.path.expanduser('~'), '.coreapi') 34 | config_dir = os.environ.get('COREAPI_CONFIG_DIR', default_dir) 35 | download_dir = os.path.join(config_dir, 'downloads') 36 | if not os.path.exists(config_dir): 37 | os.mkdir(config_dir) 38 | if not os.path.exists(download_dir): 39 | os.mkdir(download_dir) 40 | return cls(download_dir=download_dir) 41 | return cls() 42 | 43 | 44 | def get_codec_packages(): 45 | """ 46 | Returns a list of (package, codec_class) tuples. 47 | """ 48 | packages = [ 49 | (package, package.load()) for package in 50 | pkg_resources.iter_entry_points(group='coreapi.codecs') 51 | ] 52 | packages = [ 53 | (package, instantiate_codec(cls)) for (package, cls) in packages 54 | if issubclass(cls, coreapi.codecs.BaseCodec) or hasattr(cls, 'decode') or hasattr(cls, 'encode') 55 | ] 56 | return sorted(packages, key=sorting_func) 57 | 58 | 59 | def supports(codec): 60 | """ 61 | Return a list of strings indicating supported operations. 62 | """ 63 | if hasattr(codec, 'encode') and hasattr(codec, 'decode'): 64 | return ['encoding', 'decoding'] 65 | elif hasattr(codec, 'encode'): 66 | return ['encoding'] 67 | elif hasattr(codec, 'decode'): 68 | return ['decoding'] 69 | # Fallback for pre-2.0 API. 70 | return codec.supports 71 | 72 | 73 | codec_packages = get_codec_packages() 74 | 75 | codecs = collections.OrderedDict([ 76 | (package.name, codec) for (package, codec) in codec_packages 77 | ]) 78 | 79 | decoders = collections.OrderedDict([ 80 | (package.name, codec) for (package, codec) in codec_packages 81 | if 'decoding' in supports(codec) 82 | ]) 83 | 84 | encoders = collections.OrderedDict([ 85 | (package.name, codec) for (package, codec) in codec_packages 86 | if 'encoding' in supports(codec) 87 | ]) 88 | -------------------------------------------------------------------------------- /coreapi_cli/compat.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | try: 4 | # Python 3 5 | JSONDecodeError = json.decoder.JSONDecodeError 6 | except AttributeError: 7 | # Python 2 8 | JSONDecodeError = ValueError 9 | 10 | 11 | try: 12 | # Python 2 13 | import urlparse # noqa 14 | except ImportError: 15 | # Python 3 16 | import urllib.parse as urlparse # noqa 17 | -------------------------------------------------------------------------------- /coreapi_cli/debug.py: -------------------------------------------------------------------------------- 1 | from coreapi.compat import urlparse 2 | from requests.adapters import HTTPAdapter 3 | from requests.sessions import Session 4 | import click 5 | 6 | 7 | def expand_args(fmt, args): 8 | if args: 9 | return fmt % args 10 | return fmt 11 | 12 | 13 | def debug_request(request): 14 | def request_echo(fmt, *args): 15 | click.echo(click.style('> ', fg='blue') + expand_args(fmt, args)) 16 | 17 | request_echo(click.style('%s %s HTTP/1.1', bold=True), request.method, request.path_url) 18 | 19 | if 'host' not in request.headers: 20 | request_echo('Host: %s', urlparse.urlparse(request.url).netloc) 21 | 22 | for key, value in sorted(request.headers.items()): 23 | request_echo('%s: %s', key.title(), value) 24 | 25 | if request.body: 26 | body_text = request.body 27 | if isinstance(body_text, bytes): 28 | body_text = body_text.decode('utf-8') 29 | request_echo('') 30 | for line in body_text.splitlines(): 31 | request_echo(line) 32 | 33 | 34 | def debug_response(response): 35 | def success_echo(fmt, *args): 36 | prompt = click.style('< ', fg='green') 37 | click.echo(prompt + expand_args(fmt, args)) 38 | 39 | def failure_echo(fmt, *args): 40 | prompt = click.style('< ', fg='red') 41 | click.echo(prompt + expand_args(fmt, args)) 42 | 43 | def info_echo(fmt, *args): 44 | prompt = click.style('< ', fg='yellow') 45 | click.echo(prompt + expand_args(fmt, args)) 46 | 47 | response_class = ('%s' % response.status_code)[0] + 'xx' 48 | if response_class == '2xx': 49 | response_echo = success_echo 50 | elif response_class in ('4xx', '5xx'): 51 | response_echo = failure_echo 52 | else: 53 | response_echo = info_echo 54 | 55 | response_echo(click.style('%d %s', bold=True), response.status_code, response.reason) 56 | for key, value in sorted(response.headers.items()): 57 | response_echo('%s: %s', key.title(), value) 58 | if response.content: 59 | response_echo('') 60 | for line in response.text.splitlines(): 61 | response_echo(line) 62 | 63 | click.echo() 64 | 65 | 66 | class DebugAdapter(HTTPAdapter): 67 | def send(self, request, **kwargs): 68 | debug_request(request) 69 | response = super(DebugAdapter, self).send(request, **kwargs) 70 | debug_response(response) 71 | return response 72 | 73 | 74 | def DebugSession(session=None): 75 | if session is None: 76 | session = Session() 77 | session.mount('https://', DebugAdapter()) 78 | session.mount('http://', DebugAdapter()) 79 | return session 80 | -------------------------------------------------------------------------------- /coreapi_cli/display.py: -------------------------------------------------------------------------------- 1 | from coreapi.compat import string_types 2 | import coreapi 3 | import json 4 | 5 | 6 | def display(doc): 7 | if isinstance(doc, (coreapi.Document, coreapi.Error, coreapi.Object, coreapi.Array, coreapi.Link)): 8 | codec = coreapi.codecs.DisplayCodec() 9 | return codec.encode(doc, colorize=True) 10 | 11 | if doc is None: 12 | return '' 13 | 14 | if isinstance(doc, string_types): 15 | return doc 16 | 17 | try: 18 | return json.dumps(doc, indent=4, ensure_ascii=False, separators=coreapi.compat.VERBOSE_SEPARATORS) 19 | except TypeError: 20 | return '%s' % doc 21 | -------------------------------------------------------------------------------- /coreapi_cli/history.py: -------------------------------------------------------------------------------- 1 | from coreapi import Document 2 | from coreapi.compat import force_bytes 3 | import collections 4 | import itypes 5 | import json 6 | 7 | 8 | HistoryItem = collections.namedtuple('HistoryItem', ['is_active', 'document']) 9 | NavigationResult = collections.namedtuple('NavigationResult', ['document', 'history']) 10 | 11 | 12 | class History(itypes.Object): 13 | def __init__(self, items=None, idx=0, max_items=None): 14 | self._items = itypes.List(items or []) 15 | self._idx = idx 16 | self._max_items = max_items 17 | if any([not isinstance(doc, Document) for doc in self._items]): 18 | raise ValueError('items must be a list of Document instances.') 19 | 20 | @property 21 | def max_items(self): 22 | return self._max_items 23 | 24 | @property 25 | def current(self): 26 | if not self._items: 27 | return None 28 | return self._items[self._idx] 29 | 30 | @property 31 | def is_at_most_recent(self): 32 | return self._idx == 0 33 | 34 | @property 35 | def is_at_oldest(self): 36 | return self._idx + 1 >= len(self._items) 37 | 38 | def get_items(self): 39 | for idx, item in enumerate(self._items): 40 | yield HistoryItem(is_active=idx == self._idx, document=item) 41 | 42 | def __eq__(self, other): 43 | return ( 44 | isinstance(other, History) and 45 | self._idx == other._idx and 46 | self._items == other._items and 47 | self._max_items == other._max_items 48 | ) 49 | 50 | def add(self, doc): 51 | if not isinstance(doc, Document): 52 | raise ValueError('Argument must be a Document instance.') 53 | 54 | new = Document(doc.url, doc.title) 55 | current = self.current 56 | 57 | # Remove any forward history past the current item. 58 | items = self._items[self._idx:] 59 | 60 | # Add the new reference if required. 61 | if current == new: 62 | pass 63 | elif (current is not None) and (current.url == new.url): 64 | items = [new] + items[1:] 65 | else: 66 | items = [new] + items 67 | 68 | # Truncate the history if we've reached the maximum number of items. 69 | items = items[:self.max_items] 70 | return History(items, max_items=self.max_items) 71 | 72 | def back(self): 73 | if self.is_at_oldest: 74 | raise ValueError('Currently at oldest point in history. Cannot navigate back.') 75 | idx = self._idx + 1 76 | doc = self._items[idx] 77 | history = History(self._items, idx=idx, max_items=self.max_items) 78 | return NavigationResult(document=doc, history=history) 79 | 80 | def forward(self): 81 | if self.is_at_most_recent: 82 | raise ValueError('Currently at most recent point in history. Cannot navigate forward.') 83 | idx = self._idx - 1 84 | doc = self._items[idx] 85 | history = History(self._items, idx=idx, max_items=self.max_items) 86 | return NavigationResult(document=doc, history=history) 87 | 88 | 89 | def dump_history(history): 90 | history_data = { 91 | 'items': [ 92 | {'url': doc.url, 'title': doc.title} 93 | for active, doc in history.get_items() 94 | ], 95 | 'idx': history._idx, 96 | 'max_items': history.max_items 97 | } 98 | return force_bytes(json.dumps(history_data)) 99 | 100 | 101 | def load_history(bytestring): 102 | history_data = json.loads(bytestring.decode('utf-8')) 103 | items = [ 104 | Document(item['url'], item['title']) 105 | for item in history_data['items'] 106 | ] 107 | idx = history_data['idx'] 108 | max_items = history_data['max_items'] 109 | return History(items=items, idx=idx, max_items=max_items) 110 | -------------------------------------------------------------------------------- /coreapi_cli/main.py: -------------------------------------------------------------------------------- 1 | from coreapi.compat import b64encode, force_bytes 2 | from coreapi_cli import __version__ as client_version 3 | from coreapi_cli import codec_plugins 4 | from coreapi_cli.auth import DomainCredentials 5 | from coreapi_cli.compat import JSONDecodeError 6 | from coreapi_cli.display import display 7 | from coreapi_cli.debug import DebugSession 8 | from coreapi_cli.history import History, dump_history, load_history 9 | import click 10 | import coreapi 11 | import json 12 | import os 13 | import sys 14 | 15 | 16 | config_path = None 17 | 18 | document_path = None 19 | history_path = None 20 | credentials_path = None 21 | headers_path = None 22 | bookmarks_path = None 23 | 24 | 25 | def setup_paths(): 26 | global config_path, document_path, history_path 27 | global credentials_path, headers_path, bookmarks_path 28 | 29 | default_dir = os.path.join(os.path.expanduser('~'), '.coreapi') 30 | config_path = os.environ.get('COREAPI_CONFIG_DIR', default_dir) 31 | 32 | document_path = os.path.join(config_path, 'document.json') 33 | history_path = os.path.join(config_path, 'history.json') 34 | credentials_path = os.path.join(config_path, 'credentials.json') 35 | headers_path = os.path.join(config_path, 'headers.json') 36 | bookmarks_path = os.path.join(config_path, 'bookmarks.json') 37 | 38 | 39 | def coerce_key_types(doc, keys): 40 | """ 41 | Given a document and a list of keys such as ['rows', '123', 'edit'], 42 | return a list of keys, such as ['rows', 123, 'edit']. 43 | """ 44 | ret = [] 45 | active = doc 46 | for idx, key in enumerate(keys): 47 | # Coerce array lookups to integers. 48 | if isinstance(active, coreapi.Array): 49 | try: 50 | key = int(key) 51 | except ValueError: 52 | pass 53 | 54 | # Descend through the document, so we can correctly identify 55 | # any nested array lookups. 56 | ret.append(key) 57 | try: 58 | active = active[key] 59 | except (KeyError, IndexError, ValueError, TypeError): 60 | ret += keys[idx + 1:] 61 | break 62 | 63 | return ret 64 | 65 | 66 | def get_document_string(doc): 67 | if not doc.title: 68 | return '' % json.dumps(doc.url) 69 | return '<%s %s>' % (doc.title, json.dumps(doc.url)) 70 | 71 | 72 | def get_client(decoders=None, debug=False): 73 | credentials = get_credentials() 74 | headers = get_headers() 75 | session = None 76 | if debug: 77 | session = DebugSession() 78 | 79 | if decoders is None: 80 | decoders = list(codec_plugins.decoders.values()) 81 | 82 | http_transport = coreapi.transports.HTTPTransport( 83 | auth=DomainCredentials(credentials), headers=headers, session=session 84 | ) 85 | return coreapi.Client(decoders=decoders, transports=[http_transport]) 86 | 87 | 88 | def get_document(): 89 | if not os.path.exists(document_path): 90 | return None 91 | store = open(document_path, 'rb') 92 | content = store.read() 93 | store.close() 94 | codec = coreapi.codecs.CoreJSONCodec() 95 | return codec.decode(content) 96 | 97 | 98 | def set_document(doc): 99 | codec = coreapi.codecs.CoreJSONCodec() 100 | content = codec.encode(doc) 101 | store = open(document_path, 'wb') 102 | store.write(content) 103 | store.close() 104 | 105 | 106 | def json_load_bytes(bytes): 107 | return json.loads(bytes.decode('utf-8') or '{}') 108 | 109 | 110 | # Core commands 111 | 112 | @click.group(invoke_without_command=True, help='Command line client for interacting with CoreAPI services.\n\nVisit http://www.coreapi.org for more information.') 113 | @click.option('--version', is_flag=True, help='Display the package version number.') 114 | @click.pass_context 115 | def client(ctx, version): 116 | setup_paths() 117 | 118 | if os.path.isfile(config_path): 119 | os.remove(config_path) # pragma: nocover 120 | if not os.path.isdir(config_path): 121 | os.mkdir(config_path) 122 | 123 | if ctx.invoked_subcommand is not None: 124 | return 125 | 126 | if version: 127 | click.echo('coreapi command line client %s' % client_version) 128 | else: 129 | click.echo(ctx.get_help()) 130 | 131 | 132 | @click.command(help='Fetch a document from the given URL.') 133 | @click.argument('url') 134 | @click.option('--debug', '-d', is_flag=True, help='Display the request/response') 135 | @click.option('--format', default=None, help='Force a given decoder', type=click.Choice(codec_plugins.decoders.keys())) 136 | def get(url, debug, format): 137 | if format: 138 | decoders = [codec_plugins.decoders[format]] 139 | force_codec = True 140 | else: 141 | decoders = codec_plugins.decoders.values() 142 | force_codec = False 143 | 144 | client = get_client(decoders=decoders, debug=debug) 145 | history = get_history() 146 | try: 147 | doc = client.get(url, force_codec=force_codec) 148 | except coreapi.exceptions.ErrorMessage as exc: 149 | click.echo(display(exc.error)) 150 | sys.exit(1) 151 | click.echo(display(doc)) 152 | if isinstance(doc, coreapi.Document): 153 | history = history.add(doc) 154 | set_document(doc) 155 | set_history(history) 156 | 157 | 158 | @click.command(help='Load a document from disk.') 159 | @click.argument('input_file', type=click.File('rb')) 160 | @click.option('--format', default='corejson', help='Use the specified decoder', type=click.Choice(codec_plugins.decoders.keys())) 161 | def load(input_file, format): 162 | input_bytes = input_file.read() 163 | input_file.close() 164 | decoder = codec_plugins.decoders[format] 165 | 166 | history = get_history() 167 | doc = decoder.decode(input_bytes) 168 | click.echo(display(doc)) 169 | if isinstance(doc, coreapi.Document): 170 | history = history.add(doc) 171 | set_document(doc) 172 | set_history(history) 173 | 174 | 175 | @click.command(help='Dump a document to console.') 176 | @click.option('--format', default='corejson', help='Use the specified encoder', type=click.Choice(codec_plugins.encoders.keys())) 177 | def dump(format): 178 | doc = get_document() 179 | if doc is None: 180 | click.echo('No current document. Use `coreapi get` to fetch a document first.') 181 | sys.exit(1) 182 | 183 | encoder = codec_plugins.encoders[format] 184 | output = encoder.encode(doc) 185 | click.echo(output) 186 | 187 | 188 | @click.command(help='Clear the active document and other state.\n\nThis includes the current document, history, credentials, headers and bookmarks.') 189 | def clear(): 190 | for path in [ 191 | document_path, 192 | history_path, 193 | credentials_path, 194 | headers_path, 195 | bookmarks_path 196 | ]: 197 | if os.path.exists(path): 198 | os.remove(path) 199 | 200 | click.echo('Cleared.') 201 | 202 | 203 | @click.command(help='Display the current document.\n\nOptionally display just the element at the given PATH.') 204 | @click.argument('path', nargs=-1) 205 | def show(path): 206 | doc = get_document() 207 | if doc is None: 208 | click.echo('No current document. Use `coreapi get` to fetch a document first.') 209 | sys.exit(1) 210 | 211 | if path: 212 | keys = coerce_key_types(doc, path) 213 | for key in keys: 214 | try: 215 | doc = doc[key] 216 | except (KeyError, IndexError): 217 | click.echo('Key %s not found.' % repr(key).strip('u')) 218 | sys.exit(1) 219 | click.echo(display(doc)) 220 | 221 | 222 | @click.command(help='Display description for link at given PATH.') 223 | @click.argument('path', nargs=-1) 224 | def describe(path): 225 | doc = get_document() 226 | if doc is None: 227 | click.echo('No current document. Use `coreapi get` to fetch a document first.') 228 | sys.exit(1) 229 | 230 | if not path: 231 | click.echo('Missing PATH to a link in the document.') 232 | sys.exit(1) 233 | 234 | node = doc 235 | keys = coerce_key_types(doc, path) 236 | for key in keys: 237 | try: 238 | node = node[key] 239 | except (KeyError, IndexError): 240 | click.echo('Key %s not found.' % repr(key).strip('u')) 241 | sys.exit(1) 242 | 243 | if not isinstance(node, coreapi.Link): 244 | click.echo('Given PATH must index a link, not a %s.' % doc.__class__.__name__) 245 | sys.exit(1) 246 | 247 | fields_description = any([field.description for field in node.fields]) 248 | if not (node.description or fields_description): 249 | click.echo('Link has no description.') 250 | sys.exit(1) 251 | 252 | if node.description: 253 | click.echo(node.description) 254 | click.echo() 255 | for field in node.fields: 256 | name = field.name if field.required else '[%s]' % field.name 257 | if field.description: 258 | click.echo('* %s - %s' % (name, field.description)) 259 | else: 260 | click.echo('* %s' % name) 261 | 262 | 263 | def parse_params(ctx, param, tokens): 264 | ret = [] 265 | 266 | for token in tokens: 267 | if '=' not in token: 268 | raise click.BadParameter('Parameter "%s" should be in form of FIELD=VALUE') 269 | field, value = token.split('=', 1) 270 | 271 | try: 272 | pair = (field, json.loads(value)) 273 | except JSONDecodeError: 274 | if value.startswith('{') or value.startswith('['): 275 | # Guard against malformed composite objects being treated as strings. 276 | raise click.BadParameter('Unclear if parameter "%s" should be interperted as a string or data. Use --data or --string instead.' % field) 277 | pair = (field, value) 278 | ret.append(pair) 279 | 280 | return ret 281 | 282 | 283 | def parse_json(ctx, param, tokens): 284 | ret = [] 285 | 286 | for token in tokens: 287 | if '=' not in token: 288 | raise click.BadParameter('Data parameter "%s" should be in form of FIELD=VALUE') 289 | field, value = token.split('=', 1) 290 | 291 | try: 292 | pair = (field, json.loads(value)) 293 | except JSONDecodeError: 294 | raise click.BadParameter('Could not parse value for data argument "%s"' % field) 295 | ret.append(pair) 296 | 297 | return ret 298 | 299 | 300 | def parse_strings(ctx, param, tokens): 301 | ret = [] 302 | 303 | for token in tokens: 304 | if '=' not in token: 305 | raise click.BadParameter('String parameter "%s" should be in form of FIELD=VALUE') 306 | pair = token.split('=', 1) 307 | ret.append(pair) 308 | 309 | return ret 310 | 311 | 312 | def parse_files(ctx, param, values): 313 | ret = [] 314 | converter = click.File('rb') 315 | 316 | for item in values: 317 | if '=' not in item: 318 | raise click.BadParameter('String parameter "%s" should be in form of FIELD=VALUE') 319 | field, value = item.split('=', 1) 320 | input_file = converter.convert(value, param, ctx) 321 | pair = (field, input_file) 322 | ret.append(pair) 323 | 324 | return ret 325 | 326 | 327 | @click.command(help='Interact with the active document.\n\nRequires a PATH to a link in the document.\n\nExample:\n\ncoreapi action users add_user --param username tom --param is_admin true') 328 | @click.argument('path', nargs=-1) 329 | @click.option('params', '--param', '-p', callback=parse_params, multiple=True, metavar="FIELD=VALUE", help='Parameter for the action.') 330 | @click.option('strings', '--string', '-s', callback=parse_strings, multiple=True, metavar="FIELD=STRING", help='String parameter for the action.') 331 | @click.option('data', '--data', '-d', callback=parse_json, multiple=True, metavar="FIELD=DATA", help='Data parameter for the action.') 332 | @click.option('files', '--file', '-f', callback=parse_files, multiple=True, metavar="FIELD=FILENAME", help='File parameter for the action.') 333 | @click.option('--action', '-a', metavar="ACTION", help='Set the link action explicitly.', default=None) 334 | @click.option('--encoding', '-e', metavar="ENCODING", help='Set the link encoding explicitly.', default=None) 335 | @click.option('--transform', '-t', metavar="TRANSFORM", help='Set the link transform explicitly.', default=None) 336 | @click.option('--debug', '-d', is_flag=True, help='Display the request/response') 337 | def action(path, params, strings, data, files, action, encoding, transform, debug): 338 | params = dict(params) 339 | params.update(dict(strings)) 340 | params.update(dict(data)) 341 | params.update(dict(files)) 342 | 343 | if not path: 344 | click.echo('Missing PATH to a link in the document.') 345 | sys.exit(1) 346 | 347 | doc = get_document() 348 | if doc is None: 349 | click.echo('No current document. Use `coreapi get` to fetch a document first.') 350 | sys.exit(1) 351 | 352 | client = get_client(debug=debug) 353 | history = get_history() 354 | keys = coerce_key_types(doc, path) 355 | try: 356 | doc = client.action( 357 | doc, keys, params=params, 358 | action=action, encoding=encoding, transform=transform 359 | ) 360 | except coreapi.exceptions.ErrorMessage as exc: 361 | click.echo(display(exc.error)) 362 | sys.exit(1) 363 | except coreapi.exceptions.LinkLookupError as exc: 364 | click.echo(exc) 365 | sys.exit(1) 366 | click.echo(display(doc)) 367 | if isinstance(doc, coreapi.Document): 368 | history = history.add(doc) 369 | set_document(doc) 370 | set_history(history) 371 | 372 | 373 | @click.command(help='Reload the current document.') 374 | @click.option('--debug', '-d', is_flag=True, help='Display the request/response') 375 | @click.option('--format', default=None, help='Force a given decoder', type=click.Choice(codec_plugins.decoders.keys())) 376 | def reload_document(debug, format): 377 | doc = get_document() 378 | if doc is None: 379 | click.echo('No current document. Use `coreapi get` to fetch a document first.') 380 | sys.exit(1) 381 | 382 | if format: 383 | decoders = [codec_plugins.decoders[format]] 384 | force_codec = True 385 | else: 386 | decoders = codec_plugins.decoders.values() 387 | force_codec = False 388 | 389 | client = get_client(debug=debug, decoders=decoders) 390 | history = get_history() 391 | try: 392 | doc = client.reload(doc, force_codec=force_codec) 393 | except coreapi.exceptions.ErrorMessage as exc: 394 | click.echo(display(exc.error)) 395 | sys.exit(1) 396 | click.echo(display(doc)) 397 | if isinstance(doc, coreapi.Document): 398 | history = history.add(doc) 399 | set_document(doc) 400 | set_history(history) 401 | 402 | 403 | # Credentials 404 | 405 | def get_credentials(): 406 | if not os.path.isfile(credentials_path): 407 | return {} 408 | store = open(credentials_path, 'rb') 409 | credentials = json_load_bytes(store.read()) 410 | store.close() 411 | return credentials 412 | 413 | 414 | def set_credentials(credentials): 415 | store = open(credentials_path, 'wb') 416 | store.write(force_bytes(json.dumps(credentials))) 417 | store.close 418 | 419 | 420 | @click.group(help='Configure request credentials. Request credentials are associated with a given domain, and used in request "Authorization:" headers.') 421 | def credentials(): 422 | pass 423 | 424 | 425 | @click.command(help="List stored credentials.") 426 | def credentials_show(): 427 | credentials = get_credentials() 428 | if credentials: 429 | width = max([len(key) for key in credentials.keys()]) 430 | fmt = '{domain:%d} "{header}"' % width 431 | 432 | click.echo(click.style('Credentials', bold=True)) 433 | for key, value in sorted(credentials.items()): 434 | click.echo(fmt.format(domain=key, header=value)) 435 | 436 | 437 | @click.command(help="Add CREDENTIALS string for the given DOMAIN.") 438 | @click.argument('domain', nargs=1) 439 | @click.argument('credentials_string', nargs=1) 440 | @click.option('--auth', metavar="AUTH_SCHEME", help='Auth scheme to apply to the credentials string. Options: "none", "basic". Default is "none".', default='none', type=click.Choice(['none', 'basic'])) 441 | def credentials_add(domain, credentials_string, auth): 442 | if auth == 'none': 443 | header = credentials_string 444 | elif auth == 'basic': 445 | header = 'Basic ' + b64encode(credentials_string) 446 | credentials = get_credentials() 447 | credentials[domain] = header 448 | set_credentials(credentials) 449 | 450 | click.echo(click.style('Added credentials', bold=True)) 451 | click.echo('%s "%s"' % (domain, header)) 452 | 453 | 454 | @click.command(help="Remove credentials for the given DOMAIN.") 455 | @click.argument('domain', nargs=1) 456 | def credentials_remove(domain): 457 | credentials = get_credentials() 458 | credentials.pop(domain, None) 459 | set_credentials(credentials) 460 | 461 | click.echo(click.style('Removed credentials', bold=True)) 462 | click.echo(domain) 463 | 464 | 465 | # Headers 466 | 467 | def get_headers(): 468 | if not os.path.isfile(headers_path): 469 | return {} 470 | headers_file = open(headers_path, 'rb') 471 | headers = json_load_bytes(headers_file.read()) 472 | headers_file.close() 473 | return headers 474 | 475 | 476 | def set_headers(headers): 477 | headers_file = open(headers_path, 'wb') 478 | headers_file.write(force_bytes(json.dumps(headers))) 479 | headers_file.close() 480 | 481 | 482 | def titlecase(header): 483 | return '-'.join([word.title() for word in header.split('-')]) 484 | 485 | 486 | @click.group(help="Configure custom request headers.") 487 | def headers(): 488 | pass 489 | 490 | 491 | @click.command(help="List custom request headers.") 492 | def headers_show(): 493 | headers = get_headers() 494 | 495 | click.echo(click.style('Headers', bold=True)) 496 | for key, value in sorted(headers.items()): 497 | click.echo(key + ': ' + value) 498 | 499 | 500 | @click.command(help="Add custom request HEADER with given VALUE.") 501 | @click.argument('header', nargs=1) 502 | @click.argument('value', nargs=1) 503 | def headers_add(header, value): 504 | header = titlecase(header) 505 | headers = get_headers() 506 | headers[header] = value 507 | set_headers(headers) 508 | 509 | click.echo(click.style('Added header', bold=True)) 510 | click.echo('%s: %s' % (header, value)) 511 | 512 | 513 | @click.command(help="Remove custom request HEADER.") 514 | @click.argument('header', nargs=1) 515 | def headers_remove(header): 516 | header = titlecase(header) 517 | headers = get_headers() 518 | headers.pop(header, None) 519 | set_headers(headers) 520 | 521 | click.echo(click.style('Removed header', bold=True)) 522 | click.echo(header) 523 | 524 | 525 | # Headers 526 | 527 | def get_bookmarks(): 528 | if not os.path.isfile(bookmarks_path): 529 | return {} 530 | bookmarks_file = open(bookmarks_path, 'rb') 531 | bookmarks = json_load_bytes(bookmarks_file.read()) 532 | bookmarks_file.close() 533 | return bookmarks 534 | 535 | 536 | def set_bookmarks(bookmarks): 537 | bookmarks_file = open(bookmarks_path, 'wb') 538 | bookmarks_file.write(force_bytes(json.dumps(bookmarks))) 539 | bookmarks_file.close() 540 | 541 | 542 | @click.group(help="Add, remove and show bookmarks.") 543 | def bookmarks(): 544 | pass 545 | 546 | 547 | @click.command(help="List bookmarks.") 548 | def bookmarks_show(): 549 | bookmarks = get_bookmarks() 550 | 551 | if bookmarks: 552 | width = max([len(key) for key in bookmarks.keys()]) 553 | fmt = '{name:%d} <{title} {url}>' % width 554 | 555 | click.echo(click.style('Bookmarks', bold=True)) 556 | for key, value in sorted(bookmarks.items()): 557 | click.echo(fmt.format(name=key, title=value['title'] or 'Document', url=json.dumps(value['url']))) 558 | 559 | 560 | @click.command(help="Add the current document to the bookmarks, with the given NAME.") 561 | @click.argument('name', nargs=1) 562 | def bookmarks_add(name): 563 | doc = get_document() 564 | if doc is None: 565 | click.echo('No current document. Use `coreapi get` to fetch a document first.') 566 | sys.exit(1) 567 | 568 | bookmarks = get_bookmarks() 569 | bookmarks[name] = {'url': doc.url, 'title': doc.title} 570 | set_bookmarks(bookmarks) 571 | 572 | click.echo(click.style('Added bookmark', bold=True)) 573 | click.echo(name) 574 | 575 | 576 | @click.command(help="Remove a bookmark with the given NAME.") 577 | @click.argument('name', nargs=1) 578 | def bookmarks_remove(name): 579 | bookmarks = get_bookmarks() 580 | bookmarks.pop(name, None) 581 | set_bookmarks(bookmarks) 582 | 583 | click.echo(click.style('Removed bookmark', bold=True)) 584 | click.echo(name) 585 | 586 | 587 | @click.command(help="Fetch the bookmarked document with the given NAME.") 588 | @click.argument('name', nargs=1) 589 | def bookmarks_get(name): 590 | bookmarks = get_bookmarks() 591 | bookmark = bookmarks.get(name) 592 | if bookmark is None: 593 | click.echo('Bookmark "%s" does not exist.' % name) 594 | return 595 | url = bookmark['url'] 596 | 597 | client = get_client() 598 | history = get_history() 599 | try: 600 | doc = client.get(url) 601 | except coreapi.exceptions.ErrorMessage as exc: 602 | click.echo(display(exc.error)) 603 | sys.exit(1) 604 | click.echo(display(doc)) 605 | if isinstance(doc, coreapi.Document): 606 | history = history.add(doc) 607 | set_document(doc) 608 | set_history(history) 609 | 610 | 611 | # History 612 | 613 | def get_history(): 614 | if not os.path.isfile(history_path): 615 | return History(max_items=20) 616 | history_file = open(history_path, 'rb') 617 | bytestring = history_file.read() 618 | history_file.close() 619 | return load_history(bytestring) 620 | 621 | 622 | def set_history(history): 623 | bytestring = dump_history(history) 624 | history_file = open(history_path, 'wb') 625 | history_file.write(bytestring) 626 | history_file.close() 627 | 628 | 629 | @click.group(help="Navigate the browser history.") 630 | def history(): 631 | pass 632 | 633 | 634 | @click.command(help="List the browser history.") 635 | def history_show(): 636 | history = get_history() 637 | 638 | click.echo(click.style('History', bold=True)) 639 | for is_active, doc in history.get_items(): 640 | prefix = '[*] ' if is_active else '[ ] ' 641 | click.echo(prefix + get_document_string(doc)) 642 | 643 | 644 | @click.command(help="Navigate back through the browser history.") 645 | def history_back(): 646 | client = get_client() 647 | history = get_history() 648 | if history.is_at_oldest: 649 | click.echo("Currently at oldest point in history. Cannot navigate back.") 650 | return 651 | doc, history = history.back() 652 | try: 653 | doc = client.reload(doc) 654 | except coreapi.exceptions.ErrorMessage as exc: 655 | click.echo(display(exc.error)) 656 | sys.exit(1) 657 | click.echo(display(doc)) 658 | if isinstance(doc, coreapi.Document): 659 | set_document(doc) 660 | set_history(history) 661 | 662 | 663 | @click.command(help="Navigate forward through the browser history.") 664 | def history_forward(): 665 | client = get_client() 666 | history = get_history() 667 | if history.is_at_most_recent: 668 | click.echo("Currently at most recent point in history. Cannot navigate forward.") 669 | return 670 | doc, history = history.forward() 671 | try: 672 | doc = client.reload(doc) 673 | except coreapi.exceptions.ErrorMessage as exc: 674 | click.echo(display(exc.error)) 675 | sys.exit(1) 676 | click.echo(display(doc)) 677 | if isinstance(doc, coreapi.Document): 678 | set_document(doc) 679 | set_history(history) 680 | 681 | 682 | # Codecs 683 | 684 | @click.group(help="Manage the installed codecs.") 685 | def codecs(): 686 | pass 687 | 688 | 689 | @click.command(help="List the installed codecs.") 690 | def codecs_show(): 691 | # Note that this omits the data codecs of JSON and Text. 692 | 693 | col_1_len = max([len(key) for key in codec_plugins.codecs.keys()]) 694 | col_2_len = max([len(codec.media_type) for codec in codec_plugins.codecs.values()]) 695 | col_3_len = max([len(', '.join(codec_plugins.supports(codec))) for codec in codec_plugins.codecs.values()]) 696 | 697 | col_1_len = max(col_1_len, len('Codec name')) 698 | col_2_len = max(col_2_len, len('Media type')) 699 | col_3_len = max(col_3_len, len('Support')) 700 | 701 | fmt = '{key:%d} | {media_type:%s} | {supports:%d} | {dist}' % (col_1_len, col_2_len, col_3_len) 702 | header = fmt.format(key='Codec name', media_type='Media type', supports='Support', dist='Package') 703 | click.echo(click.style(header.replace('|', ' '), bold=True)) 704 | 705 | for package, codec in codec_plugins.codec_packages: 706 | name = package.name 707 | media_type = getattr(codec, 'media_type') 708 | supports = ', '.join(codec_plugins.supports(codec)) 709 | dist = package.dist.as_requirement() 710 | click.echo(fmt.format(key=name, media_type=media_type, supports=supports, dist=dist)) 711 | 712 | 713 | client.add_command(get) 714 | client.add_command(show) 715 | client.add_command(action) 716 | client.add_command(reload_document, name='reload') 717 | client.add_command(clear) 718 | client.add_command(load) 719 | client.add_command(dump) 720 | client.add_command(describe) 721 | 722 | client.add_command(credentials) 723 | credentials.add_command(credentials_add, name='add') 724 | credentials.add_command(credentials_remove, name='remove') 725 | credentials.add_command(credentials_show, name='show') 726 | 727 | client.add_command(headers) 728 | headers.add_command(headers_add, name='add') 729 | headers.add_command(headers_remove, name='remove') 730 | headers.add_command(headers_show, name='show') 731 | 732 | client.add_command(bookmarks) 733 | bookmarks.add_command(bookmarks_add, name='add') 734 | bookmarks.add_command(bookmarks_get, name='get') 735 | bookmarks.add_command(bookmarks_remove, name='remove') 736 | bookmarks.add_command(bookmarks_show, name='show') 737 | 738 | client.add_command(history) 739 | history.add_command(history_back, name='back') 740 | history.add_command(history_forward, name='forward') 741 | history.add_command(history_show, name='show') 742 | 743 | client.add_command(codecs) 744 | codecs.add_command(codecs_show, name='show') 745 | 746 | 747 | if __name__ == '__main__': 748 | client() 749 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Package requirements 2 | coreapi 3 | click 4 | 5 | # Testing requirements 6 | coverage 7 | flake8 8 | pytest 9 | -------------------------------------------------------------------------------- /runtests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import coverage 3 | import os 4 | import pytest 5 | import subprocess 6 | import sys 7 | 8 | 9 | PYTEST_ARGS = ['tests', '--tb=short'] 10 | FLAKE8_ARGS = ['coreapi_cli', 'tests', '--ignore=E501'] 11 | COVERAGE_OPTIONS = { 12 | 'include': ['coreapi_cli/*', 'tests/*'] 13 | } 14 | 15 | 16 | sys.path.append(os.path.dirname(__file__)) 17 | 18 | 19 | class NullFile(object): 20 | def write(self, data): 21 | pass 22 | 23 | 24 | def exit_on_failure(ret, message=None): 25 | if ret: 26 | sys.exit(ret) 27 | 28 | 29 | def flake8_main(args): 30 | print('Running flake8 code linting') 31 | ret = subprocess.call(['flake8'] + args) 32 | print('flake8 failed' if ret else 'flake8 passed') 33 | return ret 34 | 35 | 36 | def report_coverage(cov, fail_if_not_100=False): 37 | precent_covered = cov.report( 38 | file=NullFile(), **COVERAGE_OPTIONS 39 | ) 40 | if precent_covered == 100: 41 | print('100% coverage') 42 | return 43 | if fail_if_not_100: 44 | print('Tests passed, but not 100% coverage.') 45 | cov.report(**COVERAGE_OPTIONS) 46 | cov.html_report(**COVERAGE_OPTIONS) 47 | if fail_if_not_100: 48 | sys.exit(1) 49 | 50 | 51 | def split_class_and_function(string): 52 | class_string, function_string = string.split('.', 1) 53 | return "%s and %s" % (class_string, function_string) 54 | 55 | 56 | def is_function(string): 57 | # `True` if it looks like a test function is included in the string. 58 | return string.startswith('test_') or '.test_' in string 59 | 60 | 61 | def is_class(string): 62 | # `True` if first character is uppercase - assume it's a class name. 63 | return string[0] == string[0].upper() 64 | 65 | 66 | if __name__ == "__main__": 67 | if len(sys.argv) > 1: 68 | pytest_args = sys.argv[1:] 69 | first_arg = pytest_args[0] 70 | if first_arg.startswith('-'): 71 | # `runtests.py [flags]` 72 | pytest_args = PYTEST_ARGS + pytest_args 73 | elif is_class(first_arg) and is_function(first_arg): 74 | # `runtests.py TestCase.test_function [flags]` 75 | expression = split_class_and_function(first_arg) 76 | pytest_args = PYTEST_ARGS + ['-k', expression] + pytest_args[1:] 77 | elif is_class(first_arg) or is_function(first_arg): 78 | # `runtests.py TestCase [flags]` 79 | # `runtests.py test_function [flags]` 80 | pytest_args = PYTEST_ARGS + ['-k', pytest_args[0]] + pytest_args[1:] 81 | else: 82 | pytest_args = PYTEST_ARGS 83 | 84 | cov = coverage.coverage() 85 | cov.start() 86 | exit_on_failure(pytest.main(pytest_args)) 87 | cov.stop() 88 | exit_on_failure(flake8_main(FLAKE8_ARGS)) 89 | report_coverage(cov) 90 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | import re 6 | import os 7 | import sys 8 | 9 | 10 | def get_version(package): 11 | """ 12 | Return package version as listed in `__version__` in `init.py`. 13 | """ 14 | init_py = open(os.path.join(package, '__init__.py')).read() 15 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 16 | 17 | 18 | def get_packages(package): 19 | """ 20 | Return root package and all sub-packages. 21 | """ 22 | return [dirpath 23 | for dirpath, dirnames, filenames in os.walk(package) 24 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 25 | 26 | 27 | def get_package_data(package): 28 | """ 29 | Return all files under the root package, that are not in a 30 | package themselves. 31 | """ 32 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 33 | for dirpath, dirnames, filenames in os.walk(package) 34 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 35 | 36 | filepaths = [] 37 | for base, filenames in walk: 38 | filepaths.extend([os.path.join(base, filename) 39 | for filename in filenames]) 40 | return {package: filepaths} 41 | 42 | 43 | version = get_version('coreapi_cli') 44 | 45 | 46 | if sys.argv[-1] == 'publish': 47 | os.system("python setup.py sdist upload") 48 | print("You probably want to also tag the version now:") 49 | print(" git tag -a %s -m 'version %s'" % (version, version)) 50 | print(" git push --tags") 51 | sys.exit() 52 | 53 | 54 | setup( 55 | name='coreapi-cli', 56 | version=version, 57 | url='http://github.com/core-api/coreapi-cli/', 58 | license='BSD', 59 | description='An interactive command line client for Core API.', 60 | author='Tom Christie', 61 | author_email='tom@tomchristie.com', 62 | packages=get_packages('coreapi_cli'), 63 | package_data=get_package_data('coreapi_cli'), 64 | install_requires=[ 65 | 'coreapi>=1.32.0', 66 | 'click>=6.0' 67 | ], 68 | classifiers=[ 69 | 'Intended Audience :: Developers', 70 | 'License :: OSI Approved :: BSD License', 71 | 'Operating System :: OS Independent', 72 | 'Programming Language :: Python', 73 | 'Programming Language :: Python :: 3', 74 | ], 75 | entry_points={ 76 | 'console_scripts': [ 77 | 'coreapi=coreapi_cli.main:client' 78 | ], 79 | }, 80 | ) 81 | -------------------------------------------------------------------------------- /tests/test_example.py: -------------------------------------------------------------------------------- 1 | def test_example(): 2 | assert True 3 | -------------------------------------------------------------------------------- /tests/test_history.py: -------------------------------------------------------------------------------- 1 | from coreapi import Document 2 | from coreapi_cli.history import History, dump_history, load_history 3 | import pytest 4 | 5 | 6 | def test_empty_history(): 7 | history = History() 8 | assert history.is_at_most_recent 9 | assert history.is_at_oldest 10 | assert history.current is None 11 | assert list(history.get_items()) == [] 12 | assert load_history(dump_history(history)) == history 13 | 14 | with pytest.raises(ValueError): 15 | history.back() 16 | with pytest.raises(ValueError): 17 | history.forward() 18 | 19 | # Adding new document changes history. 20 | new_doc = Document('http://example.com', 'Example') 21 | new_history = history.add(new_doc) 22 | assert list(new_history.get_items()) == [(True, new_doc)] 23 | 24 | 25 | def test_single_history(): 26 | doc = Document('http://example.com', 'Example') 27 | history = History([doc]) 28 | assert history.is_at_most_recent 29 | assert history.is_at_oldest 30 | assert history.current == doc 31 | assert list(history.get_items()) == [(True, doc)] 32 | assert load_history(dump_history(history)) == history 33 | 34 | with pytest.raises(ValueError): 35 | history.back() 36 | with pytest.raises(ValueError): 37 | history.forward() 38 | 39 | # Adding same document does not change history. 40 | new_doc = Document('http://example.com', 'Example') 41 | new_history = history.add(new_doc) 42 | assert list(new_history.get_items()) == [(True, doc)] 43 | 44 | # Adding same URL, different title changes existing item. 45 | new_doc = Document('http://example.com', 'New') 46 | new_history = history.add(new_doc) 47 | assert list(new_history.get_items()) == [(True, new_doc)] 48 | 49 | # Adding different document changes history. 50 | new_doc = Document('http://other.com', 'Example') 51 | new_history = history.add(new_doc) 52 | assert list(new_history.get_items()) == [(True, new_doc), (False, doc)] 53 | 54 | 55 | def test_navigating_back(): 56 | first = Document('http://first.com', 'First') 57 | second = Document('http://second.com', 'Second') 58 | history = History([second, first]) 59 | assert history.is_at_most_recent 60 | assert not history.is_at_oldest 61 | assert history.current == second 62 | assert list(history.get_items()) == [(True, second), (False, first)] 63 | 64 | doc, history = history.back() 65 | assert doc == first 66 | assert list(history.get_items()) == [(False, second), (True, first)] 67 | 68 | 69 | def test_navigating_forward(): 70 | first = Document('http://first.com', 'First') 71 | second = Document('http://second.com', 'Second') 72 | history = History([second, first], idx=1) 73 | assert not history.is_at_most_recent 74 | assert history.is_at_oldest 75 | assert history.current == first 76 | assert list(history.get_items()) == [(False, second), (True, first)] 77 | 78 | doc, history = history.forward() 79 | assert doc == second 80 | assert list(history.get_items()) == [(True, second), (False, first)] 81 | 82 | 83 | def test_adding_from_midpoint(): 84 | """ 85 | Adding an item from midpoint in history removes any forwards items. 86 | """ 87 | first = Document('http://first.com', 'First') 88 | second = Document('http://second.com', 'Second') 89 | history = History([second, first], idx=1) 90 | 91 | third = Document('http://third.com', 'Third') 92 | new = history.add(third) 93 | assert list(new.get_items()) == [(True, third), (False, first)] 94 | 95 | 96 | def test_invalid_arguments(): 97 | with pytest.raises(ValueError): 98 | History([None, Document('http://example.com')]) 99 | 100 | with pytest.raises(ValueError): 101 | History().add(None) 102 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from click.testing import CliRunner 2 | from coreapi import Document, Link 3 | from coreapi.transports import HTTPTransport 4 | from coreapi_cli import __version__ as version 5 | from coreapi_cli.main import client, coerce_key_types 6 | import pytest 7 | import os 8 | import shutil 9 | import tempfile 10 | 11 | 12 | mock_response = None 13 | 14 | 15 | def set_response(doc): 16 | global mock_response 17 | mock_response = doc 18 | 19 | 20 | @pytest.fixture(scope="function") 21 | def cli(request): 22 | """ 23 | A fixture returning a runner for the command line client. 24 | """ 25 | config_dir = tempfile.mkdtemp() 26 | os.environ['COREAPI_CONFIG_DIR'] = config_dir 27 | saved = HTTPTransport.transition 28 | 29 | def transition(*args, **kwargs): 30 | return mock_response 31 | 32 | def finalize(): 33 | shutil.rmtree(config_dir) 34 | HTTPTransport.transition = saved 35 | 36 | def _cli(*args): 37 | return runner.invoke(client, args) 38 | 39 | runner = CliRunner() 40 | request.addfinalizer(finalize) 41 | HTTPTransport.transition = transition 42 | 43 | return _cli 44 | 45 | 46 | # Integration tests 47 | 48 | def test_no_command(cli): 49 | result = cli() 50 | assert result.output.startswith('Usage:') 51 | 52 | 53 | def test_version_option(cli): 54 | result = cli('--version') 55 | assert result.output == 'coreapi command line client %s\n' % version 56 | 57 | 58 | def test_cli_get(cli): 59 | set_response(Document('http://example.com', 'Example')) 60 | result = cli('get', 'http://mock') 61 | assert result.output == '\n' 62 | 63 | result = cli('show') 64 | assert result.output == '\n' 65 | 66 | 67 | def test_cli_clear(cli): 68 | set_response(Document('http://example.com', 'Example')) 69 | result = cli('get', 'http://mock') 70 | 71 | cli('clear') 72 | result = cli('show') 73 | assert result.output == 'No current document. Use `coreapi get` to fetch a document first.\n' 74 | assert result.exit_code == 1 75 | 76 | 77 | def test_cli_reload(cli): 78 | result = cli('reload') 79 | assert result.output == 'No current document. Use `coreapi get` to fetch a document first.\n' 80 | assert result.exit_code == 1 81 | 82 | set_response(Document('http://example.com', 'Example')) 83 | result = cli('get', 'http://mock') 84 | 85 | set_response(Document('http://example.com', 'New')) 86 | cli('reload') 87 | result = cli('show') 88 | assert result.output == '\n' 89 | 90 | 91 | # History 92 | 93 | def test_cli_history(cli): 94 | set_response(Document('http://1.com')) 95 | result = cli('get', 'http://mock') 96 | 97 | set_response(Document('http://2.com')) 98 | result = cli('get', 'http://mock') 99 | 100 | result = cli('history', 'show') 101 | assert result.output == ( 102 | 'History\n' 103 | '[*] \n' 104 | '[ ] \n' 105 | ) 106 | 107 | set_response(Document('http://1.com')) 108 | result = cli('history', 'back') 109 | result = cli('history', 'show') 110 | assert result.output == ( 111 | 'History\n' 112 | '[ ] \n' 113 | '[*] \n' 114 | ) 115 | result = cli('show') 116 | assert result.output == '\n' 117 | 118 | set_response(Document('http://2.com')) 119 | result = cli('history', 'forward') 120 | result = cli('history', 'show') 121 | assert result.output == ( 122 | 'History\n' 123 | '[*] \n' 124 | '[ ] \n' 125 | ) 126 | result = cli('show') 127 | assert result.output == '\n' 128 | 129 | 130 | # Credentials 131 | 132 | def test_cli_credentials(cli): 133 | result = cli('credentials', 'show') 134 | assert result.output == 'Credentials\n' 135 | 136 | result = cli('credentials', 'add', 'http://1.com', 'Token 123cat') 137 | assert result.output == 'Added credentials\nhttp://1.com "Token 123cat"\n' 138 | 139 | result = cli('credentials', 'show') 140 | assert result.output == 'Credentials\nhttp://1.com "Token 123cat"\n' 141 | 142 | 143 | # Bookmarks 144 | 145 | def test_cli_bookmarks(cli): 146 | set_response(Document('http://example.com', 'Example')) 147 | cli('get', 'http://example.com') 148 | 149 | result = cli('bookmarks', 'add', 'example') 150 | assert result.output == 'Added bookmark\nexample\n' 151 | 152 | result = cli('bookmarks', 'show') 153 | assert result.output == 'Bookmarks\nexample \n' 154 | 155 | result = cli('bookmarks', 'get', 'example') 156 | assert result.output == '\n' 157 | 158 | result = cli('bookmarks', 'remove', 'example') 159 | assert result.output == 'Removed bookmark\nexample\n' 160 | 161 | result = cli('bookmarks', 'show') 162 | assert result.output == 'Bookmarks\n' 163 | 164 | 165 | # Headers 166 | 167 | def test_cli_headers(cli): 168 | result = cli('headers', 'add', 'Cache-Control', 'public') 169 | assert result.output == 'Added header\nCache-Control: public\n' 170 | 171 | result = cli('headers', 'show') 172 | assert result.output == 'Headers\nCache-Control: public\n' 173 | 174 | result = cli('headers', 'remove', 'Cache-Control') 175 | assert result.output == 'Removed header\nCache-Control\n' 176 | 177 | result = cli('headers', 'show') 178 | assert result.output == 'Headers\n' 179 | 180 | 181 | # Test dotted path notation maps to list of keys correctly. 182 | 183 | def test_dotted_path_notation(): 184 | doc = Document(content={'rows': [Document(content={'edit': Link()})]}) 185 | keys = coerce_key_types(doc, ['rows', 0, 'edit']) 186 | assert keys == ['rows', 0, 'edit'] 187 | 188 | 189 | def test_dotted_path_notation_with_invalid_array_lookup(): 190 | doc = Document(content={'rows': [Document(content={'edit': Link()})]}) 191 | keys = coerce_key_types(doc, ['rows', 'zero', 'edit']) 192 | assert keys == ['rows', 'zero', 'edit'] 193 | 194 | 195 | def test_dotted_path_notation_with_invalid_key(): 196 | doc = Document(content={'rows': [Document(content={'edit': Link()})]}) 197 | keys = coerce_key_types(doc, ['dummy', '0', 'edit']) 198 | assert keys == ['dummy', '0', 'edit'] 199 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py34,py27 3 | [testenv] 4 | deps = -rrequirements.txt 5 | commands = ./runtests 6 | --------------------------------------------------------------------------------