marsh-lib 0.2.0__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 (44) hide show
  1. marsh/__init__.py +2 -0
  2. marsh/bash/__init__.py +3 -0
  3. marsh/bash/bash_factory.py +74 -0
  4. marsh/bash/bash_grammar.py +44 -0
  5. marsh/bash/bash_runner_decorators.py +0 -0
  6. marsh/bash/bash_script.py +71 -0
  7. marsh/constants.py +5 -0
  8. marsh/core/__init__.py +8 -0
  9. marsh/core/authenticator.py +7 -0
  10. marsh/core/cmd_run_decorator.py +289 -0
  11. marsh/core/command_grammar.py +45 -0
  12. marsh/core/connector.py +77 -0
  13. marsh/core/conveyor.py +105 -0
  14. marsh/core/executor.py +296 -0
  15. marsh/core/expression.py +208 -0
  16. marsh/core/script.py +38 -0
  17. marsh/dag/__init__.py +11 -0
  18. marsh/dag/dag.py +748 -0
  19. marsh/dag/node.py +52 -0
  20. marsh/dag/startable.py +48 -0
  21. marsh/docker/__init__.py +2 -0
  22. marsh/docker/docker_command_grammar.py +31 -0
  23. marsh/docker/docker_executor.py +181 -0
  24. marsh/exceptions.py +16 -0
  25. marsh/logger.py +116 -0
  26. marsh/modifier_functions/__init__.py +2 -0
  27. marsh/modifier_functions/case_conversion.py +17 -0
  28. marsh/modifier_functions/readers.py +11 -0
  29. marsh/powershell/__init__.py +0 -0
  30. marsh/processor_functions/__init__.py +3 -0
  31. marsh/processor_functions/printers.py +63 -0
  32. marsh/processor_functions/raisers.py +8 -0
  33. marsh/processor_functions/redirections.py +39 -0
  34. marsh/signals.py +1 -0
  35. marsh/ssh/__init__.py +3 -0
  36. marsh/ssh/ssh_command_grammar.py +50 -0
  37. marsh/ssh/ssh_connector.py +71 -0
  38. marsh/ssh/ssh_factory.py +93 -0
  39. marsh/utils/__init__.py +16 -0
  40. marsh/utils/output_streams.py +13 -0
  41. marsh_lib-0.2.0.dist-info/LICENSE +21 -0
  42. marsh_lib-0.2.0.dist-info/METADATA +380 -0
  43. marsh_lib-0.2.0.dist-info/RECORD +44 -0
  44. marsh_lib-0.2.0.dist-info/WHEEL +4 -0
