cli2 3.3.46__tar.gz → 4.0.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-4.0.0/PKG-INFO +66 -0
- cli2-4.0.0/README.rst +29 -0
- cli2-4.0.0/cli2/__init__.py +42 -0
- cli2-4.0.0/cli2/ansible/__init__.py +8 -0
- cli2-4.0.0/cli2/ansible/action.py +291 -0
- cli2-4.0.0/cli2/ansible/playbook.py +215 -0
- cli2-4.0.0/cli2/ansible/pytest.py +7 -0
- cli2-4.0.0/cli2/cli.py +1169 -0
- cli2-4.0.0/cli2/cli2.py +97 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2/client.py +78 -14
- {cli2-3.3.46 → cli2-4.0.0}/cli2/configuration.py +3 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2/display.py +37 -4
- cli2-4.0.0/cli2/examples/__init__.py +0 -0
- cli2-3.3.46/cli2/example_client.py → cli2-4.0.0/cli2/examples/client.py +13 -3
- cli2-4.0.0/cli2/examples/conf.py +6 -0
- cli2-4.0.0/cli2/examples/example.py +27 -0
- cli2-4.0.0/cli2/examples/example_obj.py +32 -0
- cli2-4.0.0/cli2/examples/obj2.py +34 -0
- cli2-4.0.0/cli2/examples/test.py +38 -0
- cli2-4.0.0/cli2/lock.py +151 -0
- cli2-3.3.46/cli2/logging.py → cli2-4.0.0/cli2/log.py +71 -3
- {cli2-3.3.46 → cli2-4.0.0}/cli2/table.py +42 -1
- cli2-4.0.0/cli2.egg-info/PKG-INFO +66 -0
- cli2-4.0.0/cli2.egg-info/SOURCES.txt +52 -0
- cli2-4.0.0/cli2.egg-info/entry_points.txt +9 -0
- {cli2-3.3.46 → cli2-4.0.0}/setup.py +9 -6
- cli2-4.0.0/tests/test_ansible.py +142 -0
- cli2-4.0.0/tests/test_cli.py +83 -0
- {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_client.py +133 -123
- {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_command.py +75 -79
- {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_decorators.py +23 -26
- {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_entry_point.py +3 -6
- {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_group.py +44 -57
- {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_inject.py +5 -7
- cli2-4.0.0/tests/test_lock.py +44 -0
- cli2-4.0.0/tests/test_node.py +67 -0
- cli2-4.0.0/tests/test_restful.py +95 -0
- cli2-3.3.46/PKG-INFO +0 -98
- cli2-3.3.46/README.rst +0 -61
- cli2-3.3.46/cli2/__init__.py +0 -56
- cli2-3.3.46/cli2/argument.py +0 -388
- cli2-3.3.46/cli2/cli.py +0 -42
- cli2-3.3.46/cli2/command.py +0 -451
- cli2-3.3.46/cli2/entry_point.py +0 -68
- cli2-3.3.46/cli2/example_client_complex.py +0 -40
- cli2-3.3.46/cli2/group.py +0 -240
- cli2-3.3.46/cli2/overrides.py +0 -8
- cli2-3.3.46/cli2/test_cli.py +0 -48
- cli2-3.3.46/cli2/test_node.py +0 -109
- cli2-3.3.46/cli2.egg-info/PKG-INFO +0 -98
- cli2-3.3.46/cli2.egg-info/SOURCES.txt +0 -43
- cli2-3.3.46/cli2.egg-info/entry_points.txt +0 -6
- {cli2-3.3.46 → cli2-4.0.0}/MANIFEST.in +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/classifiers.txt +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2/asyncio.py +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2/colors.py +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2/decorators.py +0 -0
- /cli2-3.3.46/cli2/example_nesting.py → /cli2-4.0.0/cli2/examples/nesting.py +0 -0
- /cli2-3.3.46/cli2/example_obj.py → /cli2-4.0.0/cli2/examples/obj.py +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2/node.py +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2/sphinx.py +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2/test.py +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2.egg-info/dependency_links.txt +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2.egg-info/requires.txt +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/cli2.egg-info/top_level.txt +0 -0
- {cli2-3.3.46 → cli2-4.0.0}/setup.cfg +0 -0
- {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_configuration.py +0 -0
- {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_display.py +0 -0
- {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_table.py +0 -0
cli2-4.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: cli2
|
|
3
|
+
Version: 4.0.0
|
|
4
|
+
Summary: image:: https://yourlabs.io/oss/cli2/badges/master/pipeline.svg
|
|
5
|
+
Home-page: https://yourlabs.io/oss/cli2
|
|
6
|
+
Author: James Pic
|
|
7
|
+
Author-email: jamespic@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: cli
|
|
10
|
+
Requires-Python: >=3.6
|
|
11
|
+
Description-Content-Type: text/x-rst
|
|
12
|
+
Requires-Dist: docstring_parser
|
|
13
|
+
Requires-Dist: pyyaml
|
|
14
|
+
Requires-Dist: pygments
|
|
15
|
+
Requires-Dist: structlog
|
|
16
|
+
Provides-Extra: client
|
|
17
|
+
Requires-Dist: httpx; extra == "client"
|
|
18
|
+
Requires-Dist: truststore; extra == "client"
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: freezegun; extra == "test"
|
|
21
|
+
Requires-Dist: pytest; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
23
|
+
Requires-Dist: pytest-mock; extra == "test"
|
|
24
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
25
|
+
Requires-Dist: pytest-httpx; extra == "test"
|
|
26
|
+
Dynamic: author
|
|
27
|
+
Dynamic: author-email
|
|
28
|
+
Dynamic: description
|
|
29
|
+
Dynamic: description-content-type
|
|
30
|
+
Dynamic: home-page
|
|
31
|
+
Dynamic: keywords
|
|
32
|
+
Dynamic: license
|
|
33
|
+
Dynamic: provides-extra
|
|
34
|
+
Dynamic: requires-dist
|
|
35
|
+
Dynamic: requires-python
|
|
36
|
+
Dynamic: summary
|
|
37
|
+
|
|
38
|
+
.. image:: https://yourlabs.io/oss/cli2/badges/master/pipeline.svg
|
|
39
|
+
:target: https://yourlabs.io/oss/cli2/pipelines
|
|
40
|
+
.. image:: https://codecov.io/gh/yourlabs/cli2/branch/master/graph/badge.svg
|
|
41
|
+
:target: https://codecov.io/gh/yourlabs/cli2
|
|
42
|
+
.. image:: https://img.shields.io/pypi/v/cli2.svg
|
|
43
|
+
:target: https://pypi.python.org/pypi/cli2
|
|
44
|
+
|
|
45
|
+
cli2: Python Automation Framework
|
|
46
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
47
|
+
|
|
48
|
+
A Python command line and Ansible Action plugin framework that loves meta
|
|
49
|
+
programming: do less and get more out of it, perfect for many kinds of DevOps
|
|
50
|
+
gigs to automate everything.
|
|
51
|
+
|
|
52
|
+
Batteries included, all of which are useful on their own:
|
|
53
|
+
|
|
54
|
+
- beautiful CLI alternative to click, but much less verbose, allowing more
|
|
55
|
+
creative design patterns without any boilerplate thanks to introspection
|
|
56
|
+
- which comes with a Sphinx extension to extensively document your CLIs
|
|
57
|
+
- magic 12-factor configuration library
|
|
58
|
+
- extremely beautiful structlog configuration for colorful and readable logging
|
|
59
|
+
- httpx client wrapper that handles all kind of retries, data masking...
|
|
60
|
+
- magic ORM for HTTP resources based on that client
|
|
61
|
+
- Ansible Action plugin library with all the beautiful logging and a rich
|
|
62
|
+
testing library so that you can go straight to the point in pytest
|
|
63
|
+
- a good old fcntl based locking
|
|
64
|
+
- a command line to run any python function over a beautiful CLI
|
|
65
|
+
|
|
66
|
+
`Documentation available on RTFD <https://cli2.rtfd.io>`_.
|
cli2-4.0.0/README.rst
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
.. image:: https://yourlabs.io/oss/cli2/badges/master/pipeline.svg
|
|
2
|
+
:target: https://yourlabs.io/oss/cli2/pipelines
|
|
3
|
+
.. image:: https://codecov.io/gh/yourlabs/cli2/branch/master/graph/badge.svg
|
|
4
|
+
:target: https://codecov.io/gh/yourlabs/cli2
|
|
5
|
+
.. image:: https://img.shields.io/pypi/v/cli2.svg
|
|
6
|
+
:target: https://pypi.python.org/pypi/cli2
|
|
7
|
+
|
|
8
|
+
cli2: Python Automation Framework
|
|
9
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
10
|
+
|
|
11
|
+
A Python command line and Ansible Action plugin framework that loves meta
|
|
12
|
+
programming: do less and get more out of it, perfect for many kinds of DevOps
|
|
13
|
+
gigs to automate everything.
|
|
14
|
+
|
|
15
|
+
Batteries included, all of which are useful on their own:
|
|
16
|
+
|
|
17
|
+
- beautiful CLI alternative to click, but much less verbose, allowing more
|
|
18
|
+
creative design patterns without any boilerplate thanks to introspection
|
|
19
|
+
- which comes with a Sphinx extension to extensively document your CLIs
|
|
20
|
+
- magic 12-factor configuration library
|
|
21
|
+
- extremely beautiful structlog configuration for colorful and readable logging
|
|
22
|
+
- httpx client wrapper that handles all kind of retries, data masking...
|
|
23
|
+
- magic ORM for HTTP resources based on that client
|
|
24
|
+
- Ansible Action plugin library with all the beautiful logging and a rich
|
|
25
|
+
testing library so that you can go straight to the point in pytest
|
|
26
|
+
- a good old fcntl based locking
|
|
27
|
+
- a command line to run any python function over a beautiful CLI
|
|
28
|
+
|
|
29
|
+
`Documentation available on RTFD <https://cli2.rtfd.io>`_.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# flake8: noqa
|
|
2
|
+
from .cli import (
|
|
3
|
+
cmd,
|
|
4
|
+
arg,
|
|
5
|
+
hide,
|
|
6
|
+
retrieve,
|
|
7
|
+
Argument,
|
|
8
|
+
Command,
|
|
9
|
+
Group,
|
|
10
|
+
EntryPoint,
|
|
11
|
+
)
|
|
12
|
+
from .asyncio import async_resolve
|
|
13
|
+
from .colors import colors as c
|
|
14
|
+
|
|
15
|
+
from .configuration import Configuration, cfg
|
|
16
|
+
try:
|
|
17
|
+
from .client import (
|
|
18
|
+
ClientError,
|
|
19
|
+
ResponseError,
|
|
20
|
+
TokenGetError,
|
|
21
|
+
RefusedResponseError,
|
|
22
|
+
RetriesExceededError,
|
|
23
|
+
FieldError,
|
|
24
|
+
FieldValueError,
|
|
25
|
+
FieldExternalizeError,
|
|
26
|
+
Client,
|
|
27
|
+
DateTimeField,
|
|
28
|
+
Field,
|
|
29
|
+
Handler,
|
|
30
|
+
JSONStringField,
|
|
31
|
+
Model,
|
|
32
|
+
Paginator,
|
|
33
|
+
Related,
|
|
34
|
+
)
|
|
35
|
+
except ImportError:
|
|
36
|
+
raise
|
|
37
|
+
# httpx not installed
|
|
38
|
+
pass
|
|
39
|
+
from .display import diff, diff_data, render, print, highlight
|
|
40
|
+
from .lock import Lock
|
|
41
|
+
from .log import configure, log, get_logger
|
|
42
|
+
from .table import Table
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Experimental: my base class for Ansible actions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import cli2
|
|
7
|
+
import copy
|
|
8
|
+
import mock
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import traceback
|
|
12
|
+
|
|
13
|
+
from ansible.plugins.action import ActionBase
|
|
14
|
+
|
|
15
|
+
# colors:
|
|
16
|
+
# black
|
|
17
|
+
# bright gray
|
|
18
|
+
# blue
|
|
19
|
+
# white
|
|
20
|
+
# green
|
|
21
|
+
# cyan
|
|
22
|
+
# bright green
|
|
23
|
+
# red
|
|
24
|
+
# bright cyan
|
|
25
|
+
# purple
|
|
26
|
+
# bright red
|
|
27
|
+
# yellow
|
|
28
|
+
# bright purple
|
|
29
|
+
# dark gray
|
|
30
|
+
# magenta
|
|
31
|
+
# bright magenta
|
|
32
|
+
# normal
|
|
33
|
+
|
|
34
|
+
# 7-bit C1 ANSI sequences
|
|
35
|
+
ansi_escape = re.compile(r'''
|
|
36
|
+
\x1B # ESC
|
|
37
|
+
(?: # 7-bit C1 Fe (except CSI)
|
|
38
|
+
[@-Z\\-_]
|
|
39
|
+
| # or [ for CSI, followed by a control sequence
|
|
40
|
+
\[
|
|
41
|
+
[0-?]* # Parameter bytes
|
|
42
|
+
[ -/]* # Intermediate bytes
|
|
43
|
+
[@-~] # Final byte
|
|
44
|
+
)
|
|
45
|
+
''', re.VERBOSE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
UNSET_DEFAULT = '__UNSET__DEFAULT__'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Option:
|
|
52
|
+
"""
|
|
53
|
+
Ansible Option descriptor.
|
|
54
|
+
|
|
55
|
+
.. py:attribute:: arg
|
|
56
|
+
|
|
57
|
+
Name of the task argument to get this option value from
|
|
58
|
+
|
|
59
|
+
.. py:attribute:: fact
|
|
60
|
+
|
|
61
|
+
Name of the fact, if any, to get a value for this option if no task arg
|
|
62
|
+
is provided
|
|
63
|
+
|
|
64
|
+
.. py:attribute:: default
|
|
65
|
+
|
|
66
|
+
Default value, if any, in case neither of arg and fact were defined.
|
|
67
|
+
"""
|
|
68
|
+
UNSET_DEFAULT = UNSET_DEFAULT
|
|
69
|
+
|
|
70
|
+
def __init__(self, arg=None, fact=None, default=UNSET_DEFAULT):
|
|
71
|
+
self.arg = arg
|
|
72
|
+
self.fact = fact
|
|
73
|
+
self.default = default
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def kwargs(self):
|
|
77
|
+
kwargs = dict(default=self.default)
|
|
78
|
+
if self.arg:
|
|
79
|
+
kwargs['arg_name'] = self.arg
|
|
80
|
+
if self.fact:
|
|
81
|
+
kwargs['fact_name'] = self.fact
|
|
82
|
+
return kwargs
|
|
83
|
+
|
|
84
|
+
def __get__(self, obj, objtype=None):
|
|
85
|
+
if obj is None:
|
|
86
|
+
return self
|
|
87
|
+
try:
|
|
88
|
+
return obj.get(**self.kwargs)
|
|
89
|
+
except AttributeError:
|
|
90
|
+
raise AnsibleOptionError(self)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AnsibleError(Exception):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AnsibleOptionError(AnsibleError):
|
|
98
|
+
def __init__(self, option):
|
|
99
|
+
self.option = option
|
|
100
|
+
super().__init__(option.kwargs)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def message(self):
|
|
104
|
+
message = ['Missing']
|
|
105
|
+
if self.option.arg:
|
|
106
|
+
message.append(f'arg `{self.option.arg}`')
|
|
107
|
+
if self.option.fact:
|
|
108
|
+
message.append('or')
|
|
109
|
+
if self.option.fact:
|
|
110
|
+
message.append(f'fact `{self.option.fact}`')
|
|
111
|
+
return ' '.join(message)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ActionBase(ActionBase):
|
|
115
|
+
"""
|
|
116
|
+
Base action class
|
|
117
|
+
|
|
118
|
+
.. py:attribute:: result
|
|
119
|
+
|
|
120
|
+
Result dict that will be returned to Ansible
|
|
121
|
+
|
|
122
|
+
.. py:attribute:: task_vars
|
|
123
|
+
|
|
124
|
+
The task_vars that the module was called with
|
|
125
|
+
|
|
126
|
+
.. py:attribute:: client
|
|
127
|
+
|
|
128
|
+
The client object generated by :py:meth:`client_factory` if you
|
|
129
|
+
implement it.
|
|
130
|
+
"""
|
|
131
|
+
def get(self, arg_name, fact_name=None, default=UNSET_DEFAULT):
|
|
132
|
+
if arg_name in self._task.args:
|
|
133
|
+
return self._task.args[arg_name]
|
|
134
|
+
if fact_name and fact_name in self.task_vars:
|
|
135
|
+
return self.task_vars[fact_name]
|
|
136
|
+
if default != UNSET_DEFAULT:
|
|
137
|
+
return default
|
|
138
|
+
if fact_name:
|
|
139
|
+
raise AttributeError(f'Undefined {arg_name} or {fact_name}')
|
|
140
|
+
else:
|
|
141
|
+
raise AttributeError(f'Undefined arg {arg_name}')
|
|
142
|
+
|
|
143
|
+
def run(self, tmp=None, task_vars=None):
|
|
144
|
+
self.tmp = tmp
|
|
145
|
+
self.task_vars = task_vars
|
|
146
|
+
self.result = super().run(tmp, task_vars)
|
|
147
|
+
asyncio.run(self.run_wrapped_async())
|
|
148
|
+
return self.result
|
|
149
|
+
|
|
150
|
+
async def run_wrapped_async(self):
|
|
151
|
+
self.verbosity = self.task_vars.get('ansible_verbosity', 0)
|
|
152
|
+
|
|
153
|
+
if 'LOG_LEVEL' not in os.environ and 'DEBUG' not in os.environ:
|
|
154
|
+
if self.verbosity == 1:
|
|
155
|
+
os.environ['LOG_LEVEL'] = 'INFO'
|
|
156
|
+
elif self.verbosity >= 2:
|
|
157
|
+
os.environ['LOG_LEVEL'] = 'DEBUG'
|
|
158
|
+
cli2.configure()
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
try:
|
|
162
|
+
self.client = await self.client_factory()
|
|
163
|
+
except NotImplementedError:
|
|
164
|
+
self.client = None
|
|
165
|
+
await self.run_async()
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
self.result['failed'] = True
|
|
168
|
+
|
|
169
|
+
if isinstance(exc, AnsibleError):
|
|
170
|
+
self.result['error'] = exc.message
|
|
171
|
+
elif isinstance(exc, cli2.ResponseError):
|
|
172
|
+
self.result.update(dict(
|
|
173
|
+
method=exc.method,
|
|
174
|
+
url=exc.url,
|
|
175
|
+
status_code=exc.status_code,
|
|
176
|
+
))
|
|
177
|
+
key, value = self.client.response_log_data(exc.response)
|
|
178
|
+
if key:
|
|
179
|
+
self.result[f'response_{key}'] = value
|
|
180
|
+
key, value = self.client.request_log_data(exc.request)
|
|
181
|
+
if key:
|
|
182
|
+
self.result[f'request_{key}'] = value
|
|
183
|
+
elif self.verbosity:
|
|
184
|
+
traceback.print_exc()
|
|
185
|
+
|
|
186
|
+
# for pytest to raise
|
|
187
|
+
self.exc = exc
|
|
188
|
+
finally:
|
|
189
|
+
if (
|
|
190
|
+
self._before_data != UNSET_DEFAULT
|
|
191
|
+
and self._after_data != UNSET_DEFAULT
|
|
192
|
+
):
|
|
193
|
+
diff = cli2.diff_data(
|
|
194
|
+
self._before_data,
|
|
195
|
+
self._after_data,
|
|
196
|
+
self._before_label,
|
|
197
|
+
self._after_label,
|
|
198
|
+
)
|
|
199
|
+
if self.client and self.client.mask:
|
|
200
|
+
output = '\n'.join([
|
|
201
|
+
line.rstrip() for line in diff if line.strip()
|
|
202
|
+
])
|
|
203
|
+
output = re.sub(
|
|
204
|
+
f'({"|".join(self.client.mask)}): (.*)',
|
|
205
|
+
'\\1: ***MASKED***',
|
|
206
|
+
''.join(output),
|
|
207
|
+
)
|
|
208
|
+
print(cli2.highlight(output, 'Diff'))
|
|
209
|
+
else:
|
|
210
|
+
cli2.diff(diff)
|
|
211
|
+
|
|
212
|
+
async def run_async(self):
|
|
213
|
+
"""
|
|
214
|
+
The method you are supposed to implement.
|
|
215
|
+
|
|
216
|
+
It should:
|
|
217
|
+
|
|
218
|
+
- provision the :py:attr:`result` dict
|
|
219
|
+
- find task_vars in :py:attr:`task_vars`
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
async def client_factory(self):
|
|
223
|
+
"""
|
|
224
|
+
Return a client instance.
|
|
225
|
+
|
|
226
|
+
:raise NotImplementedError: By default
|
|
227
|
+
"""
|
|
228
|
+
raise NotImplementedError()
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
async def run_test_async(cls, args=None, facts=None, client=None,
|
|
232
|
+
fail=False):
|
|
233
|
+
"""
|
|
234
|
+
Test run the module in a mocked context.
|
|
235
|
+
|
|
236
|
+
:param args: Dict of task arguments
|
|
237
|
+
:param facts: Dict of play facts
|
|
238
|
+
:param client: Client instance, overrides the factory
|
|
239
|
+
:param fail: Allow this test to fail without exception
|
|
240
|
+
"""
|
|
241
|
+
obj = cls(*[mock.Mock()] * 6)
|
|
242
|
+
obj.tmp = None
|
|
243
|
+
obj.task_vars = mock.Mock()
|
|
244
|
+
obj.result = dict()
|
|
245
|
+
obj._task = mock.Mock()
|
|
246
|
+
obj._task.args = args or {}
|
|
247
|
+
obj.task_vars = facts or {}
|
|
248
|
+
obj.task_vars['ansible_verbosity'] = 1
|
|
249
|
+
obj.exc = False
|
|
250
|
+
if client:
|
|
251
|
+
async def _factory():
|
|
252
|
+
return client
|
|
253
|
+
obj.client_factory = _factory
|
|
254
|
+
old = obj.client_factory
|
|
255
|
+
|
|
256
|
+
async def set_tries():
|
|
257
|
+
client = await old()
|
|
258
|
+
client.handler.tries = 0
|
|
259
|
+
return client
|
|
260
|
+
obj.client_factory = set_tries
|
|
261
|
+
await obj.run_wrapped_async()
|
|
262
|
+
if obj.exc and not fail:
|
|
263
|
+
raise obj.exc
|
|
264
|
+
if obj.result.get('failed', False) and not fail:
|
|
265
|
+
raise Exception('Module failed, and fail is not True {obj.result}')
|
|
266
|
+
return obj
|
|
267
|
+
|
|
268
|
+
def __init__(self, *args, **kwargs):
|
|
269
|
+
super().__init__(*args, **kwargs)
|
|
270
|
+
self._before_data = UNSET_DEFAULT
|
|
271
|
+
self._after_data = UNSET_DEFAULT
|
|
272
|
+
|
|
273
|
+
def before_set(self, data, label='before'):
|
|
274
|
+
"""
|
|
275
|
+
Set the data we're going to display the diff for at the end.
|
|
276
|
+
|
|
277
|
+
:param data: Dictionnary of data
|
|
278
|
+
:param label: Label to show in diff
|
|
279
|
+
"""
|
|
280
|
+
self._before_data = copy.deepcopy(data)
|
|
281
|
+
self._before_label = label
|
|
282
|
+
|
|
283
|
+
def after_set(self, data, label='after'):
|
|
284
|
+
"""
|
|
285
|
+
Set the data we're going to display the diff for at the end.
|
|
286
|
+
|
|
287
|
+
:param data: Dictionnary of data
|
|
288
|
+
:param label: Label to show in diff
|
|
289
|
+
"""
|
|
290
|
+
self._after_data = copy.deepcopy(data)
|
|
291
|
+
self._after_label = label
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from cli2.ansible import ansi_escape
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def which_ansible_playbook():
|
|
14
|
+
PATH = os.environ.get('PATH', os.defpath)
|
|
15
|
+
local = os.path.join(os.environ.get('HOME', '~'), '.local/bin')
|
|
16
|
+
if local not in PATH:
|
|
17
|
+
PATH = ':'.join([local, PATH])
|
|
18
|
+
path = shutil.which('ansible-playbook', path=PATH)
|
|
19
|
+
if not path:
|
|
20
|
+
raise Exception('No ansible-playbook command in $PATH=' + PATH)
|
|
21
|
+
return path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_ansible_output_for_exception(exception):
|
|
25
|
+
# if you read this, it means a Python exception was throw during Ansible
|
|
26
|
+
# execution, this must not happen for tests to pass
|
|
27
|
+
assert not exception, 'Exception detected in ansible output'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ansible_playbook(*args):
|
|
31
|
+
cmd = [
|
|
32
|
+
which_ansible_playbook(),
|
|
33
|
+
'-c',
|
|
34
|
+
'local',
|
|
35
|
+
'-vvv',
|
|
36
|
+
'--become',
|
|
37
|
+
*args,
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
readable = {shlex.join(cmd)}
|
|
42
|
+
except AttributeError: # old python
|
|
43
|
+
readable = ' '.join([shlex.quote(arg) for arg in cmd])
|
|
44
|
+
print(f'Running:\n{readable}')
|
|
45
|
+
|
|
46
|
+
data = dict(
|
|
47
|
+
ok=0,
|
|
48
|
+
changed=0,
|
|
49
|
+
unreachable=0,
|
|
50
|
+
failed=0,
|
|
51
|
+
skipped=0,
|
|
52
|
+
rescued=0,
|
|
53
|
+
ignored=0,
|
|
54
|
+
exception=False,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
|
|
58
|
+
|
|
59
|
+
lines = []
|
|
60
|
+
for line in iter(proc.stdout.readline, b''):
|
|
61
|
+
line = line.decode()
|
|
62
|
+
if 'Traceback (most recent call last)' in line:
|
|
63
|
+
data['exception'] = True
|
|
64
|
+
sys.stdout.write(line)
|
|
65
|
+
if not line.strip():
|
|
66
|
+
continue
|
|
67
|
+
lines.append(ansi_escape.sub('', line))
|
|
68
|
+
|
|
69
|
+
for line in reversed(lines):
|
|
70
|
+
if line.startswith('PLAY RECAP'):
|
|
71
|
+
break
|
|
72
|
+
try:
|
|
73
|
+
for item in re.findall('([a-z]+)=([0-9]*)', line):
|
|
74
|
+
data[item[0]] += int(item[1])
|
|
75
|
+
except (IndexError, KeyError):
|
|
76
|
+
return dict(success=False)
|
|
77
|
+
|
|
78
|
+
data['stdout'] = '\n'.join(lines)
|
|
79
|
+
|
|
80
|
+
data['success'] = (
|
|
81
|
+
data['ok']
|
|
82
|
+
and not (data['failed'] or data['unreachable'])
|
|
83
|
+
)
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Playbook:
|
|
88
|
+
"""
|
|
89
|
+
On-the-fly playbook generator
|
|
90
|
+
|
|
91
|
+
.. py:attribute:: root
|
|
92
|
+
|
|
93
|
+
This would be a tmp_path returned by pytest
|
|
94
|
+
|
|
95
|
+
.. py:attribute:: name
|
|
96
|
+
|
|
97
|
+
Name of the playbook, test name by default
|
|
98
|
+
|
|
99
|
+
.. py:attribute:: vars
|
|
100
|
+
|
|
101
|
+
Playbook vars
|
|
102
|
+
|
|
103
|
+
.. py:attribute:: roles
|
|
104
|
+
|
|
105
|
+
Playbook roles, use :py:meth:`role_add` to add a role
|
|
106
|
+
|
|
107
|
+
.. py:attribute:: tasks
|
|
108
|
+
|
|
109
|
+
Playbook tasks, use :py:meth:`task_add` to add a task
|
|
110
|
+
|
|
111
|
+
.. py:attribute:: play
|
|
112
|
+
|
|
113
|
+
Main playbook play
|
|
114
|
+
|
|
115
|
+
.. py:attribute:: plays
|
|
116
|
+
|
|
117
|
+
Playbook plays, contains the main one by default
|
|
118
|
+
|
|
119
|
+
.. py:attribute:: yaml
|
|
120
|
+
|
|
121
|
+
Property that returns the generated yaml
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, root, name):
|
|
125
|
+
self.root = root
|
|
126
|
+
self.name = name
|
|
127
|
+
self.vars = dict()
|
|
128
|
+
self.roles = []
|
|
129
|
+
self.tasks = []
|
|
130
|
+
self.play = dict(
|
|
131
|
+
hosts='localhost',
|
|
132
|
+
vars=self.vars,
|
|
133
|
+
roles=self.roles,
|
|
134
|
+
tasks=self.tasks,
|
|
135
|
+
)
|
|
136
|
+
self.plays = [self.play]
|
|
137
|
+
|
|
138
|
+
def role_add(self, name, *tasks, **variables):
|
|
139
|
+
"""
|
|
140
|
+
Create a new role with given tasks, include it with given variables
|
|
141
|
+
|
|
142
|
+
:param name: role name
|
|
143
|
+
:param tasks: List of task dicts
|
|
144
|
+
:param variables: Variables that will be passed to include_role
|
|
145
|
+
"""
|
|
146
|
+
self.roles.append(dict(
|
|
147
|
+
role=str(self.root / name),
|
|
148
|
+
tasks=tasks,
|
|
149
|
+
**variables,
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
def task_add(self, module, args=None, **kwargs):
|
|
153
|
+
"""
|
|
154
|
+
Add a module call
|
|
155
|
+
|
|
156
|
+
:param module: Name of the Ansible module
|
|
157
|
+
:param args: Ansible module args
|
|
158
|
+
:param kwargs: Task kwargs (register, etc)
|
|
159
|
+
"""
|
|
160
|
+
task = {module: args if args else None}
|
|
161
|
+
task.update(kwargs)
|
|
162
|
+
self.tasks.append(task)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def file_path(self):
|
|
166
|
+
return self.root / f'{self.name}.yml'
|
|
167
|
+
|
|
168
|
+
def yaml_dump(self, value):
|
|
169
|
+
try:
|
|
170
|
+
return yaml.dump(value, width=1000, sort_keys=False)
|
|
171
|
+
except TypeError: # python36
|
|
172
|
+
return yaml.dump(value, width=1000)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def yaml(self):
|
|
176
|
+
plays = copy.deepcopy(self.plays)
|
|
177
|
+
for play in plays:
|
|
178
|
+
for role in play.get('roles', []):
|
|
179
|
+
if 'tasks' not in role:
|
|
180
|
+
# actual role to include
|
|
181
|
+
continue
|
|
182
|
+
# create role on the fly
|
|
183
|
+
tasks = role.pop('tasks')
|
|
184
|
+
role_path = self.root / role['role']
|
|
185
|
+
tasks_path = role_path / 'tasks'
|
|
186
|
+
if not tasks_path.exists():
|
|
187
|
+
tasks_path.mkdir(parents=True)
|
|
188
|
+
with (tasks_path / 'main.yml').open('w+') as f:
|
|
189
|
+
f.write(self.yaml_dump(list(tasks)))
|
|
190
|
+
return self.yaml_dump(plays)
|
|
191
|
+
|
|
192
|
+
def write(self):
|
|
193
|
+
with open(self.file_path, 'w+') as f:
|
|
194
|
+
f.write(self.yaml)
|
|
195
|
+
|
|
196
|
+
def __call__(self, *args, fails=False, exception=False):
|
|
197
|
+
"""
|
|
198
|
+
Actually execute the playbook
|
|
199
|
+
|
|
200
|
+
:param args: Any extra ansible args
|
|
201
|
+
:param fails: Playbook failure is not accepted by default, set this to
|
|
202
|
+
True to allow a playbook to fail.
|
|
203
|
+
:param exception: Exception during playbook run is not accepted by
|
|
204
|
+
default, set this to True to allow an exception to
|
|
205
|
+
pop in the playbook.
|
|
206
|
+
"""
|
|
207
|
+
os.environ['ANSIBLE_STDOUT_CALLBACK'] = 'yaml'
|
|
208
|
+
os.environ['ANSIBLE_FORCE_COLOR'] = '1'
|
|
209
|
+
if not self.file_path.exists():
|
|
210
|
+
self.write()
|
|
211
|
+
result = ansible_playbook(*list(args) + [str(self.file_path)])
|
|
212
|
+
assert result['success'] if not fails else not result['success']
|
|
213
|
+
if not exception:
|
|
214
|
+
check_ansible_output_for_exception(result['exception'])
|
|
215
|
+
return result
|