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.
Files changed (69) hide show
  1. cli2-4.0.0/PKG-INFO +66 -0
  2. cli2-4.0.0/README.rst +29 -0
  3. cli2-4.0.0/cli2/__init__.py +42 -0
  4. cli2-4.0.0/cli2/ansible/__init__.py +8 -0
  5. cli2-4.0.0/cli2/ansible/action.py +291 -0
  6. cli2-4.0.0/cli2/ansible/playbook.py +215 -0
  7. cli2-4.0.0/cli2/ansible/pytest.py +7 -0
  8. cli2-4.0.0/cli2/cli.py +1169 -0
  9. cli2-4.0.0/cli2/cli2.py +97 -0
  10. {cli2-3.3.46 → cli2-4.0.0}/cli2/client.py +78 -14
  11. {cli2-3.3.46 → cli2-4.0.0}/cli2/configuration.py +3 -0
  12. {cli2-3.3.46 → cli2-4.0.0}/cli2/display.py +37 -4
  13. cli2-4.0.0/cli2/examples/__init__.py +0 -0
  14. cli2-3.3.46/cli2/example_client.py → cli2-4.0.0/cli2/examples/client.py +13 -3
  15. cli2-4.0.0/cli2/examples/conf.py +6 -0
  16. cli2-4.0.0/cli2/examples/example.py +27 -0
  17. cli2-4.0.0/cli2/examples/example_obj.py +32 -0
  18. cli2-4.0.0/cli2/examples/obj2.py +34 -0
  19. cli2-4.0.0/cli2/examples/test.py +38 -0
  20. cli2-4.0.0/cli2/lock.py +151 -0
  21. cli2-3.3.46/cli2/logging.py → cli2-4.0.0/cli2/log.py +71 -3
  22. {cli2-3.3.46 → cli2-4.0.0}/cli2/table.py +42 -1
  23. cli2-4.0.0/cli2.egg-info/PKG-INFO +66 -0
  24. cli2-4.0.0/cli2.egg-info/SOURCES.txt +52 -0
  25. cli2-4.0.0/cli2.egg-info/entry_points.txt +9 -0
  26. {cli2-3.3.46 → cli2-4.0.0}/setup.py +9 -6
  27. cli2-4.0.0/tests/test_ansible.py +142 -0
  28. cli2-4.0.0/tests/test_cli.py +83 -0
  29. {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_client.py +133 -123
  30. {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_command.py +75 -79
  31. {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_decorators.py +23 -26
  32. {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_entry_point.py +3 -6
  33. {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_group.py +44 -57
  34. {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_inject.py +5 -7
  35. cli2-4.0.0/tests/test_lock.py +44 -0
  36. cli2-4.0.0/tests/test_node.py +67 -0
  37. cli2-4.0.0/tests/test_restful.py +95 -0
  38. cli2-3.3.46/PKG-INFO +0 -98
  39. cli2-3.3.46/README.rst +0 -61
  40. cli2-3.3.46/cli2/__init__.py +0 -56
  41. cli2-3.3.46/cli2/argument.py +0 -388
  42. cli2-3.3.46/cli2/cli.py +0 -42
  43. cli2-3.3.46/cli2/command.py +0 -451
  44. cli2-3.3.46/cli2/entry_point.py +0 -68
  45. cli2-3.3.46/cli2/example_client_complex.py +0 -40
  46. cli2-3.3.46/cli2/group.py +0 -240
  47. cli2-3.3.46/cli2/overrides.py +0 -8
  48. cli2-3.3.46/cli2/test_cli.py +0 -48
  49. cli2-3.3.46/cli2/test_node.py +0 -109
  50. cli2-3.3.46/cli2.egg-info/PKG-INFO +0 -98
  51. cli2-3.3.46/cli2.egg-info/SOURCES.txt +0 -43
  52. cli2-3.3.46/cli2.egg-info/entry_points.txt +0 -6
  53. {cli2-3.3.46 → cli2-4.0.0}/MANIFEST.in +0 -0
  54. {cli2-3.3.46 → cli2-4.0.0}/classifiers.txt +0 -0
  55. {cli2-3.3.46 → cli2-4.0.0}/cli2/asyncio.py +0 -0
  56. {cli2-3.3.46 → cli2-4.0.0}/cli2/colors.py +0 -0
  57. {cli2-3.3.46 → cli2-4.0.0}/cli2/decorators.py +0 -0
  58. /cli2-3.3.46/cli2/example_nesting.py → /cli2-4.0.0/cli2/examples/nesting.py +0 -0
  59. /cli2-3.3.46/cli2/example_obj.py → /cli2-4.0.0/cli2/examples/obj.py +0 -0
  60. {cli2-3.3.46 → cli2-4.0.0}/cli2/node.py +0 -0
  61. {cli2-3.3.46 → cli2-4.0.0}/cli2/sphinx.py +0 -0
  62. {cli2-3.3.46 → cli2-4.0.0}/cli2/test.py +0 -0
  63. {cli2-3.3.46 → cli2-4.0.0}/cli2.egg-info/dependency_links.txt +0 -0
  64. {cli2-3.3.46 → cli2-4.0.0}/cli2.egg-info/requires.txt +0 -0
  65. {cli2-3.3.46 → cli2-4.0.0}/cli2.egg-info/top_level.txt +0 -0
  66. {cli2-3.3.46 → cli2-4.0.0}/setup.cfg +0 -0
  67. {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_configuration.py +0 -0
  68. {cli2-3.3.46/cli2 → cli2-4.0.0/tests}/test_display.py +0 -0
  69. {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,8 @@
1
+ # flake8: noqa
2
+ from .action import (
3
+ ansi_escape,
4
+ Option,
5
+ AnsibleError,
6
+ AnsibleOptionError,
7
+ ActionBase,
8
+ )
@@ -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
@@ -0,0 +1,7 @@
1
+ import pytest
2
+ from .playbook import Playbook
3
+
4
+
5
+ @pytest.fixture
6
+ def playbook(tmp_path, request):
7
+ return Playbook(tmp_path, name=request.node.originalname)