reemote 0.0.1__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 (38) hide show
  1. reemote-0.0.1/LICENSE +21 -0
  2. reemote-0.0.1/PKG-INFO +37 -0
  3. reemote-0.0.1/README.md +17 -0
  4. reemote-0.0.1/reemote/__init__.py +0 -0
  5. reemote-0.0.1/reemote/operation.py +7 -0
  6. reemote-0.0.1/reemote/operations/__init__.py +0 -0
  7. reemote-0.0.1/reemote/operations/apk/__init__.py +0 -0
  8. reemote-0.0.1/reemote/operations/apk/packages.py +66 -0
  9. reemote-0.0.1/reemote/operations/apk/update.py +45 -0
  10. reemote-0.0.1/reemote/operations/apk/upgrade.py +45 -0
  11. reemote-0.0.1/reemote/operations/filesystem/__init__.py +0 -0
  12. reemote-0.0.1/reemote/operations/filesystem/chown.py +70 -0
  13. reemote-0.0.1/reemote/operations/filesystem/directory.py +51 -0
  14. reemote-0.0.1/reemote/pre_order_generator.py +35 -0
  15. reemote-0.0.1/reemote/printers.py +232 -0
  16. reemote-0.0.1/reemote/result.py +9 -0
  17. reemote-0.0.1/reemote/run.py +40 -0
  18. reemote-0.0.1/reemote/run_command_on_host.py +46 -0
  19. reemote-0.0.1/reemote/validate_inventory_file_and_get_inventory.py +31 -0
  20. reemote-0.0.1/reemote/validate_inventory_structure.py +33 -0
  21. reemote-0.0.1/reemote/validate_root_class_name_and_get_root_class.py +25 -0
  22. reemote-0.0.1/reemote/verify_inventory_connect.py +15 -0
  23. reemote-0.0.1/reemote/verify_python_file.py +13 -0
  24. reemote-0.0.1/reemote/verify_source_file_contains_valid_class.py +39 -0
  25. reemote-0.0.1/reemote.egg-info/PKG-INFO +37 -0
  26. reemote-0.0.1/reemote.egg-info/SOURCES.txt +36 -0
  27. reemote-0.0.1/reemote.egg-info/dependency_links.txt +1 -0
  28. reemote-0.0.1/reemote.egg-info/entry_points.txt +2 -0
  29. reemote-0.0.1/reemote.egg-info/requires.txt +14 -0
  30. reemote-0.0.1/reemote.egg-info/top_level.txt +3 -0
  31. reemote-0.0.1/reemote_cli/__init__.py +0 -0
  32. reemote-0.0.1/reemote_cli/reemote.py +77 -0
  33. reemote-0.0.1/setup.cfg +4 -0
  34. reemote-0.0.1/setup.py +40 -0
  35. reemote-0.0.1/tests/__init__.py +0 -0
  36. reemote-0.0.1/tests/filesystem/__init__.py +0 -0
  37. reemote-0.0.1/tests/filesystem/test_chown.py +144 -0
  38. reemote-0.0.1/tests/filesystem/test_directory.py +67 -0
