Coverage for muutils / cli / command.py: 98%
50 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-18 02:51 -0700
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-18 02:51 -0700
1from __future__ import annotations
3import os
4import subprocess
5import sys
6from dataclasses import dataclass
7from typing import Any, List, Union
10@dataclass
11class Command:
12 """Simple typed command with shell flag and subprocess helpers."""
14 cmd: Union[List[str], str]
15 shell: bool = False
16 env: dict[str, str] | None = None
17 inherit_env: bool = True
19 def __post_init__(self) -> None:
20 """Enforce cmd type when shell is False."""
21 if self.shell is False and isinstance(self.cmd, str):
22 raise ValueError("cmd must be List[str] when shell is False")
24 def _quote_env(self) -> str:
25 """Return KEY=VAL tokens for env values. ignores `inherit_env`."""
26 if not self.env:
27 return ""
29 parts: List[str] = []
30 for k, v in self.env.items():
31 token: str = f"{k}={v}"
32 parts.append(token)
33 prefix: str = " ".join(parts)
34 return prefix
36 @property
37 def cmd_joined(self) -> str:
38 """Return cmd as a single string, joining with spaces if it's a list. no env included."""
39 if isinstance(self.cmd, str):
40 return self.cmd
41 else:
42 return " ".join(self.cmd)
44 @property
45 def cmd_for_subprocess(self) -> Union[List[str], str]:
46 """Return cmd, splitting if shell is True and cmd is a string."""
47 if self.shell:
48 if isinstance(self.cmd, str):
49 return self.cmd
50 else:
51 return " ".join(self.cmd)
52 else:
53 assert isinstance(self.cmd, list)
54 return self.cmd
56 def script_line(self) -> str:
57 """Return a single shell string, prefixing KEY=VAL for env if provided."""
58 return f"{self._quote_env()} {self.cmd_joined}".strip()
60 @property
61 def env_final(self) -> dict[str, str]:
62 """Return final env dict, merging with os.environ if inherit_env is True."""
63 return {
64 **(os.environ if self.inherit_env else {}),
65 **(self.env or {}),
66 }
68 def run(
69 self,
70 **kwargs: Any,
71 ) -> subprocess.CompletedProcess[Any]:
72 """Call subprocess.run with this command."""
73 try:
74 return subprocess.run(
75 self.cmd_for_subprocess,
76 shell=self.shell,
77 env=self.env_final,
78 **kwargs,
79 )
80 except subprocess.CalledProcessError as e:
81 print(f"Command failed: `{self.script_line()}`", file=sys.stderr)
82 raise e
84 def Popen(
85 self,
86 **kwargs: Any,
87 ) -> subprocess.Popen[Any]:
88 """Call subprocess.Popen with this command."""
89 return subprocess.Popen(
90 self.cmd_for_subprocess,
91 shell=self.shell,
92 env=self.env_final,
93 **kwargs,
94 )