smoke-optimiser 0.1.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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,9 @@
1
+ from smoke_optimiser.cli import app
2
+
3
+
4
+ def main() -> None:
5
+ app()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
smoke_optimiser/cli.py ADDED
@@ -0,0 +1,249 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from pydantic import ValidationError
7
+
8
+ from smoke_optimiser.config import OperationMode, load_file_config, resolve_config
9
+ from smoke_optimiser.optimiser.filters import apply_filters
10
+ from smoke_optimiser.optimiser.greedy import optimise
11
+ from smoke_optimiser.profiler.models import (
12
+ MachineModel,
13
+ ProfilingDataFile,
14
+ ProfilingMetaModel,
15
+ ProfilingOutcomeModel,
16
+ )
17
+ from smoke_optimiser.profiler.runner import run_profiling
18
+ from smoke_optimiser.reports.smoke_suite import write_smoke_suite
19
+ from smoke_optimiser.reports.summary import format_summary
20
+
21
+ app = typer.Typer(pretty_exceptions_show_locals=False)
22
+
23
+
24
+ def _split_comma_list(items: list[str] | None) -> list[str]:
25
+ """Split comma-separated strings in a list into individual items."""
26
+ if items is None:
27
+ return []
28
+ result = []
29
+ for item in items:
30
+ if "," in item:
31
+ result.extend([x.strip() for x in item.split(",") if x.strip()])
32
+ elif item.strip():
33
+ result.append(item.strip())
34
+ return result
35
+
36
+
37
+ @app.command()
38
+ def main(
39
+ profile_only: Annotated[
40
+ bool,
41
+ typer.Option("--profile-only", help="Run only the profiling phase."),
42
+ ] = False,
43
+ optimise_only: Annotated[
44
+ bool,
45
+ typer.Option("--optimise-only", help="Run only the optimisation phase."),
46
+ ] = False,
47
+ time_cap: Annotated[
48
+ float | None,
49
+ typer.Option("--time-cap", help="Maximum wall-clock runtime of the smoke suite."),
50
+ ] = None,
51
+ target_cov: Annotated[
52
+ float | None,
53
+ typer.Option(
54
+ "--target-cov",
55
+ help="Target percentage of the full suite's branch coverage to achieve.",
56
+ ),
57
+ ] = None,
58
+ include: Annotated[
59
+ list[str] | None,
60
+ typer.Option("--include", help="Tests or markers that MUST be in the smoke suite."),
61
+ ] = None,
62
+ exclude: Annotated[
63
+ list[str] | None,
64
+ typer.Option("--exclude", help="Tests or markers that MUST NOT be in the smoke suite."),
65
+ ] = None,
66
+ pytest_args: Annotated[
67
+ str | None,
68
+ typer.Option("--pytest-args", help="Extra arguments forwarded to pytest."),
69
+ ] = None,
70
+ output_json: Annotated[
71
+ Path | None,
72
+ typer.Option("--output-json", help="Path for the smoke suite definition file."),
73
+ ] = None,
74
+ allow_ordered: Annotated[
75
+ bool,
76
+ typer.Option(
77
+ "--allow-ordered",
78
+ help="Suppress error when pytest-randomly is not installed.",
79
+ ),
80
+ ] = False,
81
+ src: Annotated[
82
+ str | None,
83
+ typer.Option("--src", help="Source directory/package for coverage instrumentation."),
84
+ ] = None,
85
+ iterations: Annotated[
86
+ int | None,
87
+ typer.Option("--iterations", help="Number of times to run the suite to average timing."),
88
+ ] = None,
89
+ ) -> None:
90
+ """smoke-optimiser: Identify a minimal, high-value smoke test suite."""
91
+ if profile_only and optimise_only:
92
+ typer.secho(
93
+ "❌ Error: --profile-only and --optimise-only are mutually exclusive.", fg=typer.colors.RED, err=True
94
+ )
95
+ raise typer.Exit(code=1)
96
+
97
+ # Conflict check: --src and --cov in --pytest-args
98
+ if src and pytest_args and "--cov" in pytest_args:
99
+ typer.secho(
100
+ "❌ Error: Conflict detected. Cannot use --src and --cov in --pytest-args simultaneously.",
101
+ fg=typer.colors.RED,
102
+ err=True,
103
+ )
104
+ raise typer.Exit(code=1)
105
+
106
+ # Normalize comma-separated includes/excludes
107
+ final_includes = _split_comma_list(include)
108
+ final_excludes = _split_comma_list(exclude)
109
+
110
+ # Collect CLI overrides
111
+ cli_overrides = {
112
+ "profile_only": profile_only,
113
+ "optimise_only": optimise_only,
114
+ "time_cap": time_cap,
115
+ "target_cov": target_cov,
116
+ "include_mandatory": final_includes if include is not None else None,
117
+ "exclude_mandatory": final_excludes if exclude is not None else None,
118
+ "pytest_args": pytest_args,
119
+ "output_json": output_json,
120
+ "allow_ordered": allow_ordered,
121
+ "cov_source": src,
122
+ "iterations": iterations,
123
+ }
124
+
125
+ project_root = Path.cwd()
126
+ try:
127
+ file_config = load_file_config(project_root)
128
+ config = resolve_config(file_config, cli_overrides, project_root)
129
+ except ValidationError as err:
130
+ typer.secho("❌ Error: Invalid configuration in pyproject.toml", fg=typer.colors.RED, err=True)
131
+ for error in err.errors():
132
+ loc = ".".join(str(loc) for loc in error["loc"])
133
+ typer.secho(f" - {loc}: {error['msg']}", fg=typer.colors.RED, err=True)
134
+ raise typer.Exit(code=1) from None
135
+
136
+ # Inform user about heuristic fallback
137
+ # If it wasn't in CLI and isn't in pytest_args, and we are profiling
138
+ has_cov_in_args = pytest_args and "--cov" in pytest_args
139
+ if config.mode != OperationMode.OPTIMISE_ONLY and src is None and not has_cov_in_args:
140
+ # Check if it was in pyproject.toml
141
+ from_file = file_config and file_config.cov_source
142
+ if not from_file:
143
+ typer.secho(
144
+ f"⚠️ Warning: --src was not specified. Falling back to heuristic discovery: --src={config.cov_source}",
145
+ fg=typer.colors.YELLOW,
146
+ err=True,
147
+ )
148
+
149
+ profiling_data = None
150
+ intermediate_file = project_root / ".smoke_profiling_data.json"
151
+
152
+ # Phase 1: Profiling
153
+ if config.mode != OperationMode.OPTIMISE_ONLY:
154
+ typer.secho("🔍 Running profiling...", fg=typer.colors.CYAN, bold=True)
155
+ profiling_data = run_profiling(config, project_root)
156
+
157
+ if config.mode == OperationMode.PROFILE_ONLY:
158
+ # Save intermediate data
159
+ machine = profiling_data.meta.machine
160
+ machine_model = MachineModel(
161
+ os=machine.os,
162
+ os_version=machine.os_version,
163
+ platform=machine.platform,
164
+ architecture=machine.architecture,
165
+ cpu_model=machine.cpu_model,
166
+ cpu_cores_physical=machine.cpu_cores_physical,
167
+ cpu_cores_logical=machine.cpu_cores_logical,
168
+ ram_total_mb=machine.ram_total_mb,
169
+ ram_available_mb=machine.ram_available_mb,
170
+ hostname=machine.hostname,
171
+ )
172
+ meta_model = ProfilingMetaModel(
173
+ timestamp=profiling_data.meta.timestamp,
174
+ commit=profiling_data.meta.commit,
175
+ python_version=profiling_data.meta.python_version,
176
+ coverage_version=profiling_data.meta.coverage_version,
177
+ command=profiling_data.meta.command,
178
+ machine=machine_model,
179
+ )
180
+ test_models = {
181
+ tid: ProfilingOutcomeModel(
182
+ test_id=po.test_id,
183
+ duration_s=po.duration_s,
184
+ passed=po.passed,
185
+ branches_covered=list(po.branches_covered),
186
+ markers=list(po.markers),
187
+ )
188
+ for tid, po in profiling_data.tests.items()
189
+ }
190
+ file_data = ProfilingDataFile(
191
+ meta=meta_model,
192
+ tests=test_models,
193
+ total_branches=list(profiling_data.total_branches),
194
+ )
195
+ intermediate_file.unlink(missing_ok=True)
196
+ with open(intermediate_file, "w") as f:
197
+ json.dump(file_data.model_dump(mode="json"), f)
198
+ typer.secho(f"💾 Profiling data saved to {intermediate_file}", fg=typer.colors.GREEN)
199
+
200
+ # Phase 2: Optimisation
201
+ if config.mode != OperationMode.PROFILE_ONLY:
202
+ if profiling_data is None:
203
+ # Try to load from intermediate file if it exists
204
+ if not intermediate_file.exists():
205
+ typer.secho(
206
+ "❌ Error: No profiling data found. Run without --optimise-only first.",
207
+ fg=typer.colors.RED,
208
+ err=True,
209
+ )
210
+ raise typer.Exit(code=1)
211
+
212
+ try:
213
+ with open(intermediate_file, "rb") as f:
214
+ raw = json.load(f)
215
+ profiling_data = ProfilingDataFile(**raw).to_profiling_data()
216
+ except (OSError, json.JSONDecodeError) as e:
217
+ typer.secho(
218
+ f"❌ Error: Failed to parse profiling data ({intermediate_file}): {e}",
219
+ fg=typer.colors.RED,
220
+ err=True,
221
+ )
222
+ raise typer.Exit(code=1) from None
223
+
224
+ typer.secho("⚡ Optimising smoke suite...", fg=typer.colors.CYAN, bold=True)
225
+ filtered = apply_filters(profiling_data.tests, config.include_mandatory, config.exclude_mandatory)
226
+
227
+ # Warn about unmatched includes/excludes
228
+ for pattern in filtered.unmatched_includes:
229
+ typer.secho(
230
+ f"⚠️ Warning: Include pattern '{pattern}' matched no tests.",
231
+ fg=typer.colors.YELLOW,
232
+ err=True,
233
+ )
234
+ for pattern in filtered.unmatched_excludes:
235
+ typer.secho(
236
+ f"⚠️ Warning: Exclude pattern '{pattern}' matched no tests.",
237
+ fg=typer.colors.YELLOW,
238
+ err=True,
239
+ )
240
+
241
+ result = optimise(filtered, profiling_data.total_branches, config.time_cap, config.target_cov)
242
+
243
+ # Output results
244
+ write_smoke_suite(result, config, profiling_data.meta, config.output_json)
245
+ typer.echo(format_summary(result, config, profiling_data.meta))
246
+
247
+
248
+ if __name__ == "__main__":
249
+ app()
@@ -0,0 +1,152 @@
1
+ import tomllib
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import typer
8
+ from pydantic import BaseModel, Field, ValidationError
9
+
10
+
11
+ class OperationMode(Enum):
12
+ """Execution modes for smoke-optimiser."""
13
+
14
+ FULL = "full"
15
+ PROFILE_ONLY = "profile-only"
16
+ OPTIMISE_ONLY = "optimise-only"
17
+
18
+
19
+ class FileConfig(BaseModel):
20
+ """Configuration model for [tool.smoke_optimiser] in pyproject.toml."""
21
+
22
+ time_cap: float = Field(default=15.0, ge=0.0)
23
+ target_cov: float = Field(default=100.0, ge=0.0, le=100.0)
24
+ include_mandatory: list[str] = Field(default_factory=list)
25
+ exclude_mandatory: list[str] = Field(default_factory=list)
26
+ pytest_args: str = Field(default="")
27
+ output_json: Path = Field(default=Path("./.smoke_suite.json"))
28
+ allow_ordered: bool = Field(default=False)
29
+ smoke_file_path: Path = Field(default=Path("./.smoke_suite.json"))
30
+ cov_source: str | None = Field(default=None)
31
+ iterations: int = Field(default=1, ge=1)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ResolvedConfig:
36
+ """Fully resolved configuration after merging defaults, file, and CLI."""
37
+
38
+ mode: OperationMode
39
+ time_cap: float
40
+ target_cov: float
41
+ include_mandatory: list[str]
42
+ exclude_mandatory: list[str]
43
+ pytest_args: str
44
+ output_json: Path
45
+ allow_ordered: bool
46
+ cov_source: str
47
+ iterations: int
48
+
49
+
50
+ def _discover_cov_target(project_root: Path) -> str:
51
+ """Best-effort discovery of the source directory for coverage."""
52
+ # 1. src/ layout is a very strong signal
53
+ if (project_root / "src").is_dir():
54
+ return "src"
55
+
56
+ # 2. Package matching project name in pyproject.toml
57
+ pyproject_path = project_root / "pyproject.toml"
58
+ if pyproject_path.exists():
59
+ try:
60
+ with open(pyproject_path, "rb") as f:
61
+ data = tomllib.load(f)
62
+ name = data.get("project", {}).get("name")
63
+ if name:
64
+ normalized = name.replace("-", "_")
65
+ # If there's a folder matching the project name, instrument it
66
+ if (project_root / normalized).is_dir():
67
+ return normalized
68
+ except (tomllib.TOMLDecodeError, OSError) as e:
69
+ typer.secho(
70
+ f"⚠️ Warning: Failed to parse project name from pyproject.toml: {e}",
71
+ fg=typer.colors.YELLOW,
72
+ err=True,
73
+ )
74
+
75
+ return "."
76
+
77
+
78
+ def load_file_config(project_root: Path) -> FileConfig | None:
79
+ """Load configuration from pyproject.toml in project root."""
80
+ pyproject_path = project_root / "pyproject.toml"
81
+ if not pyproject_path.exists():
82
+ return None
83
+
84
+ try:
85
+ with open(pyproject_path, "rb") as f:
86
+ data = tomllib.load(f)
87
+ except (OSError, tomllib.TOMLDecodeError):
88
+ return None
89
+
90
+ tool_config = data.get("tool", {}).get("smoke_optimiser")
91
+ if tool_config is None:
92
+ return None
93
+
94
+ try:
95
+ return FileConfig(**tool_config)
96
+ except ValidationError as e:
97
+ typer.secho(
98
+ "❌ Error: Invalid configuration in pyproject.toml [tool.smoke_optimiser]:",
99
+ fg=typer.colors.RED,
100
+ err=True,
101
+ )
102
+ for err in e.errors():
103
+ loc = ".".join(str(loc_part) for loc_part in err["loc"])
104
+ typer.secho(f" - {loc}: {err['msg']}", fg=typer.colors.RED, err=True)
105
+ raise typer.Exit(code=1) from None
106
+
107
+
108
+ def resolve_config(
109
+ file_config: FileConfig | None,
110
+ cli_overrides: dict[str, Any],
111
+ project_root: Path,
112
+ ) -> ResolvedConfig:
113
+ """Merge default config, file config, and CLI overrides into a final ResolvedConfig."""
114
+ # Start with defaults from FileConfig
115
+ base_config = file_config if file_config else FileConfig()
116
+
117
+ # Apply CLI overrides
118
+ config_dict = base_config.model_dump()
119
+
120
+ # Determine mode from CLI overrides first
121
+ profile_only = cli_overrides.get("profile_only", False)
122
+ optimise_only = cli_overrides.get("optimise_only", False)
123
+
124
+ if profile_only:
125
+ mode = OperationMode.PROFILE_ONLY
126
+ elif optimise_only:
127
+ mode = OperationMode.OPTIMISE_ONLY
128
+ else:
129
+ mode = OperationMode.FULL
130
+
131
+ # Only override if CLI value is not None
132
+ for key, value in cli_overrides.items():
133
+ if key in config_dict and value is not None:
134
+ config_dict[key] = value
135
+
136
+ # If cov_source is still None (not in file and not in CLI), discover it
137
+ cov_source = config_dict["cov_source"]
138
+ if cov_source is None:
139
+ cov_source = _discover_cov_target(project_root)
140
+
141
+ return ResolvedConfig(
142
+ mode=mode,
143
+ time_cap=config_dict["time_cap"],
144
+ target_cov=config_dict["target_cov"],
145
+ include_mandatory=config_dict["include_mandatory"],
146
+ exclude_mandatory=config_dict["exclude_mandatory"],
147
+ pytest_args=config_dict["pytest_args"],
148
+ output_json=Path(config_dict["output_json"]),
149
+ allow_ordered=config_dict["allow_ordered"],
150
+ cov_source=cov_source,
151
+ iterations=config_dict["iterations"],
152
+ )
@@ -0,0 +1,86 @@
1
+ import os
2
+ import platform
3
+ import shutil
4
+ import socket
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+
8
+ try:
9
+ import psutil
10
+ except ImportError:
11
+ psutil = None # ty: ignore[invalid-assignment] - psutil is an optional dependency
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class MachineEnvironment:
16
+ """Captured details about the machine environment."""
17
+
18
+ os: str | None
19
+ os_version: str | None
20
+ platform: str | None
21
+ architecture: str | None
22
+ cpu_model: str | None
23
+ cpu_cores_physical: int | None
24
+ cpu_cores_logical: int | None
25
+ ram_total_mb: int | None
26
+ ram_available_mb: int | None
27
+ hostname: str | None
28
+
29
+
30
+ def _get_cpu_model() -> str | None:
31
+ """Best-effort CPU model name retrieval."""
32
+ if platform.system() == "Darwin":
33
+ # On macOS, we can use sysctl
34
+ sysctl_path = shutil.which("sysctl")
35
+ if sysctl_path:
36
+ try:
37
+ return subprocess.run( # noqa: S603
38
+ [sysctl_path, "-n", "machdep.cpu.brand_string"], capture_output=True, text=True, check=False
39
+ ).stdout.strip()
40
+ except (OSError, ValueError):
41
+ return None
42
+ return None
43
+ elif platform.system() == "Linux":
44
+ # On Linux, parse /proc/cpuinfo
45
+ try:
46
+ with open("/proc/cpuinfo") as f:
47
+ for line in f:
48
+ if line.startswith("model name"):
49
+ return line.split(":")[1].strip()
50
+ except (OSError, IndexError):
51
+ pass
52
+ elif platform.system() == "Windows":
53
+ # On Windows, use platform
54
+ return platform.processor()
55
+
56
+ return None
57
+
58
+
59
+ def capture_environment() -> MachineEnvironment:
60
+ """Capture machine environment information."""
61
+ cpu_cores_logical = os.cpu_count()
62
+ cpu_cores_physical = None
63
+ ram_total_mb = None
64
+ ram_available_mb = None
65
+
66
+ if psutil:
67
+ try:
68
+ cpu_cores_physical = psutil.cpu_count(logical=False)
69
+ virtual_mem = psutil.virtual_memory()
70
+ ram_total_mb = virtual_mem.total // (1024 * 1024)
71
+ ram_available_mb = virtual_mem.available // (1024 * 1024)
72
+ except (OSError, AttributeError):
73
+ pass
74
+
75
+ return MachineEnvironment(
76
+ os=platform.system(),
77
+ os_version=platform.release(),
78
+ platform=platform.platform(),
79
+ architecture=platform.machine(),
80
+ cpu_model=_get_cpu_model(),
81
+ cpu_cores_physical=cpu_cores_physical,
82
+ cpu_cores_logical=cpu_cores_logical,
83
+ ram_total_mb=ram_total_mb,
84
+ ram_available_mb=ram_available_mb,
85
+ hostname=socket.gethostname(),
86
+ )
File without changes
@@ -0,0 +1,111 @@
1
+ import fnmatch
2
+ from dataclasses import dataclass
3
+
4
+ from smoke_optimiser.profiler.models import ProfilingOutcome
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class FilteredTests:
9
+ """Grouped tests after applying filters."""
10
+
11
+ candidates: dict[str, ProfilingOutcome]
12
+ mandatory_included: dict[str, ProfilingOutcome]
13
+ excluded: dict[str, ProfilingOutcome]
14
+ failed: dict[str, ProfilingOutcome]
15
+ unmatched_includes: list[str]
16
+ unmatched_excludes: list[str]
17
+
18
+
19
+ def _matches_pattern(test_id: str, markers: frozenset[str], pattern: str) -> bool:
20
+ """Check if a test matches a glob pattern or marker pattern.
21
+
22
+ Supports:
23
+ - @pytest.mark.name
24
+ - Exact test ID (nodeid)
25
+ - File or directory prefix (e.g. 'tests/test_file.py' matches 'tests/test_file.py::test_it')
26
+ - Test name match (e.g. 'test_it' matches 'tests/test_file.py::test_it')
27
+ - Glob patterns (e.g. 'tests/unit/*')
28
+ """
29
+ if not pattern:
30
+ return False
31
+
32
+ # 1. Marker match
33
+ if pattern.startswith("@pytest.mark."):
34
+ marker_name = pattern[len("@pytest.mark.") :]
35
+ return marker_name in markers
36
+
37
+ # 2. Exact match
38
+ if test_id == pattern:
39
+ return True
40
+
41
+ # 3. File/Directory prefix match
42
+ if test_id.startswith(f"{pattern}::"):
43
+ return True
44
+
45
+ # 4. Test name match (suffix after ::)
46
+ if f"::{pattern}" in test_id:
47
+ return True
48
+
49
+ # 5. Glob match
50
+ return fnmatch.fnmatch(test_id, pattern)
51
+
52
+
53
+ def apply_filters(
54
+ tests: dict[str, ProfilingOutcome],
55
+ include_mandatory: list[str],
56
+ exclude_mandatory: list[str],
57
+ ) -> FilteredTests:
58
+ """Split tests into candidates, mandatory included, excluded, and failed.
59
+
60
+ 1. Failed tests are always hard-excluded.
61
+ 2. Exclude mandatory patterns take precedence.
62
+ 3. Include mandatory patterns force inclusion.
63
+ """
64
+ candidates = {}
65
+ mandatory_included = {}
66
+ excluded = {}
67
+ failed = {}
68
+
69
+ matched_includes = set()
70
+ matched_excludes = set()
71
+
72
+ for test_id, outcome in tests.items():
73
+ if not outcome.passed:
74
+ failed[test_id] = outcome
75
+ continue
76
+
77
+ # Check exclude first (precedence)
78
+ is_excluded = False
79
+ for pattern in exclude_mandatory:
80
+ if _matches_pattern(test_id, outcome.markers, pattern):
81
+ excluded[test_id] = outcome
82
+ is_excluded = True
83
+ matched_excludes.add(pattern)
84
+ break
85
+
86
+ if is_excluded:
87
+ continue
88
+
89
+ # Check include
90
+ is_mandatory = False
91
+ for pattern in include_mandatory:
92
+ if _matches_pattern(test_id, outcome.markers, pattern):
93
+ mandatory_included[test_id] = outcome
94
+ is_mandatory = True
95
+ matched_includes.add(pattern)
96
+ break
97
+
98
+ if not is_mandatory:
99
+ candidates[test_id] = outcome
100
+
101
+ unmatched_includes = [p for p in include_mandatory if p and p not in matched_includes]
102
+ unmatched_excludes = [p for p in exclude_mandatory if p and p not in matched_excludes]
103
+
104
+ return FilteredTests(
105
+ candidates=candidates,
106
+ mandatory_included=mandatory_included,
107
+ excluded=excluded,
108
+ failed=failed,
109
+ unmatched_includes=unmatched_includes,
110
+ unmatched_excludes=unmatched_excludes,
111
+ )