mm-std 0.5.4__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mm-std
3
- Version: 0.5.4
3
+ Version: 0.6.0
4
4
  Requires-Python: >=3.13
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mm-std"
3
- version = "0.5.4"
3
+ version = "0.6.0"
4
4
  description = ""
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
@@ -12,12 +12,12 @@ build-backend = "hatchling.build"
12
12
 
13
13
  [dependency-groups]
14
14
  dev = [
15
- "pytest~=8.4.2",
15
+ "pytest~=9.0.2",
16
16
  "pytest-xdist~=3.8.0",
17
- "ruff~=0.14.0",
18
- "mypy~=1.18.2",
19
- "bandit~=1.8.6",
20
- "pre-commit~=4.3.0",
17
+ "ruff~=0.14.10",
18
+ "mypy~=1.19.1",
19
+ "bandit~=1.9.2",
20
+ "pre-commit~=4.5.1",
21
21
  ]
22
22
 
23
23
  [tool.mypy]
@@ -3,19 +3,19 @@ from .dict_utils import replace_empty_dict_entries
3
3
  from .json_utils import ExtendedJSONEncoder, json_dumps
4
4
  from .random_utils import random_datetime, random_decimal
5
5
  from .str_utils import parse_lines, str_contains_any, str_ends_with_any, str_starts_with_any
6
- from .subprocess_utils import ShellResult, shell, ssh_shell # nosec
6
+ from .subprocess_utils import CmdResult, run_cmd, run_ssh_cmd # nosec
7
7
 
