automagix 4.0.0.dev1__py3-none-any.whl

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.
automagix/__init__.py ADDED
@@ -0,0 +1,95 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ import argparse
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from time import time, strftime, gmtime
7
+
8
+ from .batch_runner import get_script_and_batch_items, run_batch_items
9
+ from .config import init_logger, CONFIG, LOG, VERSION, arguments, MAGIC_SELECTION_INT
10
+ from .helpers import empty_queued_input_data, selector
11
+ from .parallel_runner import run_parallel_screens
12
+ from .progress_bar import setup_scroll_area, destroy_scroll_area
13
+
14
+
15
+ def run_startup_script():
16
+ if not CONFIG.get('startup_script'):
17
+ return
18
+
19
+ cmds = [os.path.expandvars(CONFIG["startup_script"])]
20
+ cmds.extend(sys.argv)
21
+ subprocess.run(cmds)
22
+
23
+
24
+ def setup(args: argparse.Namespace) -> float:
25
+ """Setup logger and print version information"""
26
+ init_logger(name=CONFIG['logger'], debug=args.debug)
27
+ starttime = time()
28
+
29
+ LOG.info(f'Automagix Version {VERSION}')
30
+ LOG.info(f'Started at: {strftime("%a, %d %b %Y %H:%M:%S UTC", gmtime(starttime))}')
31
+
32
+ configfile = CONFIG.get('config_file')
33
+ if configfile:
34
+ LOG.info(f'Using configuration from: {configfile}')
35
+ else:
36
+ LOG.warning('Configuration file not found or not configured. Using defaults.')
37
+
38
+ return starttime
39
+
40
+
41
+ def check_screen():
42
+ p = subprocess.run('screen -v', shell=True, stdout=subprocess.PIPE)
43
+ screen_version = p.stdout.decode()
44
+ if p.returncode != 0 or 'command not found' in screen_version:
45
+ raise Exception('No GNU screen version found')
46
+ if 'FAU' in screen_version:
47
+ LOG.error(
48
+ 'Parallel processing only supported for the "GNU" version of screen. You have the "FAU" version.\n'
49
+ 'On MacOS you can try to install the GNU version via Homebrew: `brew install screen`.'
50
+ )
51
+ raise Exception('No supported GNU screen version found')
52
+
53
+
54
+ def main():
55
+ if os.getenv('AUTOMAGIX_SHELL'):
56
+ print('You are running Automagix from an interactive shell of an already running Automagix!')
57
+ empty_queued_input_data()
58
+ answer = input('Do you really want to proceed? Then type "yes" and ENTER.\n')
59
+ if answer != 'yes':
60
+ sys.exit(0)
61
+
62
+ args = arguments()
63
+ run_startup_script()
64
+
65
+ starttime = setup(args=args)
66
+
67
+ script, batch_items = get_script_and_batch_items(args=args)
68
+
69
+ if args.jump_to == MAGIC_SELECTION_INT:
70
+ # next(iter(pi.items())) is here needed, because the pipeline items are all dictionaries with only one key.
71
+ pipeline_items = [next(iter(pi.items())) for pi in script['pipeline']]
72
+ args.jump_to = selector(
73
+ entries=[
74
+ (i, f'[{key}]: {cmd}')
75
+ for i, (key, cmd) in enumerate(pipeline_items)
76
+ ],
77
+ message='Please choose index of desired start command:'
78
+ )
79
+
80
+ if args.vars_file and args.parallel:
81
+ check_screen()
82
+ run_parallel_screens(script=script, batch_items=batch_items, args=args)
83
+ sys.exit(0)
84
+
85
+ try:
86
+ if CONFIG['progress_bar']:
87
+ setup_scroll_area()
88
+
89
+ run_batch_items(script=script, batch_items=batch_items, args=args)
90
+ finally:
91
+ if CONFIG['progress_bar']:
92
+ destroy_scroll_area()
93
+
94
+ if 'AUTOMAGIX_TIME' in os.environ:
95
+ LOG.info(f'The Automagix script took {round(time() - starttime)}s!')
automagix/automagix.py ADDED
@@ -0,0 +1,185 @@
1
+ import os
2
+ import sys
3
+ from argparse import Namespace
4
+ from collections import OrderedDict
5
+ from functools import cached_property
6
+
7
+ from .command import Command, AbortException, SkipBatchItemException, PERSISTENT_VARS, ReloadFromFile
8
+ from .config import get_script
9
+ from .environment import PipelineEnvironment
10
+
11
+
12
+ class Automagix:
13
+ def __init__(
14
+ self,
15
+ script: dict,
16
+ variables: dict,
17
+ config: dict,
18
+ script_fields: OrderedDict,
19
+ cmd_args: Namespace,
20
+ batch_index: int,
21
+ ):
22
+ self.script = script
23
+ self.script_fields = script_fields
24
+ self.env = PipelineEnvironment(
25
+ config=config,
26
+ script=script,
27
+ variables=variables,
28
+ batch_index=batch_index,
29
+ cmd_args=cmd_args,
30
+ )
31
+
32
+ self._command_lists: dict = {}
33
+
34
+ @cached_property
35
+ def cmd_class(self) -> type:
36
+ if self.env.config.get('bundlewrap'):
37
+ from .bundlewrap import BWCommand, AutomagixBwRepo
38
+
39
+ self.env.config['bw_repo'] = AutomagixBwRepo(repo_path=os.environ.get('BW_REPO_PATH', '.'))
40
+ return BWCommand
41
+ else:
42
+ return Command
43
+
44
+ def command_list(self, pipeline: str) -> list:
45
+ if pipeline == 'main':
46
+ pipeline = 'pipeline'
47
+ if not self._command_lists.get(pipeline):
48
+ self._command_lists[pipeline] = self.build_command_list(pipeline=pipeline)
49
+ return self._command_lists[pipeline]
50
+
51
+ def get_command_position(self, index: int, pipeline: str) -> int:
52
+ if pipeline == 'always':
53
+ return index
54
+ if pipeline in ['pipeline', 'main']:
55
+ return index + len(self.command_list('always'))
56
+ if pipeline == 'cleanup':
57
+ return index + len(self.command_list('always')) + len(self.command_list('main'))
58
+
59
+ def set_command_count(self):
60
+ if not self.env.command_count:
61
+ self.env.command_count = len(
62
+ self.command_list('always') + self.command_list('main') + self.command_list('cleanup')
63
+ )
64
+
65
+ def build_command_list(self, pipeline: str) -> list[Command]:
66
+ command_list = []
67
+ for index, cmd in enumerate(self.script.get(pipeline, [])):
68
+ new_cmd = self.cmd_class(
69
+ cmd=cmd,
70
+ index=index,
71
+ env=self.env,
72
+ pipeline=pipeline,
73
+ position=self.get_command_position(index=index, pipeline=pipeline),
74
+ )
75
+ command_list.append(new_cmd)
76
+ if new_cmd.assignment_var and new_cmd.assignment_var not in self.env.vars:
77
+ self.env.vars[new_cmd.assignment_var] = f'{{{new_cmd.assignment_var}}}'
78
+ return command_list
79
+
80
+ def reload_script(self):
81
+ self.script = get_script(args=self.env.cmd_args)
82
+ self._command_lists = {} # Clear cache
83
+
84
+ def print_main_data(self):
85
+ print('\n')
86
+ self.env.LOG.info(' ------ Overview ------')
87
+ for field_key, field_value in self.script_fields.items():
88
+ print()
89
+ self.env.LOG.info(f'{field_value}:')
90
+ for key, value in self.script.get(field_key, {}).items():
91
+ self.env.LOG.info(f" {key}: {value}")
92
+
93
+ def print_command_line_steps(self, command_list: list[Command]):
94
+ print()
95
+ self.env.LOG.info('Commandline Steps:')
96
+ for cmd in command_list:
97
+ self.env.LOG.info(f"({cmd.index}) [{cmd.orig_key}]: {cmd.get_resolved_value(dummy=True)}")
98
+ print()
99
+
100
+ def check_possibly_dangerous_vars(self):
101
+ warn = 0
102
+ for key, value in self.env.vars.items():
103
+ if isinstance(value, str) and value.lower() == 'false':
104
+ self.env.LOG.warning(
105
+ f'[vars:{key}] This variable is a string with value "{value}".'
106
+ ' Be aware that this becomes `True` for conditions and other boolean operations without conversion,'
107
+ ' because any non-empty string in Python is evaluated as `True`.'
108
+ )
109
+ warn += 1
110
+
111
+ if warn:
112
+ answer = self.env.interact(question='Do you want to proceed? Then type "yes" and ENTER.\n')
113
+ if answer != 'yes':
114
+ sys.exit(0)
115
+
116
+ def _execute_command_list(self, name: str, start_index: int, treat_as_main: bool):
117
+ try:
118
+ steps = self.script.get('_steps')
119
+ for cmd in self.command_list(name)[start_index:]:
120
+ if treat_as_main:
121
+ if steps and (self.script['_exclude'] == (cmd.index in steps)):
122
+ # Case 1: exclude is True and index is in steps => skip
123
+ # Case 2: exclude is False and index is in steps => execute
124
+ print()
125
+ self.env.LOG.notice(f'({cmd.index}) Not selected for execution: skip')
126
+ continue
127
+ cmd.execute(interactive=self.env.cmd_args.interactive, force=self.env.cmd_args.force)
128
+ else:
129
+ cmd.execute()
130
+ except ReloadFromFile as exc:
131
+ print()
132
+ self.env.LOG.info(f'Reload script from file and retry => ({exc.index})')
133
+ self.reload_script()
134
+ self._execute_command_list(name=name, start_index=exc.index, treat_as_main=treat_as_main)
135
+
136
+ def execute_pipeline(self, name: str):
137
+ if not self.command_list(name):
138
+ return
139
+
140
+ if name == 'main':
141
+ treat_as_main = True
142
+ start_index = self.env.cmd_args.jump_to
143
+ else:
144
+ treat_as_main = False
145
+ start_index = 0
146
+
147
+ print()
148
+ self.env.LOG.info('------------------------------')
149
+ self.env.LOG.info(f' --- Start {name.upper()} pipeline ---')
150
+
151
+ self._execute_command_list(name=name, start_index=start_index, treat_as_main=treat_as_main)
152
+
153
+ print()
154
+ self.env.LOG.info(f' --- End {name.upper()} pipeline ---')
155
+ self.env.LOG.info('------------------------------\n')
156
+
157
+ def run(self):
158
+ print('\n')
159
+ self.env.LOG.info('//////////////////////////////////////////////////////////////////////')
160
+ self.env.LOG.info(f"---- {self.script['name']} ----")
161
+ self.env.LOG.info('//////////////////////////////////////////////////////////////////////')
162
+
163
+ PERSISTENT_VARS.clear()
164
+
165
+ self.execute_pipeline(name='always')
166
+
167
+ self.print_main_data()
168
+ self.print_command_line_steps(command_list=self.command_list('main'))
169
+ self.check_possibly_dangerous_vars()
170
+ if self.env.cmd_args.print_overview:
171
+ sys.exit(0)
172
+
173
+ try:
174
+ self.execute_pipeline(name='main')
175
+ except (AbortException, SkipBatchItemException):
176
+ self.env.LOG.debug('Abort requested. Cleaning up.')
177
+ self.execute_pipeline(name='cleanup')
178
+ self.env.LOG.debug('Clean up done. Exiting.')
179
+ raise
180
+
181
+ self.execute_pipeline(name='cleanup')
182
+
183
+ self.env.LOG.info('---------------------------------------------------------------')
184
+ self.env.LOG.info('Automagix finished: Congratulations and have a N.I.C.E. day :-)')
185
+ self.env.LOG.info('---------------------------------------------------------------')
@@ -0,0 +1,34 @@
1
+ from automagix.command import Command
2
+ from tests.test_environment import testauto, environment
3
+
4
+ len_always = len(testauto.script.get('always', []))
5
+ len_main = len(testauto.script.get('pipeline', []))
6
+ len_cleanup = len(testauto.script.get('cleanup', []))
7
+
8
+
9
+ def test__automagix__command_list():
10
+ assert testauto.command_list('main') == testauto.command_list('pipeline')
11
+ cmd = Command(
12
+ cmd={'local': "echo 'Print this always :-)'"},
13
+ index=0,
14
+ pipeline='always',
15
+ env=environment,
16
+ position=0,
17
+ )
18
+ first_cleanup = testauto.command_list('always')[0]
19
+ assert isinstance(first_cleanup, Command)
20
+ assert vars(first_cleanup) == vars(cmd)
21
+
22
+
23
+ def test__automagix__get_command_position():
24
+ assert testauto.get_command_position(index=4, pipeline='always') == 4
25
+ assert testauto.get_command_position(index=3, pipeline='always') != 4
26
+ assert testauto.get_command_position(index=3, pipeline='main') == len_always + 3
27
+ assert testauto.get_command_position(index=3, pipeline='pipeline') == len_always + 3
28
+ assert testauto.get_command_position(index=2, pipeline='cleanup') == len_always + len_main + 2
29
+
30
+
31
+ def test__automagix__set_command_count():
32
+ testauto.env.command_count = None
33
+ testauto.set_command_count()
34
+ assert testauto.env.command_count == len_always + len_main + len_cleanup
@@ -0,0 +1,65 @@
1
+ import locale
2
+ import subprocess
3
+ from argparse import Action, Namespace
4
+
5
+ from argcomplete import warn
6
+
7
+ from .helpers import read_yaml, search_script
8
+
9
+
10
+ def _call(*args, **kwargs):
11
+ try:
12
+ return subprocess.check_output(*args, **kwargs).decode(locale.getpreferredencoding()).splitlines()
13
+ except subprocess.CalledProcessError:
14
+ return []
15
+
16
+
17
+ class ScriptFileCompleter:
18
+ """
19
+ Scriptfile completer
20
+ """
21
+
22
+ def __init__(self, script_dir: str):
23
+ self.script_dir = script_dir
24
+
25
+ def __call__(self, prefix: str, **kwargs):
26
+ completion = []
27
+ try:
28
+ completion.extend(self.find_dirs_and_files(root_path='.', prefix=prefix))
29
+ completion.extend(self.find_dirs_and_files(root_path=self.script_dir, prefix=prefix))
30
+ except Exception as exc:
31
+ warn(f'Shell completion failed: {repr(exc)}')
32
+ return completion
33
+
34
+ def find_dirs_and_files(self, root_path: str, prefix: str) -> list:
35
+ completion = []
36
+ pre_len = len(root_path) + 1
37
+ directories = _call(["bash", "-c", f"compgen -A directory -- '{root_path}/{prefix}'"])
38
+ completion += [f'{d[pre_len:]}/' for d in directories]
39
+ for ext in ['yaml', 'yml']:
40
+ files = _call(["bash", "-c", f"compgen -A file -X '!*.{ext}' -- '{root_path}/{prefix}'"])
41
+ completion += [f[pre_len:] for f in files]
42
+ return completion
43
+
44
+
45
+ class ScriptFieldCompleter:
46
+ def __init__(self, script_dir: str):
47
+ self.script_dir = script_dir
48
+
49
+ def __call__(self, action: Action, parsed_args: Namespace, **kwargs):
50
+ try:
51
+ if parsed_args.scriptfile is None:
52
+ return []
53
+
54
+ s_file = search_script(name=parsed_args.scriptfile, script_dir=self.script_dir, non_interactive=True)
55
+ if not s_file:
56
+ warn('Script not found or multiple options. Cannot complete.')
57
+ return []
58
+
59
+ script = read_yaml(s_file)
60
+ completion = [f'{key}=' for key in script.get(action.dest, {}).keys()]
61
+
62
+ return completion
63
+ except Exception as exc:
64
+ warn(f'Shell completion failed: {repr(exc)}')
65
+ return []
@@ -0,0 +1,70 @@
1
+ import sys
2
+ from argparse import Namespace
3
+ from copy import deepcopy
4
+ from csv import DictReader
5
+ from typing import Callable
6
+
7
+ from .automagix import Automagix
8
+ from .command import SkipBatchItemException, AbortException
9
+ from .config import CONFIG, get_script, LOG, update_script_from_row, collect_vars, SCRIPT_FIELDS
10
+
11
+
12
+ def get_script_and_batch_items(args: Namespace) -> (dict, list):
13
+ script = get_script(args=args)
14
+
15
+ # Empty item means: there is nothing to update, take the script as it is
16
+ batch_items: list[dict] = [{}]
17
+ if args.vars_file:
18
+ with open(args.vars_file) as csvfile:
19
+ batch_items = list(DictReader(filter(lambda row: row[0] != '#', csvfile)))
20
+
21
+ return script, batch_items
22
+
23
+
24
+ def create_automagix_list(script: dict, batch_items: list, args: Namespace) -> list[Automagix]:
25
+ automagix_list = []
26
+ for i, row in enumerate(batch_items, start=1):
27
+ script_copy = deepcopy(script)
28
+ script_copy['_batch_mode'] = len(batch_items) > 1
29
+ script_copy['_batch_items_count'] = len(batch_items)
30
+
31
+ update_script_from_row(row=row, script=script_copy, index=i)
32
+
33
+ variables = collect_vars(script_copy)
34
+
35
+ auto = Automagix(
36
+ script=script_copy,
37
+ variables=variables,
38
+ config=CONFIG,
39
+ script_fields=SCRIPT_FIELDS,
40
+ cmd_args=args,
41
+ batch_index=i,
42
+ )
43
+ automagix_list.append(auto)
44
+ return automagix_list
45
+
46
+
47
+ def run_automagix_list(automagix_list: list[Automagix], send_status_callback: Callable = None):
48
+ for auto in automagix_list:
49
+ auto.set_command_count()
50
+ auto.env.attach_logger()
51
+ auto.env.reinit_logger()
52
+ if send_status_callback:
53
+ auto.env.send_status = send_status_callback
54
+ try:
55
+ auto.run()
56
+ except SkipBatchItemException as exc:
57
+ LOG.info(str(exc))
58
+ LOG.notice('=====> Jumping to next batch item.')
59
+ continue
60
+ except AbortException as exc:
61
+ sys.exit(int(exc))
62
+ except KeyboardInterrupt:
63
+ print()
64
+ LOG.warning('Aborted by user. Exiting.')
65
+ sys.exit(130)
66
+
67
+
68
+ def run_batch_items(script: dict, batch_items: list, args: Namespace):
69
+ automagix_list = create_automagix_list(script=script, batch_items=batch_items, args=args)
70
+ run_automagix_list(automagix_list=automagix_list)
@@ -0,0 +1,75 @@
1
+ from bundlewrap.exceptions import NoSuchNode, NoSuchGroup
2
+ from bundlewrap.group import Group
3
+ from bundlewrap.node import Node
4
+ from bundlewrap.repo import Repository
5
+
6
+ from .command import Command, PA
7
+
8
+
9
+ class AutomagixBwRepo(Repository):
10
+ def reload(self):
11
+ self.__init__(repo_path=self.path)
12
+
13
+
14
+ class BWCommand(Command):
15
+ def _generate_python_vars(self):
16
+ locale_vars = {'AUTOMAGIX_BW_REPO': self.env.config['bw_repo']}
17
+ for key, value in self.env.systems.items():
18
+ if not value.startswith('hostname!'):
19
+ try:
20
+ self.env.config['bw_repo'].get_node(value)
21
+ except NoSuchNode:
22
+ try:
23
+ self.env.config['bw_repo'].get_group(value)
24
+ except NoSuchGroup:
25
+ self.env.LOG.warning(f'"{value}" is neither a BW node nor a BW group')
26
+ locale_vars['VARS'] = self.env.vars
27
+ locale_vars['NODES'] = BWNodesWrapper(repo=self.env.config['bw_repo'], systems=self.env.systems)
28
+ return locale_vars
29
+
30
+ def _remote_action(self) -> int:
31
+ bw_repo: Repository = self.env.config['bw_repo']
32
+ system = self.get_system()
33
+ if system.startswith('hostname!'):
34
+ return self._remote_action_on_hostname(hostname=system.replace('hostname!', ''))
35
+ try:
36
+ node: Node = bw_repo.get_node(system)
37
+ return self._remote_action_on_hostname(hostname=node.hostname)
38
+ except NoSuchNode as exc:
39
+ try:
40
+ group: Group = bw_repo.get_group(system)
41
+ except NoSuchGroup:
42
+ raise exc
43
+ print()
44
+ self.env.LOG.info(f' --- Executing command for all nodes in BW group >{group.name}< ---')
45
+ for node in group.nodes:
46
+ print()
47
+ self.env.LOG.info(f'- {node.name} -')
48
+ self._remote_bw_group_action(node=node)
49
+ return 0
50
+
51
+ def _remote_bw_group_action(self, node: Node):
52
+ return_code = self._remote_action_on_hostname(hostname=node.hostname)
53
+ if return_code != 0:
54
+ self.env.LOG.error(f'Command ({self.index}) on {node.name} failed with return code {return_code}.')
55
+ if self.env.cmd_args.force:
56
+ return
57
+
58
+ err_answer = self._ask_user(
59
+ question='[PF] What should I do?',
60
+ allowed_options=[PA.proceed, PA.terminal, PA.variables, PA.retry, PA.abort],
61
+ )
62
+ # _ask_user handles are answers but PA.retry, PA.skip, PA.proceed
63
+ # PA.skip is not in the allowed options'
64
+ # PA.proceed means 'proceed' so we can just go on
65
+ if err_answer == PA.retry.answer:
66
+ return self._remote_bw_group_action(node=node)
67
+
68
+
69
+ class BWNodesWrapper:
70
+ def __init__(self, repo: Repository, systems: dict):
71
+ self._repo = repo
72
+ self._systems = systems
73
+
74
+ def __getattr__(self, name):
75
+ return self._repo.get_node(self._systems[name])
automagix/colors.py ADDED
@@ -0,0 +1,42 @@
1
+ BLUE = '\033[34m'
2
+ CYAN = '\033[36m'
3
+ GREEN = '\033[32m'
4
+ RED = '\033[31m'
5
+ YELLOW = '\033[33m'
6
+
7
+ BOLD = '\033[1m'
8
+ ITALIC = '\033[3m'
9
+
10
+ RESET = '\033[0m'
11
+
12
+
13
+ def blue(text):
14
+ return f'{BLUE}{text}{RESET}'
15
+
16
+
17
+ def cyan(text):
18
+ return f'{CYAN}{text}{RESET}'
19
+
20
+
21
+ def green(text):
22
+ return f'{GREEN}{text}{RESET}'
23
+
24
+
25
+ def red(text):
26
+ return f'{RED}{text}{RESET}'
27
+
28
+
29
+ def yellow(text):
30
+ return f'{YELLOW}{text}{RESET}'
31
+
32
+
33
+ def bold(text):
34
+ return f'{BOLD}{text}{RESET}'
35
+
36
+
37
+ def italic(text):
38
+ return f'{ITALIC}{text}{RESET}'
39
+
40
+
41
+ def nocolor(text):
42
+ return f'{RESET}{text}'