dockermind 0.1.2__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.
dockermind/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ Dockermind -- Smart Dockerfile generation CLI for Node.js and Python projects.
3
+ """
4
+
5
+ __version__ = "0.1.1"
dockermind/analyzer.py ADDED
@@ -0,0 +1,285 @@
1
+ """
2
+ Project analyzer -- detects project type, framework, version, port, and scripts.
3
+
4
+ Only Node.js and Python are supported in v1.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ from dataclasses import dataclass, field
13
+
14
+ from dockermind.utils import file_exists, read_file, extract_port_from_source, yellow
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Analysis result dataclass
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ @dataclass
23
+ class AnalysisResult:
24
+ """Everything dockermind needs to know about the target project."""
25
+
26
+ # Core detection
27
+ language: str | None = None # "node" | "python" | None
28
+ framework: str | None = None # "express" | "fastapi" | "flask" | None
29
+
30
+ # Runtime version
31
+ node_version: str = "18" # Default Node version
32
+ python_version: str = "3.11" # Default Python version
33
+
34
+ # Scripts (Node)
35
+ has_build_script: bool = False
36
+ build_script: str | None = None
37
+ start_script: str | None = None
38
+
39
+ # Entry point (Python)
40
+ python_entry_module: str | None = None # e.g. "main:app"
41
+ uses_uvicorn: bool = False
42
+
43
+ # Port
44
+ port: int | None = None
45
+ port_is_default: bool = False # True if we fell back to a default
46
+
47
+ # Package manager
48
+ has_package_lock: bool = False
49
+ has_yarn_lock: bool = False
50
+ has_pnpm_lock: bool = False
51
+
52
+ # Raw Python dependency names (lowercase) for native-package inference
53
+ python_deps: set[str] = field(default_factory=set)
54
+
55
+ # Warnings generated during analysis
56
+ warnings: list[str] = field(default_factory=list)
57
+
58
+ @property
59
+ def runtime_version(self) -> str:
60
+ """Return the resolved runtime version string."""
61
+ if self.language == "node":
62
+ return self.node_version
63
+ return self.python_version
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Analyzer
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ class Analyzer:
72
+ """Analyse a project directory and return an AnalysisResult."""
73
+
74
+ def __init__(self, project_path: str) -> None:
75
+ if not os.path.isdir(project_path):
76
+ raise FileNotFoundError(f"Project path does not exist: {project_path}")
77
+ self.path = project_path
78
+
79
+ def run(self) -> AnalysisResult:
80
+ """Run full analysis pipeline and return the result."""
81
+ result = AnalysisResult()
82
+
83
+ has_node = file_exists(self.path, "package.json")
84
+ has_python = (
85
+ file_exists(self.path, "requirements.txt")
86
+ or file_exists(self.path, "pyproject.toml")
87
+ )
88
+
89
+ # Language detection -- only one allowed
90
+ if has_node and has_python:
91
+ # Hard stop: ambiguous multi-runtime projects are not supported.
92
+ raise SystemExit(
93
+ "Error: Multiple backend runtimes detected (Node.js and Python).\n"
94
+ "Please run dockermind inside the specific service directory."
95
+ )
96
+
97
+ if has_node:
98
+ result.language = "node"
99
+ self._analyse_node(result)
100
+ elif has_python:
101
+ result.language = "python"
102
+ self._analyse_python(result)
103
+ else:
104
+ # Unsupported or empty project
105
+ result.language = None
106
+
107
+ return result
108
+
109
+ # ------------------------------------------------------------------
110
+ # Node.js analysis
111
+ # ------------------------------------------------------------------
112
+
113
+ def _analyse_node(self, result: AnalysisResult) -> None:
114
+ """Populate *result* with Node.js-specific details."""
115
+ raw = read_file(self.path, "package.json")
116
+ if not raw:
117
+ result.warnings.append("package.json exists but could not be read.")
118
+ return
119
+
120
+ try:
121
+ pkg = json.loads(raw)
122
+ except json.JSONDecodeError:
123
+ result.warnings.append("package.json is not valid JSON.")
124
+ return
125
+
126
+ # -- Node version from engines field --
127
+ engines = pkg.get("engines", {})
128
+ requested_version = engines.get("node", "")
129
+ if requested_version:
130
+ result.node_version = self._parse_node_version(requested_version)
131
+ # else: stays at default "18"
132
+
133
+ # -- Scripts --
134
+ scripts = pkg.get("scripts", {})
135
+ if "build" in scripts:
136
+ result.has_build_script = True
137
+ result.build_script = scripts["build"]
138
+ result.start_script = scripts.get("start")
139
+
140
+ # -- Framework detection --
141
+ all_deps = {
142
+ **pkg.get("dependencies", {}),
143
+ **pkg.get("devDependencies", {}),
144
+ }
145
+ if "express" in all_deps:
146
+ result.framework = "express"
147
+ elif "fastify" in all_deps:
148
+ result.framework = "fastify"
149
+ elif "koa" in all_deps:
150
+ result.framework = "koa"
151
+ # For v1 we just note whatever framework; Dockerfile is the same.
152
+
153
+ # -- Lock file detection --
154
+ result.has_package_lock = file_exists(self.path, "package-lock.json")
155
+ result.has_yarn_lock = file_exists(self.path, "yarn.lock")
156
+ result.has_pnpm_lock = file_exists(self.path, "pnpm-lock.yaml")
157
+
158
+ # -- Port detection --
159
+ result.port = self._detect_node_port(pkg)
160
+ if result.port is None:
161
+ result.port = 3000
162
+ result.port_is_default = True
163
+ result.warnings.append(
164
+ "Could not detect port from source. Defaulting to 3000."
165
+ )
166
+
167
+ def _parse_node_version(self, version_spec: str) -> str:
168
+ """Extract a major version number from an engines.node specifier.
169
+
170
+ Examples:
171
+ ">=18" -> "18"
172
+ "^20.0.0" -> "20"
173
+ "18.x" -> "18"
174
+ ">=18 <22" -> "18"
175
+ """
176
+ match = re.search(r"(\d{1,3})", version_spec)
177
+ if match:
178
+ return match.group(1)
179
+ return "18" # safe fallback
180
+
181
+ def _detect_node_port(self, pkg: dict) -> int | None:
182
+ """Try to detect port from source files or scripts."""
183
+ # Check common entry points for .listen(PORT)
184
+ candidates = ["server.js", "index.js", "app.js", "src/index.js", "src/server.js",
185
+ "src/app.js", "dist/index.js", "server.ts", "index.ts", "src/index.ts"]
186
+
187
+ for filename in candidates:
188
+ source = read_file(self.path, filename)
189
+ if source:
190
+ port = extract_port_from_source(source)
191
+ if port:
192
+ return port
193
+
194
+ return None
195
+
196
+ # ------------------------------------------------------------------
197
+ # Python analysis
198
+ # ------------------------------------------------------------------
199
+
200
+ def _analyse_python(self, result: AnalysisResult) -> None:
201
+ """Populate *result* with Python-specific details."""
202
+
203
+ # Gather all dependency names
204
+ deps_lower: set[str] = set()
205
+
206
+ # From requirements.txt
207
+ reqs = read_file(self.path, "requirements.txt")
208
+ if reqs:
209
+ for line in reqs.splitlines():
210
+ line = line.strip()
211
+ if not line or line.startswith("#"):
212
+ continue
213
+ # "fastapi==0.100.0" -> "fastapi"
214
+ dep_name = re.split(r"[>=<!~\[]", line)[0].strip().lower()
215
+ if dep_name:
216
+ deps_lower.add(dep_name)
217
+
218
+ # From pyproject.toml (simple parse -- no toml library needed for v1)
219
+ pyproject = read_file(self.path, "pyproject.toml")
220
+ if pyproject:
221
+ # Quick regex extraction for dependencies list
222
+ for match in re.finditer(r'"([a-zA-Z0-9_-]+)', pyproject):
223
+ deps_lower.add(match.group(1).lower())
224
+
225
+ # Expose raw deps for native-package inference in dockerfile_generator
226
+ result.python_deps = deps_lower
227
+
228
+ # -- Framework detection --
229
+ if "fastapi" in deps_lower:
230
+ result.framework = "fastapi"
231
+ result.port = 8000
232
+ elif "flask" in deps_lower:
233
+ result.framework = "flask"
234
+ result.port = 5000
235
+ else:
236
+ # Unknown Python framework -- set a sensible default
237
+ result.framework = None
238
+ result.port = 8000
239
+ result.warnings.append(
240
+ "No recognised framework (FastAPI/Flask). Defaulting to port 8000."
241
+ )
242
+
243
+ # -- Uvicorn detection --
244
+ if "uvicorn" in deps_lower:
245
+ result.uses_uvicorn = True
246
+
247
+ # -- Detect entry module --
248
+ result.python_entry_module = self._detect_python_entry(result)
249
+
250
+ # -- Python version from pyproject.toml requires-python --
251
+ if pyproject:
252
+ match = re.search(r'requires-python\s*=\s*["\']([^"\']+)["\']', pyproject)
253
+ if match:
254
+ ver_match = re.search(r"(\d+\.\d+)", match.group(1))
255
+ if ver_match:
256
+ result.python_version = ver_match.group(1)
257
+
258
+ def _detect_python_entry(self, result: AnalysisResult) -> str | None:
259
+ """Detect the Python entry module (e.g. 'main:app')."""
260
+
261
+ # Look for common entry files
262
+ candidates = ["main.py", "app.py", "application.py", "server.py",
263
+ "src/main.py", "src/app.py"]
264
+
265
+ for filename in candidates:
266
+ source = read_file(self.path, filename)
267
+ if source is None:
268
+ continue
269
+
270
+ # Look for FastAPI() or Flask() app instantiation
271
+ app_match = re.search(
272
+ r"(\w+)\s*=\s*(?:FastAPI|Flask)\s*\(", source
273
+ )
274
+ if app_match:
275
+ app_var = app_match.group(1)
276
+ module_name = filename.replace("/", ".").replace(".py", "")
277
+ return f"{module_name}:{app_var}"
278
+
279
+ # Fallback: if main.py exists, assume main:app
280
+ if file_exists(self.path, "main.py"):
281
+ return "main:app"
282
+ if file_exists(self.path, "app.py"):
283
+ return "app:app"
284
+
285
+ return None
dockermind/cli.py ADDED
@@ -0,0 +1,288 @@
1
+ """
2
+ Dockermind CLI -- built with Typer.
3
+
4
+ Commands:
5
+ dockermind init Analyse project and generate Dockerfile
6
+ dockermind build Build Docker image from generated Dockerfile
7
+ dockermind run Run the built Docker image
8
+ dockermind doctor Check Docker environment health
9
+ dockermind version Show dockermind version
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+
17
+ import typer
18
+
19
+ from dockermind import __version__
20
+ from dockermind.analyzer import Analyzer
21
+ from dockermind.compose_generator import generate_compose
22
+ from dockermind.doctor import run_doctor
23
+ from dockermind.dockerfile_generator import generate_dockerfile, generate_dockerignore
24
+ from dockermind.runner import docker_build, docker_run
25
+ from dockermind.utils import (
26
+ bold,
27
+ confirm,
28
+ cyan,
29
+ dim,
30
+ file_exists,
31
+ green,
32
+ red,
33
+ write_file,
34
+ yellow,
35
+ )
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Typer app
39
+ # ---------------------------------------------------------------------------
40
+
41
+ app = typer.Typer(
42
+ name="dockermind",
43
+ help="Smart Dockerfile generation CLI for Node.js and Python projects.",
44
+ add_completion=False,
45
+ no_args_is_help=True,
46
+ )
47
+
48
+
49
+ def _banner() -> None:
50
+ """Print the dockermind banner."""
51
+ typer.echo()
52
+ typer.echo(f" {bold('dockermind')} {dim(f'v{__version__}')}")
53
+ typer.echo(f" {dim('Smart Dockerfile generation for Node.js & Python')}")
54
+ typer.echo()
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # init
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ @app.command()
63
+ def init(
64
+ path: str = typer.Argument(
65
+ ".",
66
+ help="Path to the project directory to analyse.",
67
+ ),
68
+ compose: bool = typer.Option(
69
+ False,
70
+ "--compose",
71
+ help="Also generate docker-compose.yml.",
72
+ ),
73
+ dry_run: bool = typer.Option(
74
+ False,
75
+ "--dry-run",
76
+ help="Show generated files without writing to disk.",
77
+ ),
78
+ force: bool = typer.Option(
79
+ False,
80
+ "--force",
81
+ help="Overwrite existing Dockerfile without asking.",
82
+ ),
83
+ ) -> None:
84
+ """Analyse a project and generate an optimised Dockerfile."""
85
+ _banner()
86
+
87
+ project_path = os.path.abspath(path)
88
+
89
+ if not os.path.isdir(project_path):
90
+ typer.echo(red(f" Error: '{project_path}' is not a valid directory."))
91
+ raise typer.Exit(code=1)
92
+
93
+ typer.echo(dim(f" Analysing: {project_path}"))
94
+ typer.echo()
95
+
96
+ # -- Run analysis --
97
+ analyzer = Analyzer(project_path)
98
+ result = analyzer.run()
99
+
100
+ if result.language is None:
101
+ typer.echo(red(" Error: Could not detect a supported project type."))
102
+ typer.echo(dim(" Supported: Node.js (package.json) or Python (requirements.txt / pyproject.toml)"))
103
+ raise typer.Exit(code=1)
104
+
105
+ # -- Display analysis summary --
106
+ typer.echo(f" {bold('Project Analysis')}")
107
+ typer.echo(" " + "-" * 40)
108
+ typer.echo(f" Language: {cyan(result.language)}")
109
+
110
+ if result.framework:
111
+ typer.echo(f" Framework: {cyan(result.framework)}")
112
+
113
+ typer.echo(f" Version: {cyan(result.runtime_version)}")
114
+ typer.echo(f" Port: {cyan(str(result.port))}")
115
+
116
+ if result.language == "node":
117
+ typer.echo(f" Build step: {cyan('yes' if result.has_build_script else 'no')}")
118
+ if result.start_script:
119
+ typer.echo(f" Start: {dim(result.start_script)}")
120
+
121
+ if result.language == "python":
122
+ if result.python_entry_module:
123
+ typer.echo(f" Entry: {dim(result.python_entry_module)}")
124
+ typer.echo(f" Uvicorn: {cyan('yes' if result.uses_uvicorn else 'no')}")
125
+
126
+ # Show warnings
127
+ if result.warnings:
128
+ typer.echo()
129
+ for w in result.warnings:
130
+ typer.echo(f" {yellow(f'Warning: {w}')}")
131
+
132
+ typer.echo()
133
+
134
+ # -- Generate files --
135
+ dockerfile_content = generate_dockerfile(result)
136
+ dockerignore_content = generate_dockerignore(result)
137
+ compose_content = generate_compose(result) if compose else None
138
+
139
+ # -- Dry run: just print and exit --
140
+ if dry_run:
141
+ typer.echo(bold(" --- Dockerfile ---"))
142
+ typer.echo()
143
+ typer.echo(dockerfile_content)
144
+
145
+ typer.echo(bold(" --- .dockerignore ---"))
146
+ typer.echo()
147
+ typer.echo(dockerignore_content)
148
+
149
+ if compose_content:
150
+ typer.echo(bold(" --- docker-compose.yml ---"))
151
+ typer.echo()
152
+ typer.echo(compose_content)
153
+
154
+ raise typer.Exit(code=0)
155
+
156
+ # -- Safety: check for existing Dockerfile --
157
+ dockerfile_path = os.path.join(project_path, "Dockerfile")
158
+ if os.path.isfile(dockerfile_path) and not force:
159
+ typer.echo(yellow(" A Dockerfile already exists in this project."))
160
+ if not confirm(" Overwrite?"):
161
+ typer.echo(dim(" Aborted."))
162
+ raise typer.Exit(code=0)
163
+
164
+ # -- Write files --
165
+ files_written: list[str] = []
166
+
167
+ write_file(dockerfile_path, dockerfile_content)
168
+ files_written.append("Dockerfile")
169
+
170
+ dockerignore_path = os.path.join(project_path, ".dockerignore")
171
+ if not os.path.isfile(dockerignore_path):
172
+ write_file(dockerignore_path, dockerignore_content)
173
+ files_written.append(".dockerignore")
174
+ else:
175
+ typer.echo(dim(" .dockerignore already exists, skipping."))
176
+
177
+ if compose_content:
178
+ compose_path = os.path.join(project_path, "docker-compose.yml")
179
+ if os.path.isfile(compose_path) and not force:
180
+ typer.echo(yellow(" docker-compose.yml already exists."))
181
+ if confirm(" Overwrite?"):
182
+ write_file(compose_path, compose_content)
183
+ files_written.append("docker-compose.yml")
184
+ else:
185
+ typer.echo(dim(" Skipped docker-compose.yml."))
186
+ else:
187
+ write_file(compose_path, compose_content)
188
+ files_written.append("docker-compose.yml")
189
+
190
+ # -- Summary --
191
+ typer.echo()
192
+ typer.echo(green(" Generated files:"))
193
+ for f in files_written:
194
+ typer.echo(green(f" - {f}"))
195
+ typer.echo()
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # build
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ @app.command()
204
+ def build(
205
+ path: str = typer.Argument(
206
+ ".",
207
+ help="Path to the project directory containing the Dockerfile.",
208
+ ),
209
+ tag: str = typer.Option(
210
+ "dockermind-app",
211
+ "--tag", "-t",
212
+ help="Docker image tag.",
213
+ ),
214
+ ) -> None:
215
+ """Build a Docker image from the project's Dockerfile."""
216
+ _banner()
217
+
218
+ project_path = os.path.abspath(path)
219
+ dockerfile = os.path.join(project_path, "Dockerfile")
220
+
221
+ if not os.path.isfile(dockerfile):
222
+ typer.echo(red(" Dockerfile not found. Run 'dockermind init' first."))
223
+ raise typer.Exit(code=1)
224
+
225
+ success = docker_build(project_path, tag=tag)
226
+ raise typer.Exit(code=0 if success else 1)
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # run
231
+ # ---------------------------------------------------------------------------
232
+
233
+
234
+ @app.command()
235
+ def run(
236
+ tag: str = typer.Option(
237
+ "dockermind-app",
238
+ "--tag", "-t",
239
+ help="Docker image tag to run.",
240
+ ),
241
+ port: int = typer.Option(
242
+ 3000,
243
+ "--port", "-p",
244
+ help="Port to map (host:container).",
245
+ ),
246
+ detach: bool = typer.Option(
247
+ False,
248
+ "--detach", "-d",
249
+ help="Run container in background.",
250
+ ),
251
+ ) -> None:
252
+ """Run a Docker container from a built image."""
253
+ _banner()
254
+ success = docker_run(tag=tag, port=port, detach=detach)
255
+ raise typer.Exit(code=0 if success else 1)
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # doctor
260
+ # ---------------------------------------------------------------------------
261
+
262
+
263
+ @app.command()
264
+ def doctor() -> None:
265
+ """Check that Docker is installed and running."""
266
+ _banner()
267
+ ok = run_doctor()
268
+ raise typer.Exit(code=0 if ok else 1)
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # version
273
+ # ---------------------------------------------------------------------------
274
+
275
+
276
+ @app.command()
277
+ def version() -> None:
278
+ """Show dockermind version."""
279
+ typer.echo(f"dockermind v{__version__}")
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Entry point
284
+ # ---------------------------------------------------------------------------
285
+
286
+
287
+ if __name__ == "__main__":
288
+ app()
@@ -0,0 +1,43 @@
1
+ """
2
+ Docker Compose generator -- produces docker-compose.yml content from AnalysisResult.
3
+
4
+ Only generated when the user explicitly requests it via ``dockermind init --compose``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dockermind.analyzer import AnalysisResult
10
+
11
+
12
+ def generate_compose(analysis: AnalysisResult, service_name: str = "app") -> str:
13
+ """Return docker-compose.yml content as a string.
14
+
15
+ Includes:
16
+ - restart: unless-stopped
17
+ - correct port mapping
18
+ - env_file support
19
+ """
20
+ port = analysis.port or (3000 if analysis.language == "node" else 8000)
21
+
22
+ lines = [
23
+ "services:",
24
+ f" {service_name}:",
25
+ " build:",
26
+ " context: .",
27
+ " dockerfile: Dockerfile",
28
+ f" ports:",
29
+ f' - "{port}:{port}"',
30
+ " restart: unless-stopped",
31
+ " env_file:",
32
+ " - .env",
33
+ ]
34
+
35
+ # Add environment section with common variables
36
+ lines += [
37
+ " environment:",
38
+ " - NODE_ENV=production" if analysis.language == "node" else " - PYTHONUNBUFFERED=1",
39
+ ]
40
+
41
+ lines.append("")
42
+
43
+ return "\n".join(lines)