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.
- smoke_optimiser/__init__.py +1 -0
- smoke_optimiser/__main__.py +9 -0
- smoke_optimiser/cli.py +249 -0
- smoke_optimiser/config.py +152 -0
- smoke_optimiser/environment.py +86 -0
- smoke_optimiser/optimiser/__init__.py +0 -0
- smoke_optimiser/optimiser/filters.py +111 -0
- smoke_optimiser/optimiser/greedy.py +189 -0
- smoke_optimiser/optimiser/models.py +38 -0
- smoke_optimiser/plugin.py +126 -0
- smoke_optimiser/profiler/__init__.py +0 -0
- smoke_optimiser/profiler/models.py +112 -0
- smoke_optimiser/profiler/parser.py +138 -0
- smoke_optimiser/profiler/runner.py +218 -0
- smoke_optimiser/py.typed +0 -0
- smoke_optimiser/reports/__init__.py +0 -0
- smoke_optimiser/reports/repro.py +47 -0
- smoke_optimiser/reports/smoke_suite.py +144 -0
- smoke_optimiser/reports/summary.py +84 -0
- smoke_optimiser-0.1.0.dist-info/METADATA +143 -0
- smoke_optimiser-0.1.0.dist-info/RECORD +24 -0
- smoke_optimiser-0.1.0.dist-info/WHEEL +4 -0
- smoke_optimiser-0.1.0.dist-info/entry_points.txt +5 -0
- smoke_optimiser-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
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
|
+
)
|