misterdev 0.2.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.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- misterdev-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Dict, Optional, Any
|
|
4
|
+
|
|
5
|
+
from misterdev.core.execution.project import Project
|
|
6
|
+
from misterdev.config import ConfigManager
|
|
7
|
+
from misterdev.logging_setup import setup_logger
|
|
8
|
+
|
|
9
|
+
logger = setup_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProjectRegistry:
|
|
13
|
+
"""Manages discovery, instantiation, and persistence of projects."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, state_file: Optional[str | Path] = None):
|
|
16
|
+
self.projects: Dict[str, Project] = {}
|
|
17
|
+
self.config_manager = ConfigManager()
|
|
18
|
+
|
|
19
|
+
if state_file:
|
|
20
|
+
self.state_file = Path(state_file)
|
|
21
|
+
else:
|
|
22
|
+
self.state_file = Path.home() / ".misterdev" / "registry.json"
|
|
23
|
+
|
|
24
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
self._load_state()
|
|
26
|
+
|
|
27
|
+
def _load_state(self):
|
|
28
|
+
"""Loads registered project paths from the state file."""
|
|
29
|
+
if self.state_file.exists():
|
|
30
|
+
try:
|
|
31
|
+
with open(self.state_file, "r", encoding="utf-8") as f:
|
|
32
|
+
state = json.load(f)
|
|
33
|
+
for project_path in state.get("registered_paths", []):
|
|
34
|
+
try:
|
|
35
|
+
self.register_project(project_path, save=False)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.error(
|
|
38
|
+
f"Failed to reload project at {project_path}: {e}"
|
|
39
|
+
)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.error(f"Failed to load registry state: {e}")
|
|
42
|
+
|
|
43
|
+
def _save_state(self):
|
|
44
|
+
"""Saves registered project paths to the state file."""
|
|
45
|
+
state = {"registered_paths": list(self.projects.keys())}
|
|
46
|
+
try:
|
|
47
|
+
with open(self.state_file, "w", encoding="utf-8") as f:
|
|
48
|
+
json.dump(state, f, indent=4)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.error(f"Failed to save registry state: {e}")
|
|
51
|
+
|
|
52
|
+
def discover_projects(self, root_dir: str | Path):
|
|
53
|
+
"""Scans for project.yaml files and registers projects."""
|
|
54
|
+
root_path = Path(root_dir)
|
|
55
|
+
logger.info(f"Scanning for projects in {root_path}")
|
|
56
|
+
|
|
57
|
+
found_any = False
|
|
58
|
+
for yaml_path in root_path.rglob("project.yaml"):
|
|
59
|
+
project_dir = yaml_path.parent
|
|
60
|
+
try:
|
|
61
|
+
self.register_project(project_dir)
|
|
62
|
+
found_any = True
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Failed to register project at {project_dir}: {e}")
|
|
65
|
+
|
|
66
|
+
if found_any:
|
|
67
|
+
self._save_state()
|
|
68
|
+
|
|
69
|
+
def register_project(self, project_path: str | Path, save: bool = True) -> Project:
|
|
70
|
+
"""Loads config and initializes a Project object."""
|
|
71
|
+
path = Path(project_path).resolve()
|
|
72
|
+
project_id = str(path)
|
|
73
|
+
|
|
74
|
+
if project_id in self.projects:
|
|
75
|
+
logger.debug(f"Project already registered: {project_id}")
|
|
76
|
+
return self.projects[project_id]
|
|
77
|
+
|
|
78
|
+
config = self.config_manager.load_project_config(path)
|
|
79
|
+
project = Project(path, config)
|
|
80
|
+
self.projects[project_id] = project
|
|
81
|
+
logger.info(f"Registered project: {project.name} at {path}")
|
|
82
|
+
|
|
83
|
+
if save:
|
|
84
|
+
self._save_state()
|
|
85
|
+
|
|
86
|
+
return project
|
|
87
|
+
|
|
88
|
+
def get_project(self, project_path: str | Path) -> Optional[Project]:
|
|
89
|
+
path = str(Path(project_path).resolve())
|
|
90
|
+
return self.projects.get(path)
|
|
91
|
+
|
|
92
|
+
def list_projects(self) -> Dict[str, Any]:
|
|
93
|
+
"""Returns a summary of all registered projects."""
|
|
94
|
+
return {
|
|
95
|
+
path: {"name": p.name, "description": p.description, "path": str(p.path)}
|
|
96
|
+
for path, p in self.projects.items()
|
|
97
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Optional runtime smoke gate.
|
|
2
|
+
|
|
3
|
+
A passing build/test suite proves the code compiles and units behave, but not
|
|
4
|
+
that the assembled artifact actually launches and responds. This gate launches
|
|
5
|
+
the built artifact, waits for a readiness signal, sends a probe, and asserts an
|
|
6
|
+
expected substring appears in the output — a cheap end-to-end liveness check.
|
|
7
|
+
|
|
8
|
+
It mirrors :mod:`misterdev.core.context.lsp`: strictly opt-in (off unless
|
|
9
|
+
``runtime.smoke`` is configured), best-effort, and run in a daemon worker thread
|
|
10
|
+
with a hard timeout so a hung launch can NEVER block the build. Absent config or
|
|
11
|
+
a timeout is a SKIP (no opinion), not a failure; only a process that exits
|
|
12
|
+
non-zero or whose output lacks the expectation is a RED. Real stdout and exit
|
|
13
|
+
code are captured as evidence.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import select
|
|
18
|
+
import signal
|
|
19
|
+
import subprocess
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from misterdev.core.execution.bounded import run_bounded
|
|
25
|
+
from misterdev.core.execution.outcomes import (
|
|
26
|
+
GREEN,
|
|
27
|
+
RED,
|
|
28
|
+
SKIP,
|
|
29
|
+
GateOutcome,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from misterdev.logging_setup import setup_logger
|
|
33
|
+
|
|
34
|
+
logger = setup_logger(__name__)
|
|
35
|
+
|
|
36
|
+
# Outcome constants. SKIP means "no opinion" (no config, timed out, or launch
|
|
37
|
+
# unavailable) and must never be treated as a pass/fail signal by callers.
|
|
38
|
+
# A hard ceiling on probe-output reading so a chatty process can't grow the
|
|
39
|
+
# evidence buffer without bound.
|
|
40
|
+
_MAX_EVIDENCE_CHARS = 16384
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SmokeResult(GateOutcome):
|
|
44
|
+
"""Outcome of a smoke run. ``status`` is SKIP/GREEN/RED; ``evidence`` is the
|
|
45
|
+
captured stdout/stderr (truncated); ``exit_code`` is the process exit code
|
|
46
|
+
when it terminated, else ``None``."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
status: str,
|
|
51
|
+
evidence: str = "",
|
|
52
|
+
exit_code: Optional[int] = None,
|
|
53
|
+
reason: str = "",
|
|
54
|
+
):
|
|
55
|
+
super().__init__(status, reason)
|
|
56
|
+
self.evidence = evidence
|
|
57
|
+
self.exit_code = exit_code
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"SmokeResult(status={self.status!r}, exit_code={self.exit_code!r})"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_smoke_gate(
|
|
64
|
+
project_root: Path,
|
|
65
|
+
smoke_config: Optional[dict],
|
|
66
|
+
runner=None,
|
|
67
|
+
) -> SmokeResult:
|
|
68
|
+
"""Run the smoke gate described by ``smoke_config``.
|
|
69
|
+
|
|
70
|
+
``smoke_config`` keys:
|
|
71
|
+
- ``launch`` (required): command that starts the artifact.
|
|
72
|
+
- ``ready`` (optional): substring that, once seen in output, means ready.
|
|
73
|
+
- ``probe`` (optional): text piped to the process's stdin after readiness.
|
|
74
|
+
- ``expect`` (required for a verdict): substring asserted in the output.
|
|
75
|
+
- ``timeout`` (optional, default 30): hard ceiling for the whole run.
|
|
76
|
+
|
|
77
|
+
Returns a :class:`SmokeResult`. SKIP when there is no config or no
|
|
78
|
+
``launch`` (feature off), or when the hard timeout fires (never blocks).
|
|
79
|
+
``runner`` is accepted for symmetry with the gate seam but smoke launches
|
|
80
|
+
run on the host (where the artifact and its ports live); it is unused today.
|
|
81
|
+
"""
|
|
82
|
+
if not smoke_config or not smoke_config.get("launch"):
|
|
83
|
+
return SmokeResult(SKIP, reason="no runtime.smoke config")
|
|
84
|
+
|
|
85
|
+
launch = smoke_config["launch"]
|
|
86
|
+
ready = smoke_config.get("ready")
|
|
87
|
+
probe = smoke_config.get("probe")
|
|
88
|
+
expect = smoke_config.get("expect")
|
|
89
|
+
timeout = float(smoke_config.get("timeout", 30))
|
|
90
|
+
|
|
91
|
+
def _work() -> SmokeResult:
|
|
92
|
+
try:
|
|
93
|
+
return _smoke(project_root, launch, ready, probe, expect, timeout)
|
|
94
|
+
except Exception as e: # any launch/IO failure is non-fatal -> skip
|
|
95
|
+
logger.debug(f"Runtime smoke gate unavailable: {e}")
|
|
96
|
+
return SmokeResult(SKIP, reason=f"error: {e}")
|
|
97
|
+
|
|
98
|
+
# A small margin over the inner timeout so a clean inner teardown is
|
|
99
|
+
# preferred, but the outer bound still guarantees we return.
|
|
100
|
+
return run_bounded(
|
|
101
|
+
_work, timeout + 5, SmokeResult(SKIP, reason="timed out"), "Runtime smoke gate"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _smoke(
|
|
106
|
+
project_root: Path,
|
|
107
|
+
launch: str,
|
|
108
|
+
ready: Optional[str],
|
|
109
|
+
probe: Optional[str],
|
|
110
|
+
expect: Optional[str],
|
|
111
|
+
timeout: float,
|
|
112
|
+
) -> SmokeResult:
|
|
113
|
+
"""Launch, await readiness, probe, assert. Always tears the process down."""
|
|
114
|
+
deadline = time.monotonic() + timeout
|
|
115
|
+
# Binary, unbuffered pipes so reads go through select()+os.read and are
|
|
116
|
+
# deadline-bounded (a text-mode readline() blocks until a newline, defeating
|
|
117
|
+
# the inner timeout). start_new_session puts the launch in its own process
|
|
118
|
+
# group so teardown can kill the whole tree, not just the shell.
|
|
119
|
+
proc = subprocess.Popen(
|
|
120
|
+
launch,
|
|
121
|
+
shell=True,
|
|
122
|
+
cwd=str(project_root),
|
|
123
|
+
stdin=subprocess.PIPE,
|
|
124
|
+
stdout=subprocess.PIPE,
|
|
125
|
+
stderr=subprocess.STDOUT,
|
|
126
|
+
start_new_session=True,
|
|
127
|
+
)
|
|
128
|
+
captured: list[str] = []
|
|
129
|
+
try:
|
|
130
|
+
if ready:
|
|
131
|
+
if not _wait_for_substring(proc, captured, ready, deadline):
|
|
132
|
+
return _finish(proc, captured, RED, "readiness signal not seen")
|
|
133
|
+
elif probe is None:
|
|
134
|
+
# No readiness signal and no probe: give the process a brief moment
|
|
135
|
+
# to emit startup output before we evaluate it.
|
|
136
|
+
_drain_until(proc, captured, deadline, max_wait=min(2.0, timeout))
|
|
137
|
+
|
|
138
|
+
if probe is not None and proc.stdin is not None:
|
|
139
|
+
try:
|
|
140
|
+
data = probe if probe.endswith("\n") else probe + "\n"
|
|
141
|
+
proc.stdin.write(data.encode("utf-8"))
|
|
142
|
+
proc.stdin.flush()
|
|
143
|
+
except (BrokenPipeError, OSError):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
if expect:
|
|
147
|
+
seen = _wait_for_substring(proc, captured, expect, deadline)
|
|
148
|
+
status = GREEN if seen else RED
|
|
149
|
+
reason = "" if seen else f"expected substring {expect!r} not found"
|
|
150
|
+
return _finish(proc, captured, status, reason)
|
|
151
|
+
|
|
152
|
+
# No expectation to assert: GREEN if it launched and is still healthy
|
|
153
|
+
# (or exited cleanly).
|
|
154
|
+
_drain_until(proc, captured, deadline, max_wait=0.2)
|
|
155
|
+
code = proc.poll()
|
|
156
|
+
if code is not None and code != 0:
|
|
157
|
+
return _finish(proc, captured, RED, f"exited {code}")
|
|
158
|
+
return _finish(proc, captured, GREEN, "")
|
|
159
|
+
finally:
|
|
160
|
+
_terminate(proc)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _read_available(proc: subprocess.Popen, captured: list, deadline: float) -> None:
|
|
164
|
+
"""Append whatever stdout is ready, waiting at most until ``deadline``.
|
|
165
|
+
|
|
166
|
+
Uses ``select`` + ``os.read`` so a process that emits no newline (or nothing
|
|
167
|
+
at all) can never block past the deadline — unlike ``readline()``, which the
|
|
168
|
+
inner timeout was silently relying on and which made the timeout a no-op.
|
|
169
|
+
"""
|
|
170
|
+
fd = proc.stdout
|
|
171
|
+
if fd is None:
|
|
172
|
+
return
|
|
173
|
+
remaining = deadline - time.monotonic()
|
|
174
|
+
if remaining <= 0:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
ready, _, _ = select.select([fd], [], [], min(remaining, 0.1))
|
|
178
|
+
except (OSError, ValueError):
|
|
179
|
+
return
|
|
180
|
+
if not ready:
|
|
181
|
+
return
|
|
182
|
+
try:
|
|
183
|
+
chunk = os.read(fd.fileno(), 65536)
|
|
184
|
+
except (OSError, ValueError):
|
|
185
|
+
return
|
|
186
|
+
if chunk:
|
|
187
|
+
captured.append(chunk.decode("utf-8", "replace"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _drain_remaining(proc: subprocess.Popen, captured: list) -> None:
|
|
191
|
+
"""Read all output still buffered after the process has exited (non-blocking)."""
|
|
192
|
+
fd = proc.stdout
|
|
193
|
+
if fd is None:
|
|
194
|
+
return
|
|
195
|
+
while True:
|
|
196
|
+
try:
|
|
197
|
+
ready, _, _ = select.select([fd], [], [], 0)
|
|
198
|
+
if not ready:
|
|
199
|
+
return
|
|
200
|
+
chunk = os.read(fd.fileno(), 65536)
|
|
201
|
+
except (OSError, ValueError):
|
|
202
|
+
return
|
|
203
|
+
if not chunk: # EOF
|
|
204
|
+
return
|
|
205
|
+
captured.append(chunk.decode("utf-8", "replace"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _wait_for_substring(
|
|
209
|
+
proc: subprocess.Popen, captured: list, needle: str, deadline: float
|
|
210
|
+
) -> bool:
|
|
211
|
+
"""Read output until ``needle`` appears, the process exits, or the deadline
|
|
212
|
+
passes. Returns True iff ``needle`` was seen in the cumulative output."""
|
|
213
|
+
while time.monotonic() < deadline:
|
|
214
|
+
if needle in "".join(captured):
|
|
215
|
+
return True
|
|
216
|
+
if proc.poll() is not None:
|
|
217
|
+
# Process gone; drain any remaining buffered output then check once.
|
|
218
|
+
_drain_remaining(proc, captured)
|
|
219
|
+
return needle in "".join(captured)
|
|
220
|
+
_read_available(proc, captured, deadline)
|
|
221
|
+
return needle in "".join(captured)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _drain_until(
|
|
225
|
+
proc: subprocess.Popen, captured: list, deadline: float, max_wait: float
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Read output for up to ``max_wait`` seconds (bounded by ``deadline``)."""
|
|
228
|
+
stop = min(deadline, time.monotonic() + max_wait)
|
|
229
|
+
while time.monotonic() < stop and proc.poll() is None:
|
|
230
|
+
_read_available(proc, captured, stop)
|
|
231
|
+
if proc.poll() is not None:
|
|
232
|
+
_drain_remaining(proc, captured)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _finish(
|
|
236
|
+
proc: subprocess.Popen, captured: list, status: str, reason: str
|
|
237
|
+
) -> SmokeResult:
|
|
238
|
+
code = proc.poll()
|
|
239
|
+
evidence = "".join(captured)[:_MAX_EVIDENCE_CHARS]
|
|
240
|
+
return SmokeResult(status, evidence=evidence, exit_code=code, reason=reason)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _signal_group(proc: subprocess.Popen, sig: int) -> None:
|
|
244
|
+
"""Send ``sig`` to the launch's whole process group so a server that forked
|
|
245
|
+
its own children (e.g. ``npm start`` -> node worker) is torn down too, not
|
|
246
|
+
just the shell. Falls back to signalling the direct child where process
|
|
247
|
+
groups are unavailable (Windows) or already gone."""
|
|
248
|
+
if hasattr(os, "killpg"):
|
|
249
|
+
try:
|
|
250
|
+
os.killpg(os.getpgid(proc.pid), sig)
|
|
251
|
+
return
|
|
252
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
253
|
+
pass
|
|
254
|
+
try:
|
|
255
|
+
proc.terminate() if sig == signal.SIGTERM else proc.kill()
|
|
256
|
+
except OSError:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _terminate(proc: subprocess.Popen) -> None:
|
|
261
|
+
"""Best-effort teardown: close stdin, SIGTERM the group, then SIGKILL it."""
|
|
262
|
+
if proc.poll() is not None:
|
|
263
|
+
return
|
|
264
|
+
try:
|
|
265
|
+
if proc.stdin is not None:
|
|
266
|
+
proc.stdin.close()
|
|
267
|
+
except OSError:
|
|
268
|
+
pass
|
|
269
|
+
_signal_group(proc, signal.SIGTERM)
|
|
270
|
+
try:
|
|
271
|
+
proc.wait(timeout=3)
|
|
272
|
+
return
|
|
273
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
274
|
+
pass
|
|
275
|
+
_signal_group(proc, signal.SIGKILL)
|
|
276
|
+
try:
|
|
277
|
+
proc.wait(timeout=3)
|
|
278
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
279
|
+
logger.debug("Smoke process group did not exit after kill.")
|
misterdev/core/gitcmd.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Shared runner for one-off, read-only git commands.
|
|
2
|
+
|
|
3
|
+
Several modules run a single git command (``rev-parse``, ``diff``, ``log``,
|
|
4
|
+
``ls-files``) the same way: ``subprocess.run`` with ``shell=True`` against a repo
|
|
5
|
+
directory, capturing text output, bounded by a short timeout, and degrading to a
|
|
6
|
+
no-op on any failure. This centralizes that boilerplate; callers keep their own
|
|
7
|
+
post-processing (which legitimately differs — a sha, a diff body, stat text).
|
|
8
|
+
|
|
9
|
+
Only for fixed, read-only commands. Commands that interpolate untrusted values
|
|
10
|
+
must NOT use this shell path; they belong in the list-form helpers that avoid the
|
|
11
|
+
shell entirely.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_git(
|
|
20
|
+
command: str, cwd: Path, timeout: float = 30
|
|
21
|
+
) -> Optional[subprocess.CompletedProcess]:
|
|
22
|
+
"""Run a fixed git ``command`` (shell string) in ``cwd``.
|
|
23
|
+
|
|
24
|
+
Returns the ``CompletedProcess`` (with ``.returncode`` and ``.stdout``), or
|
|
25
|
+
``None`` when it could not run at all — missing git, a timeout, or an OS
|
|
26
|
+
error. Never raises, so callers can treat ``None`` (and a non-zero
|
|
27
|
+
``returncode``) as "no result" and degrade gracefully.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
return subprocess.run(
|
|
31
|
+
command,
|
|
32
|
+
shell=True,
|
|
33
|
+
cwd=cwd,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
timeout=timeout,
|
|
37
|
+
)
|
|
38
|
+
except (OSError, subprocess.SubprocessError):
|
|
39
|
+
return None
|
|
File without changes
|