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.
@@ -0,0 +1,389 @@
1
+ """
2
+ Dockerfile generator -- produces optimised Dockerfile content from an AnalysisResult.
3
+
4
+ Rules (v1):
5
+ - Node with build script -> multi-stage (builder + runtime)
6
+ - Node without build -> single-stage
7
+ - Python -> single-stage with pip install
8
+ - Never use Alpine images -- always -slim (Debian bookworm)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dockermind.analyzer import AnalysisResult
14
+
15
+
16
+ def generate_dockerfile(analysis: AnalysisResult) -> str:
17
+ """Return the full Dockerfile content as a string."""
18
+ if analysis.language == "node":
19
+ return _generate_node(analysis)
20
+ elif analysis.language == "python":
21
+ return _generate_python(analysis)
22
+ else:
23
+ raise ValueError(f"Unsupported language: {analysis.language}")
24
+
25
+
26
+ def generate_dockerignore(analysis: AnalysisResult) -> str:
27
+ """Return .dockerignore content appropriate for the project."""
28
+ if analysis.language == "node":
29
+ return _node_dockerignore()
30
+ elif analysis.language == "python":
31
+ return _python_dockerignore()
32
+ return _generic_dockerignore()
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Node.js Dockerfile generation
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ def _generate_node(analysis: AnalysisResult) -> str:
41
+ """Generate a Node.js Dockerfile.
42
+
43
+ Multi-stage when a build script exists; single-stage otherwise.
44
+ """
45
+ version = analysis.node_version
46
+ base_image = f"node:{version}-slim"
47
+
48
+ # Determine install command based on lock file
49
+ install_cmd = _node_install_cmd(analysis)
50
+ install_cmd_prod = _node_install_cmd(analysis, production=True)
51
+
52
+ # Determine the CMD
53
+ start_cmd = _node_start_cmd(analysis)
54
+
55
+ port = analysis.port or 3000
56
+
57
+ if analysis.has_build_script:
58
+ return _node_multistage(
59
+ base_image=base_image,
60
+ install_cmd=install_cmd,
61
+ install_cmd_prod=install_cmd_prod,
62
+ build_script=analysis.build_script or "npm run build",
63
+ start_cmd=start_cmd,
64
+ port=port,
65
+ )
66
+ else:
67
+ return _node_singlestage(
68
+ base_image=base_image,
69
+ install_cmd=install_cmd_prod,
70
+ start_cmd=start_cmd,
71
+ port=port,
72
+ )
73
+
74
+
75
+ def _node_multistage(
76
+ *,
77
+ base_image: str,
78
+ install_cmd: str,
79
+ install_cmd_prod: str,
80
+ build_script: str,
81
+ start_cmd: str,
82
+ port: int,
83
+ ) -> str:
84
+ """Multi-stage Node.js Dockerfile (build + runtime)."""
85
+
86
+ # Determine run build command
87
+ if "pnpm" in install_cmd:
88
+ run_build = "pnpm run build"
89
+ elif "yarn" in install_cmd:
90
+ run_build = "yarn build"
91
+ else:
92
+ run_build = "npm run build"
93
+
94
+ lines = [
95
+ f"# ---- Stage 1: Build ----",
96
+ f"FROM {base_image} AS builder",
97
+ "",
98
+ "WORKDIR /app",
99
+ "",
100
+ "# Copy dependency manifests first for better layer caching",
101
+ "COPY package*.json ./",
102
+ ]
103
+
104
+ # Copy lock files based on what exists
105
+ if "pnpm" in install_cmd:
106
+ lines.append("COPY pnpm-lock.yaml ./")
107
+ elif "yarn" in install_cmd:
108
+ lines.append("COPY yarn.lock ./")
109
+
110
+ lines += [
111
+ "",
112
+ f"RUN {install_cmd}",
113
+ "",
114
+ "# Copy source code and build",
115
+ "COPY . .",
116
+ f"RUN {run_build}",
117
+ "",
118
+ f"# ---- Stage 2: Production ----",
119
+ f"FROM {base_image}",
120
+ "",
121
+ "WORKDIR /app",
122
+ "",
123
+ "# Copy dependency manifests",
124
+ "COPY package*.json ./",
125
+ ]
126
+
127
+ if "pnpm" in install_cmd_prod:
128
+ lines.append("COPY pnpm-lock.yaml ./")
129
+ elif "yarn" in install_cmd_prod:
130
+ lines.append("COPY yarn.lock ./")
131
+
132
+ lines += [
133
+ "",
134
+ f"# Install production dependencies only",
135
+ f"RUN {install_cmd_prod}",
136
+ "",
137
+ "# Copy built output from builder stage",
138
+ "COPY --from=builder /app/dist ./dist",
139
+ "",
140
+ f"EXPOSE {port}",
141
+ "",
142
+ f'CMD {start_cmd}',
143
+ "",
144
+ ]
145
+
146
+ return "\n".join(lines)
147
+
148
+
149
+ def _node_singlestage(
150
+ *,
151
+ base_image: str,
152
+ install_cmd: str,
153
+ start_cmd: str,
154
+ port: int,
155
+ ) -> str:
156
+ """Single-stage Node.js Dockerfile (no build step)."""
157
+ lines = [
158
+ f"FROM {base_image}",
159
+ "",
160
+ "WORKDIR /app",
161
+ "",
162
+ "# Copy dependency manifests first for better layer caching",
163
+ "COPY package*.json ./",
164
+ ]
165
+
166
+ if "pnpm" in install_cmd:
167
+ lines.append("COPY pnpm-lock.yaml ./")
168
+ elif "yarn" in install_cmd:
169
+ lines.append("COPY yarn.lock ./")
170
+
171
+ lines += [
172
+ "",
173
+ f"RUN {install_cmd}",
174
+ "",
175
+ "# Copy application source",
176
+ "COPY . .",
177
+ "",
178
+ f"EXPOSE {port}",
179
+ "",
180
+ f'CMD {start_cmd}',
181
+ "",
182
+ ]
183
+
184
+ return "\n".join(lines)
185
+
186
+
187
+ def _node_install_cmd(analysis: AnalysisResult, *, production: bool = False) -> str:
188
+ """Return the appropriate npm/yarn/pnpm install command."""
189
+ if analysis.has_pnpm_lock:
190
+ # pnpm requires corepack or global install; keep it simple
191
+ base = "corepack enable && pnpm install"
192
+ if production:
193
+ return base + " --prod"
194
+ return base + " --frozen-lockfile"
195
+
196
+ if analysis.has_yarn_lock:
197
+ if production:
198
+ return "yarn install --production --frozen-lockfile"
199
+ return "yarn install --frozen-lockfile"
200
+
201
+ # Default: npm
202
+ if production:
203
+ return "npm ci --omit=dev"
204
+ return "npm ci"
205
+
206
+
207
+ def _node_start_cmd(analysis: AnalysisResult) -> str:
208
+ """Determine the CMD instruction for a Node project."""
209
+ if analysis.start_script:
210
+ script = analysis.start_script.strip()
211
+
212
+ # If the start script begins with "node", extract the entry file safely.
213
+ if script.startswith("node "):
214
+ try:
215
+ # Use shlex-style split to handle paths with spaces / flags.
216
+ parts = script.split()
217
+ # parts[0] == "node", parts[1:] are args.
218
+ if len(parts) >= 2:
219
+ entry_file = parts[1].strip()
220
+ if entry_file:
221
+ return f'["node", "{entry_file}"]'
222
+ except (IndexError, ValueError):
223
+ pass # fall through to package-manager start below
224
+
225
+ # Otherwise wrap in npm/yarn/pnpm start
226
+ if analysis.has_pnpm_lock:
227
+ return '["pnpm", "start"]'
228
+ if analysis.has_yarn_lock:
229
+ return '["yarn", "start"]'
230
+ return '["npm", "start"]'
231
+
232
+ # No start script -- try common entry files
233
+ return '["node", "index.js"]'
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Python Dockerfile generation
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ def _infer_system_packages(analysis: AnalysisResult) -> list[str]:
242
+ """Return a list of apt packages required by detected Python dependencies."""
243
+ mapping: dict[str, str] = {
244
+ "psycopg2": "libpq-dev",
245
+ "pillow": "libjpeg-dev",
246
+ "lxml": "libxml2-dev",
247
+ "numpy": "build-essential",
248
+ }
249
+ packages: list[str] = []
250
+ deps = analysis.python_deps
251
+ for dep_substring, apt_pkg in mapping.items():
252
+ if any(dep_substring in d for d in deps) and apt_pkg not in packages:
253
+ packages.append(apt_pkg)
254
+ return sorted(packages)
255
+
256
+
257
+ def _generate_python(analysis: AnalysisResult) -> str:
258
+ """Generate a Python Dockerfile (always single-stage)."""
259
+ version = analysis.python_version
260
+ base_image = f"python:{version}-slim"
261
+ port = analysis.port or 8000
262
+
263
+ cmd = _python_cmd(analysis, port)
264
+ system_packages = _infer_system_packages(analysis)
265
+
266
+ lines = [
267
+ f"FROM {base_image}",
268
+ "",
269
+ "# Prevent Python from writing .pyc files and enable unbuffered output",
270
+ "ENV PYTHONDONTWRITEBYTECODE=1",
271
+ "ENV PYTHONUNBUFFERED=1",
272
+ "",
273
+ "WORKDIR /app",
274
+ "",
275
+ ]
276
+
277
+ # Install system-level native dependencies if needed
278
+ if system_packages:
279
+ pkg_list = " ".join(system_packages)
280
+ lines += [
281
+ "# Install native build dependencies",
282
+ "RUN apt-get update && \\",
283
+ f" apt-get install -y {pkg_list} && \\",
284
+ " rm -rf /var/lib/apt/lists/*",
285
+ "",
286
+ ]
287
+
288
+ lines += [
289
+ "# Install dependencies first for better layer caching",
290
+ "COPY requirements.txt ./",
291
+ "RUN pip install --no-cache-dir -r requirements.txt",
292
+ "",
293
+ "# Copy application source",
294
+ "COPY . .",
295
+ "",
296
+ f"EXPOSE {port}",
297
+ "",
298
+ f'CMD {cmd}',
299
+ "",
300
+ ]
301
+
302
+ return "\n".join(lines)
303
+
304
+
305
+ def _python_cmd(analysis: AnalysisResult, port: int) -> str:
306
+ """Determine the CMD instruction for a Python project."""
307
+ entry = analysis.python_entry_module or "main:app"
308
+
309
+ if analysis.uses_uvicorn or analysis.framework == "fastapi":
310
+ return f'["uvicorn", "{entry}", "--host", "0.0.0.0", "--port", "{port}"]'
311
+
312
+ if analysis.framework == "flask":
313
+ # For Flask, use flask run with proper host/port binding
314
+ module = entry.split(":")[0] if ":" in entry else "app"
315
+ return (
316
+ f'["flask", "run", "--host", "0.0.0.0", "--port", "{port}"]'
317
+ )
318
+
319
+ # Generic fallback
320
+ return f'["python", "-m", "{entry.split(":")[0] if ":" in entry else entry}"]'
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # .dockerignore content
325
+ # ---------------------------------------------------------------------------
326
+
327
+
328
+ def _node_dockerignore() -> str:
329
+ return """\
330
+ node_modules
331
+ npm-debug.log*
332
+ .git
333
+ .gitignore
334
+ .dockerignore
335
+ Dockerfile
336
+ docker-compose*.yml
337
+ .env
338
+ .env.*
339
+ .vscode
340
+ .idea
341
+ coverage
342
+ .nyc_output
343
+ dist
344
+ build
345
+ *.md
346
+ LICENSE
347
+ """
348
+
349
+
350
+ def _python_dockerignore() -> str:
351
+ return """\
352
+ __pycache__
353
+ *.pyc
354
+ *.pyo
355
+ .git
356
+ .gitignore
357
+ .dockerignore
358
+ Dockerfile
359
+ docker-compose*.yml
360
+ .env
361
+ .env.*
362
+ .vscode
363
+ .idea
364
+ .venv
365
+ venv
366
+ .tox
367
+ .pytest_cache
368
+ *.egg-info
369
+ dist
370
+ build
371
+ *.md
372
+ LICENSE
373
+ """
374
+
375
+
376
+ def _generic_dockerignore() -> str:
377
+ return """\
378
+ .git
379
+ .gitignore
380
+ .dockerignore
381
+ Dockerfile
382
+ docker-compose*.yml
383
+ .env
384
+ .env.*
385
+ .vscode
386
+ .idea
387
+ *.md
388
+ LICENSE
389
+ """
dockermind/doctor.py ADDED
@@ -0,0 +1,88 @@
1
+ """
2
+ Docker doctor -- checks that Docker is installed and running.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import subprocess
8
+
9
+ from dockermind.utils import command_available, run_command, green, red, yellow, dim
10
+
11
+
12
+ def check_docker_installed() -> bool:
13
+ """Return True if the ``docker`` binary is on the PATH."""
14
+ return command_available("docker")
15
+
16
+
17
+ def check_docker_running() -> bool:
18
+ """Return True if the Docker daemon is responsive."""
19
+ try:
20
+ run_command(["docker", "info"], capture=True)
21
+ return True
22
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
23
+ return False
24
+
25
+
26
+ def run_doctor() -> bool:
27
+ """Run all health checks and print results. Return True if everything passes."""
28
+ all_ok = True
29
+
30
+ print()
31
+ print(" Docker Environment Check")
32
+ print(" " + "-" * 40)
33
+
34
+ # Check 1: Docker installed
35
+ if check_docker_installed():
36
+ print(f" {green('PASS')} Docker CLI is installed")
37
+ else:
38
+ print(f" {red('FAIL')} Docker CLI is not installed")
39
+ print(f" {dim('Install Docker: https://docs.docker.com/get-docker/')}")
40
+ all_ok = False
41
+
42
+ # Check 2: Docker daemon running
43
+ if all_ok:
44
+ if check_docker_running():
45
+ print(f" {green('PASS')} Docker daemon is running")
46
+
47
+ # Bonus: show Docker version
48
+ try:
49
+ result = run_command(["docker", "version", "--format", "{{.Server.Version}}"])
50
+ version = result.stdout.strip()
51
+ print(f" {dim(f'Server version: {version}')}")
52
+ except (subprocess.CalledProcessError, FileNotFoundError):
53
+ pass
54
+ else:
55
+ print(f" {red('FAIL')} Docker daemon is not running")
56
+ print(f" {dim('Start Docker Desktop or run: sudo systemctl start docker')}")
57
+ all_ok = False
58
+
59
+ # Check 3: Docker Compose available
60
+ compose_available = False
61
+ try:
62
+ run_command(["docker", "compose", "version"], capture=True)
63
+ compose_available = True
64
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
65
+ pass
66
+
67
+ if not compose_available:
68
+ # Try legacy docker-compose
69
+ try:
70
+ run_command(["docker-compose", "version"], capture=True)
71
+ compose_available = True
72
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
73
+ pass
74
+
75
+ if compose_available:
76
+ print(f" {green('PASS')} Docker Compose is available")
77
+ else:
78
+ print(f" {yellow('WARN')} Docker Compose not found (optional)")
79
+
80
+ print()
81
+
82
+ if all_ok:
83
+ print(f" {green('All checks passed. Ready to build.')}")
84
+ else:
85
+ print(f" {red('Some checks failed. Fix the issues above.')}")
86
+
87
+ print()
88
+ return all_ok
dockermind/runner.py ADDED
@@ -0,0 +1,139 @@
1
+ """
2
+ Docker runner -- wraps ``docker build`` and ``docker run`` with clean error handling.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+
11
+ from dockermind.utils import green, red, yellow, dim, bold
12
+
13
+
14
+ def _check_port_available(port: int) -> bool:
15
+ """Return True if *port* is free on localhost; False if already bound."""
16
+ try:
17
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
18
+ s.bind(("127.0.0.1", port))
19
+ return True
20
+ except OSError:
21
+ return False
22
+
23
+
24
+ def docker_build(
25
+ project_path: str,
26
+ *,
27
+ tag: str = "dockermind-app",
28
+ ) -> bool:
29
+ """Run ``docker build`` and stream output. Return True on success."""
30
+ print()
31
+ print(f" {bold('Building image:')} {tag}")
32
+ print(f" {dim(f'Context: {project_path}')}")
33
+ print()
34
+
35
+ cmd = [
36
+ "docker", "build",
37
+ "-t", tag,
38
+ project_path,
39
+ ]
40
+
41
+ try:
42
+ # Stream build output directly to terminal
43
+ process = subprocess.Popen(
44
+ cmd,
45
+ stdout=sys.stdout,
46
+ stderr=sys.stderr,
47
+ )
48
+ exit_code = process.wait()
49
+
50
+ print()
51
+ if exit_code == 0:
52
+ print(f" {green(f'Image built successfully: {tag}')}")
53
+ return True
54
+ else:
55
+ print(f" {red(f'Build failed with exit code {exit_code}')}")
56
+ return False
57
+
58
+ except FileNotFoundError:
59
+ print(f" {red('Docker is not installed or not on PATH.')}")
60
+ print(f" {dim('Run `dockermind doctor` to diagnose.')}")
61
+ return False
62
+ except OSError as e:
63
+ print(f" {red(f'Failed to run docker build: {e}')}")
64
+ return False
65
+
66
+
67
+ def docker_run(
68
+ *,
69
+ tag: str = "dockermind-app",
70
+ port: int = 3000,
71
+ detach: bool = False,
72
+ ) -> bool:
73
+ """Run ``docker run`` with port mapping. Return True on success."""
74
+ print()
75
+ print(f" {bold('Running container:')} {tag}")
76
+ print(f" {dim(f'Port mapping: {port}:{port}')}")
77
+
78
+ # Lightweight port-in-use warning (non-blocking)
79
+ if not _check_port_available(port):
80
+ print(f" {yellow(f'Warning: Port {port} appears to be in use.')}")
81
+
82
+ print()
83
+
84
+ cmd = [
85
+ "docker", "run",
86
+ "--rm",
87
+ "-p", f"{port}:{port}",
88
+ ]
89
+
90
+ if detach:
91
+ cmd.append("-d")
92
+
93
+ cmd.append(tag)
94
+
95
+ try:
96
+ if detach:
97
+ result = subprocess.run(
98
+ cmd,
99
+ capture_output=True,
100
+ text=True,
101
+ )
102
+ if result.returncode == 0:
103
+ container_id = result.stdout.strip()[:12]
104
+ print(f" {green(f'Container started: {container_id}')}")
105
+ print(f" {dim(f'Listening on http://localhost:{port}')}")
106
+ return True
107
+ else:
108
+ print(f" {red(f'Failed to start container.')}")
109
+ if result.stderr:
110
+ print(f" {dim(result.stderr.strip())}")
111
+ return False
112
+ else:
113
+ # Foreground mode -- stream output
114
+ print(f" {dim(f'Listening on http://localhost:{port}')}")
115
+ print(f" {dim('Press Ctrl+C to stop.')}")
116
+ print()
117
+
118
+ process = subprocess.Popen(
119
+ cmd,
120
+ stdout=sys.stdout,
121
+ stderr=sys.stderr,
122
+ )
123
+ try:
124
+ exit_code = process.wait()
125
+ except KeyboardInterrupt:
126
+ print()
127
+ print(f" {yellow('Stopping container...')}")
128
+ process.terminate()
129
+ process.wait(timeout=10)
130
+ return True
131
+
132
+ return exit_code == 0
133
+
134
+ except FileNotFoundError:
135
+ print(f" {red('Docker is not installed or not on PATH.')}")
136
+ return False
137
+ except OSError as e:
138
+ print(f" {red(f'Failed to run docker run: {e}')}")
139
+ return False