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,9 @@
1
+ """springdocker CLI package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ __all__ = ["__version__"]
6
+ try:
7
+ __version__ = version("springdocker")
8
+ except PackageNotFoundError: # pragma: no cover - local source tree fallback
9
+ __version__ = "0.1.0"
@@ -0,0 +1,284 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ import math
6
+ import statistics
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ REQUIRED_COLUMNS = {
11
+ "scenario",
12
+ "variant",
13
+ "build_ms",
14
+ "startup_ms",
15
+ "image_bytes",
16
+ "status",
17
+ }
18
+ OPTIONAL_COLUMNS = {"rss_bytes", "cpu_pct", "host", "docker_version", "run_profile"}
19
+ OPTIONAL_COLUMNS |= {
20
+ "gc_pause_ms",
21
+ "alloc_mb",
22
+ "startup_phase_boot_ms",
23
+ "startup_phase_context_ms",
24
+ "startup_phase_web_server_ms",
25
+ }
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class VariantSummary:
30
+ scenario: str
31
+ variant: str
32
+ runs: int
33
+ build_avg_ms: float | None
34
+ startup_avg_ms: float | None
35
+ startup_p95_ms: float | None
36
+ image_mb_avg: float | None
37
+ success_rate_pct: float
38
+ build_stddev_ms: float | None = None
39
+ build_ci95_low_ms: float | None = None
40
+ build_ci95_high_ms: float | None = None
41
+ startup_p99_ms: float | None = None
42
+ startup_stddev_ms: float | None = None
43
+ startup_ci95_low_ms: float | None = None
44
+ startup_ci95_high_ms: float | None = None
45
+ gc_pause_ms_avg: float | None = None
46
+ alloc_mb_avg: float | None = None
47
+ startup_phase_boot_ms_avg: float | None = None
48
+ startup_phase_context_ms_avg: float | None = None
49
+ startup_phase_web_server_ms_avg: float | None = None
50
+ startup_phase_total_ms_avg: float | None = None
51
+ rss_mb_avg: float | None = None
52
+ cpu_pct_avg: float | None = None
53
+ host: str | None = None
54
+ docker_version: str | None = None
55
+ run_profile: str | None = None
56
+
57
+
58
+ def _to_int_or_none(value: str) -> int | None:
59
+ try:
60
+ return int(value)
61
+ except (TypeError, ValueError):
62
+ return None
63
+
64
+
65
+ def _to_float_or_none(value: str) -> float | None:
66
+ try:
67
+ return float(value)
68
+ except (TypeError, ValueError):
69
+ return None
70
+
71
+
72
+ def _p95(values: list[int]) -> float | None:
73
+ if not values:
74
+ return None
75
+ if len(values) == 1:
76
+ return float(values[0])
77
+ return statistics.quantiles(values, n=20)[18]
78
+
79
+
80
+ def _p99(values: list[int]) -> float | None:
81
+ if not values:
82
+ return None
83
+ if len(values) == 1:
84
+ return float(values[0])
85
+ return statistics.quantiles(values, n=100)[98]
86
+
87
+
88
+ def _stddev(values: list[int]) -> float | None:
89
+ if len(values) < 2:
90
+ return None
91
+ return statistics.stdev(values)
92
+
93
+
94
+ def _ci95(values: list[int]) -> tuple[float | None, float | None]:
95
+ if len(values) < 2:
96
+ return None, None
97
+ mean = statistics.mean(values)
98
+ stddev = statistics.stdev(values)
99
+ margin = 1.96 * (stddev / math.sqrt(len(values)))
100
+ return mean - margin, mean + margin
101
+
102
+
103
+ def _mean_float(values: list[float]) -> float | None:
104
+ return statistics.mean(values) if values else None
105
+
106
+
107
+ def summarize_csv(path: Path, scenario: str | None = None, variant: str | None = None) -> list[VariantSummary]:
108
+ with path.open(newline="", encoding="utf-8") as f:
109
+ reader = csv.DictReader(f)
110
+ fieldnames = set(reader.fieldnames or [])
111
+ missing = sorted(REQUIRED_COLUMNS - fieldnames)
112
+ if missing:
113
+ raise ValueError(f"CSV missing required columns: {', '.join(missing)}")
114
+
115
+ rows: list[dict[str, str]] = list(reader)
116
+
117
+ groups: dict[tuple[str, str], list[dict[str, str]]] = {}
118
+ for row in rows:
119
+ sc = row.get("scenario", "")
120
+ vr = row.get("variant", "")
121
+ if scenario and sc != scenario:
122
+ continue
123
+ if variant and vr != variant:
124
+ continue
125
+ groups.setdefault((sc, vr), []).append(row)
126
+
127
+ summaries: list[VariantSummary] = []
128
+ for (sc, vr), items in sorted(groups.items()):
129
+ build = [v for i in items if (v := _to_int_or_none(i.get("build_ms", ""))) is not None and v >= 0]
130
+ startup = [v for i in items if (v := _to_int_or_none(i.get("startup_ms", ""))) is not None and v >= 0]
131
+ image = [v for i in items if (v := _to_int_or_none(i.get("image_bytes", ""))) is not None and v >= 0]
132
+ rss: list[int] = []
133
+ for item in items:
134
+ rss_value = _to_int_or_none(item.get("rss_bytes", ""))
135
+ if rss_value is not None and rss_value >= 0:
136
+ rss.append(rss_value)
137
+
138
+ cpu: list[float] = []
139
+ for item in items:
140
+ cpu_value = _to_float_or_none(item.get("cpu_pct", ""))
141
+ if cpu_value is not None and cpu_value >= 0.0:
142
+ cpu.append(cpu_value)
143
+
144
+ gc_pause: list[float] = []
145
+ alloc: list[float] = []
146
+ phase_boot: list[float] = []
147
+ phase_context: list[float] = []
148
+ phase_web_server: list[float] = []
149
+ for item in items:
150
+ if (value := _to_float_or_none(item.get("gc_pause_ms", ""))) is not None and value >= 0.0:
151
+ gc_pause.append(value)
152
+ if (value := _to_float_or_none(item.get("alloc_mb", ""))) is not None and value >= 0.0:
153
+ alloc.append(value)
154
+ if (value := _to_float_or_none(item.get("startup_phase_boot_ms", ""))) is not None and value >= 0.0:
155
+ phase_boot.append(value)
156
+ if (value := _to_float_or_none(item.get("startup_phase_context_ms", ""))) is not None and value >= 0.0:
157
+ phase_context.append(value)
158
+ if (value := _to_float_or_none(item.get("startup_phase_web_server_ms", ""))) is not None and value >= 0.0:
159
+ phase_web_server.append(value)
160
+
161
+ ok = sum(1 for i in items if i.get("status") == "ok")
162
+ total = len(items)
163
+ first = items[0] if items else {}
164
+ build_ci95_low, build_ci95_high = _ci95(build)
165
+ startup_ci95_low, startup_ci95_high = _ci95(startup)
166
+
167
+ summaries.append(
168
+ VariantSummary(
169
+ scenario=sc,
170
+ variant=vr,
171
+ runs=total,
172
+ build_avg_ms=statistics.mean(build) if build else None,
173
+ build_stddev_ms=_stddev(build),
174
+ build_ci95_low_ms=build_ci95_low,
175
+ build_ci95_high_ms=build_ci95_high,
176
+ startup_avg_ms=statistics.mean(startup) if startup else None,
177
+ startup_p95_ms=_p95(startup),
178
+ startup_p99_ms=_p99(startup),
179
+ startup_stddev_ms=_stddev(startup),
180
+ startup_ci95_low_ms=startup_ci95_low,
181
+ startup_ci95_high_ms=startup_ci95_high,
182
+ gc_pause_ms_avg=_mean_float(gc_pause),
183
+ alloc_mb_avg=_mean_float(alloc),
184
+ startup_phase_boot_ms_avg=_mean_float(phase_boot),
185
+ startup_phase_context_ms_avg=_mean_float(phase_context),
186
+ startup_phase_web_server_ms_avg=_mean_float(phase_web_server),
187
+ startup_phase_total_ms_avg=(
188
+ (_mean_float(phase_boot) or 0.0)
189
+ + (_mean_float(phase_context) or 0.0)
190
+ + (_mean_float(phase_web_server) or 0.0)
191
+ if any([phase_boot, phase_context, phase_web_server])
192
+ else None
193
+ ),
194
+ image_mb_avg=(statistics.mean(image) / (1024 * 1024)) if image else None,
195
+ rss_mb_avg=(statistics.mean(rss) / (1024 * 1024)) if rss else None,
196
+ cpu_pct_avg=statistics.mean(cpu) if cpu else None,
197
+ success_rate_pct=((ok / total) * 100.0) if total else 0.0,
198
+ host=first.get("host") or None,
199
+ docker_version=first.get("docker_version") or None,
200
+ run_profile=first.get("run_profile") or None,
201
+ )
202
+ )
203
+
204
+ return summaries
205
+
206
+
207
+ def format_table(summaries: list[VariantSummary]) -> str:
208
+ lines = [
209
+ "| Scenario | Variant | Runs | Build avg (ms) | Build stddev (ms) | Build CI95 (ms) | Startup avg (ms) | Startup stddev (ms) | Startup p95 (ms) | Startup p99 (ms) | Startup CI95 (ms) | GC pause avg (ms) | Alloc avg (MB) | Boot avg (ms) | Context avg (ms) | Web server avg (ms) | Startup phase total (ms) | Image MB avg | RSS MB avg | CPU avg (%) | Success rate | Host | Docker | Profile |",
210
+ "|---|---|---:|---:|---:|---|---:|---:|---:|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---|---|",
211
+ ]
212
+
213
+ for s in summaries:
214
+ build_avg = f"{s.build_avg_ms:.1f}" if s.build_avg_ms is not None else "-"
215
+ build_stddev = f"{s.build_stddev_ms:.1f}" if s.build_stddev_ms is not None else "-"
216
+ build_ci95 = (
217
+ f"{s.build_ci95_low_ms:.1f}..{s.build_ci95_high_ms:.1f}"
218
+ if s.build_ci95_low_ms is not None and s.build_ci95_high_ms is not None
219
+ else "-"
220
+ )
221
+ startup_avg = f"{s.startup_avg_ms:.1f}" if s.startup_avg_ms is not None else "-"
222
+ startup_stddev = f"{s.startup_stddev_ms:.1f}" if s.startup_stddev_ms is not None else "-"
223
+ startup_p95 = f"{s.startup_p95_ms:.1f}" if s.startup_p95_ms is not None else "-"
224
+ startup_p99 = f"{s.startup_p99_ms:.1f}" if s.startup_p99_ms is not None else "-"
225
+ startup_ci95 = (
226
+ f"{s.startup_ci95_low_ms:.1f}..{s.startup_ci95_high_ms:.1f}"
227
+ if s.startup_ci95_low_ms is not None and s.startup_ci95_high_ms is not None
228
+ else "-"
229
+ )
230
+ gc_pause = f"{s.gc_pause_ms_avg:.1f}" if s.gc_pause_ms_avg is not None else "-"
231
+ alloc = f"{s.alloc_mb_avg:.2f}" if s.alloc_mb_avg is not None else "-"
232
+ boot = f"{s.startup_phase_boot_ms_avg:.1f}" if s.startup_phase_boot_ms_avg is not None else "-"
233
+ context = f"{s.startup_phase_context_ms_avg:.1f}" if s.startup_phase_context_ms_avg is not None else "-"
234
+ web_server = f"{s.startup_phase_web_server_ms_avg:.1f}" if s.startup_phase_web_server_ms_avg is not None else "-"
235
+ phase_total = (
236
+ f"{s.startup_phase_total_ms_avg:.1f}" if s.startup_phase_total_ms_avg is not None else "-"
237
+ )
238
+ image_mb = f"{s.image_mb_avg:.2f}" if s.image_mb_avg is not None else "-"
239
+ rss_mb = f"{s.rss_mb_avg:.2f}" if s.rss_mb_avg is not None else "-"
240
+ cpu_pct = f"{s.cpu_pct_avg:.1f}" if s.cpu_pct_avg is not None else "-"
241
+ lines.append(
242
+ f"| {s.scenario} | {s.variant} | {s.runs} | {build_avg} | {build_stddev} | {build_ci95} | "
243
+ f"{startup_avg} | {startup_stddev} | {startup_p95} | {startup_p99} | {startup_ci95} | "
244
+ f"{gc_pause} | {alloc} | {boot} | {context} | {web_server} | {phase_total} | "
245
+ f"{image_mb} | {rss_mb} | {cpu_pct} | {s.success_rate_pct:.1f}% | "
246
+ f"{s.host or '-'} | {s.docker_version or '-'} | {s.run_profile or '-'} |"
247
+ )
248
+
249
+ return "\n".join(lines)
250
+
251
+
252
+ def format_json(summaries: list[VariantSummary]) -> str:
253
+ payload = [
254
+ {
255
+ "scenario": s.scenario,
256
+ "variant": s.variant,
257
+ "runs": s.runs,
258
+ "build_avg_ms": s.build_avg_ms,
259
+ "build_stddev_ms": s.build_stddev_ms,
260
+ "build_ci95_low_ms": s.build_ci95_low_ms,
261
+ "build_ci95_high_ms": s.build_ci95_high_ms,
262
+ "startup_avg_ms": s.startup_avg_ms,
263
+ "startup_p95_ms": s.startup_p95_ms,
264
+ "startup_p99_ms": s.startup_p99_ms,
265
+ "startup_stddev_ms": s.startup_stddev_ms,
266
+ "startup_ci95_low_ms": s.startup_ci95_low_ms,
267
+ "startup_ci95_high_ms": s.startup_ci95_high_ms,
268
+ "gc_pause_ms_avg": s.gc_pause_ms_avg,
269
+ "alloc_mb_avg": s.alloc_mb_avg,
270
+ "startup_phase_boot_ms_avg": s.startup_phase_boot_ms_avg,
271
+ "startup_phase_context_ms_avg": s.startup_phase_context_ms_avg,
272
+ "startup_phase_web_server_ms_avg": s.startup_phase_web_server_ms_avg,
273
+ "startup_phase_total_ms_avg": s.startup_phase_total_ms_avg,
274
+ "image_mb_avg": s.image_mb_avg,
275
+ "rss_mb_avg": s.rss_mb_avg,
276
+ "cpu_pct_avg": s.cpu_pct_avg,
277
+ "success_rate_pct": s.success_rate_pct,
278
+ "host": s.host,
279
+ "docker_version": s.docker_version,
280
+ "run_profile": s.run_profile,
281
+ }
282
+ for s in summaries
283
+ ]
284
+ return json.dumps(payload, indent=2, sort_keys=True)
@@ -0,0 +1,2 @@
1
+ """Benchmark generation and execution helpers."""
2
+
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from springdocker.dockerfile import DockerfileOptions, build_dockerfile
7
+
8
+ EXPECTED_CSV_HEADER = (
9
+ "date,scenario,variant,run,build_ms,image_bytes,startup_ms,status,notes,host,docker_version,run_profile,"
10
+ "gc_pause_ms,alloc_mb,startup_phase_boot_ms,startup_phase_context_ms,startup_phase_web_server_ms\n"
11
+ )
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ScenarioDefinition:
16
+ id: str
17
+ variants: tuple[tuple[str, DockerfileOptions], ...]
18
+ run_overrides: dict[str, int] | None = None
19
+ scenario_type: str = "standard"
20
+
21
+
22
+ def default_scenarios(build_tool: str, java_version: int) -> list[ScenarioDefinition]:
23
+ base = DockerfileOptions(build_tool=build_tool, java_version=java_version)
24
+ return [
25
+ ScenarioDefinition(
26
+ id="01-multi-stage-build-structure",
27
+ variants=(
28
+ ("specialized-multi-stage", base),
29
+ ("simple-two-stage", DockerfileOptions(build_tool=build_tool, java_version=java_version, use_buildkit_cache=False)),
30
+ ),
31
+ ),
32
+ ScenarioDefinition(
33
+ id="02-buildkit-gradle-cache",
34
+ variants=(
35
+ ("with-buildkit-cache", base),
36
+ ("without-buildkit-cache", DockerfileOptions(build_tool=build_tool, java_version=java_version, use_buildkit_cache=False)),
37
+ ),
38
+ ),
39
+ ScenarioDefinition(
40
+ id="03-custom-jre-jlink",
41
+ variants=(
42
+ ("with-jlink-runtime", base),
43
+ ("without-jlink-runtime", DockerfileOptions(build_tool=build_tool, java_version=java_version, use_jlink=False)),
44
+ ),
45
+ ),
46
+ ScenarioDefinition(
47
+ id="04-jep483-aot-cache",
48
+ variants=(
49
+ ("with-aot-cache", base),
50
+ ("without-aot-cache", DockerfileOptions(build_tool=build_tool, java_version=java_version, tuned_jvm_flags=False)),
51
+ ),
52
+ run_overrides={"quick": 8, "full": 15},
53
+ ),
54
+ ScenarioDefinition(
55
+ id="05-jvm-container-flags",
56
+ variants=(
57
+ ("tuned-flags", base),
58
+ ("defaults-like", DockerfileOptions(build_tool=build_tool, java_version=java_version, tuned_jvm_flags=False)),
59
+ ),
60
+ ),
61
+ ScenarioDefinition(
62
+ id="06-base-image-choice",
63
+ variants=(
64
+ ("temurin-jre", DockerfileOptions(build_tool=build_tool, java_version=java_version, use_jlink=False)),
65
+ (
66
+ "distroless-nonroot",
67
+ DockerfileOptions(
68
+ build_tool=build_tool,
69
+ java_version=java_version,
70
+ use_jlink=False,
71
+ runtime_image="distroless",
72
+ ),
73
+ ),
74
+ ),
75
+ ),
76
+ ScenarioDefinition(
77
+ id="07-native-vs-jvm",
78
+ variants=(),
79
+ scenario_type="native",
80
+ ),
81
+ ]
82
+
83
+
84
+ def generate_benchmark_assets(project_root: Path, build_tool: str, java_version: int) -> None:
85
+ bench_root = project_root / "benchmarks"
86
+ bench_root.mkdir(parents=True, exist_ok=True)
87
+
88
+ for scenario in default_scenarios(build_tool=build_tool, java_version=java_version):
89
+ scenario_dir = bench_root / scenario.id
90
+ variants_dir = scenario_dir / "variants"
91
+ results_dir = scenario_dir / "results"
92
+ scenario_dir.mkdir(parents=True, exist_ok=True)
93
+ variants_dir.mkdir(parents=True, exist_ok=True)
94
+ results_dir.mkdir(parents=True, exist_ok=True)
95
+
96
+ if scenario.scenario_type == "standard":
97
+ for name, opts in scenario.variants:
98
+ variant_dir = variants_dir / name
99
+ variant_dir.mkdir(parents=True, exist_ok=True)
100
+ (variant_dir / "Dockerfile").write_text(build_dockerfile(opts), encoding="utf-8")
101
+
102
+ csv = results_dir / "raw.csv"
103
+ if not csv.exists():
104
+ csv.write_text(EXPECTED_CSV_HEADER, encoding="utf-8")