marsh/dag/node.py ADDED
@@ -0,0 +1,52 @@
1
+ from typing import Tuple
2
+
3
+ from ..logger import result_logging_decorator
4
+ from ..core import Conveyor
5
+ from .startable import Startable
6
+
7
+
8
+ class Node(Startable):
9
+ """
10
+ A Node represents an object that can be started, typically as part of a workflow or graph.
11
+
12
+ This class extends `Startable` and represents a node in a directed acyclic graph (DAG) or similar structure.
13
+ Each node is responsible for starting a process using a `Conveyor` object. The `start` method invokes the `Conveyor`
14
+ with the provided keyword arguments, returning the result as a 2-tuple of bytes.
15
+
16
+ Attributes:
17
+ name (str): The name of the node, inherited from the `Startable` base class.
18
+ conveyor (Conveyor): The `Conveyor` object responsible for carrying out the node's operation.
19
+ _kwargs (dict): A dictionary of keyword arguments used when calling the `Conveyor`.
20
+
21
+ Methods:
22
+ start() -> Tuple[bytes, bytes]:
23
+ Executes the `Conveyor` with the provided arguments, returning the result as a tuple of bytes.
24
+ """
25
+
26
+ def __init__(self, name: str, conveyor: Conveyor, **kwargs):
27
+ """
28
+ Initializes a Node object with a name and a Conveyor.
29
+
30
+ Args:
31
+ name (str): The name of the node.
32
+ conveyor (Conveyor): The `Conveyor` object that will perform the operation associated with this node.
33
+ **kwargs: Additional keyword arguments to be passed to the `Conveyor` when called.
34
+ """
35
+ super().__init__(name)
36
+ self.conveyor = conveyor
37
+
38
+ # Conveyor keyword arguments for __call__
39
+ self._kwargs = kwargs
40
+
41
+ @result_logging_decorator(__name__)
42
+ def start(self) -> Tuple[bytes, bytes]:
43
+ """
44
+ Starts the operation associated with the Node by invoking the Conveyor.
45
+
46
+ This method calls the `Conveyor` object with the stored keyword arguments and returns a tuple of bytes
47
+ representing the result of the operation.
48
+
49
+ Returns:
50
+ Tuple[bytes, bytes]: A tuple containing two byte sequences as the result of the Conveyor operation.
51
+ """
52
+ return self.conveyor(**self._kwargs)
marsh/dag/startable.py ADDED
@@ -0,0 +1,48 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional, Any
3
+
4
+
5
+ class Startable(ABC):
6
+ """
7
+ Abstract base class for objects that can be started, such as nodes or DAGs.
8
+
9
+ This class defines a common interface for objects that need to be started.
10
+ Subclasses like `Node` and `Dag` will implement the `start` method with their own logic,
11
+ while inheriting from `Startable` to ensure they follow the expected contract for starting.
12
+
13
+ Attributes:
14
+ name (str): A hashable name that identifies the object within a node or directed acyclic graph (DAG).
15
+ This will be used to define the graph in `graphlib.TopologicalSorter`.
16
+
17
+ Methods:
18
+ start(*args, **kwargs):
19
+ Starts the object with optional arguments. Subclasses must implement this method with their own logic,
20
+ defining how the object is started. It may return any type or `None`.
21
+ """
22
+
23
+ def __init__(self, name: str):
24
+ """
25
+ Initializes a Startable object with a given name.
26
+
27
+ Args:
28
+ name (str): A hashable name for the node or DAG.
29
+ This name is used to identify and reference the object.
30
+ """
31
+ self.name = name # Hashable name for Node and Dag
32
+
33
+ @abstractmethod
34
+ def start(self, *args, **kwargs) -> Optional[Any]:
35
+ """
36
+ Abstract method that starts the object.
37
+
38
+ This method must be implemented by subclasses such as `Node` or `Dag` to define the specific behavior
39
+ for starting the object. It should handle the logic for initiating the object's state or execution.
40
+
41
+ Args:
42
+ *args: Positional arguments passed to the method.
43
+ **kwargs: Keyword arguments passed to the method.
44
+
45
+ Returns:
46
+ Optional[Any]: The result of the start operation, which may be of any type, or `None`.
47
+ """
48
+ pass
@@ -0,0 +1,2 @@
1
+ from marsh.docker.docker_executor import DockerContainer, DockerCommandExecutor
2
+ from marsh.docker.docker_command_grammar import DockerCommandGrammar
@@ -0,0 +1,31 @@
1
+ from marsh import CommandGrammar
2
+
3
+
4
+ class DockerCommandGrammar(CommandGrammar):
5
+ def __init__(self, shell_command: str = "/bin/sh -c"):
6
+ if not shell_command.strip():
7
+ raise ValueError("The shell command cannot be empty. Example: Use '/bin/sh -c'")
8
+ self.shell_command = shell_command
9
+
10
+ def build_cmd(self,
11
+ command: str | list[str],
12
+ x_stdout: str | bytes | None = None
13
+ ) -> list[str]:
14
+ shell_command = self.shell_command.split()
15
+
16
+ x_stdout = x_stdout.decode() if isinstance(x_stdout, bytes)\
17
+ else x_stdout if isinstance(x_stdout, str)\
18
+ else None
19
+
20
+ if isinstance(command, str):
21
+ full_command = [*shell_command, command]
22
+ else:
23
+ full_command = [*shell_command, " ".join(command)]
24
+
25
+ if x_stdout:
26
+ full_command[-1] = full_command[-1] + " " + x_stdout.strip()
27
+
28
+ return full_command
29
+
30
+ def pipe_prev_stdout(self, x_stdout: str | bytes) -> list[str]:
31
+ pass
@@ -0,0 +1,181 @@
1
+ import uuid
2
+ import time
3
+ import threading
4
+ from typing import Optional
5
+
6
+ import docker
7
+ from docker.models.containers import Container
8
+ from docker.errors import DockerException, NotFound
9
+
10
+ from marsh.exceptions import DockerError, DockerClientError
11
+ from marsh import Executor
12
+ from .docker_command_grammar import DockerCommandGrammar
13
+
14
+
15
+ def generate_random_container_name(prefix="ephemeral-container"):
16
+ return prefix + "-" + str(uuid.uuid4()).replace('-', '')[:8]
17
+
18
+
19
+ class DockerContainer:
20
+ def __init__(self,
21
+ image: str,
22
+ *create_args,
23
+ client_args=(),
24
+ client_kwargs=None,
25
+ name: str | None = None,
26
+ timeout: int = 600, # in seconds
27
+ start_timeout: int = 1.5, # Wait for few seconds after a container started
28
+ **create_kwargs
29
+ ):
30
+ client_kwargs = client_kwargs or {}
31
+
32
+ self._image = image
33
+ self._name = name or generate_random_container_name()
34
+
35
+ self._start_timeout = start_timeout
36
+ self._timeout = timeout
37
+ self._timer: threading.Timer | None = None
38
+
39
+ self._container: Optional[Container] = None
40
+
41
+ try:
42
+ self._client = docker.DockerClient(*client_args, **client_kwargs)
43
+ except Exception as err:
44
+ raise DockerClientError(err)
45
+
46
+ self._create_args = create_args
47
+ self._create_kwargs = create_kwargs
48
+
49
+ def __enter__(self) -> Container:
50
+ try:
51
+ self._container = self._client.containers.create(
52
+ self._image,
53
+ *self._create_args,
54
+ command="tail -f /dev/null", # To run the container continuously
55
+ name=self._name,
56
+ detach=True,
57
+ auto_remove=True,
58
+ **self._create_kwargs
59
+ )
60
+
61
+ # Start the container
62
+ self._container.start()
63
+ time.sleep(self._start_timeout) # Wait for few seconds
64
+
65
+ # Set the Timer to stop the container on timeout
66
+ self._timer = threading.Timer(self._timeout, self._throw_timeout_error)
67
+ self._timer.start()
68
+
69
+ except Exception as err:
70
+ self._clean()
71
+ raise DockerError(err)
72
+
73
+ return self._container
74
+
75
+ def __exit__(self, exc_type, exc_value, traceback):
76
+ # Clean the resources
77
+ self._clean()
78
+
79
+ if exc_type is TimeoutError:
80
+ return False
81
+
82
+ return True
83
+
84
+ def _throw_timeout_error(self) -> None:
85
+ self._clean()
86
+ raise TimeoutError(f"Timeout reached for container '{self._name}'.")
87
+
88
+ def _clean(self) -> None:
89
+ # 1. Cancel the Timer
90
+ # 2. Remove the Container
91
+ # 3. Close the Docker Client
92
+
93
+ # Cancel the Timer
94
+ if self._timer:
95
+ self._timer.cancel()
96
+
97
+ # Remove the Container
98
+ all_containers: list[Container] = self._client.containers.list(all=True)
99
+ for container in all_containers:
100
+ if container.name == self._name:
101
+ try:
102
+ container.stop(timeout=0)
103
+ except NotFound as err:
104
+ # This occurs when timeout event occurred.
105
+ pass
106
+
107
+ # Close the docker client
108
+ try:
109
+ self._client.close()
110
+ except DockerException as err:
111
+ raise DockerClientError(err)
112
+
113
+
114
+ class DockerCommandExecutor(Executor):
115
+ def __init__(self,
116
+ image: str,
117
+ *create_args,
118
+ container_name: str | None = None,
119
+ pipe_prev_stdout: bool = False,
120
+ timeout: int = 600,
121
+ shell_command="/bin/sh -c",
122
+ client_args: tuple = (),
123
+ client_kwargs: dict | None = None,
124
+ **create_kwargs,
125
+ ):
126
+
127
+ self._client_args = client_args
128
+ self._client_kwargs = client_kwargs or {}
129
+
130
+ self._create_args = create_args
131
+ self._create_kwargs = create_kwargs
132
+
133
+ self._container_name = container_name
134
+ self.timeout = timeout
135
+ self.image = image # Docker container image
136
+
137
+ # Create a DockerCommandGrammar and clean the given command
138
+ self._command_grammar = DockerCommandGrammar(shell_command=shell_command)
139
+
140
+ self._pipe_prev_stdout = pipe_prev_stdout
141
+
142
+ def run(self,
143
+ x_stdout: bytes,
144
+ x_stderr: bytes,
145
+ command: str | list[str],
146
+ **run_kwargs
147
+ ) -> tuple[bytes, bytes]:
148
+
149
+ stdout: bytes = b""
150
+ stderr: bytes = b""
151
+
152
+ with DockerContainer(self.image,
153
+ *self._create_args,
154
+ name=self._container_name,
155
+ timeout=self.timeout,
156
+ client_args=self._client_args,
157
+ client_kwargs=self._client_kwargs,
158
+ **self._create_kwargs,
159
+ ) as container:
160
+
161
+ # Unix Pipes, if specified
162
+ if self._pipe_prev_stdout:
163
+ full_command = self._command_grammar.build_cmd(command, x_stdout)
164
+ else:
165
+ full_command = self._command_grammar.build_cmd(command)
166
+
167
+ # Run a command in the container
168
+ try:
169
+ result = container.exec_run(full_command, **run_kwargs)
170
+ except TimeoutError:
171
+ raise
172
+
173
+ # Check exit code and update output streams
174
+ if result.exit_code == 0:
175
+ # Update STDOUT (no strip)
176
+ stdout = result.output
177
+ else:
178
+ # Update STDERR (no strip)
179
+ stderr = result.output
180
+
181
+ return stdout, stderr
marsh/exceptions.py ADDED
@@ -0,0 +1,16 @@
1
+ class CommandError(Exception):
2
+ """Base Class for all Command Errors."""
3
+ pass
4
+
5
+
6
+ class ScriptError(Exception):
7
+ """Base Class for all Script Errors."""
8
+ pass
9
+
10
+
11
+ class DockerError(Exception):
12
+ """Base Class for all Docker Errors."""
13
+
14
+
15
+ class DockerClientError(DockerError):
16
+ pass
marsh/logger.py ADDED
@@ -0,0 +1,116 @@
1
+ import os
2
+ import functools
3
+ import logging
4
+ from logging.handlers import RotatingFileHandler
5
+
6
+
7
+ class LoggerTemplate:
8
+ def __init__(self,
9
+ logger_name: str,
10
+ log_level: int,
11
+ log_handlers: list,
12
+ log_formatter: logging.Formatter
13
+ ):
14
+ self.logger_name = logger_name
15
+ self.log_level = log_level
16
+ self.log_handlers = log_handlers
17
+ self.log_formatter = log_formatter
18
+
19
+ def create_logger(self) -> logging.Logger:
20
+ logger = logging.getLogger(self.logger_name)
21
+ logger.setLevel(self.log_level)
22
+
23
+ for handler in self.log_handlers:
24
+ handler.setFormatter(self.log_formatter)
25
+ logger.addHandler(handler)
26
+
27
+ return logger
28
+
29
+
30
+ class CustomRotatingFileHandler(RotatingFileHandler):
31
+ def rotation_filename(self, default_name):
32
+ base_name = default_name.split(".")[0]
33
+ count = default_name.split(".")[-1]
34
+ return f"{base_name}_{count}.log"
35
+
36
+
37
+ def create_rotating_file_logger(logger_name: str,
38
+ log_file_path: str,
39
+ size_mb: int = 1,
40
+ bak_count: int = 2
41
+ ) -> logging.Logger:
42
+ # Define the log format
43
+ log_formatter = logging.Formatter(
44
+ "[%(levelname)s] %(asctime)s | %(name)s | Line %(lineno)d | %(message)s"
45
+ )
46
+
47
+ file_path = str(LOG_DIR_PATH / log_file_path)
48
+
49
+ # Set up the rotating file handler
50
+ rotating_handler = CustomRotatingFileHandler(
51
+ file_path,
52
+ maxBytes=size_mb * 1024 * 1024,
53
+ backupCount=bak_count
54
+ )
55
+
56
+ # Create a logger using the template
57
+ logger_template = LoggerTemplate(
58
+ logger_name=logger_name,
59
+ log_level=logging.DEBUG,
60
+ log_handler=rotating_handler,
61
+ log_formatter=log_formatter
62
+ )
63
+
64
+ return logger_template.create_logger()
65
+
66
+
67
+ def create_console_logger(logger_name: str) -> logging.Logger:
68
+ # Define the log format
69
+ log_formatter = logging.Formatter(
70
+ "[%(levelname)s] %(asctime)s | %(name)s | Line %(lineno)d | %(message)s"
71
+ )
72
+
73
+ console_handler = logging.StreamHandler()
74
+
75
+ # Create a logger using the template
76
+ logger_template = LoggerTemplate(
77
+ logger_name=logger_name,
78
+ log_level=logging.DEBUG,
79
+ log_handlers=[console_handler],
80
+ log_formatter=log_formatter
81
+ )
82
+
83
+ return logger_template.create_logger()
84
+
85
+
86
+ def result_logging_decorator(name: str):
87
+ """
88
+ Result logging decorator.
89
+
90
+ Usage:
91
+ Decorate any callable with return values
92
+ Step 1: Create Environment Variable `MARSH_USE_LOGGER` with value `true` in terminal.
93
+ - On Linux/MacOS: `export MARSH_USE_LOGGER=true`
94
+ - On Windows (PowerShell): `$env:MARSH_USE_LOGGER="true"`
95
+ Step 2: Decorate any callable with return values.
96
+ `@result_logging_decorator(__name__)`
97
+ """
98
+
99
+ use_logger = True if os.getenv("MARSH_USE_LOGGER") == "true" else False
100
+ logger = create_console_logger(name)
101
+
102
+ def outer(func):
103
+ @functools.wraps(func)
104
+ def wrapper(*args, **kwargs):
105
+ try:
106
+ result = func(*args, **kwargs)
107
+ if use_logger:
108
+ logger.info(f"{func.__name__} result: {result}")
109
+ return result
110
+ except Exception as err:
111
+ if use_logger:
112
+ logger.error(str(err))
113
+
114
+ return wrapper
115
+
116
+ return outer
@@ -0,0 +1,2 @@
1
+ from .case_conversion import *
2
+ from .readers import *
@@ -0,0 +1,17 @@
1
+ def case_conversion(inp_stdout: bytes,
2
+ inp_stderr: bytes,
3
+ upper=True,
4
+ output_stream="stdout",
5
+ ) -> tuple[bytes, bytes]:
6
+ if output_stream not in ["stdout", "stderr"]:
7
+ raise ValueError("Output stream must be 'stdout' or 'stderr'.")
8
+
9
+ if output_stream == "stdout":
10
+ return inp_stdout.upper() if upper else inp_stdout.lower(), inp_stderr
11
+ else:
12
+ return inp_stderr.upper() if upper else inp_stderr.lower(), inp_stderr
13
+
14
+
15
+ __all__ = (
16
+ "case_conversion",
17
+ )
@@ -0,0 +1,11 @@
1
+ def read_file(inp_stdout: bytes, inp_stderr: bytes, file_path: str, encoding='utf-8') -> tuple[bytes, bytes]:
2
+ try:
3
+ with open(file_path, 'r') as file:
4
+ return file.read().encode(encoding), b""
5
+ except (FileNotFoundError, PermissionError, IsADirectoryError, OSError, UnicodeEncodeError, IOError) as e:
6
+ return b"", str(e).encode(encoding)
7
+
8
+
9
+ __all__ = (
10
+ "read_file",
11
+ )
File without changes
@@ -0,0 +1,3 @@
1
+ from .printers import *
2
+ from .raisers import *
3
+ from .redirections import *
@@ -0,0 +1,63 @@
1
+ import logging
2
+
3
+
4
+ def print_output_stream(inp_stdout: bytes,
5
+ inp_stderr: bytes,
6
+ *args,
7
+ output_stream="stdout",
8
+ encoding='utf-8',
9
+ **kwargs
10
+ ) -> None:
11
+ if output_stream not in ["stdout", "stderr"]:
12
+ raise ValueError("Output stream must be 'stdout' or 'stderr'.")
13
+
14
+ if output_stream == "stdout":
15
+ if inp_stdout.strip():
16
+ print(inp_stdout.decode(encoding).strip(), *args, **kwargs)
17
+ else:
18
+ if inp_stderr.strip():
19
+ print(inp_stderr.decode(encoding).strip(), *args, **kwargs)
20
+
21
+
22
+ def print_stdout(inp_stdout: bytes, inp_stderr: bytes, *args, encoding='utf-8', **kwargs) -> None:
23
+ print_output_stream(inp_stdout, inp_stderr, *args, output_stream="stdout", encoding=encoding, **kwargs)
24
+
25
+
26
+ def print_stderr(inp_stdout: bytes, inp_stderr: bytes, *args, encoding='utf-8', **kwargs) -> None:
27
+ print_output_stream(inp_stdout, inp_stderr, *args, output_stream="stderr", encoding=encoding, **kwargs)
28
+
29
+
30
+ def print_all_output_streams(inp_stdout: bytes, inp_stderr: bytes, *args, encoding='utf-8', **kwargs) -> None:
31
+ print_stdout(inp_stdout, inp_stderr, *args, encoding=encoding, **kwargs)
32
+ print_stderr(inp_stdout, inp_stderr, *args, encoding=encoding, **kwargs)
33
+
34
+
35
+ def log_output_streams(inp_stdout: bytes,
36
+ inp_stderr: bytes,
37
+ name="ConsoleLogger",
38
+ log_level=logging.DEBUG,
39
+ format_="[%(levelname)s] %(asctime)s | %(message)s",
40
+ encoding='utf-8'
41
+ ) -> None:
42
+ logger = logging.getLogger(name)
43
+ logger.setLevel(log_level)
44
+ stream_handler = logging.StreamHandler()
45
+ stream_handler.setLevel(logging.DEBUG)
46
+ formatter = logging.Formatter(format_)
47
+ stream_handler.setFormatter(formatter)
48
+ logger.addHandler(stream_handler)
49
+
50
+ if inp_stderr.strip():
51
+ logger.error(inp_stderr.decode(encoding).strip())
52
+
53
+ if inp_stdout.strip():
54
+ logger.info(inp_stdout.decode(encoding).strip())
55
+
56
+
57
+ __all__ = (
58
+ "print_output_stream",
59
+ "print_stdout",
60
+ "print_stderr",
61
+ "print_all_output_streams",
62
+ "log_output_streams"
63
+ )
@@ -0,0 +1,8 @@
1
+ def raise_stderr(inp_stdout: bytes, inp_stderr: bytes, exception: type[Exception], *args, encoding='utf-8', **kwargs) -> None:
2
+ if inp_stderr.strip():
3
+ raise exception(inp_stderr.decode(encoding).strip(), *args, **kwargs)
4
+
5
+
6
+ __all__ = (
7
+ "raise_stderr",
8
+ )
@@ -0,0 +1,39 @@
1
+ def redirect_output_stream(inp_stdout: bytes,
2
+ inp_stderr: bytes,
3
+ file_path: str,
4
+ output_stream="stdout",
5
+ mode='w',
6
+ encoding='utf-8',
7
+ ) -> None:
8
+ if output_stream not in ["stdout", "stderr"]:
9
+ raise ValueError("Output stream must be 'stdout' or 'stderr'.")
10
+
11
+ with open(file_path, mode) as file:
12
+ if output_stream == "stdout":
13
+ file.write(inp_stdout.decode(encoding).strip())
14
+ else:
15
+ file.write(inp_stderr.decode(encoding).strip())
16
+
17
+
18
+ def redirect_stdout(inp_stdout: bytes,
19
+ inp_stderr: bytes,
20
+ file_path: str,
21
+ mode='w',
22
+ encoding='utf-8') -> None:
23
+ redirect_output_stream(inp_stdout, inp_stderr, file_path, output_stream="stdout", mode=mode, encoding=encoding)
24
+
25
+
26
+ def redirect_stderr(inp_stdout: bytes,
27
+ inp_stderr: bytes,
28
+ file_path: str,
29
+ mode='w',
30
+ encoding='utf-8'
31
+ ) -> None:
32
+ redirect_output_stream(inp_stdout, inp_stderr, file_path, output_stream="stderr", mode=mode, encoding=encoding)
33
+
34
+
35
+ __all__ = (
36
+ "redirect_output_stream",
37
+ "redirect_stdout",
38
+ "redirect_stderr",
39
+ )
marsh/signals.py ADDED
@@ -0,0 +1 @@
1
+ import signal
marsh/ssh/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .ssh_connector import SshConnector
2
+ from .ssh_command_grammar import SshCommandGrammar
3
+ from .ssh_factory import SshFactory
@@ -0,0 +1,50 @@
1
+ from typing import Optional
2
+
3
+ from marsh import CommandGrammar
4
+
5
+
6
+ class SshCommandGrammar(CommandGrammar):
7
+ """
8
+ SSH Command Grammar for building SSH commands by adding custom logic.
9
+
10
+ Inherits from `CommandGrammar` and provides a method to build commands with
11
+ optional piping logic.
12
+ """
13
+ def build_cmd(self,
14
+ commands: list[str],
15
+ prev_stdout: Optional[bytes] = None,
16
+ *pipe_args,
17
+ **pipe_kwargs,
18
+ ) -> list[str]:
19
+ """
20
+ Builds the full SSH command by optionally piping the stdout of a previous command to the next.
21
+
22
+ Args:
23
+ commands (list[str]): The list of commands to execute.
24
+ prev_stdout (Optional[bytes]): The stdout from the previous command to pipe into the current one.
25
+ *pipe_args: Arguments for the piping logic.
26
+ **pipe_kwargs: Keyword arguments for the piping logic.
27
+
28
+ Returns:
29
+ list[str]: A list of strings representing the final SSH command with any necessary piping logic.
30
+ """
31
+ if prev_stdout:
32
+ return commands + [self._pipe_stdout(prev_stdout, *pipe_args, **pipe_kwargs)]
33
+ return commands
34
+
35
+ @staticmethod
36
+ def _pipe_stdout(stdout: str | bytes, encoding: str = "utf-8") -> str:
37
+ """
38
+ Creates a formatted string to pipe the stdout of a previous command into the next.
39
+
40
+ Args:
41
+ stdout (str | bytes): The stdout to be piped.
42
+ encoding (str): The encoding to use if `stdout` is a byte string. Default is "utf-8".
43
+
44
+ Returns:
45
+ str: The formatted string to pipe the stdout.
46
+ """
47
+ return rf"""<<'EOF'
48
+ {stdout if isinstance(stdout, str) else stdout.decode(encoding)}
49
+ EOF
50
+ """