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.
- {mm_std-0.5.4 → mm_std-0.6.0}/PKG-INFO +1 -1
- {mm_std-0.5.4 → mm_std-0.6.0}/pyproject.toml +6 -6
- {mm_std-0.5.4 → mm_std-0.6.0}/src/mm_std/__init__.py +4 -4
- {mm_std-0.5.4 → mm_std-0.6.0}/src/mm_std/json_utils.py +6 -6
- mm_std-0.6.0/src/mm_std/subprocess_utils.py +96 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/tests/test_subprocess_utils.py +36 -32
- mm_std-0.6.0/uv.lock +393 -0
- mm_std-0.5.4/src/mm_std/subprocess_utils.py +0 -75
- mm_std-0.5.4/uv.lock +0 -351
- {mm_std-0.5.4 → mm_std-0.6.0}/.claude/settings.local.json +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/.gitignore +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/.pre-commit-config.yaml +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/CLAUDE.md +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/README.md +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/justfile +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/src/mm_std/date_utils.py +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/src/mm_std/dict_utils.py +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/src/mm_std/py.typed +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/src/mm_std/random_utils.py +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/src/mm_std/str_utils.py +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/tests/__init__.py +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/tests/test_date_utils.py +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/tests/test_dict_utils.py +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/tests/test_json_utils.py +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/tests/test_random_utils.py +0 -0
- {mm_std-0.5.4 → mm_std-0.6.0}/tests/test_str_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mm-std"
|
|
3
|
-
version = "0.
|
|
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~=
|
|
15
|
+
"pytest~=9.0.2",
|
|
16
16
|
"pytest-xdist~=3.8.0",
|
|
17
|
-
"ruff~=0.14.
|
|
18
|
-
"mypy~=1.
|
|
19
|
-
"bandit~=1.
|
|
20
|
-
"pre-commit~=4.
|
|
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
|
|
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
|
-
"
|
|
18
|
-
"
|
|
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,
|
|
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(
|
|
56
|
-
return handler(
|
|
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(
|
|
60
|
-
return asdict(
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
25
|
+
result = CmdResult(stdout="", stderr="", code=0)
|
|
26
26
|
assert result.combined_output == ""
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
class
|
|
29
|
+
class TestRunCmd:
|
|
30
30
|
def test_successful_command(self):
|
|
31
31
|
"""Test execution of a successful command."""
|
|
32
|
-
result =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
72
|
-
result =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
103
|
+
|
|
104
|
+
class TestRunSshCmd:
|
|
93
105
|
def test_ssh_command_construction(self):
|
|
94
106
|
"""Test that SSH command is properly constructed and quoted."""
|
|
95
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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()
|