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 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
@@ -0,0 +1,8 @@
1
+ """Allow running dockseed via `python -m dockseed`."""
2
+
3
+ import sys
4
+
5
+ from dockseed.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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
+ ]