cli2 3.2.0__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {cli2-3.2.0/cli2.egg-info → cli2-3.3.0}/PKG-INFO +1 -1
  2. {cli2-3.2.0 → cli2-3.3.0}/cli2/__init__.py +1 -0
  3. {cli2-3.2.0 → cli2-3.3.0}/cli2/command.py +39 -1
  4. cli2-3.3.0/cli2/configuration.py +190 -0
  5. {cli2-3.2.0 → cli2-3.3.0}/cli2/example_client.py +5 -0
  6. {cli2-3.2.0 → cli2-3.3.0}/cli2/test_client.py +22 -1
  7. cli2-3.3.0/cli2/test_configuration.py +62 -0
  8. {cli2-3.2.0 → cli2-3.3.0/cli2.egg-info}/PKG-INFO +1 -1
  9. {cli2-3.2.0 → cli2-3.3.0}/cli2.egg-info/SOURCES.txt +2 -0
  10. {cli2-3.2.0 → cli2-3.3.0}/setup.py +1 -1
  11. {cli2-3.2.0 → cli2-3.3.0}/MANIFEST.in +0 -0
  12. {cli2-3.2.0 → cli2-3.3.0}/README.rst +0 -0
  13. {cli2-3.2.0 → cli2-3.3.0}/classifiers.txt +0 -0
  14. {cli2-3.2.0 → cli2-3.3.0}/cli2/argument.py +0 -0
  15. {cli2-3.2.0 → cli2-3.3.0}/cli2/asyncio.py +0 -0
  16. {cli2-3.2.0 → cli2-3.3.0}/cli2/cli.py +0 -0
  17. {cli2-3.2.0 → cli2-3.3.0}/cli2/client.py +0 -0
  18. {cli2-3.2.0 → cli2-3.3.0}/cli2/colors.py +0 -0
  19. {cli2-3.2.0 → cli2-3.3.0}/cli2/decorators.py +0 -0
  20. {cli2-3.2.0 → cli2-3.3.0}/cli2/display.py +0 -0
  21. {cli2-3.2.0 → cli2-3.3.0}/cli2/entry_point.py +0 -0
  22. {cli2-3.2.0 → cli2-3.3.0}/cli2/example_client_complex.py +0 -0
  23. {cli2-3.2.0 → cli2-3.3.0}/cli2/example_nesting.py +0 -0
  24. {cli2-3.2.0 → cli2-3.3.0}/cli2/example_obj.py +0 -0
  25. {cli2-3.2.0 → cli2-3.3.0}/cli2/group.py +0 -0
  26. {cli2-3.2.0 → cli2-3.3.0}/cli2/logging.py +0 -0
  27. {cli2-3.2.0 → cli2-3.3.0}/cli2/node.py +0 -0
  28. {cli2-3.2.0 → cli2-3.3.0}/cli2/sphinx.py +0 -0
  29. {cli2-3.2.0 → cli2-3.3.0}/cli2/table.py +0 -0
  30. {cli2-3.2.0 → cli2-3.3.0}/cli2/test.py +0 -0
  31. {cli2-3.2.0 → cli2-3.3.0}/cli2/test_cli.py +0 -0
  32. {cli2-3.2.0 → cli2-3.3.0}/cli2/test_command.py +0 -0
  33. {cli2-3.2.0 → cli2-3.3.0}/cli2/test_decorators.py +0 -0
  34. {cli2-3.2.0 → cli2-3.3.0}/cli2/test_display.py +0 -0
  35. {cli2-3.2.0 → cli2-3.3.0}/cli2/test_entry_point.py +0 -0
  36. {cli2-3.2.0 → cli2-3.3.0}/cli2/test_group.py +0 -0
  37. {cli2-3.2.0 → cli2-3.3.0}/cli2/test_inject.py +0 -0
  38. {cli2-3.2.0 → cli2-3.3.0}/cli2/test_node.py +0 -0
  39. {cli2-3.2.0 → cli2-3.3.0}/cli2/test_table.py +0 -0
  40. {cli2-3.2.0 → cli2-3.3.0}/cli2.egg-info/dependency_links.txt +0 -0
  41. {cli2-3.2.0 → cli2-3.3.0}/cli2.egg-info/entry_points.txt +0 -0
  42. {cli2-3.2.0 → cli2-3.3.0}/cli2.egg-info/requires.txt +0 -0
  43. {cli2-3.2.0 → cli2-3.3.0}/cli2.egg-info/top_level.txt +0 -0
  44. {cli2-3.2.0 → cli2-3.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cli2
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: image:: https://yourlabs.io/oss/cli2/badges/master/pipeline.svg
5
5
  Home-page: https://yourlabs.io/oss/cli2
6
6
  Author: James Pic
@@ -3,6 +3,7 @@ from .argument import Argument
3
3
  from .colors import colors as c
4
4
  import importlib.metadata
5
5
 
6
+ from .configuration import Configuration, cfg
6
7
  from .command import Command
7
8
  try:
8
9
  from .client import (
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import inspect
3
+ import json
3
4
  import sys
4
5
 
5
6
  from docstring_parser import parse
@@ -295,10 +296,47 @@ class Command(EntryPoint, dict):
295
296
  except KeyboardInterrupt:
296
297
  print('exiting')
297
298
  sys.exit(1)
299
+ except HTTPStatusError as exc:
300
+ # we probably can have a generic exception handler registry
301
+ # of some sort instead of this, but this will do for now
302
+ self.http_exception_enhance(exc)
303
+ raise
298
304
  finally:
299
- self.post_result = await async_resolve(self.post_call())
305
+ try:
306
+ self.post_result = await async_resolve(self.post_call())
307
+ except HTTPStatusError as exc:
308
+ self.http_exception_enhance(exc)
309
+ raise
300
310
  return result
301
311
 
312
+ def http_exception_enhance(self, exc):
313
+ """
314
+ Enhance an httpx.HTTPStatusError
315
+
316
+ Adds beatiful request/response data to the exception.
317
+
318
+ :param exc: httpx.HTTPStatusError
319
+ """
320
+ try:
321
+ request = display.yaml_dump(
322
+ json.loads(exc.request.content.decode()),
323
+ )
324
+ except json.JSONDecodeError:
325
+ request = exc.request.content
326
+
327
+ try:
328
+ response = display.yaml_dump(exc.response.json())
329
+ except json.JSONDecodeError:
330
+ response = exc.response.content
331
+
332
+ exc.args = ('\n'.join([
333
+ exc.args[0],
334
+ 'Request data:',
335
+ request,
336
+ 'Response data:',
337
+ response,
338
+ ]),)
339
+
302
340
  def ordered(self, factories=False):
303
341
  """
304
342
  Order the parameters by priority.
@@ -0,0 +1,190 @@
1
+ """
2
+ 12-factor interactive self-building lazy configuration.
3
+
4
+ The developer story we are after:
5
+
6
+ - there's nothing to do, just expect ``cli2.cfg['YOUR_ENV_VAR']`` to work one
7
+ way or another
8
+ - when there's a minute to be nice to the user, add some help that will be
9
+ displayed to them: ``cli2.cfg.questions['YOUR_ENV_VAR'] = 'this is a help
10
+ text that will be display to the user when we prompt them for YOUR_ENV_VAR'``
11
+
12
+ This is the user story we are after:
13
+
14
+ - user runs your cli2 command right after install without any configuration
15
+ - the user is prompted for a variable
16
+ - the variable is saved in their ~/.profile in a new export line
17
+ - the user runs a command again in the same shell: we should find the variable
18
+ in ~/.profile so he doesn't have to start a new shell for his new
19
+ configuration to work
20
+
21
+ Of course, if the environment variable is already present in the environment
22
+ then this basically returns it from ``os.environ``.
23
+ """
24
+
25
+ import functools
26
+ import os
27
+ import re
28
+ import shlex
29
+ from pathlib import Path
30
+
31
+
32
+ class Configuration(dict):
33
+ """
34
+ Configuration object.
35
+
36
+ Wraps around environment variable and can question the user for missing
37
+ variables and save them in his shell profile.
38
+
39
+ .. py:attribute:: questions
40
+
41
+ A dict of ``ENV_VAR=question_string``, if an env var is missing from
42
+ configuration then question_string will be used as text to prompt the
43
+ user.
44
+
45
+ .. py:attribute:: profile_path
46
+
47
+ Path to the shell profile to save/read variables, defaults to
48
+ ~/.profile which should work in many shells.
49
+
50
+ You can also just work with the module level ("singleton") instance, have
51
+ scripts like:
52
+
53
+ .. code-block:: python
54
+
55
+ import cli2
56
+
57
+ cli = cli2.Group()
58
+
59
+ cli2.cfg['API_URL'] = 'What is your API URL?'
60
+
61
+ @cli.cmd
62
+ def foo():
63
+ api_url = cli2.cfg['API_URL']
64
+
65
+ # when there's no question, it'll use the var name as prompt
66
+ api_url = cli2.cfg['USERNAME']
67
+
68
+ """
69
+ def __init__(self, profile_path=None, **questions):
70
+ self.questions = questions
71
+ self.profile_path = Path(
72
+ profile_path or os.getenv('HOME') + '/.profile'
73
+ )
74
+ self._profile_script = None
75
+ self._profile_variables = dict()
76
+ self.environ = os.environ.copy()
77
+
78
+ def input(self, prompt):
79
+ """
80
+ Wraps around Python's input but adds confirmation.
81
+
82
+ :param prompt: Prompt text to display.
83
+ """
84
+ value = input(prompt + '\n> ')
85
+ confirm = None
86
+ while confirm not in ('', 'y', 'Y', 'n'):
87
+ confirm = input(f'Confirm value of:\n{value}?\n(Y/n) >')
88
+ if confirm in ('', 'y', 'Y'):
89
+ # user is satisfied
90
+ return value
91
+ # ok let's try again
92
+ return self.input(prompt)
93
+
94
+ def __getitem__(self, key):
95
+ """
96
+ If the key is not in self, call :py:meth:`configure`.
97
+
98
+ :param key: Environment variable name
99
+ """
100
+ if key not in self:
101
+ self[key] = self.configure(key)
102
+ return super().__getitem__(key)
103
+
104
+ @property
105
+ def profile_script(self):
106
+ """
107
+ Cached :py:attr:`profile_path` reader.
108
+ """
109
+ if self._profile_script:
110
+ return self._profile_script
111
+
112
+ if self.profile_path.exists():
113
+ with self.profile_path.open('r') as f:
114
+ self._profile_script = f.read()
115
+ else:
116
+ self.profile_path.touch()
117
+ self._profile_script = ''
118
+ return self._profile_script
119
+
120
+ @functools.cached_property
121
+ def profile_variables(self):
122
+ """
123
+ Cached environment variable parsing from :py:attr:`profile_path`.
124
+ """
125
+ if self._profile_variables:
126
+ return self._profile_variables
127
+
128
+ for line in self.profile_script.split('\n'):
129
+ if not line.startswith('export '):
130
+ continue
131
+ name, value = re.findall('export ([^=]*)=(.*)', line)[0]
132
+ value = shlex.split(value)[0]
133
+ self._profile_variables[name] = value
134
+ return self._profile_variables
135
+
136
+ def configure(self, key):
137
+ """
138
+ Core logic to figure a variable.
139
+
140
+ - if present in os.environ: return that
141
+ - if parsed in profile_variables: return that
142
+ - otherwise prompt it, with the question if any, then save it to
143
+ :py:attr:`profile_path`
144
+ """
145
+ if key in self.environ:
146
+ return self.environ[key]
147
+
148
+ # ok, let's love our user, and try to parse the variable
149
+ # from self.profile, after all, perhaps they are running
150
+ # their command for the second time in the same shell
151
+ if key in self.profile_variables:
152
+ return self.profile_variables[key]
153
+
154
+ prompt = self.questions.get(key, key)
155
+ value = self.input(prompt)
156
+ escaped_value = shlex.quote(value)
157
+ with self.profile_path.open('a') as f:
158
+ f.write(f'\nexport {key}={escaped_value}')
159
+ return value
160
+
161
+ def delete(self, key, reason=None):
162
+ """
163
+ Delete a variable from everywhere, useful if an api key expired.
164
+
165
+ :param key: Env var name to delete
166
+ :param reason: Reason to print to the user
167
+ """
168
+ with self.profile_path.open('r') as f:
169
+ lines = f.read().split('\n')
170
+
171
+ contents = [
172
+ line
173
+ for line in lines
174
+ if not line.startswith(f'export {key}=')
175
+ ]
176
+ if len(contents) != len(lines):
177
+ if reason:
178
+ print(reason)
179
+ print(f'Removing {key} configuration')
180
+
181
+ new_script = '\n'.join(contents)
182
+ with self.profile_path.open('w') as f:
183
+ f.write(new_script)
184
+ self._profile_script = new_script
185
+ self._profile_variables.pop(key, None)
186
+ self.pop(key, None)
187
+ self.environ.pop(key, None)
188
+
189
+
190
+ cfg = Configuration()
@@ -16,5 +16,10 @@ class Object(APIClient.model):
16
16
  url_list = '/objects'
17
17
  url_detail = '/objects/{self.url_id}'
18
18
 
19
+ @classmethod
20
+ @cli.cmd
21
+ async def fail(cls):
22
+ await cls.client.post('/foo', json=[1])
23
+
19
24
 
20
25
  cli.cmd(Object.find)
@@ -1,6 +1,7 @@
1
1
  import cli2
2
2
  import httpx
3
3
  import pytest
4
+ import textwrap
4
5
  from datetime import datetime
5
6
 
6
7
 
@@ -13,7 +14,7 @@ raised = False
13
14
 
14
15
 
15
16
  @pytest.mark.asyncio
16
- async def test_error(httpx_mock):
17
+ async def test_error_remote(httpx_mock):
17
18
  client = Client()
18
19
  httpx_mock.add_response(url='http://lol', json=[1])
19
20
 
@@ -29,6 +30,26 @@ async def test_error(httpx_mock):
29
30
  assert response.json() == [1]
30
31
 
31
32
 
33
+ @pytest.mark.asyncio
34
+ async def test_error_status(httpx_mock):
35
+ client = Client()
36
+ httpx_mock.add_response(url='http://lol', status_code=403, json=[1])
37
+
38
+ async def request():
39
+ await client.post('http://lol', json=[2])
40
+ cmd = cli2.Command(request)
41
+ with pytest.raises(httpx.HTTPStatusError) as excinfo:
42
+ await cmd.async_call()
43
+ expected = textwrap.dedent('''
44
+ Request data:
45
+ - 2
46
+
47
+ Response data:
48
+ - 1
49
+ ''').strip()
50
+ assert excinfo.value.args[0].strip().endswith(expected)
51
+
52
+
32
53
  @pytest.mark.asyncio
33
54
  async def test_token(httpx_mock):
34
55
  class HasToken(Client):
@@ -0,0 +1,62 @@
1
+ import cli2
2
+ import os
3
+ import subprocess
4
+
5
+
6
+ class Configuration(cli2.Configuration):
7
+ def __init__(self, *args, **kwargs):
8
+ super().__init__(*args, **kwargs)
9
+ self.expected_prompts = dict()
10
+
11
+ def input(self, prompt):
12
+ return self.expected_prompts.pop(prompt)
13
+
14
+
15
+ def test_idempotent(tmp_path):
16
+ profile_path = tmp_path / 'bashrc'
17
+ cfg = Configuration(
18
+ profile_path=profile_path,
19
+ ENV_VAR1='What 1?',
20
+ )
21
+
22
+ # get something hard to escape
23
+ value = '''foo$a#e'"'''
24
+ cfg.expected_prompts['What 1?'] = value
25
+ assert cfg['ENV_VAR1'] == value
26
+
27
+ # no expected prompt here: it should parse it again from profile_path
28
+ cfg = Configuration(
29
+ profile_path=profile_path,
30
+ ENV_VAR1='What 1?',
31
+ )
32
+ assert 'ENV_VAR1' in cfg.profile_variables
33
+ assert cfg['ENV_VAR1'] == value
34
+
35
+ # let's make sure we get the same value from new shells
36
+ result = subprocess.check_output(
37
+ f'. {profile_path} && echo $ENV_VAR1',
38
+ shell=True,
39
+ )
40
+ assert result.decode().strip() == value
41
+
42
+ # we don't like the value of the variable anymore (ie. password changed,
43
+ # api key revoked ...), ask for a new one
44
+ with cfg.profile_path.open('r') as f:
45
+ before = f.read()
46
+ assert 'export ENV_VAR1=' in before
47
+ cfg.delete('ENV_VAR1')
48
+ with cfg.profile_path.open('r') as f:
49
+ after = f.read()
50
+ assert 'export ENV_VAR1=' not in after
51
+ assert 'export ENV_VAR1=' not in cfg.profile_script
52
+ assert 'ENV_VAR1' not in cfg.profile_variables
53
+ assert 'ENV_VAR1' not in cfg.environ
54
+ assert 'ENV_VAR1' not in cfg
55
+
56
+ # it should also find it from os.environ
57
+ os.environ['ENV_VAR2'] = value
58
+ cfg = Configuration(
59
+ profile_path=profile_path,
60
+ ENV_VAR2='What 2?',
61
+ )
62
+ assert cfg['ENV_VAR2'] == value
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cli2
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: image:: https://yourlabs.io/oss/cli2/badges/master/pipeline.svg
5
5
  Home-page: https://yourlabs.io/oss/cli2
6
6
  Author: James Pic
@@ -9,6 +9,7 @@ cli2/cli.py
9
9
  cli2/client.py
10
10
  cli2/colors.py
11
11
  cli2/command.py
12
+ cli2/configuration.py
12
13
  cli2/decorators.py
13
14
  cli2/display.py
14
15
  cli2/entry_point.py
@@ -25,6 +26,7 @@ cli2/test.py
25
26
  cli2/test_cli.py
26
27
  cli2/test_client.py
27
28
  cli2/test_command.py
29
+ cli2/test_configuration.py
28
30
  cli2/test_decorators.py
29
31
  cli2/test_display.py
30
32
  cli2/test_entry_point.py
@@ -3,7 +3,7 @@ from setuptools import setup
3
3
 
4
4
  setup(
5
5
  name='cli2',
6
- version='3.2.0',
6
+ version='3.3.0',
7
7
  setup_requires='setupmeta',
8
8
  install_requires=[
9
9
  'docstring_parser',
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes