nornir-collection 0.0.1__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.
Files changed (59) hide show
  1. nornir_collection/__init__.py +0 -0
  2. nornir_collection/batfish/__init__.py +0 -0
  3. nornir_collection/batfish/assert_config.py +358 -0
  4. nornir_collection/batfish/utils.py +129 -0
  5. nornir_collection/cisco/__init__.py +0 -0
  6. nornir_collection/cisco/configuration_management/__init__.py +0 -0
  7. nornir_collection/cisco/configuration_management/cli/__init__.py +0 -0
  8. nornir_collection/cisco/configuration_management/cli/config_tasks.py +569 -0
  9. nornir_collection/cisco/configuration_management/cli/config_workflow.py +107 -0
  10. nornir_collection/cisco/configuration_management/cli/show_tasks.py +677 -0
  11. nornir_collection/cisco/configuration_management/netconf/__init__.py +0 -0
  12. nornir_collection/cisco/configuration_management/netconf/config_tasks.py +564 -0
  13. nornir_collection/cisco/configuration_management/netconf/config_workflow.py +298 -0
  14. nornir_collection/cisco/configuration_management/netconf/nr_cfg_iosxe_netconf.py +186 -0
  15. nornir_collection/cisco/configuration_management/netconf/ops_tasks.py +307 -0
  16. nornir_collection/cisco/configuration_management/processor.py +151 -0
  17. nornir_collection/cisco/configuration_management/pyats.py +236 -0
  18. nornir_collection/cisco/configuration_management/restconf/__init__.py +0 -0
  19. nornir_collection/cisco/configuration_management/restconf/cisco_rpc.py +514 -0
  20. nornir_collection/cisco/configuration_management/restconf/config_workflow.py +95 -0
  21. nornir_collection/cisco/configuration_management/restconf/tasks.py +325 -0
  22. nornir_collection/cisco/configuration_management/utils.py +511 -0
  23. nornir_collection/cisco/software_upgrade/__init__.py +0 -0
  24. nornir_collection/cisco/software_upgrade/cisco_software_upgrade.py +283 -0
  25. nornir_collection/cisco/software_upgrade/utils.py +794 -0
  26. nornir_collection/cisco/support_api/__init__.py +0 -0
  27. nornir_collection/cisco/support_api/api_calls.py +1173 -0
  28. nornir_collection/cisco/support_api/cisco_maintenance_report.py +221 -0
  29. nornir_collection/cisco/support_api/cisco_support.py +727 -0
  30. nornir_collection/cisco/support_api/reports.py +747 -0
  31. nornir_collection/cisco/support_api/utils.py +316 -0
  32. nornir_collection/fortinet/__init__.py +0 -0
  33. nornir_collection/fortinet/utils.py +36 -0
  34. nornir_collection/git.py +224 -0
  35. nornir_collection/netbox/__init__.py +0 -0
  36. nornir_collection/netbox/custom_script.py +107 -0
  37. nornir_collection/netbox/inventory.py +360 -0
  38. nornir_collection/netbox/scan_prefixes_and_update_ip_addresses.py +989 -0
  39. nornir_collection/netbox/set_device_status.py +67 -0
  40. nornir_collection/netbox/sync_datasource.py +111 -0
  41. nornir_collection/netbox/update_cisco_inventory_data.py +158 -0
  42. nornir_collection/netbox/update_cisco_support_plugin_data.py +339 -0
  43. nornir_collection/netbox/update_fortinet_inventory_data.py +161 -0
  44. nornir_collection/netbox/update_purestorage_inventory_data.py +144 -0
  45. nornir_collection/netbox/utils.py +261 -0
  46. nornir_collection/netbox/verify_device_primary_ip.py +202 -0
  47. nornir_collection/nornir_plugins/__init__.py +0 -0
  48. nornir_collection/nornir_plugins/inventory/__init__.py +0 -0
  49. nornir_collection/nornir_plugins/inventory/netbox.py +250 -0
  50. nornir_collection/nornir_plugins/inventory/staggered_yaml.py +143 -0
  51. nornir_collection/nornir_plugins/inventory/utils.py +277 -0
  52. nornir_collection/purestorage/__init__.py +0 -0
  53. nornir_collection/purestorage/utils.py +53 -0
  54. nornir_collection/utils.py +741 -0
  55. nornir_collection-0.0.1.dist-info/LICENSE +21 -0
  56. nornir_collection-0.0.1.dist-info/METADATA +136 -0
  57. nornir_collection-0.0.1.dist-info/RECORD +59 -0
  58. nornir_collection-0.0.1.dist-info/WHEEL +5 -0
  59. nornir_collection-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,741 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ This module contains general functions and tasks related to Nornir.
