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.
- {cli2-3.2.0/cli2.egg-info → cli2-3.3.0}/PKG-INFO +1 -1
- {cli2-3.2.0 → cli2-3.3.0}/cli2/__init__.py +1 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/command.py +39 -1
- cli2-3.3.0/cli2/configuration.py +190 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/example_client.py +5 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test_client.py +22 -1
- cli2-3.3.0/cli2/test_configuration.py +62 -0
- {cli2-3.2.0 → cli2-3.3.0/cli2.egg-info}/PKG-INFO +1 -1
- {cli2-3.2.0 → cli2-3.3.0}/cli2.egg-info/SOURCES.txt +2 -0
- {cli2-3.2.0 → cli2-3.3.0}/setup.py +1 -1
- {cli2-3.2.0 → cli2-3.3.0}/MANIFEST.in +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/README.rst +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/classifiers.txt +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/argument.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/asyncio.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/cli.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/client.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/colors.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/decorators.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/display.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/entry_point.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/example_client_complex.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/example_nesting.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/example_obj.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/group.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/logging.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/node.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/sphinx.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/table.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test_cli.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test_command.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test_decorators.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test_display.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test_entry_point.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test_group.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test_inject.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test_node.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2/test_table.py +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2.egg-info/dependency_links.txt +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2.egg-info/entry_points.txt +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2.egg-info/requires.txt +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/cli2.egg-info/top_level.txt +0 -0
- {cli2-3.2.0 → cli2-3.3.0}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
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()
|
|
@@ -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
|
|
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
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|