cicada-mcp 0.1.4__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.
Potentially problematic release.
This version of cicada-mcp might be problematic. Click here for more details.
- cicada/__init__.py +30 -0
- cicada/clean.py +297 -0
- cicada/command_logger.py +293 -0
- cicada/dead_code_analyzer.py +282 -0
- cicada/extractors/__init__.py +36 -0
- cicada/extractors/base.py +66 -0
- cicada/extractors/call.py +176 -0
- cicada/extractors/dependency.py +361 -0
- cicada/extractors/doc.py +179 -0
- cicada/extractors/function.py +246 -0
- cicada/extractors/module.py +123 -0
- cicada/extractors/spec.py +151 -0
- cicada/find_dead_code.py +270 -0
- cicada/formatter.py +918 -0
- cicada/git_helper.py +646 -0
- cicada/indexer.py +629 -0
- cicada/install.py +724 -0
- cicada/keyword_extractor.py +364 -0
- cicada/keyword_search.py +553 -0
- cicada/lightweight_keyword_extractor.py +298 -0
- cicada/mcp_server.py +1559 -0
- cicada/mcp_tools.py +291 -0
- cicada/parser.py +124 -0
- cicada/pr_finder.py +435 -0
- cicada/pr_indexer/__init__.py +20 -0
- cicada/pr_indexer/cli.py +62 -0
- cicada/pr_indexer/github_api_client.py +431 -0
- cicada/pr_indexer/indexer.py +297 -0
- cicada/pr_indexer/line_mapper.py +209 -0
- cicada/pr_indexer/pr_index_builder.py +253 -0
- cicada/setup.py +339 -0
- cicada/utils/__init__.py +52 -0
- cicada/utils/call_site_formatter.py +95 -0
- cicada/utils/function_grouper.py +57 -0
- cicada/utils/hash_utils.py +173 -0
- cicada/utils/index_utils.py +290 -0
- cicada/utils/path_utils.py +240 -0
- cicada/utils/signature_builder.py +106 -0
- cicada/utils/storage.py +111 -0
- cicada/utils/subprocess_runner.py +182 -0
- cicada/utils/text_utils.py +90 -0
- cicada/version_check.py +116 -0
- cicada_mcp-0.1.4.dist-info/METADATA +619 -0
- cicada_mcp-0.1.4.dist-info/RECORD +48 -0
- cicada_mcp-0.1.4.dist-info/WHEEL +5 -0
- cicada_mcp-0.1.4.dist-info/entry_points.txt +8 -0
- cicada_mcp-0.1.4.dist-info/licenses/LICENSE +21 -0
- cicada_mcp-0.1.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Path manipulation utilities.
|
|
3
|
+
|
|
4
|
+
This module provides centralized path normalization and resolution
|
|
5
|
+
functions used throughout the codebase.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, Union
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_file_path(
|
|
13
|
+
file_path: Union[str, Path],
|
|
14
|
+
strip_leading_dot: bool = True,
|
|
15
|
+
strip_trailing_whitespace: bool = True,
|
|
16
|
+
) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Normalize a file path for consistent comparisons.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
file_path: Path to normalize
|
|
22
|
+
strip_leading_dot: Remove leading './' if present
|
|
23
|
+
strip_trailing_whitespace: Remove trailing whitespace
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Normalized path string
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
normalize_file_path('./lib/user.ex') -> 'lib/user.ex'
|
|
30
|
+
normalize_file_path(' lib/user.ex ') -> 'lib/user.ex'
|
|
31
|
+
"""
|
|
32
|
+
path_str = str(file_path)
|
|
33
|
+
|
|
34
|
+
if strip_trailing_whitespace:
|
|
35
|
+
path_str = path_str.strip()
|
|
36
|
+
|
|
37
|
+
if strip_leading_dot:
|
|
38
|
+
# Remove leading './' prefix (not individual '.' or '/' characters)
|
|
39
|
+
while path_str.startswith("./"):
|
|
40
|
+
path_str = path_str[2:]
|
|
41
|
+
|
|
42
|
+
return path_str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_to_repo_root(
|
|
46
|
+
file_path: Union[str, Path],
|
|
47
|
+
repo_root: Union[str, Path],
|
|
48
|
+
) -> Path:
|
|
49
|
+
"""
|
|
50
|
+
Resolve a file path relative to repository root.
|
|
51
|
+
|
|
52
|
+
Handles both absolute and relative paths, converting absolute paths
|
|
53
|
+
to be relative to the repository root.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
file_path: Path to resolve (can be absolute or relative)
|
|
57
|
+
repo_root: Repository root directory
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Path relative to repo root
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If absolute path is not within repo root
|
|
64
|
+
|
|
65
|
+
Example:
|
|
66
|
+
resolve_to_repo_root('/repo/lib/user.ex', '/repo') -> Path('lib/user.ex')
|
|
67
|
+
resolve_to_repo_root('lib/user.ex', '/repo') -> Path('lib/user.ex')
|
|
68
|
+
"""
|
|
69
|
+
file_path_obj = Path(file_path)
|
|
70
|
+
repo_root_obj = Path(repo_root).resolve()
|
|
71
|
+
|
|
72
|
+
# If already relative, return as-is
|
|
73
|
+
if not file_path_obj.is_absolute():
|
|
74
|
+
return file_path_obj
|
|
75
|
+
|
|
76
|
+
# Convert absolute to relative
|
|
77
|
+
try:
|
|
78
|
+
return file_path_obj.relative_to(repo_root_obj)
|
|
79
|
+
except ValueError:
|
|
80
|
+
raise ValueError(f"File path {file_path} is not within repository {repo_root}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def match_file_path(
|
|
84
|
+
candidate: Union[str, Path],
|
|
85
|
+
target: Union[str, Path],
|
|
86
|
+
normalize: bool = True,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""
|
|
89
|
+
Check if two file paths match, with flexible matching rules.
|
|
90
|
+
|
|
91
|
+
Supports:
|
|
92
|
+
- Exact match
|
|
93
|
+
- Candidate ends with target
|
|
94
|
+
- Target ends with candidate
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
candidate: File path to check
|
|
98
|
+
target: Target file path
|
|
99
|
+
normalize: Whether to normalize paths before comparison
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if paths match
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
match_file_path('lib/user.ex', 'lib/user.ex') -> True
|
|
106
|
+
match_file_path('/repo/lib/user.ex', 'lib/user.ex') -> True
|
|
107
|
+
match_file_path('user.ex', 'lib/user.ex') -> True
|
|
108
|
+
"""
|
|
109
|
+
if normalize:
|
|
110
|
+
candidate_str = normalize_file_path(candidate)
|
|
111
|
+
target_str = normalize_file_path(target)
|
|
112
|
+
else:
|
|
113
|
+
candidate_str = str(candidate)
|
|
114
|
+
target_str = str(target)
|
|
115
|
+
|
|
116
|
+
# Exact match
|
|
117
|
+
if candidate_str == target_str:
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
# Candidate ends with target (absolute path provided, target is relative)
|
|
121
|
+
if candidate_str.endswith(target_str):
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
# Target ends with candidate (partial path provided)
|
|
125
|
+
if target_str.endswith(candidate_str):
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def find_repo_root(start_path: Optional[Union[str, Path]] = None) -> Optional[Path]:
|
|
132
|
+
"""
|
|
133
|
+
Find the git repository root starting from a given path.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
start_path: Path to start searching from (default: current directory)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Path to repository root, or None if not in a git repo
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
find_repo_root('/repo/lib/user') -> Path('/repo')
|
|
143
|
+
find_repo_root('/not/a/repo') -> None
|
|
144
|
+
"""
|
|
145
|
+
if start_path is None:
|
|
146
|
+
current = Path.cwd()
|
|
147
|
+
else:
|
|
148
|
+
current = Path(start_path).resolve()
|
|
149
|
+
|
|
150
|
+
# Walk up the directory tree looking for .git
|
|
151
|
+
for parent in [current] + list(current.parents):
|
|
152
|
+
if (parent / ".git").exists():
|
|
153
|
+
return parent
|
|
154
|
+
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def ensure_relative_to_repo(
|
|
159
|
+
file_path: Union[str, Path],
|
|
160
|
+
repo_root: Union[str, Path],
|
|
161
|
+
) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Ensure a file path is relative to the repository root.
|
|
164
|
+
|
|
165
|
+
This is a convenience function that combines normalization and
|
|
166
|
+
resolution. If the path is already relative, it's normalized.
|
|
167
|
+
If it's absolute, it's converted to relative.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
file_path: File path to process
|
|
171
|
+
repo_root: Repository root directory
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Normalized path string relative to repo root
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
ensure_relative_to_repo('/repo/./lib/user.ex', '/repo') -> 'lib/user.ex'
|
|
178
|
+
ensure_relative_to_repo('lib/user.ex', '/repo') -> 'lib/user.ex'
|
|
179
|
+
"""
|
|
180
|
+
resolved = resolve_to_repo_root(file_path, repo_root)
|
|
181
|
+
return normalize_file_path(resolved)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def ensure_gitignore_has_cicada(repo_root: Union[str, Path]) -> bool:
|
|
185
|
+
"""
|
|
186
|
+
Ensure .gitignore contains .cicada/ directory entry.
|
|
187
|
+
|
|
188
|
+
If .gitignore exists and doesn't already contain .cicada/, adds it.
|
|
189
|
+
If .gitignore doesn't exist, this function does nothing.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
repo_root: Repository root directory
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
True if .cicada/ was added to .gitignore, False otherwise
|
|
196
|
+
|
|
197
|
+
Example:
|
|
198
|
+
ensure_gitignore_has_cicada('/repo') -> True (if added)
|
|
199
|
+
ensure_gitignore_has_cicada('/repo') -> False (if already present or no .gitignore)
|
|
200
|
+
"""
|
|
201
|
+
repo_root_path = Path(repo_root).resolve()
|
|
202
|
+
gitignore_path = repo_root_path / ".gitignore"
|
|
203
|
+
|
|
204
|
+
# Do nothing if .gitignore doesn't exist
|
|
205
|
+
if not gitignore_path.exists():
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Read existing .gitignore
|
|
210
|
+
with open(gitignore_path, "r") as f:
|
|
211
|
+
content = f.read()
|
|
212
|
+
|
|
213
|
+
# Check if .cicada/ is already present in actual gitignore patterns
|
|
214
|
+
# (ignore comment lines starting with #)
|
|
215
|
+
for line in content.splitlines():
|
|
216
|
+
# Strip whitespace and skip empty lines and comments
|
|
217
|
+
stripped = line.strip()
|
|
218
|
+
if stripped and not stripped.startswith("#"):
|
|
219
|
+
# Check if this line contains .cicada as a gitignore pattern
|
|
220
|
+
# Valid patterns: .cicada, .cicada/, /.cicada, /.cicada/, **/.cicada/, etc.
|
|
221
|
+
if (
|
|
222
|
+
stripped in (".cicada", ".cicada/")
|
|
223
|
+
or stripped.endswith("/.cicada")
|
|
224
|
+
or stripped.endswith("/.cicada/")
|
|
225
|
+
):
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
# Add .cicada/ to .gitignore
|
|
229
|
+
with open(gitignore_path, "a") as f:
|
|
230
|
+
# Add newline if file doesn't end with one
|
|
231
|
+
if content and not content.endswith("\n"):
|
|
232
|
+
f.write("\n")
|
|
233
|
+
|
|
234
|
+
f.write(".cicada/\n")
|
|
235
|
+
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
except (IOError, OSError):
|
|
239
|
+
# Fail silently if we can't read/write the file
|
|
240
|
+
return False
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Function signature building utilities.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for formatting function signatures,
|
|
5
|
+
eliminating duplication across the formatter module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, List, Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SignatureBuilder:
|
|
12
|
+
"""
|
|
13
|
+
Builds formatted function signatures from function data.
|
|
14
|
+
|
|
15
|
+
This class consolidates signature formatting logic that appears
|
|
16
|
+
in multiple places in the formatter module.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def build(func: Dict[str, Any]) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Build a formatted function signature.
|
|
23
|
+
|
|
24
|
+
Creates signatures like:
|
|
25
|
+
- "func_name(arg1: type1, arg2: type2) :: return_type"
|
|
26
|
+
- "func_name(arg1, arg2)"
|
|
27
|
+
- "func_name/2"
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
func: Function dictionary with keys:
|
|
31
|
+
- name: Function name
|
|
32
|
+
- arity: Function arity
|
|
33
|
+
- args: Optional list of argument names
|
|
34
|
+
- args_with_types: Optional list of {name, type} dicts
|
|
35
|
+
- return_type: Optional return type string
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Formatted signature string
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
func = {
|
|
42
|
+
'name': 'create_user',
|
|
43
|
+
'arity': 2,
|
|
44
|
+
'args_with_types': [
|
|
45
|
+
{'name': 'attrs', 'type': 'map'},
|
|
46
|
+
{'name': 'opts', 'type': 'keyword'}
|
|
47
|
+
],
|
|
48
|
+
'return_type': '{:ok, User.t()} | {:error, Ecto.Changeset.t()}'
|
|
49
|
+
}
|
|
50
|
+
sig = SignatureBuilder.build(func)
|
|
51
|
+
# Returns: "create_user(attrs: map, opts: keyword) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}"
|
|
52
|
+
"""
|
|
53
|
+
func_name = func["name"]
|
|
54
|
+
signature = ""
|
|
55
|
+
|
|
56
|
+
# If we have args_with_types, use that for rich signatures
|
|
57
|
+
if "args_with_types" in func and func["args_with_types"]:
|
|
58
|
+
args_str = SignatureBuilder._format_args_with_types(func["args_with_types"])
|
|
59
|
+
signature = f"{func_name}({args_str})"
|
|
60
|
+
|
|
61
|
+
# Otherwise, fallback to args without types
|
|
62
|
+
elif "args" in func and func["args"]:
|
|
63
|
+
args_str = ", ".join(func["args"])
|
|
64
|
+
signature = f"{func_name}({args_str})"
|
|
65
|
+
|
|
66
|
+
# No args, just show function name with empty parens or /0
|
|
67
|
+
elif func["arity"] == 0:
|
|
68
|
+
signature = f"{func_name}()"
|
|
69
|
+
|
|
70
|
+
# Fallback to name/arity notation
|
|
71
|
+
else:
|
|
72
|
+
signature = f"{func_name}/{func['arity']}"
|
|
73
|
+
|
|
74
|
+
# Append return type if available
|
|
75
|
+
if "return_type" in func and func["return_type"]:
|
|
76
|
+
signature += f" :: {func['return_type']}"
|
|
77
|
+
|
|
78
|
+
return signature
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _format_args_with_types(args_with_types: List[Dict[str, str]]) -> str:
|
|
82
|
+
"""
|
|
83
|
+
Format arguments with type annotations.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
args_with_types: List of dicts with 'name' and 'type' keys
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Comma-separated string of "name: type" pairs
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
args = [
|
|
93
|
+
{'name': 'attrs', 'type': 'map'},
|
|
94
|
+
{'name': 'opts', 'type': 'keyword'}
|
|
95
|
+
]
|
|
96
|
+
formatted = SignatureBuilder._format_args_with_types(args)
|
|
97
|
+
# Returns: "attrs: map, opts: keyword"
|
|
98
|
+
"""
|
|
99
|
+
formatted_args: list[str] = []
|
|
100
|
+
for arg in args_with_types:
|
|
101
|
+
if arg.get("type"):
|
|
102
|
+
formatted_args.append(f"{arg['name']}: {arg['type']}")
|
|
103
|
+
else:
|
|
104
|
+
formatted_args.append(arg["name"])
|
|
105
|
+
|
|
106
|
+
return ", ".join(formatted_args)
|
cicada/utils/storage.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage management utilities for Cicada.
|
|
3
|
+
|
|
4
|
+
Handles creation and management of storage directories for index files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_repo_hash(repo_path: str | Path) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Generate a unique hash for a repository path.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
repo_path: Path to the repository
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Hex string hash of the repository path
|
|
20
|
+
"""
|
|
21
|
+
repo_path_str = str(Path(repo_path).resolve())
|
|
22
|
+
return hashlib.sha256(repo_path_str.encode()).hexdigest()[:16]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_storage_dir(repo_path: str | Path) -> Path:
|
|
26
|
+
"""
|
|
27
|
+
Get the storage directory for a repository.
|
|
28
|
+
|
|
29
|
+
Storage structure:
|
|
30
|
+
~/.cicada/projects/<repo_hash>/
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
repo_path: Path to the repository
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Path to the storage directory for this repository
|
|
37
|
+
"""
|
|
38
|
+
repo_hash = get_repo_hash(repo_path)
|
|
39
|
+
storage_dir = Path.home() / ".cicada" / "projects" / repo_hash
|
|
40
|
+
return storage_dir
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_storage_dir(repo_path: str | Path) -> Path:
|
|
44
|
+
"""
|
|
45
|
+
Create the storage directory for a repository if it doesn't exist.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
repo_path: Path to the repository
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Path to the created storage directory
|
|
52
|
+
"""
|
|
53
|
+
storage_dir = get_storage_dir(repo_path)
|
|
54
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
return storage_dir
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_index_path(repo_path: str | Path) -> Path:
|
|
59
|
+
"""
|
|
60
|
+
Get the path to the index file for a repository.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
repo_path: Path to the repository
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Path to the index.json file
|
|
67
|
+
"""
|
|
68
|
+
storage_dir = get_storage_dir(repo_path)
|
|
69
|
+
return storage_dir / "index.json"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_config_path(repo_path: str | Path) -> Path:
|
|
73
|
+
"""
|
|
74
|
+
Get the path to the config file for a repository.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
repo_path: Path to the repository
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Path to the config.yaml file
|
|
81
|
+
"""
|
|
82
|
+
storage_dir = get_storage_dir(repo_path)
|
|
83
|
+
return storage_dir / "config.yaml"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_hashes_path(repo_path: str | Path) -> Path:
|
|
87
|
+
"""
|
|
88
|
+
Get the path to the hashes file for a repository.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
repo_path: Path to the repository
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Path to the hashes.json file
|
|
95
|
+
"""
|
|
96
|
+
storage_dir = get_storage_dir(repo_path)
|
|
97
|
+
return storage_dir / "hashes.json"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_pr_index_path(repo_path: str | Path) -> Path:
|
|
101
|
+
"""
|
|
102
|
+
Get the path to the PR index file for a repository.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
repo_path: Path to the repository
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Path to the pr_index.json file
|
|
109
|
+
"""
|
|
110
|
+
storage_dir = get_storage_dir(repo_path)
|
|
111
|
+
return storage_dir / "pr_index.json"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Subprocess execution utilities.
|
|
3
|
+
|
|
4
|
+
This module provides centralized subprocess execution with consistent
|
|
5
|
+
error handling and logging patterns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional, List, Union
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SubprocessRunner:
|
|
15
|
+
"""
|
|
16
|
+
Centralized subprocess execution with error handling.
|
|
17
|
+
|
|
18
|
+
This class provides consistent subprocess execution patterns used
|
|
19
|
+
throughout the codebase, reducing duplication and ensuring uniform
|
|
20
|
+
error handling.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, cwd: Optional[Union[str, Path]] = None, verbose: bool = False):
|
|
24
|
+
"""
|
|
25
|
+
Initialize the subprocess runner.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
cwd: Working directory for commands (default: current directory)
|
|
29
|
+
verbose: If True, print command output to stderr
|
|
30
|
+
"""
|
|
31
|
+
self.cwd = Path(cwd) if cwd else None
|
|
32
|
+
self.verbose = verbose
|
|
33
|
+
|
|
34
|
+
def run(
|
|
35
|
+
self,
|
|
36
|
+
cmd: Union[str, List[str]],
|
|
37
|
+
capture_output: bool = True,
|
|
38
|
+
text: bool = True,
|
|
39
|
+
check: bool = True,
|
|
40
|
+
timeout: Optional[int] = None,
|
|
41
|
+
) -> subprocess.CompletedProcess:
|
|
42
|
+
"""
|
|
43
|
+
Run a subprocess command with error handling.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
cmd: Command to execute (string or list)
|
|
47
|
+
capture_output: Whether to capture stdout/stderr
|
|
48
|
+
text: Whether to return output as text (vs bytes)
|
|
49
|
+
check: Whether to raise exception on non-zero exit
|
|
50
|
+
timeout: Optional timeout in seconds
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
CompletedProcess instance
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
subprocess.CalledProcessError: If command fails and check=True
|
|
57
|
+
subprocess.TimeoutExpired: If timeout is reached
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
cmd,
|
|
62
|
+
cwd=self.cwd,
|
|
63
|
+
capture_output=capture_output,
|
|
64
|
+
text=text,
|
|
65
|
+
check=check,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if self.verbose and result.stdout:
|
|
70
|
+
print(result.stdout, file=sys.stderr)
|
|
71
|
+
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
except subprocess.CalledProcessError as e:
|
|
75
|
+
if self.verbose:
|
|
76
|
+
print(f"Command failed: {cmd}", file=sys.stderr)
|
|
77
|
+
if e.stderr:
|
|
78
|
+
print(f"Error: {e.stderr}", file=sys.stderr)
|
|
79
|
+
raise
|
|
80
|
+
except subprocess.TimeoutExpired as e:
|
|
81
|
+
if self.verbose:
|
|
82
|
+
print(f"Command timed out: {cmd}", file=sys.stderr)
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
def run_git_command(
|
|
86
|
+
self,
|
|
87
|
+
args: Union[str, List[str]],
|
|
88
|
+
check: bool = True,
|
|
89
|
+
) -> subprocess.CompletedProcess:
|
|
90
|
+
"""
|
|
91
|
+
Run a git command with consistent error handling.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
args: Git command arguments (without 'git')
|
|
95
|
+
check: Whether to raise exception on non-zero exit
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
CompletedProcess instance
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
runner.run_git_command(['status', '--short'])
|
|
102
|
+
runner.run_git_command('log --oneline -n 5')
|
|
103
|
+
"""
|
|
104
|
+
if isinstance(args, str):
|
|
105
|
+
cmd = f"git {args}"
|
|
106
|
+
else:
|
|
107
|
+
cmd = ["git"] + args
|
|
108
|
+
|
|
109
|
+
return self.run(cmd, check=check)
|
|
110
|
+
|
|
111
|
+
def run_gh_command(
|
|
112
|
+
self,
|
|
113
|
+
args: Union[str, List[str]],
|
|
114
|
+
check: bool = True,
|
|
115
|
+
) -> subprocess.CompletedProcess:
|
|
116
|
+
"""
|
|
117
|
+
Run a GitHub CLI command with consistent error handling.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
args: gh command arguments (without 'gh')
|
|
121
|
+
check: Whether to raise exception on non-zero exit
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
CompletedProcess instance
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
runner.run_gh_command(['pr', 'list'])
|
|
128
|
+
runner.run_gh_command('api repos/owner/repo/pulls')
|
|
129
|
+
"""
|
|
130
|
+
if isinstance(args, str):
|
|
131
|
+
cmd = f"gh {args}"
|
|
132
|
+
else:
|
|
133
|
+
cmd = ["gh"] + args
|
|
134
|
+
|
|
135
|
+
return self.run(cmd, check=check)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Convenience functions for simple use cases
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run_git_command(
|
|
142
|
+
args: Union[str, List[str]],
|
|
143
|
+
cwd: Optional[Union[str, Path]] = None,
|
|
144
|
+
check: bool = True,
|
|
145
|
+
verbose: bool = False,
|
|
146
|
+
) -> subprocess.CompletedProcess:
|
|
147
|
+
"""
|
|
148
|
+
Run a git command (convenience function).
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
args: Git command arguments (without 'git')
|
|
152
|
+
cwd: Working directory
|
|
153
|
+
check: Whether to raise exception on non-zero exit
|
|
154
|
+
verbose: Whether to print output
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
CompletedProcess instance
|
|
158
|
+
"""
|
|
159
|
+
runner = SubprocessRunner(cwd=cwd, verbose=verbose)
|
|
160
|
+
return runner.run_git_command(args, check=check)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run_gh_command(
|
|
164
|
+
args: Union[str, List[str]],
|
|
165
|
+
cwd: Optional[Union[str, Path]] = None,
|
|
166
|
+
check: bool = True,
|
|
167
|
+
verbose: bool = False,
|
|
168
|
+
) -> subprocess.CompletedProcess:
|
|
169
|
+
"""
|
|
170
|
+
Run a GitHub CLI command (convenience function).
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
args: gh command arguments (without 'gh')
|
|
174
|
+
cwd: Working directory
|
|
175
|
+
check: Whether to raise exception on non-zero exit
|
|
176
|
+
verbose: Whether to print output
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
CompletedProcess instance
|
|
180
|
+
"""
|
|
181
|
+
runner = SubprocessRunner(cwd=cwd, verbose=verbose)
|
|
182
|
+
return runner.run_gh_command(args, check=check)
|