4
+
5
+ The functions are ordered as followed:
6
+ - Helper Functions
7
+ - Nornir print functions
8
+ - Nornir Helper Tasks
9
+ - Nornir print_result deviation
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import argparse
15
+ import json
16
+ import hashlib
17
+ import logging
18
+ import pprint
19
+ import threading
20
+ import urllib
21
+ import operator
22
+ from functools import reduce
23
+ from typing import Generator, Tuple, List, NoReturn, Union
24
+ from collections import OrderedDict
25
+ from datetime import datetime
26
+ import yaml
27
+ import __main__
28
+ from colorama import Fore, Style, init
29
+ from pyfiglet import figlet_format
30
+ from pandas import DataFrame
31
+ from nornir.core import Nornir
32
+ from nornir.core.filter import F
33
+ from nornir.core.task import AggregatedResult, MultiResult, Result
34
+ from nornir_salt.plugins.functions import FFun
35
+
36
+
37
+ init(autoreset=True, strip=False)
38
+
39
+ #### Helper Functions ########################################################################################
40
+
41
+
42
+ class CustomArgParse(argparse.ArgumentParser):
43
+ """
44
+ This class takes the argparse.ArgumentParser function as a superclass and overwrites the argparse error
45
+ function. Every time that argparse calls the error function the following error function will be executed.
46
+ """
47
+
48
+ def error(self, message):
49
+ """
50
+ This function overwrites the standard argparse error function
51
+ """
52
+ print(task_error(text="ARGPARSE verify arguments", changed=False))
53
+ print("'ARGPARSE verify arguments' -> ArgparseResponse <Success: False>\n")
54
+ print(f"error: {message}\n")
55
+ self.print_help()
56
+ print("\n")
57
+ sys.exit(1)
58
+
59
+
60
+ class CustomArgParseWidthFormatter(argparse.RawTextHelpFormatter):
61
+ """
62
+ This class can be specified as formatter_class argument in argparse.ArgumentParser. This solution is
63
+ preferred as formatter_class argument expects to use a class, not a class instance.
64
+ """
65
+
66
+ def __init__(self, prog) -> None:
67
+ super().__init__(prog, width=100)
68
+
69
+
70
+ def get_env_vars(envs: List[str], task_text: str) -> Tuple:
71
+ """
72
+ This function loads the environment variables from a list and returns them as a Tuple. If an environment
73
+ variable is not found, the script will exit with an error message.
74
+ """
75
+ # Initialize an empty dictionary
76
+ env_vars = {}
77
+
78
+ try:
79
+ # Load the environment variables from the list
80
+ for env in envs:
81
+ env_vars[env] = os.environ[env]
82
+
83
+ except KeyError as error:
84
+ # Print the error message and exit the script
85
+ print(task_error(text=task_text, changed=False))
86
+ print(f"'Load environment variable {error}' -> OS.EnvironResponse <Success: False>")
87
+ print(f"-> Environment variable {error} not found\n")
88
+ sys.exit(1)
89
+
90
+ # Return the environment variables as a dictionary
91
+ return env_vars
92
+
93
+
94
+ def get_dict_value_by_path(data_dict, map_list):
95
+ """
96
+ Access a nested object in data_dict by map_list sequence.
97
+ """
98
+ return reduce(operator.getitem, map_list, data_dict)
99
+
100
+
101
+ def set_dict_value_by_path(data_dict, map_list, value):
102
+ """
103
+ Set a value in a nested object in data_dict by map_list sequence.
104
+ """
105
+ get_dict_value_by_path(data_dict, map_list[:-1])[map_list[-1]] = value
106
+
107
+
108
+ def del_dict_key_value_by_path(data_dict, map_list):
109
+ """
110
+ Delete a key-value in a nested object in data_dict by map_list sequence.
111
+ """
112
+ del get_dict_value_by_path(data_dict, map_list[:-1])[map_list[-1]]
113
+
114
+
115
+ def get_rel_path_from_cwd(path):
116
+ """
117
+ TBD
118
+ """
119
+ # Compute the absolute path if path is a relative path
120
+ if not os.path.isabs(path):
121
+ path = os.path.join(os.path.abspath(os.path.dirname(__main__.__file__)), path)
122
+
123
+ # Get the relative path from the current working directory to to the end of the absolute path
124
+ relative_path_from_cwd = os.path.relpath(path, os.getcwd())
125
+
126
+ return relative_path_from_cwd
127
+
128
+
129
+ def load_multiple_yaml_files_to_string(file_list: list, silent: bool = False) -> str:
130
+ """
131
+ This function loads multiple yaml files into a string
132
+ """
133
+ # pylint: disable=invalid-name
134
+
135
+ task_text = "Load multiple yaml files to string"
136
+ yaml_string = str()
137
+
138
+ # Exit the function if the file_list is empty
139
+ if not file_list:
140
+ print(task_error(text=task_text, changed=False))
141
+ print(f"'{task_text}' -> NornirResponse <Success: False>")
142
+ print("-> No files provided for yaml string loading ('file_list' is empty)")
143
+ return yaml_string
144
+
145
+ # Load the yaml files from file_list and concatinate them to one string
146
+ # TestsProcessor templates the yaml string with Jinja2 and loads the yaml string into a dict
147
+ try:
148
+ for file in file_list:
149
+ with open(file, "r", encoding="utf-8") as f:
150
+ for line in f.readlines():
151
+ yaml_string += line if "---" not in line else ""
152
+ yaml_string += "\n"
153
+
154
+ if not silent:
155
+ print(task_info(text=task_text, changed=False))
156
+ print(f"'{task_text}' -> NornirResponse <Success: True>")
157
+ for file in file_list:
158
+ print(f"-> {file}")
159
+
160
+ except FileNotFoundError as error:
161
+ print(task_error(text=task_text, changed=False))
162
+ print(f"'{task_text}' -> NornirResponse <Success: False>")
163
+ print(f"-> {error}")
164
+
165
+ return yaml_string
166
+
167
+
168
+ def load_yaml_file(file: str, text: str = False, silent: bool = False, verbose: bool = False):
169
+ """
170
+ Load the yaml file into a variable.
171
+ """
172
+ text = text if text else "YAML Load File"
173
+ success_message = (
174
+ f"{task_name(text=text)}\n"
175
+ f"{task_info(text=text, changed=False)}\n"
176
+ f"'{text}' -> PythonResult <Success: True>"
177
+ )
178
+ error_message = (
179
+ f"{task_name(text=text)}\n"
180
+ f"{task_error(text=text, changed=False)}\n"
181
+ f"'{text}' -> PythonResult <Success: False>"
182
+ )
183
+
184
+ try:
185
+ with open(file, "r", encoding="utf-8") as stream:
186
+ yaml_dict = yaml.safe_load(stream)
187
+
188
+ if not silent:
189
+ print(success_message)
190
+ print(f"-> Loaded YAML file: {file}")
191
+ if verbose:
192
+ print("\n" + json.dumps(yaml_dict, indent=4))
193
+
194
+ # Return the loaded yaml file as python dictionary
195
+ return yaml_dict
196
+
197
+ except (TypeError, FileNotFoundError, yaml.parser.ParserError) as yaml_error:
198
+ print(error_message)
199
+ print(f"\n{yaml_error}")
200
+
201
+ # Return an empty python dictionary
202
+ return {}
203
+
204
+
205
+ def construct_filename_with_current_date(filename: str, name: Union[str, None], silent: bool = False) -> str:
206
+ """
207
+ Construct the new path and filename from the filename argument string variable. The current date will be
208
+ added at the end of the filename. The function returns the new constructed filename.
209
+ """
210
+ # Set a custom name for stdout print if name is set
211
+ name = name if name else "PYTHON construct file path with current date"
212
+
213
+ # Create some variables to construct the destination path and filename
214
+ # Get the path and the filename from file variable string
215
+ path, filename = os.path.split(filename)
216
+
217
+ # Create the path folder if it don't exists
218
+ if not os.path.exists(path):
219
+ os.makedirs(path)
220
+
221
+ # Get the filename and the extension from the filename variable
222
+ filename, file_extension = os.path.splitext(filename)
223
+
224
+ # Destination filename with current date time in format YYYY-mm-dd
225
+ filename = f"{path}/{filename}_{datetime.today().date()}{file_extension}"
226
+
227
+ if not silent:
228
+ print_task_name(text=name)
229
+ print(task_info(text=name, changed=False))
230
+ print(f"'{name}' -> PythonResult <Success: True>")
231
+ print(f"-> Constructed {filename}")
232
+
233
+ return filename
234
+
235
+
236
+ def get_pandas_column_width(df: DataFrame) -> List[int]: # pylint: disable=invalid-name
237
+ """
238
+ Helper function to get the width of each pandas dataframe column.
239
+ """
240
+ # Find the maximum length of the index column
241
+ idx_max = max([len(str(s)) for s in df.index.values] + [len(str(df.index.name))])
242
+
243
+ # Concatenate this to the max of the lengths of column name and its values for each column, left to right
244
+ return [idx_max] + [max([len(str(s)) for s in df[col].values] + [len(col)]) for col in df.columns]
245
+
246
+
247
+ def list_flatten(original_list: list) -> list:
248
+ """
249
+ This function creates with recursion a flat list from a list of lists and strings or other data types.
250
+ """
251
+ new_list = []
252
+ for item in original_list:
253
+ if isinstance(item, list):
254
+ new_list.extend(list_flatten(item))
255
+ else:
256
+ new_list.append(item)
257
+
258
+ return new_list
259
+
260
+
261
+ def compute_hash(source: str, algorithm: str = "md5") -> str:
262
+ """
263
+ This is a helper function which takes a file path or a http url as argument and computes a md5 hash which
264
+ is the return of the function. Additionally the default hash algorithm can be changed from md5 to sha1,
265
+ sha265, sha384 or sha512.
266
+ """
267
+ # Use mapping with lambda to avoid long if elif else statements
268
+ algorithms = {
269
+ "md5": hashlib.md5, # nosec
270
+ "sha1": hashlib.sha1, # nosec
271
+ "sha256": hashlib.sha256, # nosec
272
+ "sha384": hashlib.sha384, # nosec
273
+ "sha512": hashlib.sha512, # nosec
274
+ }
275
+ # Execute the correct lambda hash function by the dictionary key which matches the algorithm argument
276
+ hash_obj = algorithms[algorithm]()
277
+
278
+ if source.lower().startswith("http"):
279
+ # Bandit "B310: urllib_urlopen" if solved to raise a ValueError is the value starts not with http
280
+ if source.lower().startswith("http"):
281
+ response = urllib.request.Request(source)
282
+ with urllib.request.urlopen(response) as response: # nosec
283
+ for chunk in iter(lambda: response.read(4096), b""):
284
+ hash_obj.update(chunk)
285
+ else:
286
+ raise ValueError from None
287
+ else:
288
+ with open(source, "rb") as file:
289
+ for chunk in iter(lambda: file.read(4096), b""):
290
+ hash_obj.update(chunk)
291
+
292
+ return hash_obj.hexdigest()
293
+
294
+
295
+ def iterate_all(iterable: Union[list, dict], returned: str = "key") -> Generator:
296
+ """Returns an iterator that returns all keys or values of a (nested) iterable.
297
+ Arguments:
298
+ - iterable: <list> or <dictionary>
299
+ - returned: <string> "key" or "value" or <tuple of strings> "key-value"
300
+ Returns:
301
+ - <Generator>
302
+ """
303
+ if isinstance(iterable, dict):
304
+ for key, value in iterable.items():
305
+ if returned == "key":
306
+ yield key
307
+ elif returned == "value":
308
+ if not isinstance(value, dict) or isinstance(value, list):
309
+ yield value
310
+ elif returned == "key-value":
311
+ if not isinstance(value, dict) or isinstance(value, list):
312
+ yield key, value
313
+ else:
314
+ raise ValueError("'returned' keyword only accepts 'key' or 'value' or 'key-value'.")
315
+ for ret in iterate_all(value, returned=returned):
316
+ yield ret
317
+ elif isinstance(iterable, list):
318
+ for item in iterable:
319
+ for ret in iterate_all(item, returned=returned):
320
+ yield ret
321
+
322
+
323
+ def transform_env(iterable: dict, startswith: str = "env_") -> dict:
324
+ """
325
+ This function loops over a nested dictionary and if the key startswith the specific string and the value
326
+ is a string, it loads the environment variable specified by the value and replace the value with the
327
+ environment variable.
328
+ """
329
+ for key, value in iterable.copy().items():
330
+ # If Value == DICT -> Continue with nested dict
331
+ if isinstance(value, dict):
332
+ iterable[key] = transform_env(value, startswith)
333
+ # If Value == LIST -> Replace the value of each list item with the env variable
334
+ elif isinstance(value, list):
335
+ if key.startswith(startswith):
336
+ for index, item in enumerate(value.copy()):
337
+ iterable[key][index] = os.environ[item]
338
+ # If Value == STR -> Replace the value with the env variable
339
+ elif isinstance(value, str):
340
+ if key.startswith(startswith):
341
+ iterable[key] = os.environ[value]
342
+
343
+ return iterable
344
+
345
+
346
+ #### Nornir Helper Functions #################################################################################
347
+
348
+
349
+ def nr_filter_by_role_and_tag(
350
+ nr: Nornir, task_text: str = None, role: str = None, tags: list[str] = None, silent: bool = False
351
+ ):
352
+ """
353
+ TBD
354
+ """
355
+ # pylint: disable=invalid-name
356
+
357
+ if not task_text:
358
+ task_text = "NORNIR filter inventory"
359
+ provided_filters = ""
360
+
361
+ if role:
362
+ # Filter by device role (A device can have only one role)
363
+ nr = nr.filter(F(role__slug__contains=role))
364
+ provided_filters += f"-> Provided role: '{role}'\n"
365
+
366
+ if tags:
367
+ # Loop over all provided tags and filter by each tag
368
+ for tag in tags:
369
+ nr = nr.filter(F(tags__contains=tag))
370
+ provided_filters += f"-> Provided tags: {tags}\n"
371
+
372
+ # If the filteres object have no hosts, exit with a error message
373
+ if not nr.inventory.hosts.keys():
374
+ print(task_error(text=task_text, changed=False))
375
+ print(f"'{task_text} by role and/or tags' -> NornirResult <Success: False>")
376
+ exit_error(
377
+ task_text=task_text,
378
+ text="ALERT: NO HOST WITH ROLE AND/OR TAGS IN NORNIR INVENTORY",
379
+ msg=[
380
+ "-> Analyse the Nornir inventory and filter for a role and/or tags assigned to hosts",
381
+ f"-> {provided_filters}",
382
+ ],
383
+ )
384
+
385
+ if not silent:
386
+ print(task_info(text=task_text, changed=False))
387
+ print(
388
+ f"'{task_text} by role and/or tags' -> NornirResult <Success: True>\n" + f"{provided_filters}",
389
+ )
390
+
391
+ return nr
392
+
393
+
394
+ def nr_filter_by_hosts(nr: Nornir, hosts: Union[str, list], task_text: str = None, silent: bool = False):
395
+ """
396
+ TBD
397
+ """
398
+ # pylint: disable=invalid-name
399
+
400
+ if not task_text:
401
+ task_text = "NORNIR filter inventory"
402
+
403
+ if isinstance(hosts, str):
404
+ # Create a list from the comma separated hosts argument
405
+ hosts = hosts.split(",")
406
+
407
+ # Use NornirSalt FFun Filter-List option to filter on a list of hosts
408
+ nr_filtered = FFun(nr, FL=hosts)
409
+
410
+ # Create a list with all filtered Nornir hosts for verification
411
+ nr_hosts = list(nr_filtered.inventory.hosts.keys())
412
+
413
+ # Verify that each host in from the hosts argument is part of the filtered Nornir inventory, else
414
+ # the diff host will be in the list host_diff list
415
+ host_diff = [host for host in hosts if host not in nr_hosts]
416
+ if host_diff:
417
+ print(task_error(text=task_text, changed=False))
418
+ print(f"'{task_text} by hosts' -> NornirResponse <Success: False>")
419
+ for host in host_diff:
420
+ print(f"-> {host}")
421
+ exit_error(
422
+ task_text=task_text,
423
+ text="ALERT: ONE OR MORE HOSTS ARE NOT PART OF THE NORNIR INVENTORY",
424
+ msg="-> Analyse the Nornir inventory and filter for an existing host",
425
+ )
426
+
427
+ if not silent:
428
+ print(task_info(text=task_text, changed=False))
429
+ print(f"'{task_text} by hosts' -> NornirResponse <Success: True>")
430
+ for host in nr_hosts:
431
+ print(f"-> {host}")
432
+
433
+ return nr_filtered
434
+
435
+
436
+ def nr_filter_args(nr: Nornir, args: argparse.Namespace) -> Nornir:
437
+ """
438
+ This function filters the Nornir inventory with a tag or a host argument provided by argparse. Prior
439
+ Argparse validation needs to ensure that only one argument is present and that the tag or host argument
440
+ creates a correct inventory filtering will be verified. The new filtered Nornir object will be returned
441
+ or the script terminates with an error message.
442
+ """
443
+ # pylint: disable=invalid-name
444
+
445
+ task_text = "NORNIR filter inventory"
446
+ print_task_name(task_text)
447
+
448
+ # If the --hosts argument is set, verify that the host exist
449
+ if hasattr(args, "hosts"):
450
+ nr = nr_filter_by_hosts(nr=nr, hosts=args.hosts, silent=False)
451
+
452
+ return nr
453
+
454
+ # If the --role and/or --tags argument is set, verify that the tag has hosts assigned to
455
+ if hasattr(args, "role") or hasattr(args, "tags"):
456
+ role = args.role if hasattr(args, "role") else None
457
+ tags = args.tags.split(",") if hasattr(args, "tags") else None
458
+ nr = nr_filter_by_role_and_tag(nr=nr, role=role, tags=tags, silent=False)
459
+
460
+ return nr
461
+
462
+ exit_error(
463
+ task_text=task_text,
464
+ text="ALERT: NOT SUPPORTET ARGPARSE ARGUMENT FOR NORNIR INVENTORY FILTERING!",
465
+ msg="-> Analyse the python function for missing Argparse filtering",
466
+ )
467
+
468
+
469
+ def nr_filter_inventory_from_host_list(nr: Nornir, filter_reason: str, host_list: List[str]) -> Nornir:
470
+ """
471
+ This function takes a Nornir object, a filter reason to print in Nornir style to std-out and a list of
472
+ hosts. It can be a list of hosts or a list of strings where the hostname is part of the string, as the
473
+ function checks if the hostname from the Nornir object is in that host list or list of strings. Every host
474
+ that matches will be added to the new filter target and the new filtered Nornir object will be returned.
475
+ """
476
+ # pylint: disable=invalid-name
477
+
478
+ task_text = "NORNIR re-filter inventory"
479
+ print_task_name(task_text)
480
+
481
+ # Re-filter the Nornir inventory only on hosts that need to be reconfigured.
482
+ # Create an empty list to fill with all hosts that need reconfiguration.
483
+ filter_target = []
484
+
485
+ # Iterate over all diff files and add the host to the filter_target list if the Nornir inventory host is
486
+ # in the diff file name
487
+ for item in host_list:
488
+ for host in nr.inventory.hosts.keys():
489
+ if host == item:
490
+ filter_target.append(host)
491
+ break
492
+
493
+ # Remove possible duplicate hosts in the list
494
+ filter_target = list(set(filter_target))
495
+
496
+ # Use Nornir-Salt FFun Filter-List option to filter on a list of hosts
497
+ nr = FFun(nr, FL=filter_target)
498
+
499
+ print(task_info(text=task_text, changed=False))
500
+ print(f"'{task_text} for hosts' -> NornirResponse <Success: True>")
501
+ print(f"{filter_reason}")
502
+ for host in nr.inventory.hosts.keys():
503
+ print(f"-> {host}")
504
+
505
+ return nr
506
+
507
+
508
+ #### Nornir Print Functions ##################################################################################
509
+
510
+
511
+ def print_script_banner(title: str, font: str = "small", text: str = None) -> None:
512
+ """
513
+ Print a custom script banner with pyfiglet.
514
+ """
515
+ banner = figlet_format(title, font, width=110)
516
+ print("\n")
517
+ for line in banner.splitlines(): # Workaround to print in Azure DevOps all lines in color
518
+ print(f"{Style.BRIGHT}{Fore.GREEN}{line}")
519
+ if text:
520
+ print(f"{Style.BRIGHT}{Fore.GREEN}{text}")
521
+
522
+
523
+ def print_task_title(title: str) -> None:
524
+ """
525
+ Prints a Nornir style title.
526
+ """
527
+ msg = f"**** {title} "
528
+ print(f"\n{Style.BRIGHT}{Fore.GREEN}{msg}{'*' * (90 - len(msg))}{Fore.RESET}{Style.RESET_ALL}")
529
+
530
+
531
+ def print_task_name(text: str) -> None:
532
+ """
533
+ Prints a Nornir style host task title.
534
+ """
535
+ msg = f"{text} "
536
+ print(f"\n{Style.BRIGHT}{Fore.CYAN}{msg}{'*' * (90 - len(msg))}{Fore.RESET}{Style.RESET_ALL}")
537
+
538
+
539
+ def task_name(text: str) -> None:
540
+ """
541
+ Prints a Nornir style host task title.
542
+ """
543
+ msg = f"{text} "
544
+ return f"\n{Style.BRIGHT}{Fore.CYAN}{msg}{'*' * (90 - len(msg))}{Fore.RESET}{Style.RESET_ALL}"
545
+
546
+
547
+ def task_host(host: str, changed: bool) -> str:
548
+ """
549
+ Returns a Nornir style host task name.
550
+ """
551
+ msg = f"* {host} ** changed : {str(changed)} "
552
+ return f"{Style.BRIGHT}{Fore.BLUE}{msg}{'*' * (90 - len(msg))}{Fore.RESET}{Style.RESET_ALL}"
553
+
554
+
555
+ def task_result(text: str, changed: bool, level_name, failed: bool = False) -> str:
556
+ """
557
+ Returns a Nornir style task info or error message based on the arguments.
558
+ This function should be the successor of task_info and task_error.
559
+ """
560
+ if failed or level_name == "ERROR":
561
+ color = Fore.RED
562
+ elif changed:
563
+ color = Fore.YELLOW
564
+ else:
565
+ color = Fore.GREEN
566
+
567
+ msg = f"---- {text} ** changed : {str(changed)} "
568
+ return f"{Style.BRIGHT}{color}{msg}{'-' * (90 - len(msg))} {level_name}{Fore.RESET}{Style.RESET_ALL}"
569
+
570
+
571
+ def task_info(text: str, changed: bool) -> str:
572
+ """
573
+ Returns a Nornir style task info message.
574
+ """
575
+ color = Fore.YELLOW if changed else Fore.GREEN
576
+ msg = f"---- {text} ** changed : {str(changed)} "
577
+ return f"{Style.BRIGHT}{color}{msg}{'-' * (90 - len(msg))} INFO{Fore.RESET}{Style.RESET_ALL}"
578
+
579
+
580
+ def task_error(text: str, changed: bool) -> str:
581
+ """
582
+ Returns a Nornir style task error message.
583
+ """
584
+ msg = f"---- {text} ** changed : {str(changed)} "
585
+ return f"{Style.BRIGHT}{Fore.RED}{msg}{'-' * (90 - len(msg))} ERROR{Fore.RESET}{Style.RESET_ALL}"
586
+
587
+
588
+ def exit_info(
589
+ task_text: str, text: str = False, msg: Union[list[str], str] = False, changed: bool = False
590
+ ) -> NoReturn:
591
+ """
592
+ TBD
593
+ """
594
+ # Set text to task_text if text if False
595
+ text = text if text else task_text
596
+
597
+ # Print the info and exit the script with exit code 0
598
+ print(task_info(text=task_text, changed=changed))
599
+ print(f"\u2728 {text.upper()} \u2728")
600
+ if isinstance(msg, list):
601
+ for line in msg:
602
+ print(f"{Style.BRIGHT}{Fore.GREEN}{line}")
603
+ elif isinstance(msg, str):
604
+ print(f"{Style.BRIGHT}{Fore.GREEN}{msg}")
605
+ print("\n")
606
+ sys.exit(0)
607
+
608
+
609
+ def exit_error(task_text: str, text: str = False, msg: Union[list[str], str, None] = "default") -> NoReturn:
610
+ """
611
+ TBD
612
+ """
613
+ # Set text to task_text if text if False
614
+ text = text if text else task_text
615
+
616
+ # Print the error and exit the script with exit code 1
617
+ print(task_error(text=task_text, changed=False))
618
+ print(f"\U0001f4a5 {text.upper()} \U0001f4a5")
619
+ if isinstance(msg, list):
620
+ for line in msg:
621
+ print(f"{Style.BRIGHT}{Fore.RED}{line}")
622
+ elif isinstance(msg, str) and "default" not in msg:
623
+ print(f"{Style.BRIGHT}{Fore.RED}{msg}")
624
+ elif "default" in msg:
625
+ print(
626
+ f"{Style.BRIGHT}{Fore.RED}-> Analyse the Nornir output for failed task results\n"
627
+ "-> May apply Nornir inventory changes and run the script again"
628
+ )
629
+ print("\n")
630
+ sys.exit(1)
631
+
632
+
633
+ #### Nornir print_result Deviation ###########################################################################
634
+
635
+
636
+ def _print_individual_result(
637
+ result: Result, result_sub_list: bool, attrs: List[str], failed: bool, severity_level: int
638
+ ) -> None:
639
+ """
640
+ This function is part of the deviation of the official Nornir print_result function.
641
+ """
642
+
643
+ if result.severity_level < severity_level:
644
+ return
645
+
646
+ # Get the task level INFO or ERROR, the colorama color and the changed boolian
647
+ level_name = logging.getLevelName(result.severity_level)
648
+ changed = "" if result.changed is None else result.changed
649
+
650
+ for attribute in attrs:
651
+ item = getattr(result, attribute, "")
652
+ if isinstance(item, BaseException):
653
+ # Deviation to print the dsc_nornir task_result function
654
+ print(task_result(text=result.name, changed=changed, level_name=level_name, failed=failed))
655
+ # for consistency between py3.6 and py3.7
656
+ print(f"{item.__class__.__name__}{item.args}")
657
+
658
+ # Deviation to print the dsc_nornir task_result function
659
+ elif item and result_sub_list and isinstance(item, list):
660
+ for list_item in item:
661
+ print(list_item)
662
+
663
+ elif item and not isinstance(item, str):
664
+ if isinstance(item, OrderedDict):
665
+ # Deviation to print the dsc_nornir task_result function
666
+ print(task_result(text=result.name, changed=changed, level_name=level_name, failed=failed))
667
+ print(json.dumps(item, indent=4))
668
+ else:
669
+ # Deviation to print the dsc_nornir task_result function
670
+ print(task_result(text=result.name, changed=changed, level_name=level_name, failed=failed))
671
+ pprint.pprint(item, indent=4)
672
+ elif item:
673
+ # Deviation to print the dsc_nornir task_result function
674
+ print(task_result(text=result.name, changed=changed, level_name=level_name, failed=failed))
675
+ print(item)
676
+
677
+
678
+ def _print_result(
679
+ result: Result,
680
+ result_sub_list: bool = False,
681
+ attrs: List[str] = None,
682
+ failed: bool = False,
683
+ severity_level: int = logging.INFO,
684
+ ) -> None:
685
+ """
686
+ This function is part of the deviation of the official Nornir print_result function.
687
+ """
688
+
689
+ # If attrs is not None use attrs else use the list below
690
+ attrs = attrs or ["diff", "result", "stdout"]
691
+ if isinstance(attrs, str):
692
+ attrs = [attrs]
693
+
694
+ if isinstance(result, AggregatedResult):
695
+ # Deviation to print the dsc_nornir print_task_name function
696
+ print_task_name(text=result.name)
697
+
698
+ for host, host_data in sorted(result.items()):
699
+ changed = "" if host_data.changed is None else host_data.changed
700
+ # Deviation to print the dsc_nornir task_host function
701
+ print(task_host(host=host, changed=changed))
702
+ # Recursion to print all MultiResult objects of the Nornir AggregatedResult object
703
+ _print_result(host_data, result_sub_list, attrs, failed, severity_level)
704
+
705
+ elif isinstance(result, MultiResult):
706
+ # Deviation to not print the task MultiResult or Subtask failed result
707
+ if not (str(result[0]).startswith("MultiResult") or str(result[0]).startswith("Subtask")):
708
+ _print_individual_result(result[0], result_sub_list, attrs, failed, severity_level)
709
+ # Recursion to print all results of the Nornir MultiResult object
710
+ for result_item in result[1:]:
711
+ _print_result(result_item, result_sub_list, attrs, failed, severity_level)
712
+
713
+ elif isinstance(result, Result):
714
+ # Print the Nornir Result object
715
+ _print_individual_result(result, result_sub_list, attrs, failed, severity_level)
716
+
717
+
718
+ def print_result(
719
+ result: Result,
720
+ result_sub_list: bool = False,
721
+ attrs: List[str] = None,
722
+ failed: bool = False,
723
+ severity_level: int = logging.INFO,
724
+ ) -> None:
725
+ """
726
+ This function is a deviation of the official Nornir print_result function.
727
+ Prints an object of type `nornir.core.task.Result`
728
+ Arguments:
729
+ result: from a previous task
730
+ attrs: Which attributes you want to print
731
+ failed: if ``True`` assume the task failed
732
+ severity_level: Print only errors with this severity level or higher
733
+ """
734
+ # pylint: disable=consider-using-with
735
+
736
+ lock = threading.Lock()
737
+ lock.acquire()
738
+ try:
739
+ _print_result(result, result_sub_list, attrs, failed, severity_level)
740
+ finally:
741
+ lock.release()