dockseed 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.
- dockseed/__init__.py +47 -0
- dockseed/__main__.py +8 -0
- dockseed/cli.py +364 -0
- dockseed/detectors/__init__.py +23 -0
- dockseed/detectors/entrypoint.py +387 -0
- dockseed/detectors/framework.py +341 -0
- dockseed/detectors/native_deps.py +36 -0
- dockseed/detectors/package_manager.py +334 -0
- dockseed/detectors/python_version.py +281 -0
- dockseed/environment.py +92 -0
- dockseed/exceptions.py +42 -0
- dockseed/generators/__init__.py +9 -0
- dockseed/generators/dockerfile.py +230 -0
- dockseed/generators/dockerignore.py +57 -0
- dockseed/inference.py +109 -0
- dockseed/models.py +173 -0
- dockseed/py.typed +0 -0
- dockseed/scanner.py +147 -0
- dockseed/validators/__init__.py +15 -0
- dockseed/validators/dockerfile.py +208 -0
- dockseed/writer.py +61 -0
- dockseed-0.1.0.dist-info/METADATA +139 -0
- dockseed-0.1.0.dist-info/RECORD +26 -0
- dockseed-0.1.0.dist-info/WHEEL +4 -0
- dockseed-0.1.0.dist-info/entry_points.txt +3 -0
- dockseed-0.1.0.dist-info/licenses/LICENSE +21 -0
dockseed/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Dockseed — Automated, deterministic Dockerfile generator for Python projects."""
|
|
2
|
+
|
|
3
|
+
from dockseed.exceptions import (
|
|
4
|
+
AmbiguityError,
|
|
5
|
+
DockseedError,
|
|
6
|
+
OverwriteBlockedError,
|
|
7
|
+
ValidationError,
|
|
8
|
+
)
|
|
9
|
+
from dockseed.generators import generate_dockerfile, generate_dockerignore
|
|
10
|
+
from dockseed.inference import format_scan_report, scan_project
|
|
11
|
+
from dockseed.models import (
|
|
12
|
+
CLIOverrides,
|
|
13
|
+
ConfidenceLevel,
|
|
14
|
+
Detection,
|
|
15
|
+
Evidence,
|
|
16
|
+
ExitCode,
|
|
17
|
+
Framework,
|
|
18
|
+
GenerationResult,
|
|
19
|
+
PackageManager,
|
|
20
|
+
ProjectModel,
|
|
21
|
+
)
|
|
22
|
+
from dockseed.validators import ValidationReport, validate_dockerfile
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__version__",
|
|
28
|
+
"AmbiguityError",
|
|
29
|
+
"CLIOverrides",
|
|
30
|
+
"ConfidenceLevel",
|
|
31
|
+
"Detection",
|
|
32
|
+
"DockseedError",
|
|
33
|
+
"Evidence",
|
|
34
|
+
"ExitCode",
|
|
35
|
+
"Framework",
|
|
36
|
+
"GenerationResult",
|
|
37
|
+
"OverwriteBlockedError",
|
|
38
|
+
"PackageManager",
|
|
39
|
+
"ProjectModel",
|
|
40
|
+
"ValidationError",
|
|
41
|
+
"ValidationReport",
|
|
42
|
+
"format_scan_report",
|
|
43
|
+
"generate_dockerfile",
|
|
44
|
+
"generate_dockerignore",
|
|
45
|
+
"scan_project",
|
|
46
|
+
"validate_dockerfile",
|
|
47
|
+
]
|
dockseed/__main__.py
ADDED
dockseed/cli.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Command-line interface for Dockseed."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import shlex
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from dockseed import __version__
|
|
10
|
+
from dockseed.exceptions import (
|
|
11
|
+
AmbiguityError,
|
|
12
|
+
DockseedError,
|
|
13
|
+
ExitCode,
|
|
14
|
+
OverwriteBlockedError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
from dockseed.generators import generate_dockerfile, generate_dockerignore
|
|
18
|
+
from dockseed.inference import format_scan_report, scan_project
|
|
19
|
+
from dockseed.models import (
|
|
20
|
+
CLIOverrides,
|
|
21
|
+
ConfidenceLevel,
|
|
22
|
+
PackageManager,
|
|
23
|
+
ProjectModel,
|
|
24
|
+
)
|
|
25
|
+
from dockseed.validators import run_build_validation, validate_dockerfile
|
|
26
|
+
from dockseed.writer import write_project_files
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
30
|
+
"""Construct and return the CLI argument parser."""
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="dockseed",
|
|
33
|
+
description="Inspect Python projects and generate deterministic Dockerfiles.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--version",
|
|
37
|
+
action="version",
|
|
38
|
+
version=f"%(prog)s {__version__}",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
subparsers = parser.add_subparsers(dest="command", required=False)
|
|
42
|
+
|
|
43
|
+
# --- dockseed scan ---
|
|
44
|
+
scan_parser = subparsers.add_parser(
|
|
45
|
+
"scan",
|
|
46
|
+
help="Analyze a project without writing files.",
|
|
47
|
+
)
|
|
48
|
+
scan_parser.add_argument(
|
|
49
|
+
"path",
|
|
50
|
+
nargs="?",
|
|
51
|
+
type=Path,
|
|
52
|
+
default=Path("."),
|
|
53
|
+
help="Path to the Python project root (default: current directory).",
|
|
54
|
+
)
|
|
55
|
+
scan_parser.add_argument(
|
|
56
|
+
"--format",
|
|
57
|
+
choices=["text", "json"],
|
|
58
|
+
default="text",
|
|
59
|
+
dest="output_format",
|
|
60
|
+
help="Output format for the scan report (default: text).",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# --- dockseed init ---
|
|
64
|
+
init_parser = subparsers.add_parser(
|
|
65
|
+
"init",
|
|
66
|
+
help="Generate Dockerfile and .dockerignore for a project.",
|
|
67
|
+
)
|
|
68
|
+
init_parser.add_argument(
|
|
69
|
+
"--path",
|
|
70
|
+
type=Path,
|
|
71
|
+
default=Path("."),
|
|
72
|
+
help="Path to the Python project root (default: current directory).",
|
|
73
|
+
)
|
|
74
|
+
init_parser.add_argument(
|
|
75
|
+
"--dry-run",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Print planned changes without writing files.",
|
|
78
|
+
)
|
|
79
|
+
init_parser.add_argument(
|
|
80
|
+
"--yes",
|
|
81
|
+
"-y",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="Accept safe defaults without prompting (fails on critical ambiguities).",
|
|
84
|
+
)
|
|
85
|
+
init_parser.add_argument(
|
|
86
|
+
"--overwrite",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="Explicitly authorize replacing existing Dockerfile and .dockerignore.",
|
|
89
|
+
)
|
|
90
|
+
init_parser.add_argument(
|
|
91
|
+
"--comments",
|
|
92
|
+
action=argparse.BooleanOptionalAction,
|
|
93
|
+
default=True,
|
|
94
|
+
help="Include explanatory inline comments in generated Dockerfile (default: True).",
|
|
95
|
+
)
|
|
96
|
+
init_parser.add_argument(
|
|
97
|
+
"--python",
|
|
98
|
+
type=str,
|
|
99
|
+
default=None,
|
|
100
|
+
dest="python_version",
|
|
101
|
+
help="Explicitly override the target Python version (e.g., 3.12).",
|
|
102
|
+
)
|
|
103
|
+
init_parser.add_argument(
|
|
104
|
+
"--package-manager",
|
|
105
|
+
type=str,
|
|
106
|
+
choices=[pm.value for pm in PackageManager],
|
|
107
|
+
default=None,
|
|
108
|
+
help="Explicitly override the detected package manager.",
|
|
109
|
+
)
|
|
110
|
+
init_parser.add_argument(
|
|
111
|
+
"--entrypoint",
|
|
112
|
+
type=str,
|
|
113
|
+
default=None,
|
|
114
|
+
help="Explicitly override the startup command or application entrypoint.",
|
|
115
|
+
)
|
|
116
|
+
init_parser.add_argument(
|
|
117
|
+
"--port",
|
|
118
|
+
type=int,
|
|
119
|
+
default=None,
|
|
120
|
+
help="Explicitly override the exposed application port.",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# --- dockseed check ---
|
|
124
|
+
check_parser = subparsers.add_parser(
|
|
125
|
+
"check",
|
|
126
|
+
help="Validate an existing Dockerfile or project configuration.",
|
|
127
|
+
)
|
|
128
|
+
check_parser.add_argument(
|
|
129
|
+
"path",
|
|
130
|
+
nargs="?",
|
|
131
|
+
type=Path,
|
|
132
|
+
default=Path("."),
|
|
133
|
+
help="Path to the Python project root (default: current directory).",
|
|
134
|
+
)
|
|
135
|
+
check_parser.add_argument(
|
|
136
|
+
"--build",
|
|
137
|
+
action="store_true",
|
|
138
|
+
help="Opt-in build validation using an installed container CLI.",
|
|
139
|
+
)
|
|
140
|
+
check_parser.add_argument(
|
|
141
|
+
"--runtime",
|
|
142
|
+
choices=["docker", "podman", "nerdctl"],
|
|
143
|
+
default=None,
|
|
144
|
+
help="Container runtime to use with --build (auto-detected if omitted).",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return parser
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _prompt_choice(prompt_text: str, options: list[str]) -> int:
|
|
151
|
+
"""Prompt the user in an interactive terminal to choose an option index (0-based)."""
|
|
152
|
+
print(prompt_text)
|
|
153
|
+
for idx, opt in enumerate(options, start=1):
|
|
154
|
+
print(f" [{idx}] {opt}")
|
|
155
|
+
while True:
|
|
156
|
+
raw = input(f"Select an option [1-{len(options)}] (default: 1): ").strip()
|
|
157
|
+
if not raw:
|
|
158
|
+
return 0
|
|
159
|
+
if raw.isdigit():
|
|
160
|
+
choice = int(raw)
|
|
161
|
+
if 1 <= choice <= len(options):
|
|
162
|
+
return choice - 1
|
|
163
|
+
print(f"Please enter a number between 1 and {len(options)}.")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _resolve_ambiguities(
|
|
167
|
+
model: ProjectModel,
|
|
168
|
+
overrides: CLIOverrides,
|
|
169
|
+
interactive: bool,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Resolve critical ambiguities via interactive prompts or fail fast with exit code 3."""
|
|
172
|
+
if model.blockers:
|
|
173
|
+
blocker_list = "\n - ".join(model.blockers)
|
|
174
|
+
raise ValidationError(f"Critical containerization blockers detected:\n - {blocker_list}")
|
|
175
|
+
|
|
176
|
+
# 1. Conflicting Python constraints
|
|
177
|
+
if model.python_requirement.conflicts and not overrides.python_version:
|
|
178
|
+
cands = model.resolved_python_version.candidates or ["3.12"]
|
|
179
|
+
if not interactive:
|
|
180
|
+
raise AmbiguityError(
|
|
181
|
+
f"{model.python_requirement.conflicts[0]} "
|
|
182
|
+
"Re-run with '--python <version>' (e.g., --python 3.12) to resolve."
|
|
183
|
+
)
|
|
184
|
+
idx = _prompt_choice(
|
|
185
|
+
"Conflicting Python version constraints found. Select target Python version:",
|
|
186
|
+
cands,
|
|
187
|
+
)
|
|
188
|
+
model.resolved_python_version.value = cands[idx]
|
|
189
|
+
model.resolved_python_version.confidence = ConfidenceLevel.HIGH
|
|
190
|
+
|
|
191
|
+
# 2. Competing package managers with lockfiles
|
|
192
|
+
if len(model.package_manager.candidates) > 1 and not overrides.package_manager:
|
|
193
|
+
pm_cands = [m.value for m in model.package_manager.candidates]
|
|
194
|
+
if not interactive:
|
|
195
|
+
raise AmbiguityError(
|
|
196
|
+
f"Multiple package managers detected ({', '.join(pm_cands)}). "
|
|
197
|
+
"Re-run with '--package-manager <name>' to resolve."
|
|
198
|
+
)
|
|
199
|
+
idx = _prompt_choice(
|
|
200
|
+
"Multiple package managers detected. Select authoritative package manager:",
|
|
201
|
+
pm_cands,
|
|
202
|
+
)
|
|
203
|
+
model.package_manager.value = model.package_manager.candidates[idx]
|
|
204
|
+
model.package_manager.confidence = ConfidenceLevel.HIGH
|
|
205
|
+
|
|
206
|
+
# 3. Multiple candidate entrypoints
|
|
207
|
+
if len(model.entry_point.candidates) > 1 and not overrides.entrypoint:
|
|
208
|
+
ep_cands = [shlex.join(c) for c in model.entry_point.candidates]
|
|
209
|
+
if not interactive:
|
|
210
|
+
raise AmbiguityError(
|
|
211
|
+
f"Multiple plausible entry points detected ({', '.join(ep_cands)}). "
|
|
212
|
+
"Re-run with '--entrypoint \"<command>\"' to resolve."
|
|
213
|
+
)
|
|
214
|
+
idx = _prompt_choice(
|
|
215
|
+
"Multiple entry points detected. Select application startup command:",
|
|
216
|
+
ep_cands,
|
|
217
|
+
)
|
|
218
|
+
model.entry_point.value = model.entry_point.candidates[idx]
|
|
219
|
+
model.entry_point.confidence = ConfidenceLevel.HIGH
|
|
220
|
+
|
|
221
|
+
# 4. Missing entrypoint altogether
|
|
222
|
+
if not model.entry_point.value:
|
|
223
|
+
if not interactive:
|
|
224
|
+
raise AmbiguityError(
|
|
225
|
+
"Could not infer an application entry point automatically. "
|
|
226
|
+
"Re-run with '--entrypoint \"<command>\"' to specify how the application starts."
|
|
227
|
+
)
|
|
228
|
+
raw_cmd = input(
|
|
229
|
+
"Could not infer startup command. Enter startup command (e.g., python main.py): "
|
|
230
|
+
).strip()
|
|
231
|
+
if not raw_cmd:
|
|
232
|
+
raise AmbiguityError("No startup command provided.")
|
|
233
|
+
model.entry_point.value = shlex.split(raw_cmd)
|
|
234
|
+
model.entry_point.confidence = ConfidenceLevel.HIGH
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _handle_init(args: argparse.Namespace) -> int:
|
|
238
|
+
"""Execute the `dockseed init` workflow."""
|
|
239
|
+
pm_override = PackageManager(args.package_manager) if args.package_manager else None
|
|
240
|
+
overrides = CLIOverrides(
|
|
241
|
+
python_version=args.python_version,
|
|
242
|
+
package_manager=pm_override,
|
|
243
|
+
entrypoint=args.entrypoint,
|
|
244
|
+
port=args.port,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
model = scan_project(args.path, overrides=overrides)
|
|
248
|
+
|
|
249
|
+
dockerfile_path = model.root / "Dockerfile"
|
|
250
|
+
if not args.dry_run and not args.overwrite and dockerfile_path.exists():
|
|
251
|
+
raise OverwriteBlockedError(
|
|
252
|
+
f"'{dockerfile_path}' already exists. Pass '--overwrite' to replace it."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
is_tty = sys.stdin.isatty()
|
|
256
|
+
interactive = is_tty and not args.yes
|
|
257
|
+
|
|
258
|
+
_resolve_ambiguities(model, overrides, interactive=interactive)
|
|
259
|
+
|
|
260
|
+
df_res = generate_dockerfile(model, include_comments=args.comments)
|
|
261
|
+
di_res = generate_dockerignore(model)
|
|
262
|
+
|
|
263
|
+
val_report = validate_dockerfile(df_res.content, model, di_res.content)
|
|
264
|
+
if not val_report.is_valid:
|
|
265
|
+
err_msg = "\n - ".join(val_report.errors)
|
|
266
|
+
raise ValidationError(f"Generated configuration failed static validation:\n - {err_msg}")
|
|
267
|
+
|
|
268
|
+
print(format_scan_report(model, output_format="text"))
|
|
269
|
+
print("")
|
|
270
|
+
print("Planned configuration:")
|
|
271
|
+
print(f" Base image: python:{model.resolved_python_version.value}-slim")
|
|
272
|
+
print(" Runtime user: appuser (non-root)")
|
|
273
|
+
if model.entry_point.value:
|
|
274
|
+
print(f" Startup command: {shlex.join(model.entry_point.value)}")
|
|
275
|
+
|
|
276
|
+
if args.dry_run:
|
|
277
|
+
print("\n--- Dockerfile (dry-run) ---")
|
|
278
|
+
print(df_res.content, end="")
|
|
279
|
+
print("--- .dockerignore (dry-run) ---")
|
|
280
|
+
print(di_res.content, end="")
|
|
281
|
+
return int(ExitCode.SUCCESS)
|
|
282
|
+
|
|
283
|
+
if interactive:
|
|
284
|
+
ans = input("\nProceed with creating files? [Y/n]: ").strip().lower()
|
|
285
|
+
if ans not in ("", "y", "yes"):
|
|
286
|
+
print("Aborted by user.")
|
|
287
|
+
return int(ExitCode.SUCCESS)
|
|
288
|
+
|
|
289
|
+
written = write_project_files(
|
|
290
|
+
model.root,
|
|
291
|
+
df_res.content,
|
|
292
|
+
di_res.content,
|
|
293
|
+
overwrite=args.overwrite,
|
|
294
|
+
)
|
|
295
|
+
for p in written:
|
|
296
|
+
print(f"Created {p.name}")
|
|
297
|
+
print("\nReview the generated files before building the image.")
|
|
298
|
+
return int(ExitCode.SUCCESS)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _handle_check(args: argparse.Namespace) -> int:
|
|
302
|
+
"""Execute the `dockseed check` workflow."""
|
|
303
|
+
model = scan_project(args.path)
|
|
304
|
+
dockerfile_path = model.root / "Dockerfile"
|
|
305
|
+
dockerignore_path = model.root / ".dockerignore"
|
|
306
|
+
|
|
307
|
+
if not dockerfile_path.is_file():
|
|
308
|
+
raise ValidationError(
|
|
309
|
+
f"No Dockerfile found at '{dockerfile_path}'. Run 'dockseed init' first."
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
df_content = dockerfile_path.read_text(encoding="utf-8")
|
|
313
|
+
di_content = (
|
|
314
|
+
dockerignore_path.read_text(encoding="utf-8") if dockerignore_path.is_file() else None
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
report = validate_dockerfile(df_content, model, di_content)
|
|
318
|
+
for w in report.warnings:
|
|
319
|
+
print(f"Warning: {w}")
|
|
320
|
+
|
|
321
|
+
if not report.is_valid:
|
|
322
|
+
for e in report.errors:
|
|
323
|
+
print(f"Validation error: {e}", file=sys.stderr)
|
|
324
|
+
raise ValidationError("Dockerfile validation failed.")
|
|
325
|
+
|
|
326
|
+
print("Static Dockerfile validation passed.")
|
|
327
|
+
|
|
328
|
+
if args.build:
|
|
329
|
+
print("Running container image build validation...")
|
|
330
|
+
run_build_validation(model.root, runtime=args.runtime)
|
|
331
|
+
print("Container image build validation passed.")
|
|
332
|
+
|
|
333
|
+
return int(ExitCode.SUCCESS)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
337
|
+
"""Main entry point for the dockseed CLI."""
|
|
338
|
+
parser = build_parser()
|
|
339
|
+
args = parser.parse_args(argv)
|
|
340
|
+
|
|
341
|
+
if not args.command:
|
|
342
|
+
parser.print_help()
|
|
343
|
+
return int(ExitCode.SUCCESS)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
if args.command == "scan":
|
|
347
|
+
model = scan_project(args.path)
|
|
348
|
+
print(format_scan_report(model, output_format=args.output_format))
|
|
349
|
+
return int(ExitCode.SUCCESS)
|
|
350
|
+
if args.command == "init":
|
|
351
|
+
return _handle_init(args)
|
|
352
|
+
if args.command == "check":
|
|
353
|
+
return _handle_check(args)
|
|
354
|
+
return int(ExitCode.SUCCESS) # pragma: no cover
|
|
355
|
+
except DockseedError as exc:
|
|
356
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
357
|
+
return int(exc.exit_code)
|
|
358
|
+
except Exception as exc: # pragma: no cover
|
|
359
|
+
print(f"Unexpected error: {exc}", file=sys.stderr)
|
|
360
|
+
return int(ExitCode.GENERAL_ERROR)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
if __name__ == "__main__": # pragma: no cover
|
|
364
|
+
sys.exit(main())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Detectors for Python version, package managers, native deps, frameworks, and entrypoints."""
|
|
2
|
+
|
|
3
|
+
from dockseed.detectors.entrypoint import detect_entrypoint
|
|
4
|
+
from dockseed.detectors.framework import (
|
|
5
|
+
PythonFileASTInfo,
|
|
6
|
+
analyze_python_file,
|
|
7
|
+
detect_framework,
|
|
8
|
+
scan_python_sources,
|
|
9
|
+
)
|
|
10
|
+
from dockseed.detectors.native_deps import detect_native_dependencies
|
|
11
|
+
from dockseed.detectors.package_manager import detect_package_manager
|
|
12
|
+
from dockseed.detectors.python_version import detect_python_version
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"PythonFileASTInfo",
|
|
16
|
+
"analyze_python_file",
|
|
17
|
+
"detect_entrypoint",
|
|
18
|
+
"detect_framework",
|
|
19
|
+
"detect_native_dependencies",
|
|
20
|
+
"detect_package_manager",
|
|
21
|
+
"detect_python_version",
|
|
22
|
+
"scan_python_sources",
|
|
23
|
+
]
|