8
8
  __all__ = [
9
+ "CmdResult",
9
10
  "ExtendedJSONEncoder",
10
- "ShellResult",
11
11
  "json_dumps",
12
12
  "parse_date",
13
13
  "parse_lines",
14
14
  "random_datetime",
15
15
  "random_decimal",
16
16
  "replace_empty_dict_entries",
17
- "shell",
18
- "ssh_shell",
17
+ "run_cmd",
18
+ "run_ssh_cmd",
19
19
  "str_contains_any",
20
20
  "str_ends_with_any",
21
21
  "str_starts_with_any",
@@ -49,17 +49,17 @@ class ExtendedJSONEncoder(json.JSONEncoder):
49
49
  raise ValueError(f"Cannot override built-in JSON type: {type_.__name__}")
50
50
  cls._type_handlers[type_] = serializer
51
51
 
52
- def default(self, obj: Any) -> Any: # noqa: ANN401
52
+ def default(self, o: Any) -> Any: # noqa: ANN401
53
53
  # Check registered type handlers first
54
54
  for type_, handler in self._type_handlers.items():
55
- if isinstance(obj, type_):
56
- return handler(obj)
55
+ if isinstance(o, type_):
56
+ return handler(o)
57
57
 
58
58
  # Special case: dataclasses (requires is_dataclass check, not isinstance)
59
- if is_dataclass(obj) and not isinstance(obj, type):
60
- return asdict(obj) # Don't need recursive serialization
59
+ if is_dataclass(o) and not isinstance(o, type):
60
+ return asdict(o) # Don't need recursive serialization
61
61
 
62
- return super().default(obj)
62
+ return super().default(o)
63
63
 
64
64
 
65
65
  def json_dumps(data: Any, type_handlers: dict[type[Any], Callable[[Any], Any]] | None = None, **kwargs: Any) -> str: # noqa: ANN401
@@ -0,0 +1,96 @@
1
+ import shlex
2
+ import subprocess # nosec
3
+ from dataclasses import dataclass
4
+
5
+ TIMEOUT_EXIT_CODE = 255
6
+
7
+
8
+ @dataclass
9
+ class CmdResult:
10
+ """Result of command execution."""
11
+
12
+ stdout: str
13
+ stderr: str
14
+ code: int
15
+
16
+ @property
17
+ def combined_output(self) -> str:
18
+ """Combined stdout and stderr output."""
19
+ result = ""
20
+ if self.stdout:
21
+ result += self.stdout
22
+ if self.stderr:
23
+ if result:
24
+ result += "\n"
25
+ result += self.stderr
26
+ return result
27
+
28
+
29
+ def run_cmd(
30
+ cmd: str,
31
+ timeout: int | None = 60,
32
+ capture_output: bool = True,
33
+ echo_command: bool = False,
34
+ shell: bool = False,
35
+ ) -> CmdResult:
36
+ """Execute a command.
37
+
38
+ Args:
39
+ cmd: Command to execute
40
+ timeout: Timeout in seconds, None for no timeout
41
+ capture_output: Whether to capture stdout/stderr
42
+ echo_command: Whether to print the command before execution
43
+ shell: If False (default), the command is parsed with shlex.split() and
44
+ executed without shell interpretation. Special characters like
45
+ backticks, $(), pipes (|), redirects (>, <), and wildcards (*) are
46
+ treated as literal text. This is the safe mode for commands with
47
+ user input.
48
+ If True, the command is passed to the shell as-is, enabling pipes,
49
+ redirects, command substitution, and other shell features. Use this
50
+ only for trusted commands that need shell functionality.
51
+
52
+ Returns:
53
+ CmdResult with stdout, stderr and exit code
54
+ """
55
+ if echo_command:
56
+ print(cmd) # noqa: T201
57
+ try:
58
+ if shell:
59
+ process = subprocess.run( # noqa: S602 # nosec
60
+ cmd, timeout=timeout, capture_output=capture_output, shell=True, check=False
61
+ )
62
+ else:
63
+ process = subprocess.run( # noqa: S603 # nosec
64
+ shlex.split(cmd), timeout=timeout, capture_output=capture_output, shell=False, check=False
65
+ )
66
+ stdout = process.stdout.decode("utf-8", errors="replace") if capture_output else ""
67
+ stderr = process.stderr.decode("utf-8", errors="replace") if capture_output else ""
68
+ return CmdResult(stdout=stdout, stderr=stderr, code=process.returncode)
69
+ except subprocess.TimeoutExpired:
70
+ return CmdResult(stdout="", stderr="timeout", code=TIMEOUT_EXIT_CODE)
71
+
72
+
73
+ def run_ssh_cmd(
74
+ host: str,
75
+ cmd: str,
76
+ ssh_key_path: str | None = None,
77
+ timeout: int = 60,
78
+ echo_command: bool = False,
79
+ ) -> CmdResult:
80
+ """Execute a command on remote host via SSH.
81
+
82
+ Args:
83
+ host: Remote host to connect to
84
+ cmd: Command to execute on remote host
85
+ ssh_key_path: Path to SSH private key file
86
+ timeout: Timeout in seconds
87
+ echo_command: Whether to print the command before execution
88
+
89
+ Returns:
90
+ CmdResult with stdout, stderr and exit code
91
+ """
92
+ ssh_cmd = "ssh -o 'StrictHostKeyChecking=no' -o 'LogLevel=ERROR'"
93
+ if ssh_key_path:
94
+ ssh_cmd += f" -i {shlex.quote(ssh_key_path)}"
95
+ ssh_cmd += f" {shlex.quote(host)} {shlex.quote(cmd)}"
96
+ return run_cmd(ssh_cmd, timeout=timeout, echo_command=echo_command)
@@ -1,75 +1,75 @@
1
1
  import os
2
2
 
3
- from mm_std import ShellResult, shell, ssh_shell
3
+ from mm_std import CmdResult, run_cmd, run_ssh_cmd
4
4
  from mm_std.subprocess_utils import TIMEOUT_EXIT_CODE
5
5
 
6
6
 
7
- class TestShellResult:
7
+ class TestCmdResult:
8
8
  def test_combined_output_with_both_stdout_and_stderr(self):
9
9
  """Test combined output when both stdout and stderr are present."""
10
- result = ShellResult(stdout="output", stderr="error", code=0)
10
+ result = CmdResult(stdout="output", stderr="error", code=0)
11
11
  assert result.combined_output == "output\nerror"
12
12
 
13
13
  def test_combined_output_with_only_stdout(self):
14
14
  """Test combined output when only stdout is present."""
15
- result = ShellResult(stdout="output", stderr="", code=0)
15
+ result = CmdResult(stdout="output", stderr="", code=0)
16
16
  assert result.combined_output == "output"
17
17
 
18
18
  def test_combined_output_with_only_stderr(self):
19
19
  """Test combined output when only stderr is present."""
20
- result = ShellResult(stdout="", stderr="error", code=1)
20
+ result = CmdResult(stdout="", stderr="error", code=1)
21
21
  assert result.combined_output == "error"
22
22
 
23
23
  def test_combined_output_with_neither(self):
24
24
  """Test combined output when both stdout and stderr are empty."""
25
- result = ShellResult(stdout="", stderr="", code=0)
25
+ result = CmdResult(stdout="", stderr="", code=0)
26
26
  assert result.combined_output == ""
27
27
 
28
28
 
29
- class TestShell:
29
+ class TestRunCmd:
30
30
  def test_successful_command(self):
31
31
  """Test execution of a successful command."""
32
- result = shell("echo 'hello world'")
32
+ result = run_cmd("echo 'hello world'")
33
33
  assert result.code == 0
34
34
  assert result.stdout.strip() == "hello world"
35
35
  assert result.stderr == ""
36
36
 
37
37
  def test_command_with_exit_code(self):
38
38
  """Test command that returns non-zero exit code."""
39
- result = shell("exit 42")
39
+ result = run_cmd("exit 42", shell=True)
40
40
  assert result.code == 42
41
41
  assert result.stdout == ""
42
42
 
43
43
  def test_command_with_stderr(self):
44
44
  """Test command that outputs to stderr."""
45
- result = shell("echo 'error message' >&2")
45
+ result = run_cmd("echo 'error message' >&2", shell=True)
46
46
  assert result.code == 0
47
47
  assert result.stdout == ""
48
48
  assert result.stderr.strip() == "error message"
49
49
 
50
50
  def test_capture_output_false(self):
51
51
  """Test command execution without capturing output."""
52
- result = shell("echo 'test'", capture_output=False)
52
+ result = run_cmd("echo 'test'", capture_output=False)
53
53
  assert result.code == 0
54
54
  assert result.stdout == ""
55
55
  assert result.stderr == ""
56
56
 
57
57
  def test_timeout_handling(self):
58
58
  """Test command timeout handling."""
59
- result = shell("sleep 2", timeout=1)
59
+ result = run_cmd("sleep 2", timeout=1)
60
60
  assert result.code == TIMEOUT_EXIT_CODE
61
61
  assert result.stdout == ""
62
62
  assert result.stderr == "timeout"
63
63
 
64
64
  def test_echo_command(self, capsys):
65
65
  """Test echo_command parameter prints the command."""
66
- shell("echo 'test'", echo_command=True)
66
+ run_cmd("echo 'test'", echo_command=True)
67
67
  captured = capsys.readouterr()
68
68
  assert "echo 'test'" in captured.out
69
69
 
70
70
  def test_command_with_pipes(self):
71
- """Test command with pipes and complex shell operations."""
72
- result = shell("echo 'line1\nline2\nline3' | grep 'line2'")
71
+ """Test command with pipes requires shell=True."""
72
+ result = run_cmd("echo 'line1\nline2\nline3' | grep 'line2'", shell=True)
73
73
  assert result.code == 0
74
74
  assert result.stdout.strip() == "line2"
75
75
 
@@ -78,51 +78,55 @@ class TestShell:
78
78
  test_file = tmp_path / "test.txt"
79
79
  test_file.write_text("test content")
80
80
 
81
- result = shell(f"cat {test_file}")
81
+ result = run_cmd(f"cat {test_file}")
82
82
  assert result.code == 0
83
83
  assert result.stdout.strip() == "test content"
84
84
 
85
85
  def test_environment_variables(self):
86
- """Test command that uses environment variables."""
87
- result = shell("echo $HOME")
86
+ """Test command that uses environment variables requires shell=True."""
87
+ result = run_cmd("echo $HOME", shell=True)
88
88
  assert result.code == 0
89
89
  assert result.stdout.strip() == os.environ.get("HOME", "")
90
90
 
91
+ def test_safe_mode_backticks_literal(self):
92
+ """Test that backticks are treated as literal text in safe mode."""
93
+ result = run_cmd("echo 'hello `world`'")
94
+ assert result.code == 0
95
+ assert "`world`" in result.stdout
96
+
97
+ def test_safe_mode_dollar_literal(self):
98
+ """Test that $() is treated as literal text in safe mode."""
99
+ result = run_cmd("echo 'hello $(whoami)'")
100
+ assert result.code == 0
101
+ assert "$(whoami)" in result.stdout
91
102
 
92
- class TestSshShell:
103
+
104
+ class TestRunSshCmd:
93
105
  def test_ssh_command_construction(self):
94
106
  """Test that SSH command is properly constructed and quoted."""
95
- # We can't test actual SSH without a server, but we can test the command construction
96
- # by checking what command gets passed to the shell function
97
- result = ssh_shell("nonexistent-host", "echo 'test'", timeout=1)
107
+ result = run_ssh_cmd("nonexistent-host", "echo 'test'", timeout=1)
98
108
 
99
- # This will fail with connection error, but that's expected
100
- # We're testing that the function doesn't crash on command construction
101
- assert result.code != 0 # Should fail to connect
109
+ assert result.code != 0
102
110
  assert "timeout" in result.stderr or "connect" in result.stderr.lower() or "resolve" in result.stderr.lower()
103
111
 
104
112
  def test_ssh_with_key_path(self):
105
113
  """Test SSH command with key path parameter."""
106
- result = ssh_shell("nonexistent-host", "echo 'test'", ssh_key_path="/path/to/key", timeout=1)
114
+ result = run_ssh_cmd("nonexistent-host", "echo 'test'", ssh_key_path="/path/to/key", timeout=1)
107
115
 
108
- # Should fail to connect but not crash
109
116
  assert result.code != 0
110
117
  assert "timeout" in result.stderr or "connect" in result.stderr.lower() or "resolve" in result.stderr.lower()
111
118
 
112
119
  def test_ssh_echo_command(self, capsys):
113
120
  """Test that SSH command echoing works."""
114
- ssh_shell("nonexistent-host", "echo 'test'", echo_command=True, timeout=1)
121
+ run_ssh_cmd("nonexistent-host", "echo 'test'", echo_command=True, timeout=1)
115
122
  captured = capsys.readouterr()
116
123
 
117
- # Should see the constructed SSH command
118
124
  assert "ssh" in captured.out
119
125
  assert "nonexistent-host" in captured.out
120
126
 
121
127
  def test_ssh_command_quoting(self):
122
128
  """Test that SSH commands with special characters are properly quoted."""
123
- # Test with command that has special characters
124
- result = ssh_shell("nonexistent-host", "echo 'hello; rm -rf /'", timeout=1)
129
+ result = run_ssh_cmd("nonexistent-host", "echo 'hello; echo world'", timeout=1)
125
130
 
126
- # Should fail to connect but not execute dangerous commands locally
127
131
  assert result.code != 0
128
132
  assert "timeout" in result.stderr or "connect" in result.stderr.lower() or "resolve" in result.stderr.lower()