springdocker 1.0.1__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.
- springdocker/__init__.py +9 -0
- springdocker/analyze.py +284 -0
- springdocker/benchmarks/__init__.py +2 -0
- springdocker/benchmarks/generate.py +104 -0
- springdocker/benchmarks/runner.py +343 -0
- springdocker/cli.py +289 -0
- springdocker/commands.py +388 -0
- springdocker/compare.py +138 -0
- springdocker/config.py +365 -0
- springdocker/dockerfile.py +332 -0
- springdocker/errors.py +16 -0
- springdocker/plugins.py +75 -0
- springdocker/project_detect.py +256 -0
- springdocker/regression.py +151 -0
- springdocker/services/__init__.py +2 -0
- springdocker/services/benchmark_service.py +124 -0
- springdocker/services/dockerfile_service.py +76 -0
- springdocker/services/project_service.py +37 -0
- springdocker-1.0.1.dist-info/METADATA +189 -0
- springdocker-1.0.1.dist-info/RECORD +23 -0
- springdocker-1.0.1.dist-info/WHEEL +5 -0
- springdocker-1.0.1.dist-info/entry_points.txt +2 -0
- springdocker-1.0.1.dist-info/top_level.txt +1 -0
springdocker/commands.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from .benchmarks.generate import generate_benchmark_assets
|
|
11
|
+
from .benchmarks.runner import run_benchmarks
|
|
12
|
+
from .errors import EXIT_FAILURE, EXIT_OK, EXIT_USAGE, print_error, print_warning
|
|
13
|
+
from .project_detect import inspect_project
|
|
14
|
+
from .regression import format_regression_json, format_regression_table
|
|
15
|
+
from .services import benchmark_service, dockerfile_service, project_service
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_checked(command: list[str], cwd: Path) -> int:
|
|
19
|
+
print("$ " + " ".join(command))
|
|
20
|
+
completed = subprocess.run(command, cwd=cwd)
|
|
21
|
+
return completed.returncode
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_doctor(project_root: Path, build_tool: str | None) -> int:
|
|
25
|
+
try:
|
|
26
|
+
info = project_service.load_project_info(project_root, build_tool)
|
|
27
|
+
except ValueError as exc:
|
|
28
|
+
print_error(str(exc))
|
|
29
|
+
return EXIT_USAGE
|
|
30
|
+
|
|
31
|
+
print(f"project_root: {info.root}")
|
|
32
|
+
print(f"build_tool: {info.build_tool}")
|
|
33
|
+
print(f"spring_markers: {'yes' if info.has_spring_markers else 'no'}")
|
|
34
|
+
if not info.has_spring_markers:
|
|
35
|
+
print_warning("Spring Boot markers were not found; continue only if this is intentional.")
|
|
36
|
+
return EXIT_OK
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _render_inspect_table(info) -> str:
|
|
40
|
+
lines = [
|
|
41
|
+
"| Field | Value |",
|
|
42
|
+
"|---|---|",
|
|
43
|
+
f"| Project root | {info.root} |",
|
|
44
|
+
f"| Build tool | {info.build_tool} |",
|
|
45
|
+
f"| Spring markers | {'yes' if info.has_spring_markers else 'no'} |",
|
|
46
|
+
f"| Java version | {info.java_version if info.java_version is not None else '-'} |",
|
|
47
|
+
f"| Spring Boot version | {info.spring_boot_version or '-'} |",
|
|
48
|
+
f"| Config exists | {'yes' if info.config_exists else 'no'} |",
|
|
49
|
+
f"| Generated Dockerfiles | {', '.join(info.generated_dockerfiles) or '-'} |",
|
|
50
|
+
f"| Direct dependencies | {', '.join(info.direct_dependencies) or '-'} |",
|
|
51
|
+
f"| Reflection hits | {len(info.reflection_hits)} |",
|
|
52
|
+
f"| Runtime compatibility | {info.runtime_compatibility} |",
|
|
53
|
+
f"| Recommendations | {'; '.join(info.recommendations) or '-'} |",
|
|
54
|
+
]
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_inspect(project_root: Path, build_tool: str | None, output_format: str) -> int:
|
|
59
|
+
try:
|
|
60
|
+
info = project_service.load_project_details(project_root, build_tool)
|
|
61
|
+
except ValueError as exc:
|
|
62
|
+
print_error(str(exc))
|
|
63
|
+
return EXIT_USAGE
|
|
64
|
+
|
|
65
|
+
payload = {
|
|
66
|
+
"project_root": str(info.root),
|
|
67
|
+
"build_tool": info.build_tool,
|
|
68
|
+
"has_spring_markers": info.has_spring_markers,
|
|
69
|
+
"java_version": info.java_version,
|
|
70
|
+
"spring_boot_version": info.spring_boot_version,
|
|
71
|
+
"direct_dependencies": list(info.direct_dependencies),
|
|
72
|
+
"config_exists": info.config_exists,
|
|
73
|
+
"generated_dockerfiles": list(info.generated_dockerfiles),
|
|
74
|
+
"reflection_hits": list(info.reflection_hits),
|
|
75
|
+
"runtime_compatibility": info.runtime_compatibility,
|
|
76
|
+
"recommendations": list(info.recommendations),
|
|
77
|
+
}
|
|
78
|
+
if output_format == "json":
|
|
79
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
80
|
+
else:
|
|
81
|
+
print(_render_inspect_table(info))
|
|
82
|
+
return EXIT_OK
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _render_explain_table(payload: dict[str, object]) -> str:
|
|
86
|
+
features = cast(list[dict[str, object]], payload.get("features", []))
|
|
87
|
+
feature_names = ", ".join(
|
|
88
|
+
str(feature["name"]) for feature in features if feature.get("enabled") and "name" in feature
|
|
89
|
+
)
|
|
90
|
+
notes = cast(list[str], payload.get("notes", []))
|
|
91
|
+
return "\n".join(
|
|
92
|
+
[
|
|
93
|
+
"| Field | Value |",
|
|
94
|
+
"|---|---|",
|
|
95
|
+
f"| Source | {payload.get('source', '-')} |",
|
|
96
|
+
f"| Build tool | {payload.get('build_tool') or '-'} |",
|
|
97
|
+
f"| Java version | {payload.get('java_version') if payload.get('java_version') is not None else '-'} |",
|
|
98
|
+
f"| Stage count | {payload.get('stage_count', '-')} |",
|
|
99
|
+
f"| Features | {feature_names or '-'} |",
|
|
100
|
+
f"| Summary | {payload.get('summary', '-')} |",
|
|
101
|
+
f"| Notes | {'; '.join(notes) if notes else '-'} |",
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_benchmark_compare(
|
|
107
|
+
project_root: Path,
|
|
108
|
+
raw_csv: str,
|
|
109
|
+
baseline_variant: str,
|
|
110
|
+
output_format: str,
|
|
111
|
+
scenario: str | None,
|
|
112
|
+
) -> int:
|
|
113
|
+
try:
|
|
114
|
+
rendered = benchmark_service.render_comparison(
|
|
115
|
+
project_root=project_root,
|
|
116
|
+
raw_csv=raw_csv,
|
|
117
|
+
baseline_variant=baseline_variant,
|
|
118
|
+
output_format=output_format,
|
|
119
|
+
scenario=scenario,
|
|
120
|
+
)
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
print_error(str(exc))
|
|
123
|
+
return EXIT_USAGE
|
|
124
|
+
|
|
125
|
+
print(rendered)
|
|
126
|
+
return EXIT_OK
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_explain(project_root: Path, dockerfile_path: str, output_format: str) -> int:
|
|
130
|
+
try:
|
|
131
|
+
payload = dockerfile_service.explain_dockerfile(project_root, dockerfile_path)
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
print_error(str(exc))
|
|
134
|
+
return EXIT_USAGE
|
|
135
|
+
|
|
136
|
+
if output_format == "json":
|
|
137
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
138
|
+
else:
|
|
139
|
+
print(_render_explain_table(payload))
|
|
140
|
+
return EXIT_OK
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_init(
|
|
144
|
+
project_root: Path,
|
|
145
|
+
build_tool: str | None,
|
|
146
|
+
config_path: Path,
|
|
147
|
+
profile: str,
|
|
148
|
+
force: bool,
|
|
149
|
+
print_only: bool,
|
|
150
|
+
) -> int:
|
|
151
|
+
try:
|
|
152
|
+
result = project_service.prepare_default_config(
|
|
153
|
+
project_root=project_root,
|
|
154
|
+
build_tool=build_tool,
|
|
155
|
+
config_path=config_path,
|
|
156
|
+
profile=profile,
|
|
157
|
+
force=force,
|
|
158
|
+
print_only=print_only,
|
|
159
|
+
)
|
|
160
|
+
except ValueError as exc:
|
|
161
|
+
print_error(str(exc))
|
|
162
|
+
return EXIT_USAGE
|
|
163
|
+
except FileExistsError as exc:
|
|
164
|
+
print_error(str(exc))
|
|
165
|
+
print("hint: rerun with --force to overwrite", file=sys.stderr)
|
|
166
|
+
return EXIT_USAGE
|
|
167
|
+
|
|
168
|
+
if result.rendered is not None:
|
|
169
|
+
print(result.rendered)
|
|
170
|
+
return EXIT_OK
|
|
171
|
+
|
|
172
|
+
print(f"wrote config: {config_path}")
|
|
173
|
+
print("next: springdocker benchmark run")
|
|
174
|
+
return EXIT_OK
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _use_legacy_scripts(explicit: bool) -> bool:
|
|
178
|
+
if explicit:
|
|
179
|
+
return True
|
|
180
|
+
return os.environ.get("SPRINGDOCKER_LEGACY_SCRIPTS", "").lower() in {"1", "true", "yes", "on"}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_dockerfile_generate(
|
|
184
|
+
project_root: Path,
|
|
185
|
+
build_tool: str | None,
|
|
186
|
+
output: str,
|
|
187
|
+
java_version: int,
|
|
188
|
+
must_have_modules_file: str | None,
|
|
189
|
+
extra_args: list[str],
|
|
190
|
+
use_legacy_scripts: bool,
|
|
191
|
+
) -> int:
|
|
192
|
+
try:
|
|
193
|
+
info = inspect_project(project_root, build_tool)
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
print_error(str(exc))
|
|
196
|
+
return EXIT_USAGE
|
|
197
|
+
|
|
198
|
+
if _use_legacy_scripts(use_legacy_scripts):
|
|
199
|
+
script = project_root / "tools" / "dockerfile_wizard.py"
|
|
200
|
+
if not script.exists():
|
|
201
|
+
print_error(f"missing script: {script}")
|
|
202
|
+
return EXIT_USAGE
|
|
203
|
+
|
|
204
|
+
cmd = [
|
|
205
|
+
"python3",
|
|
206
|
+
str(script),
|
|
207
|
+
"--build-tool",
|
|
208
|
+
info.build_tool,
|
|
209
|
+
"--output",
|
|
210
|
+
output,
|
|
211
|
+
]
|
|
212
|
+
cmd.extend(extra_args)
|
|
213
|
+
return run_checked(cmd, project_root)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
generated = dockerfile_service.generate_dockerfile(
|
|
217
|
+
project_root=project_root,
|
|
218
|
+
output_path=output,
|
|
219
|
+
build_tool=info.build_tool,
|
|
220
|
+
java_version=java_version,
|
|
221
|
+
must_have_modules_file=must_have_modules_file,
|
|
222
|
+
)
|
|
223
|
+
except ValueError as exc:
|
|
224
|
+
print_error(str(exc))
|
|
225
|
+
return EXIT_USAGE
|
|
226
|
+
for warning in generated.plugin_warnings:
|
|
227
|
+
print_warning(warning)
|
|
228
|
+
print(f"wrote dockerfile: {generated.path}")
|
|
229
|
+
return EXIT_OK
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cmd_benchmark_generate(
|
|
233
|
+
project_root: Path,
|
|
234
|
+
build_tool: str | None,
|
|
235
|
+
java_version: int,
|
|
236
|
+
use_legacy_scripts: bool,
|
|
237
|
+
) -> int:
|
|
238
|
+
try:
|
|
239
|
+
info = inspect_project(project_root, build_tool)
|
|
240
|
+
except ValueError as exc:
|
|
241
|
+
print_error(str(exc))
|
|
242
|
+
return EXIT_USAGE
|
|
243
|
+
|
|
244
|
+
if _use_legacy_scripts(use_legacy_scripts):
|
|
245
|
+
script = project_root / "benchmarks" / "setup_benchmark_folders.py"
|
|
246
|
+
if not script.exists():
|
|
247
|
+
print_error(f"missing script: {script}")
|
|
248
|
+
return EXIT_USAGE
|
|
249
|
+
|
|
250
|
+
return run_checked(
|
|
251
|
+
[
|
|
252
|
+
"python3",
|
|
253
|
+
str(script),
|
|
254
|
+
"--build-tool",
|
|
255
|
+
info.build_tool,
|
|
256
|
+
"--java-version",
|
|
257
|
+
str(java_version),
|
|
258
|
+
],
|
|
259
|
+
project_root,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
generate_benchmark_assets(project_root=project_root, build_tool=info.build_tool, java_version=java_version)
|
|
263
|
+
print("generated benchmark scenarios")
|
|
264
|
+
return EXIT_OK
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cmd_benchmark_run(
|
|
268
|
+
project_root: Path,
|
|
269
|
+
build_tool: str | None,
|
|
270
|
+
profile: str,
|
|
271
|
+
extra_args: list[str],
|
|
272
|
+
cpuset_cpus: str | None,
|
|
273
|
+
memory_limit: str | None,
|
|
274
|
+
warmup_runs: int,
|
|
275
|
+
normalized_runtime: bool,
|
|
276
|
+
use_legacy_scripts: bool,
|
|
277
|
+
max_workers: int = 1,
|
|
278
|
+
) -> int:
|
|
279
|
+
try:
|
|
280
|
+
info = inspect_project(project_root, build_tool)
|
|
281
|
+
except ValueError as exc:
|
|
282
|
+
print_error(str(exc))
|
|
283
|
+
return EXIT_USAGE
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
benchmark_service.validate_reproducibility_with_legacy(
|
|
287
|
+
use_legacy_scripts=use_legacy_scripts,
|
|
288
|
+
cpuset_cpus=cpuset_cpus,
|
|
289
|
+
memory_limit=memory_limit,
|
|
290
|
+
warmup_runs=warmup_runs,
|
|
291
|
+
max_workers=max_workers,
|
|
292
|
+
normalized_runtime=normalized_runtime,
|
|
293
|
+
)
|
|
294
|
+
except ValueError as exc:
|
|
295
|
+
print_error(str(exc))
|
|
296
|
+
return EXIT_USAGE
|
|
297
|
+
|
|
298
|
+
if _use_legacy_scripts(use_legacy_scripts):
|
|
299
|
+
script = project_root / "benchmarks" / "common" / "run_all_benchmarks.py"
|
|
300
|
+
if not script.exists():
|
|
301
|
+
print_error(f"missing script: {script}")
|
|
302
|
+
return EXIT_USAGE
|
|
303
|
+
|
|
304
|
+
cmd = [
|
|
305
|
+
"python3",
|
|
306
|
+
str(script),
|
|
307
|
+
"--profile",
|
|
308
|
+
profile,
|
|
309
|
+
"--build-tool",
|
|
310
|
+
info.build_tool,
|
|
311
|
+
]
|
|
312
|
+
cmd.extend(extra_args)
|
|
313
|
+
return run_checked(cmd, project_root)
|
|
314
|
+
|
|
315
|
+
return run_benchmarks(
|
|
316
|
+
project_root=project_root,
|
|
317
|
+
build_tool=info.build_tool,
|
|
318
|
+
profile=profile,
|
|
319
|
+
extra_args=extra_args,
|
|
320
|
+
cpuset_cpus=cpuset_cpus,
|
|
321
|
+
memory_limit=memory_limit,
|
|
322
|
+
warmup_runs=warmup_runs,
|
|
323
|
+
max_workers=max_workers,
|
|
324
|
+
normalized_runtime=normalized_runtime,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def cmd_benchmark_analyze(
|
|
329
|
+
project_root: Path,
|
|
330
|
+
raw_csv: str,
|
|
331
|
+
output_format: str,
|
|
332
|
+
scenario: str | None,
|
|
333
|
+
variant: str | None,
|
|
334
|
+
output_path: str | None,
|
|
335
|
+
fail_on_success_rate_below: float | None,
|
|
336
|
+
baseline_path: str | None,
|
|
337
|
+
fail_on_regression_above: float | None,
|
|
338
|
+
) -> int:
|
|
339
|
+
try:
|
|
340
|
+
outcome = benchmark_service.analyze_csv(
|
|
341
|
+
project_root=project_root,
|
|
342
|
+
raw_csv=raw_csv,
|
|
343
|
+
output_format=output_format,
|
|
344
|
+
scenario=scenario,
|
|
345
|
+
variant=variant,
|
|
346
|
+
output_path=output_path,
|
|
347
|
+
fail_on_success_rate_below=fail_on_success_rate_below,
|
|
348
|
+
baseline_path=baseline_path,
|
|
349
|
+
fail_on_regression_above=fail_on_regression_above,
|
|
350
|
+
)
|
|
351
|
+
except ValueError as exc:
|
|
352
|
+
print_error(str(exc))
|
|
353
|
+
return EXIT_USAGE
|
|
354
|
+
|
|
355
|
+
if outcome.rendered == "No rows matched the provided filters.":
|
|
356
|
+
print(outcome.rendered)
|
|
357
|
+
return EXIT_OK
|
|
358
|
+
|
|
359
|
+
if outcome.output_destination is not None:
|
|
360
|
+
destination = outcome.output_destination
|
|
361
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
destination.write_text(outcome.rendered + "\n", encoding="utf-8")
|
|
363
|
+
print(f"wrote analysis: {destination}")
|
|
364
|
+
else:
|
|
365
|
+
print(outcome.rendered)
|
|
366
|
+
|
|
367
|
+
if outcome.success_rate_violations:
|
|
368
|
+
for violation in outcome.success_rate_violations:
|
|
369
|
+
print_error(violation)
|
|
370
|
+
return EXIT_FAILURE
|
|
371
|
+
|
|
372
|
+
if outcome.baseline_missing is not None:
|
|
373
|
+
print_warning(f"baseline report not found; skipping regression check: {outcome.baseline_missing}")
|
|
374
|
+
return EXIT_OK
|
|
375
|
+
|
|
376
|
+
if outcome.regression_violations:
|
|
377
|
+
rendered_violations = (
|
|
378
|
+
format_regression_json(list(outcome.regression_violations))
|
|
379
|
+
if output_format == "json"
|
|
380
|
+
else format_regression_table(list(outcome.regression_violations))
|
|
381
|
+
)
|
|
382
|
+
print(rendered_violations)
|
|
383
|
+
print_error(
|
|
384
|
+
f"regressions above {outcome.regression_threshold_pct:.1f}% detected against {outcome.baseline_path_used}"
|
|
385
|
+
)
|
|
386
|
+
return EXIT_FAILURE
|
|
387
|
+
|
|
388
|
+
return EXIT_OK
|
springdocker/compare.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
from .analyze import VariantSummary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class VariantDelta:
|
|
12
|
+
scenario: str
|
|
13
|
+
variant: str
|
|
14
|
+
baseline_variant: str
|
|
15
|
+
is_baseline: bool
|
|
16
|
+
build_delta_ms: float | None
|
|
17
|
+
build_delta_pct: float | None
|
|
18
|
+
startup_delta_ms: float | None
|
|
19
|
+
startup_delta_pct: float | None
|
|
20
|
+
image_delta_mb: float | None
|
|
21
|
+
image_delta_pct: float | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _pct_delta(value: float | None, baseline: float | None) -> float | None:
|
|
25
|
+
if value is None or baseline is None or baseline == 0:
|
|
26
|
+
return None
|
|
27
|
+
return ((value - baseline) / baseline) * 100.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _abs_delta(value: float | None, baseline: float | None) -> float | None:
|
|
31
|
+
if value is None or baseline is None:
|
|
32
|
+
return None
|
|
33
|
+
return value - baseline
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compare_summaries(baseline_variant: str, summaries: list[VariantSummary]) -> list[VariantDelta]:
|
|
37
|
+
by_scenario: dict[str, list[VariantSummary]] = {}
|
|
38
|
+
for summary in summaries:
|
|
39
|
+
by_scenario.setdefault(summary.scenario, []).append(summary)
|
|
40
|
+
|
|
41
|
+
deltas: list[VariantDelta] = []
|
|
42
|
+
for scenario, items in sorted(by_scenario.items()):
|
|
43
|
+
baseline = next((item for item in items if item.variant == baseline_variant), None)
|
|
44
|
+
if baseline is None:
|
|
45
|
+
available = ", ".join(sorted(item.variant for item in items)) or "-"
|
|
46
|
+
raise ValueError(f"baseline variant '{baseline_variant}' not found for scenario '{scenario}'; available: {available}")
|
|
47
|
+
|
|
48
|
+
deltas.append(
|
|
49
|
+
VariantDelta(
|
|
50
|
+
scenario=scenario,
|
|
51
|
+
variant=baseline.variant,
|
|
52
|
+
baseline_variant=baseline_variant,
|
|
53
|
+
is_baseline=True,
|
|
54
|
+
build_delta_ms=0.0,
|
|
55
|
+
build_delta_pct=0.0,
|
|
56
|
+
startup_delta_ms=0.0,
|
|
57
|
+
startup_delta_pct=0.0,
|
|
58
|
+
image_delta_mb=0.0,
|
|
59
|
+
image_delta_pct=0.0,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
for item in sorted(items, key=lambda summary: summary.variant):
|
|
64
|
+
if item.variant == baseline_variant:
|
|
65
|
+
continue
|
|
66
|
+
deltas.append(
|
|
67
|
+
VariantDelta(
|
|
68
|
+
scenario=scenario,
|
|
69
|
+
variant=item.variant,
|
|
70
|
+
baseline_variant=baseline_variant,
|
|
71
|
+
is_baseline=False,
|
|
72
|
+
build_delta_ms=_abs_delta(item.build_avg_ms, baseline.build_avg_ms),
|
|
73
|
+
build_delta_pct=_pct_delta(item.build_avg_ms, baseline.build_avg_ms),
|
|
74
|
+
startup_delta_ms=_abs_delta(item.startup_avg_ms, baseline.startup_avg_ms),
|
|
75
|
+
startup_delta_pct=_pct_delta(item.startup_avg_ms, baseline.startup_avg_ms),
|
|
76
|
+
image_delta_mb=_abs_delta(item.image_mb_avg, baseline.image_mb_avg),
|
|
77
|
+
image_delta_pct=_pct_delta(item.image_mb_avg, baseline.image_mb_avg),
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return deltas
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _render_delta(value: float | None, suffix: str = "") -> str:
|
|
85
|
+
if value is None:
|
|
86
|
+
return "-"
|
|
87
|
+
if suffix == "%":
|
|
88
|
+
return f"{value:+.1f}%"
|
|
89
|
+
if suffix == "mb":
|
|
90
|
+
return f"{value:+.2f}"
|
|
91
|
+
if suffix == "ms":
|
|
92
|
+
return f"{value:+.1f}"
|
|
93
|
+
return f"{value:+.1f}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def format_delta_table(deltas: Iterable[VariantDelta]) -> str:
|
|
97
|
+
lines = [
|
|
98
|
+
"| Scenario | Variant | Baseline | Build Δms | Build Δ% | Startup Δms | Startup Δ% | Image ΔMB | Image Δ% |",
|
|
99
|
+
"|---|---|---|---:|---:|---:|---:|---:|---:|",
|
|
100
|
+
]
|
|
101
|
+
for delta in deltas:
|
|
102
|
+
lines.append(
|
|
103
|
+
"| "
|
|
104
|
+
+ " | ".join(
|
|
105
|
+
[
|
|
106
|
+
delta.scenario,
|
|
107
|
+
delta.variant,
|
|
108
|
+
delta.baseline_variant,
|
|
109
|
+
_render_delta(delta.build_delta_ms, "ms"),
|
|
110
|
+
_render_delta(delta.build_delta_pct, "%"),
|
|
111
|
+
_render_delta(delta.startup_delta_ms, "ms"),
|
|
112
|
+
_render_delta(delta.startup_delta_pct, "%"),
|
|
113
|
+
_render_delta(delta.image_delta_mb, "mb"),
|
|
114
|
+
_render_delta(delta.image_delta_pct, "%"),
|
|
115
|
+
]
|
|
116
|
+
)
|
|
117
|
+
+ " |"
|
|
118
|
+
)
|
|
119
|
+
return "\n".join(lines)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def format_delta_json(deltas: Iterable[VariantDelta]) -> str:
|
|
123
|
+
payload = [
|
|
124
|
+
{
|
|
125
|
+
"scenario": delta.scenario,
|
|
126
|
+
"variant": delta.variant,
|
|
127
|
+
"baseline_variant": delta.baseline_variant,
|
|
128
|
+
"is_baseline": delta.is_baseline,
|
|
129
|
+
"build_delta_ms": delta.build_delta_ms,
|
|
130
|
+
"build_delta_pct": delta.build_delta_pct,
|
|
131
|
+
"startup_delta_ms": delta.startup_delta_ms,
|
|
132
|
+
"startup_delta_pct": delta.startup_delta_pct,
|
|
133
|
+
"image_delta_mb": delta.image_delta_mb,
|
|
134
|
+
"image_delta_pct": delta.image_delta_pct,
|
|
135
|
+
}
|
|
136
|
+
for delta in deltas
|
|
137
|
+
]
|
|
138
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|