mm-std 0.5.4__py3-none-any.whl → 0.7.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.
- mm_std/__init__.py +20 -23
- mm_std/date_utils.py +19 -16
- mm_std/dict_utils.py +17 -15
- mm_std/json_utils.py +18 -15
- mm_std/random_utils.py +33 -14
- mm_std/str_utils.py +4 -6
- mm_std/subprocess_utils.py +57 -19
- mm_std-0.7.0.dist-info/METADATA +4 -0
- mm_std-0.7.0.dist-info/RECORD +11 -0
- {mm_std-0.5.4.dist-info → mm_std-0.7.0.dist-info}/WHEEL +1 -1
- mm_std-0.5.4.dist-info/METADATA +0 -4
- mm_std-0.5.4.dist-info/RECORD +0 -11
mm_std/__init__.py
CHANGED
|
@@ -1,24 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
from .dict_utils import replace_empty_dict_entries
|
|
3
|
-
from .json_utils import ExtendedJSONEncoder, json_dumps
|
|
4
|
-
from .random_utils import random_datetime, random_decimal
|
|
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
|
|
1
|
+
"""mm-std: Python utilities for common data manipulation tasks."""
|
|
7
2
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
3
|
+
from .date_utils import parse_datetime as parse_datetime
|
|
4
|
+
from .date_utils import utc_from_timestamp as utc_from_timestamp
|
|
5
|
+
from .date_utils import utc_now as utc_now
|
|
6
|
+
from .date_utils import utc_now_offset as utc_now_offset
|
|
7
|
+
from .dict_utils import compact_dict as compact_dict
|
|
8
|
+
from .json_utils import ExtendedJSONEncoder as ExtendedJSONEncoder
|
|
9
|
+
from .json_utils import json_dumps as json_dumps
|
|
10
|
+
from .random_utils import random_datetime as random_datetime
|
|
11
|
+
from .random_utils import random_datetime_offset as random_datetime_offset
|
|
12
|
+
from .random_utils import random_decimal as random_decimal
|
|
13
|
+
from .str_utils import parse_lines as parse_lines
|
|
14
|
+
from .str_utils import str_contains_any as str_contains_any
|
|
15
|
+
from .str_utils import str_ends_with_any as str_ends_with_any
|
|
16
|
+
from .str_utils import str_starts_with_any as str_starts_with_any
|
|
17
|
+
|
|
18
|
+
# B404: re-exporting subprocess utilities with documented security considerations
|
|
19
|
+
from .subprocess_utils import CmdResult as CmdResult # nosec
|
|
20
|
+
from .subprocess_utils import run_cmd as run_cmd # nosec
|
|
21
|
+
from .subprocess_utils import run_ssh_cmd as run_ssh_cmd # nosec
|
mm_std/date_utils.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""UTC-focused datetime operations and flexible date parsing."""
|
|
2
|
+
|
|
1
3
|
from datetime import UTC, datetime, timedelta
|
|
2
4
|
|
|
3
5
|
|
|
@@ -6,37 +8,38 @@ def utc_now() -> datetime:
|
|
|
6
8
|
return datetime.now(UTC)
|
|
7
9
|
|
|
8
10
|
|
|
9
|
-
def
|
|
10
|
-
*,
|
|
11
|
-
days: int | None = None,
|
|
12
|
-
hours: int | None = None,
|
|
13
|
-
minutes: int | None = None,
|
|
14
|
-
seconds: int | None = None,
|
|
11
|
+
def utc_now_offset(
|
|
12
|
+
*, days: int | None = None, hours: int | None = None, minutes: int | None = None, seconds: int | None = None
|
|
15
13
|
) -> datetime:
|
|
16
14
|
"""Get UTC time shifted by the specified delta.
|
|
17
15
|
|
|
18
16
|
Use negative values to get time in the past.
|
|
19
17
|
"""
|
|
20
18
|
params = {}
|
|
21
|
-
if days:
|
|
19
|
+
if days is not None:
|
|
22
20
|
params["days"] = days
|
|
23
|
-
if hours:
|
|
21
|
+
if hours is not None:
|
|
24
22
|
params["hours"] = hours
|
|
25
|
-
if minutes:
|
|
23
|
+
if minutes is not None:
|
|
26
24
|
params["minutes"] = minutes
|
|
27
|
-
if seconds:
|
|
25
|
+
if seconds is not None:
|
|
28
26
|
params["seconds"] = seconds
|
|
29
27
|
return datetime.now(UTC) + timedelta(**params)
|
|
30
28
|
|
|
31
29
|
|
|
32
|
-
def
|
|
30
|
+
def utc_from_timestamp(timestamp: float) -> datetime:
|
|
31
|
+
"""Create UTC datetime from Unix timestamp."""
|
|
32
|
+
return datetime.fromtimestamp(timestamp, UTC)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_datetime(date_str: str, ignore_tz: bool = False) -> datetime:
|
|
33
36
|
"""Parse date string in various formats, with timezone handling.
|
|
34
37
|
|
|
35
38
|
Converts 'Z' suffix to '+00:00' for ISO format compatibility.
|
|
36
39
|
Use ignore_tz=True to strip timezone info from the result.
|
|
37
40
|
"""
|
|
38
|
-
if
|
|
39
|
-
|
|
41
|
+
if date_str.lower().endswith("z"):
|
|
42
|
+
date_str = date_str[:-1] + "+00:00"
|
|
40
43
|
date_formats = [
|
|
41
44
|
"%Y-%m-%d %H:%M:%S.%f%z",
|
|
42
45
|
"%Y-%m-%dT%H:%M:%S.%f%z",
|
|
@@ -53,10 +56,10 @@ def parse_date(value: str, ignore_tz: bool = False) -> datetime:
|
|
|
53
56
|
|
|
54
57
|
for fmt in date_formats:
|
|
55
58
|
try:
|
|
56
|
-
dt = datetime.strptime(
|
|
59
|
+
dt = datetime.strptime(date_str, fmt) # noqa: DTZ007 - timezone deliberately ignored when ignore_tz=True
|
|
57
60
|
if ignore_tz and dt.tzinfo is not None:
|
|
58
61
|
dt = dt.replace(tzinfo=None)
|
|
59
|
-
return dt # noqa: TRY300
|
|
62
|
+
return dt # noqa: TRY300 - return in try block is intentional for parse flow
|
|
60
63
|
except ValueError:
|
|
61
64
|
continue
|
|
62
|
-
raise ValueError(f"Time data '{
|
|
65
|
+
raise ValueError(f"Time data '{date_str}' does not match any known format.")
|
mm_std/dict_utils.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Dictionary manipulation utilities with type preservation."""
|
|
2
|
+
|
|
1
3
|
from collections import OrderedDict, defaultdict
|
|
2
4
|
from collections.abc import Mapping, MutableMapping
|
|
3
5
|
from decimal import Decimal
|
|
@@ -8,8 +10,8 @@ V = TypeVar("V")
|
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
@overload
|
|
11
|
-
def
|
|
12
|
-
|
|
13
|
+
def compact_dict(
|
|
14
|
+
mapping: defaultdict[K, V],
|
|
13
15
|
defaults: Mapping[K, V] | None = None,
|
|
14
16
|
treat_zero_as_empty: bool = False,
|
|
15
17
|
treat_false_as_empty: bool = False,
|
|
@@ -18,8 +20,8 @@ def replace_empty_dict_entries(
|
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
@overload
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
+
def compact_dict(
|
|
24
|
+
mapping: OrderedDict[K, V],
|
|
23
25
|
defaults: Mapping[K, V] | None = None,
|
|
24
26
|
treat_zero_as_empty: bool = False,
|
|
25
27
|
treat_false_as_empty: bool = False,
|
|
@@ -28,8 +30,8 @@ def replace_empty_dict_entries(
|
|
|
28
30
|
|
|
29
31
|
|
|
30
32
|
@overload
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
+
def compact_dict(
|
|
34
|
+
mapping: dict[K, V],
|
|
33
35
|
defaults: Mapping[K, V] | None = None,
|
|
34
36
|
treat_zero_as_empty: bool = False,
|
|
35
37
|
treat_false_as_empty: bool = False,
|
|
@@ -37,15 +39,14 @@ def replace_empty_dict_entries(
|
|
|
37
39
|
) -> dict[K, V]: ...
|
|
38
40
|
|
|
39
41
|
|
|
40
|
-
def
|
|
41
|
-
|
|
42
|
+
def compact_dict(
|
|
43
|
+
mapping: MutableMapping[K, V],
|
|
42
44
|
defaults: Mapping[K, V] | None = None,
|
|
43
45
|
treat_zero_as_empty: bool = False,
|
|
44
46
|
treat_false_as_empty: bool = False,
|
|
45
47
|
treat_empty_string_as_empty: bool = True,
|
|
46
48
|
) -> MutableMapping[K, V]:
|
|
47
|
-
"""
|
|
48
|
-
Replace empty entries in a dictionary with defaults or remove them entirely.
|
|
49
|
+
"""Replace empty entries in a dictionary with defaults or remove them entirely.
|
|
49
50
|
|
|
50
51
|
Preserves the exact type of the input mapping:
|
|
51
52
|
- dict[str, int] → dict[str, int]
|
|
@@ -53,7 +54,7 @@ def replace_empty_dict_entries(
|
|
|
53
54
|
- OrderedDict[str, str] → OrderedDict[str, str]
|
|
54
55
|
|
|
55
56
|
Args:
|
|
56
|
-
|
|
57
|
+
mapping: The dictionary to process
|
|
57
58
|
defaults: Default values to use for empty entries. If None or key not found, empty entries are removed
|
|
58
59
|
treat_zero_as_empty: Treat 0 as empty value
|
|
59
60
|
treat_false_as_empty: Treat False as empty value
|
|
@@ -61,16 +62,17 @@ def replace_empty_dict_entries(
|
|
|
61
62
|
|
|
62
63
|
Returns:
|
|
63
64
|
New dictionary of the same concrete type with empty entries replaced or removed
|
|
65
|
+
|
|
64
66
|
"""
|
|
65
67
|
if defaults is None:
|
|
66
68
|
defaults = {}
|
|
67
69
|
|
|
68
|
-
if isinstance(
|
|
69
|
-
result: MutableMapping[K, V] = defaultdict(
|
|
70
|
+
if isinstance(mapping, defaultdict):
|
|
71
|
+
result: MutableMapping[K, V] = defaultdict(mapping.default_factory)
|
|
70
72
|
else:
|
|
71
|
-
result =
|
|
73
|
+
result = mapping.__class__()
|
|
72
74
|
|
|
73
|
-
for key, value in
|
|
75
|
+
for key, value in mapping.items():
|
|
74
76
|
should_replace = (
|
|
75
77
|
value is None
|
|
76
78
|
or (treat_false_as_empty and value is False)
|
mm_std/json_utils.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
"""Extended JSON encoder with support for Python types."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from collections.abc import Callable
|
|
@@ -35,47 +35,50 @@ class ExtendedJSONEncoder(json.JSONEncoder):
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
@classmethod
|
|
38
|
-
def register(cls, type_: type[Any],
|
|
38
|
+
def register(cls, type_: type[Any], handler: Callable[[Any], Any]) -> None:
|
|
39
39
|
"""Register a custom type with its serialization function.
|
|
40
40
|
|
|
41
41
|
Args:
|
|
42
42
|
type_: The type to register
|
|
43
|
-
|
|
43
|
+
handler: Function that converts objects of this type to JSON-serializable data
|
|
44
44
|
|
|
45
45
|
Raises:
|
|
46
46
|
ValueError: If type_ is a built-in JSON type
|
|
47
|
+
|
|
47
48
|
"""
|
|
48
49
|
if type_ in (str, int, float, bool, list, dict, type(None)):
|
|
49
50
|
raise ValueError(f"Cannot override built-in JSON type: {type_.__name__}")
|
|
50
|
-
cls._type_handlers[type_] =
|
|
51
|
+
cls._type_handlers[type_] = handler
|
|
51
52
|
|
|
52
|
-
def default(self,
|
|
53
|
+
def default(self, o: Any) -> Any: # noqa: ANN401 - Any required for generic JSON encoding
|
|
54
|
+
"""Encode object to JSON-serializable format."""
|
|
53
55
|
# Check registered type handlers first
|
|
54
56
|
for type_, handler in self._type_handlers.items():
|
|
55
|
-
if isinstance(
|
|
56
|
-
return handler(
|
|
57
|
+
if isinstance(o, type_):
|
|
58
|
+
return handler(o)
|
|
57
59
|
|
|
58
60
|
# Special case: dataclasses (requires is_dataclass check, not isinstance)
|
|
59
|
-
if is_dataclass(
|
|
60
|
-
return asdict(
|
|
61
|
+
if is_dataclass(o) and not isinstance(o, type):
|
|
62
|
+
return asdict(o) # Don't need recursive serialization
|
|
61
63
|
|
|
62
|
-
return super().default(
|
|
64
|
+
return super().default(o)
|
|
63
65
|
|
|
64
66
|
|
|
65
|
-
def json_dumps(
|
|
67
|
+
def json_dumps(obj: Any, type_handlers: dict[type[Any], Callable[[Any], Any]] | None = None, **kwargs: Any) -> str: # noqa: ANN401 - Any required for generic type handler
|
|
66
68
|
"""Serialize object to JSON with extended type support.
|
|
67
69
|
|
|
68
70
|
Unlike standard json.dumps, uses ExtendedJSONEncoder which automatically handles
|
|
69
71
|
UUID, Decimal, Path, datetime, dataclasses, enums, pydantic models, and other Python types.
|
|
70
72
|
|
|
71
73
|
Args:
|
|
72
|
-
|
|
74
|
+
obj: Object to serialize to JSON
|
|
73
75
|
type_handlers: Optional additional type handlers for this call only.
|
|
74
76
|
These handlers take precedence over default ones.
|
|
75
77
|
**kwargs: Additional arguments passed to json.dumps
|
|
76
78
|
|
|
77
79
|
Returns:
|
|
78
80
|
JSON string representation
|
|
81
|
+
|
|
79
82
|
"""
|
|
80
83
|
if type_handlers:
|
|
81
84
|
# Type narrowing for mypy
|
|
@@ -83,7 +86,7 @@ def json_dumps(data: Any, type_handlers: dict[type[Any], Callable[[Any], Any]] |
|
|
|
83
86
|
|
|
84
87
|
class TemporaryEncoder(ExtendedJSONEncoder):
|
|
85
88
|
_type_handlers: ClassVar[dict[type[Any], Callable[[Any], Any]]] = {
|
|
86
|
-
**ExtendedJSONEncoder._type_handlers, # noqa: SLF001
|
|
89
|
+
**ExtendedJSONEncoder._type_handlers, # noqa: SLF001 - accessing class internals for type handler inheritance
|
|
87
90
|
**handlers,
|
|
88
91
|
}
|
|
89
92
|
|
|
@@ -91,14 +94,14 @@ def json_dumps(data: Any, type_handlers: dict[type[Any], Callable[[Any], Any]] |
|
|
|
91
94
|
else:
|
|
92
95
|
encoder_cls = ExtendedJSONEncoder
|
|
93
96
|
|
|
94
|
-
return json.dumps(
|
|
97
|
+
return json.dumps(obj, cls=encoder_cls, **kwargs)
|
|
95
98
|
|
|
96
99
|
|
|
97
100
|
def _auto_register_optional_types() -> None:
|
|
98
101
|
"""Register handlers for optional dependencies if available."""
|
|
99
102
|
# Pydantic models
|
|
100
103
|
try:
|
|
101
|
-
from pydantic import BaseModel #
|
|
104
|
+
from pydantic import BaseModel # noqa: PLC0415 - optional pydantic import at runtime
|
|
102
105
|
|
|
103
106
|
ExtendedJSONEncoder.register(BaseModel, lambda obj: obj.model_dump())
|
|
104
107
|
except ImportError:
|
mm_std/random_utils.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Type-safe random generation for decimals and datetimes."""
|
|
2
|
+
|
|
1
3
|
import random
|
|
2
4
|
from datetime import datetime, timedelta
|
|
3
5
|
from decimal import Decimal
|
|
@@ -18,6 +20,7 @@ def random_decimal(from_value: Decimal, to_value: Decimal) -> Decimal:
|
|
|
18
20
|
|
|
19
21
|
Raises:
|
|
20
22
|
ValueError: If from_value > to_value
|
|
23
|
+
|
|
21
24
|
"""
|
|
22
25
|
if from_value > to_value:
|
|
23
26
|
raise ValueError("from_value must be <= to_value")
|
|
@@ -37,14 +40,33 @@ def random_decimal(from_value: Decimal, to_value: Decimal) -> Decimal:
|
|
|
37
40
|
return Decimal(random_int) / Decimal(multiplier)
|
|
38
41
|
|
|
39
42
|
|
|
40
|
-
def random_datetime(
|
|
41
|
-
from_time
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
def random_datetime(from_time: datetime, to_time: datetime) -> datetime:
|
|
44
|
+
"""Generate a random datetime between from_time and to_time.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
from_time: Minimum datetime (inclusive)
|
|
48
|
+
to_time: Maximum datetime (inclusive)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Random datetime in the specified range
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: If from_time > to_time
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
if from_time > to_time:
|
|
58
|
+
raise ValueError("from_time must be <= to_time")
|
|
59
|
+
|
|
60
|
+
delta = (to_time - from_time).total_seconds()
|
|
61
|
+
if delta == 0:
|
|
62
|
+
return from_time
|
|
63
|
+
|
|
64
|
+
random_seconds = random.uniform(0, delta) # nosec B311
|
|
65
|
+
return from_time + timedelta(seconds=random_seconds)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def random_datetime_offset(from_time: datetime, *, hours: int = 0, minutes: int = 0, seconds: int = 0) -> datetime:
|
|
69
|
+
"""Generate a random datetime within a specified offset from base time.
|
|
48
70
|
|
|
49
71
|
Returns a random datetime between from_time and from_time + offset,
|
|
50
72
|
where offset is calculated from the provided hours, minutes, and seconds.
|
|
@@ -60,13 +82,10 @@ def random_datetime(
|
|
|
60
82
|
|
|
61
83
|
Raises:
|
|
62
84
|
ValueError: If any offset value is negative
|
|
85
|
+
|
|
63
86
|
"""
|
|
64
87
|
if hours < 0 or minutes < 0 or seconds < 0:
|
|
65
|
-
raise ValueError("
|
|
88
|
+
raise ValueError("Offset values must be non-negative")
|
|
66
89
|
|
|
67
90
|
total_seconds = hours * 3600 + minutes * 60 + seconds
|
|
68
|
-
|
|
69
|
-
return from_time
|
|
70
|
-
|
|
71
|
-
random_seconds = random.uniform(0, total_seconds) # nosec B311
|
|
72
|
-
return from_time + timedelta(seconds=random_seconds)
|
|
91
|
+
return random_datetime(from_time, from_time + timedelta(seconds=total_seconds))
|
mm_std/str_utils.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""String matching utilities and multiline text parsing."""
|
|
2
|
+
|
|
1
3
|
from collections.abc import Iterable
|
|
2
4
|
|
|
3
5
|
|
|
@@ -16,12 +18,7 @@ def str_contains_any(value: str, substrings: Iterable[str]) -> bool:
|
|
|
16
18
|
return any(substring in value for substring in substrings)
|
|
17
19
|
|
|
18
20
|
|
|
19
|
-
def parse_lines(
|
|
20
|
-
text: str,
|
|
21
|
-
lowercase: bool = False,
|
|
22
|
-
remove_comments: bool = False,
|
|
23
|
-
deduplicate: bool = False,
|
|
24
|
-
) -> list[str]:
|
|
21
|
+
def parse_lines(text: str, lowercase: bool = False, remove_comments: bool = False, deduplicate: bool = False) -> list[str]:
|
|
25
22
|
"""Parse multiline text into a list of cleaned lines.
|
|
26
23
|
|
|
27
24
|
Args:
|
|
@@ -32,6 +29,7 @@ def parse_lines(
|
|
|
32
29
|
|
|
33
30
|
Returns:
|
|
34
31
|
List of non-empty, stripped lines after applying specified transformations
|
|
32
|
+
|
|
35
33
|
"""
|
|
36
34
|
if lowercase:
|
|
37
35
|
text = text.lower()
|
mm_std/subprocess_utils.py
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
|
+
"""Safe shell command execution with result handling."""
|
|
2
|
+
|
|
1
3
|
import shlex
|
|
2
4
|
import subprocess # nosec
|
|
3
5
|
from dataclasses import dataclass
|
|
4
6
|
|
|
5
7
|
TIMEOUT_EXIT_CODE = 255
|
|
8
|
+
"""Exit code returned when command execution times out."""
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
@dataclass
|
|
9
|
-
class
|
|
10
|
-
"""Result of
|
|
11
|
-
|
|
12
|
-
Args:
|
|
13
|
-
stdout: Standard output from the command
|
|
14
|
-
stderr: Standard error from the command
|
|
15
|
-
code: Exit code of the command
|
|
16
|
-
"""
|
|
12
|
+
class CmdResult:
|
|
13
|
+
"""Result of command execution."""
|
|
17
14
|
|
|
18
15
|
stdout: str
|
|
19
16
|
stderr: str
|
|
@@ -31,31 +28,66 @@ class ShellResult:
|
|
|
31
28
|
result += self.stderr
|
|
32
29
|
return result
|
|
33
30
|
|
|
31
|
+
@property
|
|
32
|
+
def is_success(self) -> bool:
|
|
33
|
+
"""True if command completed successfully (exit code 0)."""
|
|
34
|
+
return self.code == 0
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_timeout(self) -> bool:
|
|
38
|
+
"""True if command timed out."""
|
|
39
|
+
return self.code == TIMEOUT_EXIT_CODE
|
|
40
|
+
|
|
34
41
|
|
|
35
|
-
def
|
|
36
|
-
|
|
42
|
+
def run_cmd(
|
|
43
|
+
cmd: str, timeout: int | None = 60, capture_output: bool = True, echo_command: bool = False, shell: bool = False
|
|
44
|
+
) -> CmdResult:
|
|
45
|
+
"""Execute a command.
|
|
37
46
|
|
|
38
47
|
Args:
|
|
39
48
|
cmd: Command to execute
|
|
40
49
|
timeout: Timeout in seconds, None for no timeout
|
|
41
50
|
capture_output: Whether to capture stdout/stderr
|
|
42
51
|
echo_command: Whether to print the command before execution
|
|
52
|
+
shell: If False (default), the command is parsed with shlex.split() and
|
|
53
|
+
executed without shell interpretation. Special characters like
|
|
54
|
+
backticks, $(), pipes (|), redirects (>, <), and wildcards (*) are
|
|
55
|
+
treated as literal text. This is the safe mode for commands with
|
|
56
|
+
user input.
|
|
57
|
+
If True, the command is passed to the shell as-is, enabling pipes,
|
|
58
|
+
redirects, command substitution, and other shell features. Use this
|
|
59
|
+
only for trusted commands that need shell functionality.
|
|
43
60
|
|
|
44
61
|
Returns:
|
|
45
|
-
|
|
62
|
+
CmdResult with stdout, stderr and exit code
|
|
63
|
+
|
|
46
64
|
"""
|
|
47
65
|
if echo_command:
|
|
48
|
-
print(cmd) # noqa: T201
|
|
66
|
+
print(cmd) # noqa: T201 - print is intentional for echo_command feature
|
|
49
67
|
try:
|
|
50
|
-
|
|
68
|
+
if shell:
|
|
69
|
+
process = subprocess.run( # noqa: S602 # nosec - shell=True required for pipe support
|
|
70
|
+
cmd, timeout=timeout, capture_output=capture_output, shell=True, check=False
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
process = subprocess.run( # noqa: S603 # nosec - subprocess with shell=False is safe
|
|
74
|
+
shlex.split(cmd), timeout=timeout, capture_output=capture_output, shell=False, check=False
|
|
75
|
+
)
|
|
51
76
|
stdout = process.stdout.decode("utf-8", errors="replace") if capture_output else ""
|
|
52
77
|
stderr = process.stderr.decode("utf-8", errors="replace") if capture_output else ""
|
|
53
|
-
return
|
|
78
|
+
return CmdResult(stdout=stdout, stderr=stderr, code=process.returncode)
|
|
54
79
|
except subprocess.TimeoutExpired:
|
|
55
|
-
return
|
|
80
|
+
return CmdResult(stdout="", stderr="timeout", code=TIMEOUT_EXIT_CODE)
|
|
56
81
|
|
|
57
82
|
|
|
58
|
-
def
|
|
83
|
+
def run_ssh_cmd(
|
|
84
|
+
host: str,
|
|
85
|
+
cmd: str,
|
|
86
|
+
ssh_key_path: str | None = None,
|
|
87
|
+
timeout: int = 60,
|
|
88
|
+
echo_command: bool = False,
|
|
89
|
+
strict_host_key_checking: bool | None = None,
|
|
90
|
+
) -> CmdResult:
|
|
59
91
|
"""Execute a command on remote host via SSH.
|
|
60
92
|
|
|
61
93
|
Args:
|
|
@@ -64,12 +96,18 @@ def ssh_shell(host: str, cmd: str, ssh_key_path: str | None = None, timeout: int
|
|
|
64
96
|
ssh_key_path: Path to SSH private key file
|
|
65
97
|
timeout: Timeout in seconds
|
|
66
98
|
echo_command: Whether to print the command before execution
|
|
99
|
+
strict_host_key_checking: If True/False, explicitly set StrictHostKeyChecking.
|
|
100
|
+
If None, leave SSH defaults unchanged.
|
|
67
101
|
|
|
68
102
|
Returns:
|
|
69
|
-
|
|
103
|
+
CmdResult with stdout, stderr and exit code
|
|
104
|
+
|
|
70
105
|
"""
|
|
71
|
-
ssh_cmd = "ssh -o '
|
|
106
|
+
ssh_cmd = "ssh -o 'LogLevel=ERROR'"
|
|
107
|
+
if strict_host_key_checking is not None:
|
|
108
|
+
option_value = "yes" if strict_host_key_checking else "no"
|
|
109
|
+
ssh_cmd += f" -o 'StrictHostKeyChecking={option_value}'"
|
|
72
110
|
if ssh_key_path:
|
|
73
111
|
ssh_cmd += f" -i {shlex.quote(ssh_key_path)}"
|
|
74
112
|
ssh_cmd += f" {shlex.quote(host)} {shlex.quote(cmd)}"
|
|
75
|
-
return
|
|
113
|
+
return run_cmd(ssh_cmd, timeout=timeout, echo_command=echo_command)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
mm_std/__init__.py,sha256=7hmdftEHF19ef6jtwVDH3J7vEZLV3j5ZgIksZTLd_l0,1161
|
|
2
|
+
mm_std/date_utils.py,sha256=cGdTEXwsIQS6ZPTvjQRlkTTIf0yL2GCj_uYa8i0pzps,2117
|
|
3
|
+
mm_std/dict_utils.py,sha256=bddxRbemL7UZ9rp7k2bkBDsYX-NbVXv8CPL7ESjPxXc,2882
|
|
4
|
+
mm_std/json_utils.py,sha256=lOlleRdI9mdrB-s0nAfxiaQ5LTcbfEea2uObh0W-dzs,4196
|
|
5
|
+
mm_std/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
mm_std/random_utils.py,sha256=oGZhOg6rIft3SRpWv2lB6IAyvdWEN08-7BVrC5_vfoc,2889
|
|
7
|
+
mm_std/str_utils.py,sha256=p6hxxoe3xiuPSqb1vc3TbNfUoSwdW6z_OJG_3Qx7ITA,1563
|
|
8
|
+
mm_std/subprocess_utils.py,sha256=Sr8ENMg29CkhgpJi5PaW7h7tEviaEFurVJKcfR16itE,4057
|
|
9
|
+
mm_std-0.7.0.dist-info/METADATA,sha256=scnEov8zndpkmqWOgIde_iVjbb-arHejeuiKDt7Csbg,74
|
|
10
|
+
mm_std-0.7.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
11
|
+
mm_std-0.7.0.dist-info/RECORD,,
|
mm_std-0.5.4.dist-info/METADATA
DELETED
mm_std-0.5.4.dist-info/RECORD
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
mm_std/__init__.py,sha256=V1na3dOxX52p44hOwhHFaIgrZEHR3yEjFdmSIcB2n0c,715
|
|
2
|
-
mm_std/date_utils.py,sha256=aFdIacoNgDSPGeUkZihXZADd86TeHu4hr1uIT9zcqvw,1732
|
|
3
|
-
mm_std/dict_utils.py,sha256=Gq_LYuidf24SWvzNmUx8wVPGuop9263GOxcIflB__uQ,2850
|
|
4
|
-
mm_std/json_utils.py,sha256=NuDomTThfCkJBCmfQ6vkr7dvChKGsuLoAyLW0TON_dQ,3997
|
|
5
|
-
mm_std/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
mm_std/random_utils.py,sha256=3q5ZylCqfFHKN3VaDXuy4heo8C1jNbLHNNOFvyYJoZY,2253
|
|
7
|
-
mm_std/str_utils.py,sha256=I6vVC81dGBDTHm7FxW-ka5OlUPjHmgagei7Zjld65lk,1520
|
|
8
|
-
mm_std/subprocess_utils.py,sha256=6Bkw6ZYHT1NLIYbQUV9LGkBzUl5RtaVzMJiLJrTGq48,2507
|
|
9
|
-
mm_std-0.5.4.dist-info/METADATA,sha256=SnanvJ0gBE6Y_5-dSOkUaH5BVV482k3KPXFhuUKeV5Q,74
|
|
10
|
-
mm_std-0.5.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
11
|
-
mm_std-0.5.4.dist-info/RECORD,,
|