reemote-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kim Jarvis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
reemote-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: reemote
3
+ Version: 0.0.1
4
+ Summary: A Python package for reemote functionality
5
+ Home-page: https://github.com/kimjarvis/reemote
6
+ Author: Kim Jarvis
7
+ Author-email: kim.jarvis@tpfsystems.com
8
+ License: MIT
9
+ Requires-Python: >=3.6
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: cryptography
13
+ Requires-Dist: bcrypt
14
+ Requires-Dist: asyncssh
15
+ Requires-Dist: tabulate
16
+ Provides-Extra: dev
17
+ Requires-Dist: setuptools; extra == "dev"
18
+ Requires-Dist: asyncssh; extra == "dev"
19
+ Provides-Extra: doc
20
+ Requires-Dist: sphinx; extra == "doc"
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest; extra == "test"
23
+ Dynamic: author
24
+ Dynamic: author-email
25
+ Dynamic: description
26
+ Dynamic: description-content-type
27
+ Dynamic: home-page
28
+ Dynamic: license
29
+ Dynamic: license-file
30
+ Dynamic: provides-extra
31
+ Dynamic: requires-dist
32
+ Dynamic: requires-python
33
+ Dynamic: summary
34
+
35
+
36
+ Reemote is a Python API for task automation, configuration management and application deployment.
37
+
@@ -0,0 +1,17 @@
1
+ # Reemote
2
+
3
+ Reemote is a Python API for task automation, configuration management and application deployment.
4
+
5
+ # Example
6
+
7
+ The [GUI example](examples/gui) demonstrates a switch that creates or removes the directory `/tmp/mydir` on all the servers in an inventory.
8
+
9
+ ![GUI Demo](examples/gui/gui_demo.png)
10
+
11
+ # Installation
12
+
13
+ Install from PyPI
14
+
15
+ ```bash
16
+ pip install reemote
17
+ ```
File without changes
@@ -0,0 +1,7 @@
1
+ class Operation:
2
+
3
+ def __init__(self, command: str, changed: bool = False):
4
+ self.command = command
5
+ self.host_info = None
6
+ self.sudo_info = None
7
+
File without changes
File without changes
@@ -0,0 +1,66 @@
1
+ from reemote.printers import print_ssh_completed_process
2
+ from typing import List
3
+
4
+ class Packages:
5
+ """
6
+ A class to manage package operations on a remote system using `apk` (Alpine Linux package manager).
7
+
8
+ Attributes:
9
+ packages (List[str]): A list of package names to be added or removed.
10
+ present (bool): Indicates whether the packages should be present (`True`) or absent (`False`) on the system.
11
+ sudo (bool): If `True`, the commands will be executed with `sudo` privileges.
12
+ su (bool): If `True`, the commands will be executed with `su` privileges.
13
+
14
+ Usage:
15
+ This class is designed to be used in a generator-based workflow where commands are yielded for execution.
16
+ It supports adding or removing packages based on the `present` flag and allows privilege escalation via `sudo` or `su`.
17
+
18
+ Notes:
19
+ - Commands are constructed based on the `present`, `sudo`, and `su` flags.
20
+ - The `changed` flag is set if the package state changes after execution.
21
+ """
22
+
23
+ def __init__(self,
24
+ packages: List[str],
25
+ present: bool,
26
+ repository: str = None ,
27
+ sudo: bool = False,
28
+ su: bool = False):
29
+ self.packages: List[str] = packages
30
+ self.present: bool = present
31
+ self.repository: str = repository
32
+ self.sudo: bool = sudo
33
+ self.su: bool = su
34
+
35
+ # Construct the operation string from the list of packages
36
+ op: List[str] = []
37
+ op.extend(self.packages)
38
+ if repository:
39
+ op.append(f"--repository {repository}")
40
+ self.op: str = " ".join(op)
41
+
42
+ def __repr__(self) -> str:
43
+ return (f"Packages(packages={self.packages!r}, present={self.present!r},"
44
+ f"repository={self.repository!r},"
45
+ f"sudo={self.sudo!r}, su={self.su!r})")
46
+
47
+ def execute(self):
48
+ r0 = yield f"echo {self}"
49
+ _sudo: str = "sudo -S " if self.sudo else ""
50
+ _su: str = "su -c " if self.su else ""
51
+
52
+ # Retrieve the current list of installed packages
53
+ r1 = yield f"{_sudo}apk info -v"
54
+
55
+ # Add or remove packages based on the `present` flag
56
+ if self.present:
57
+ r2 = yield f"{_sudo}{_su}'apk add {self.op}'"
58
+ else:
59
+ r2 = yield f"{_sudo}{_su}'apk del {self.op}'"
60
+
61
+ # Retrieve the updated list of installed packages
62
+ r3 = yield f"{_sudo}apk info -v"
63
+
64
+ # Set the `changed` flag if the package state has changed
65
+ if r1.cp.stdout != r3.cp.stdout:
66
+ r2.changed = True
@@ -0,0 +1,45 @@
1
+ from reemote.printers import print_ssh_completed_process
2
+ from typing import List
3
+
4
+ class Update:
5
+ """
6
+ A class to manage package operations on a remote system using `apk` (Alpine Linux package manager).
7
+
8
+ Attributes:
9
+ sudo (bool): If `True`, the commands will be executed with `sudo` privileges.
10
+ su (bool): If `True`, the commands will be executed with `su` privileges.
11
+
12
+ Usage:
13
+ Update installed packages.
14
+
15
+ Notes:
16
+ - Commands are constructed based on the `present`, `sudo`, and `su` flags.
17
+ - The `changed` flag is set if the package state changes after execution.
18
+ """
19
+
20
+ def __init__(self,
21
+ sudo: bool = False,
22
+ su: bool = False):
23
+ self.sudo: bool = sudo
24
+ self.su: bool = su
25
+
26
+ def __repr__(self) -> str:
27
+ return (f"Update("
28
+ f"sudo={self.sudo!r}, su={self.su!r})")
29
+
30
+ def execute(self):
31
+ r0 = yield f"echo {self}"
32
+ _sudo: str = "sudo -S " if self.sudo else ""
33
+ _su: str = "su -c " if self.su else ""
34
+
35
+ # Retrieve the current list of installed packages
36
+ r1 = yield f"{_sudo}apk info -v"
37
+
38
+ r2 = yield f"{_sudo}{_su}'apk update'"
39
+
40
+ # Retrieve the updated list of installed packages
41
+ r3 = yield f"{_sudo}apk info -v"
42
+
43
+ # Set the `changed` flag if the package state has changed
44
+ if r1.cp.stdout != r3.cp.stdout:
45
+ r2.changed = True
@@ -0,0 +1,45 @@
1
+ from reemote.printers import print_ssh_completed_process
2
+ from typing import List
3
+
4
+ class Upgrade:
5
+ """
6
+ A class to manage package operations on a remote system using `apk` (Alpine Linux package manager).
7
+
8
+ Attributes:
9
+ sudo (bool): If `True`, the commands will be executed with `sudo` privileges.
10
+ su (bool): If `True`, the commands will be executed with `su` privileges.
11
+
12
+ Usage:
13
+ Upgrade installed packages.
14
+
15
+ Notes:
16
+ - Commands are constructed based on the `present`, `sudo`, and `su` flags.
17
+ - The `changed` flag is set if the package state changes after execution.
18
+ """
19
+
20
+ def __init__(self,
21
+ sudo: bool = False,
22
+ su: bool = False):
23
+ self.sudo: bool = sudo
24
+ self.su: bool = su
25
+
26
+ def __repr__(self) -> str:
27
+ return (f"Upgrade("
28
+ f"sudo={self.sudo!r}, su={self.su!r})")
29
+
30
+ def execute(self):
31
+ r0 = yield f"echo {self}"
32
+ _sudo: str = "sudo -S " if self.sudo else ""
33
+ _su: str = "su -c " if self.su else ""
34
+
35
+ # Retrieve the current list of installed packages
36
+ r1 = yield f"{_sudo}apk info -v"
37
+
38
+ r2 = yield f"{_sudo}{_su}'apk upgrade'"
39
+
40
+ # Retrieve the upgraded list of installed packages
41
+ r3 = yield f"{_sudo}apk info -v"
42
+
43
+ # Set the `changed` flag if the package state has changed
44
+ if r1.cp.stdout != r3.cp.stdout:
45
+ r2.changed = True
@@ -0,0 +1,70 @@
1
+ class Chown:
2
+ """
3
+ A class to encapsulate the functionality of the `chown` or `chgrp` command in Unix-like operating systems.
4
+ It allows users to specify a target file or directory, along with optional user and group ownership changes,
5
+ additional command-line options, and the ability to execute the command with elevated privileges (`sudo`).
6
+
7
+ Attributes:
8
+ target (str): The file or directory whose ownership is to be changed.
9
+ user (Optional[str]): The new user owner. Defaults to `None`.
10
+ group (Optional[str]): The new group owner. Defaults to `None`.
11
+ options (List[str]): Additional command-line options for the `chown` or `chgrp` command.
12
+ sudo (bool): If `True`, the commands will be executed with `sudo` privileges.
13
+ su (bool): If `True`, the commands will be executed with `su` privileges.
14
+ """
15
+ def __init__(self, target: str,
16
+ user: str | None = None,
17
+ group: str | None = None,
18
+ options=None,
19
+ sudo: bool = False):
20
+
21
+ self.target = target
22
+ self.user = user
23
+ self.group = group
24
+ self.options = options
25
+ self.sudo = sudo
26
+
27
+ if options is None:
28
+ options = []
29
+
30
+ command = "chown"
31
+ user_group = None
32
+
33
+ if user and group:
34
+ user_group = f"{user}:{group}"
35
+ elif user:
36
+ user_group = user
37
+ elif group:
38
+ command = "chgrp"
39
+ user_group = group
40
+ else:
41
+ raise ValueError("Either user or group must be specified")
42
+
43
+ op = []
44
+ op.append(command)
45
+ op.extend(options)
46
+ op.append(user_group)
47
+ op.append(target)
48
+
49
+ self.chown = " ".join(op)
50
+
51
+ def __repr__(self):
52
+ return (f"Chown(target={self.target!r}, user={self.user!r}, "
53
+ f"group={self.group!r}, options={self.options!r}, sudo={self.sudo!r})")
54
+
55
+ def execute(self):
56
+ yield f"echo {self}"
57
+ _sudo = "sudo -S " if self.sudo else ""
58
+
59
+ # Get initial file info
60
+ r0 = yield f"{_sudo}ls -ld {self.target}"
61
+
62
+ # Execute chown command
63
+ r1 = yield f"{_sudo}{self.chown}"
64
+
65
+ # Get final file info to check if changed
66
+ r2 = yield f"{_sudo}ls -ld {self.target}"
67
+
68
+ # Set changed flag if the output differs
69
+ if r0.cp.stdout != r2.cp.stdout:
70
+ r1.changed = True
@@ -0,0 +1,51 @@
1
+ from reemote.result import Result
2
+
3
+ class Directory:
4
+ class Directory:
5
+ """
6
+ A class to manage directory states on a filesystem.
7
+
8
+ Attributes:
9
+ path (str): The absolute or relative path of the directory to manage. This is the target directory whose state will be checked or modified.
10
+ present (bool): Indicates whether the directory should exist (`True`) or not (`False`) on the system. If `True`, the directory will be created if it does not exist. If `False`, the directory will be removed if it exists.
11
+ sudo (bool): If `True`, the commands will be executed with `sudo` privileges. Defaults to `False`.
12
+
13
+ Usage:
14
+ This class is designed to be used in a generator-based workflow where commands are yielded for execution.
15
+ It supports creating or removing directories based on the `present` flag and allows privilege escalation via `sudo`.
16
+
17
+ Notes:
18
+ - Commands are constructed based on the `present` and `sudo` flags.
19
+ - The `changed` flag is set if the directory state changes after execution.
20
+ """
21
+ def __init__(self, path: str, present: bool, sudo: bool = False):
22
+ self.path = path
23
+ self.present = present
24
+ self.sudo = sudo
25
+
26
+ def __repr__(self):
27
+ return f"Directory(path={self.path!r}, present={self.present!r}, sudo={self.sudo!r})"
28
+
29
+ def execute(self):
30
+ yield f"echo {self}"
31
+ """
32
+ Execute the directory management logic as a generator.
33
+
34
+ This method checks if the directory exists and performs actions based on the `present` flag.
35
+ """
36
+ _sudo = "sudo -S " if self.sudo else ""
37
+ # Check whether the directory exists
38
+ r0: Result = yield f"{_sudo}[ -d {self.path} ]"
39
+ # print(f">>>>> Received in Directory: {r}")
40
+
41
+ if self.present and r0.cp.returncode != 0:
42
+ # Present directory does not exist, so create it
43
+ r1 =yield f"{_sudo}mkdir -p {self.path}"
44
+ # print(f">>>>> Received in Directory: {r1}")
45
+ r1.changed=True
46
+
47
+ if not self.present and r0.cp.returncode == 0:
48
+ # Not Present directory exists, so remove it
49
+ r2 = yield f"{_sudo}rmdir -p {self.path}"
50
+ # print(f">>>>> Received in Directory: {r2}")
51
+ r2.changed=True
@@ -0,0 +1,35 @@
1
+ from reemote.operation import Operation
2
+
3
+
4
+ def pre_order_generator(node):
5
+ """
6
+ Enhanced generator function with better error handling and string wrapping.
7
+ """
8
+ stack = [(node, iter(node.execute()))]
9
+ result = None
10
+
11
+ while stack:
12
+ current_node, iterator = stack[-1]
13
+ try:
14
+ value = iterator.send(result) if result is not None else next(iterator)
15
+ result = None
16
+
17
+ # Handle different types of yielded values
18
+ if isinstance(value, Operation):
19
+ result = yield value
20
+ elif isinstance(value, str):
21
+ # Auto-wrap strings in Operation objects
22
+ operation = Operation(value)
23
+ result = yield operation
24
+ elif hasattr(value, 'execute') and callable(value.execute):
25
+ # If it's a node with execute method, push to stack
26
+ stack.append((value, iter(value.execute())))
27
+ else:
28
+ raise TypeError(f"Unsupported yield type: {type(value)}")
29
+
30
+ except StopIteration:
31
+ stack.pop()
32
+ except Exception as e:
33
+ # Handle errors in node execution
34
+ print(f"Error in node execution: {e}")
35
+ stack.pop()
@@ -0,0 +1,232 @@
1
+ from asyncssh import SSHCompletedProcess
2
+ from asyncssh import ProcessError
3
+ from typing import Optional, Mapping, Tuple, Union
4
+ import json
5
+ import pandas as pd
6
+ from tabulate import tabulate
7
+
8
+ def print_ssh_completed_process(process_result):
9
+ """
10
+ Prints the fields of an SSHCompletedProcess object.
11
+
12
+ :param process_result: The completed process result to inspect.
13
+ :type process_result: SSHCompletedProcess
14
+ """
15
+ # Ensure the input is of the expected type
16
+ if not isinstance(process_result, SSHCompletedProcess):
17
+ raise ValueError("The provided argument is not an instance of SSHCompletedProcess.")
18
+
19
+ # Define the fields to extract and their descriptions
20
+ fields = [
21
+ ("env", "The environment the client requested to be set for the process", "dict or None"),
22
+ ("command", "The command the client requested the process to execute (if any)", "str or None"),
23
+ ("subsystem", "The subsystem the client requested the process to open (if any)", "str or None"),
24
+ ("exit_status", "The exit status returned, or -1 if an exit signal is sent", "int"),
25
+ ("exit_signal", "The exit signal sent (if any) in the form of a tuple containing the signal name, "
26
+ "a bool for whether a core dump occurred, a message associated with the signal, "
27
+ "and the language the message was in", "tuple or None"),
28
+ ("returncode", "The exit status returned, or negative of the signal number when an exit signal is sent", "int"),
29
+ ("stdout", "The output sent by the process to stdout (if not redirected)", "str or bytes"),
30
+ ("stderr", "The output sent by the process to stderr (if not redirected)", "str or bytes"),
31
+ ]
32
+
33
+ # Print each field and its value
34
+ print("Fields of SSHCompletedProcess:")
35
+ print("--------------------------------")
36
+ for field_name, description, field_type in fields:
37
+ value = getattr(process_result, field_name, None)
38
+ print(f"{field_name}: {value}")
39
+ print(f" Description: {description}")
40
+ print(f" Type: {field_type}")
41
+ print()
42
+
43
+
44
+ def print_process_error_fields(process_error):
45
+ """
46
+ Prints the fields of an asyncssh.ProcessError exception.
47
+
48
+ Args:
49
+ process_error (asyncssh.ProcessError): The exception instance to inspect.
50
+ """
51
+ if not isinstance(process_error, ProcessError):
52
+ raise ValueError("The provided argument is not an instance of asyncssh.ProcessError.")
53
+
54
+ # Define the fields to extract and their descriptions
55
+ fields = [
56
+ ("env", "The environment the client requested to be set for the process"),
57
+ ("command", "The command the client requested the process to execute (if any)"),
58
+ ("subsystem", "The subsystem the client requested the process to open (if any)"),
59
+ ("exit_status", "The exit status returned, or -1 if an exit signal is sent"),
60
+ ("exit_signal", "The exit signal sent (if any) in the form of a tuple containing the signal name, "
61
+ "a bool for whether a core dump occurred, a message associated with the signal, "
62
+ "and the language the message was in"),
63
+ ("returncode", "The exit status returned, or negative of the signal number when an exit signal is sent"),
64
+ ("stdout", "The output sent by the process to stdout (if not redirected)"),
65
+ ("stderr", "The output sent by the process to stderr (if not redirected)"),
66
+ ("reason", "The reason for the error"),
67
+ ("lang", "The language of the error message")
68
+ ]
69
+
70
+ # Print each field and its value
71
+ print("Fields of asyncssh.ProcessError:")
72
+ print("--------------------------------")
73
+ for field_name, description in fields:
74
+ value = getattr(process_error, field_name, None)
75
+ print(f"{field_name}: {value}")
76
+ print(f" Description: {description}")
77
+ print()
78
+
79
+ def construct_host_ops(operations, results):
80
+ """
81
+ Construct a list of dictionaries grouping operations and results by host.
82
+
83
+ Args:
84
+ operations (list): List of Operation objects.
85
+ results (list): List of Result objects.
86
+
87
+ Returns:
88
+ list: A list of dictionaries in the desired format.
89
+ """
90
+ # Dictionary to group operations and results by host
91
+ host_dict = {}
92
+
93
+ # Iterate over operations and results simultaneously
94
+ for operation, result in zip(operations, results):
95
+ host = operation.host_info['host'] # Extract host from operation
96
+ stdout = result.cp.stdout.strip() if result.cp.stdout else "<no stdout>" # Extract stdout from result
97
+
98
+ # Group by host
99
+ if host not in host_dict:
100
+ host_dict[host] = []
101
+ host_dict[host].append(
102
+ ({"command": operation.command},
103
+ {"stdout": stdout, "returncode": result.cp.returncode,
104
+ "changed": result.changed}))
105
+
106
+ # Convert the dictionary into the desired list of dictionaries format
107
+ host_ops_list = [{"host": host, "ops": ops} for host, ops in host_dict.items()]
108
+
109
+ return host_ops_list
110
+
111
+ def print_json_ops(host_ops):
112
+ pretty_output = json.dumps(host_ops, indent=4)
113
+ # Print the formatted output
114
+ print(pretty_output)
115
+
116
+
117
+ def generate_change_df(host_ops):
118
+ # Step 1: Extract commands, changed, and executed values for each host
119
+ data = {}
120
+ for host_data in host_ops:
121
+ host = host_data["host"]
122
+ data[host] = []
123
+ for op_pair in host_data["ops"]:
124
+ # Extract the command from the first dictionary
125
+ command = op_pair[0]["command"]
126
+ # Merge the changed values from both dictionaries
127
+ changed = any(d.get("changed", False) for d in op_pair)
128
+ # Get the executed value from the second dictionary
129
+ executed = op_pair[1].get("executed", False)
130
+ data[host].append((command, changed, executed))
131
+ # Step 2: Align commands across hosts
132
+ commands = [op[0] for op in data[next(iter(data))]] # Use the first host's commands as reference
133
+ df_data = {}
134
+ # Add columns for each host: <host>_changed and <host>_executed
135
+ for host, ops in data.items():
136
+ df_data[(host, "changed")] = [changed for _, changed, _ in ops]
137
+ df_data[(host, "executed")] = [executed for _, _, executed in ops]
138
+ # Step 3: Create the DataFrame with MultiIndex columns
139
+ df = pd.DataFrame(df_data, index=commands)
140
+ # Rename the index to "Command"
141
+ df.index.name = "Command"
142
+ # Display the DataFrame
143
+ return df
144
+
145
+ def convert_df_to_ag_grid(df):
146
+ # Reset index to make the index a column
147
+ df_reset = df.reset_index()
148
+
149
+ # Convert all column names to strings
150
+ df_reset.columns = [str(col) if not isinstance(col, tuple) else ' '.join(map(str, col)).strip()
151
+ for col in df_reset.columns]
152
+
153
+ # Create column definitions
154
+ column_defs = []
155
+ for col in df_reset.columns:
156
+ field_name = col.lower().replace(' ', '_')
157
+ column_defs.append({'headerName': col, 'field': field_name})
158
+
159
+ # Create row data
160
+ row_data = []
161
+ for index, row in df_reset.iterrows():
162
+ row_dict = {}
163
+ for col in df_reset.columns:
164
+ field_name = col.lower().replace(' ', '_')
165
+ row_dict[field_name] = row[col]
166
+ row_data.append(row_dict)
167
+
168
+ return {
169
+ 'columnDefs': column_defs,
170
+ 'rowData': row_data
171
+ }
172
+
173
+
174
+ def summarize_data_for_aggrid(data):
175
+ # Initialize the result dictionary
176
+ result = {"columnDefs": [], "rowData": []}
177
+
178
+ # Extract the list of hosts from the input data
179
+ hosts = [host_data["host"] for host_data in data]
180
+
181
+ # Add the 'Command' column to columnDefs
182
+ result["columnDefs"].append({"headerName": "Command", "field": "command"})
183
+
184
+ # Add a column for each host
185
+ for i, host in enumerate(hosts):
186
+ result["columnDefs"].append({"headerName": host, "field": f"host{i}"})
187
+
188
+ # Populate rowData
189
+ for ops_index, ops in enumerate(data[0]["ops"]): # Assuming all hosts have the same commands
190
+ row = {}
191
+ # Add the command to the row
192
+ if ops[0]["command"].startswith("echo"):
193
+ row["command"] = ops[0]["command"].replace("echo", ">>>>")
194
+ else:
195
+ row["command"] = ops[0]["command"]
196
+
197
+ # Add boolean values for each host
198
+ for host_index, host_data in enumerate(data):
199
+ command_dict, result_dict = host_data["ops"][ops_index]
200
+ changed = result_dict.get("changed", False)
201
+ row[f"host{host_index}"] = changed
202
+
203
+ # Append the row to rowData
204
+ result["rowData"].append(row)
205
+
206
+ return result
207
+
208
+
209
+ def get_printable_aggrid(data):
210
+ """
211
+ Formats and prints the given AGGrid data as a formatted grid.
212
+
213
+ Parameters:
214
+ data (dict): A dictionary containing 'columnDefs' and 'rowData'.
215
+ - 'columnDefs': List of dictionaries with 'headerName' and 'field'.
216
+ - 'rowData': List of dictionaries representing rows of data.
217
+
218
+ Returns:
219
+ None: Prints the formatted grid.
220
+ """
221
+ # Step 1: Extract headers and field mappings
222
+ headers = [col['headerName'] for col in data['columnDefs']]
223
+ field_mapping = {col['field']: col['headerName'] for col in data['columnDefs']}
224
+
225
+ # Step 2: Prepare the table data
226
+ table_data = []
227
+ for row in data['rowData']:
228
+ formatted_row = [row[field] for field in field_mapping.keys()]
229
+ table_data.append(formatted_row)
230
+
231
+ # Step 3: Print the formatted grid using tabulate
232
+ print(tabulate(table_data, headers=headers, tablefmt="grid"))
@@ -0,0 +1,9 @@
1
+ from asyncssh import SSHCompletedProcess
2
+
3
+ class Result:
4
+
5
+ def __init__(self, cp: SSHCompletedProcess, host: str, changed: bool = False):
6
+ self.cp = cp
7
+ self.host = host
8
+ self.changed = changed
9
+