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.
@@ -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
@@ -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)