From 3c4d6ab77d8d6fd2342821bac872f1e9029443d7 Mon Sep 17 00:00:00 2001 From: caydyan Date: Sun, 14 Jun 2026 21:51:14 +0800 Subject: [PATCH] Add docker compose generation --- helpers/devcontainer_helpers.py | 2 +- helpers/docker_compose_helpers.py | 114 ++++++++++++++++++++++++ main.py | 17 +++- tests/test_docker_compose_generation.py | 72 +++++++++++++++ 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 helpers/docker_compose_helpers.py create mode 100644 tests/test_docker_compose_generation.py diff --git a/helpers/devcontainer_helpers.py b/helpers/devcontainer_helpers.py index ef4f34c..e774eca 100644 --- a/helpers/devcontainer_helpers.py +++ b/helpers/devcontainer_helpers.py @@ -136,4 +136,4 @@ def save_devcontainer(new_devcontainer): return result.data[0] if result.data else None except Exception as e: logging.error(f"Error saving devcontainer to Supabase: {str(e)}") - raise \ No newline at end of file + raise diff --git a/helpers/docker_compose_helpers.py b/helpers/docker_compose_helpers.py new file mode 100644 index 0000000..9b6384e --- /dev/null +++ b/helpers/docker_compose_helpers.py @@ -0,0 +1,114 @@ +import json +import logging +from urllib.parse import urlparse + +try: + import yaml +except ImportError: + yaml = None + + +def _repo_slug(repo_url): + parsed = urlparse(repo_url) + repo_name = parsed.path.rstrip("/").split("/")[-1] or "workspace" + return "".join(char.lower() if char.isalnum() else "-" for char in repo_name).strip("-") or "workspace" + + +def _service_name(devcontainer_config, repo_url): + raw_name = devcontainer_config.get("name") or _repo_slug(repo_url) + name = "".join(char.lower() if char.isalnum() else "-" for char in raw_name).strip("-") + return name or "devcontainer" + + +def _load_devcontainer_config(devcontainer_json): + try: + return json.loads(devcontainer_json) + except (TypeError, json.JSONDecodeError) as e: + logging.warning(f"Unable to parse devcontainer.json for compose generation: {e}") + return {} + + +def _normalize_compose_env(container_env): + if not isinstance(container_env, dict): + return None + + environment = {} + for key, value in container_env.items(): + if isinstance(value, str) and value.startswith("${localEnv:") and value.endswith("}"): + environment[key] = "${" + value[len("${localEnv:"):-1] + "}" + else: + environment[key] = value + return environment or None + + +def _normalize_compose_ports(forward_ports): + if not isinstance(forward_ports, list): + return None + + ports = [] + for port in forward_ports: + if isinstance(port, int): + ports.append(f"{port}:{port}") + elif isinstance(port, str) and port.strip(): + cleaned_port = port.strip() + ports.append(cleaned_port if ":" in cleaned_port else f"{cleaned_port}:{cleaned_port}") + return ports or None + + +def _dump_yaml(data, indent=0): + lines = [] + prefix = " " * indent + for key, value in data.items(): + if isinstance(value, dict): + lines.append(f"{prefix}{key}:") + lines.extend(_dump_yaml(value, indent + 2)) + elif isinstance(value, list): + lines.append(f"{prefix}{key}:") + for item in value: + lines.append(f"{prefix} - {json.dumps(item)}") + else: + lines.append(f"{prefix}{key}: {json.dumps(value)}") + return lines + + +def generate_docker_compose_yml(devcontainer_json, repo_url): + devcontainer_config = _load_devcontainer_config(devcontainer_json) + workspace_name = _repo_slug(repo_url) + service = { + "image": devcontainer_config.get("image", "mcr.microsoft.com/devcontainers/base:ubuntu"), + "volumes": [f".:/workspaces/{workspace_name}:cached"], + "working_dir": f"/workspaces/{workspace_name}", + "command": "sleep infinity", + } + + if isinstance(devcontainer_config.get("build"), dict): + service.pop("image", None) + service["build"] = { + "context": devcontainer_config["build"].get("context", "."), + } + dockerfile = devcontainer_config["build"].get("dockerfile") or devcontainer_config["build"].get("dockerFile") + if dockerfile: + service["build"]["dockerfile"] = dockerfile + elif devcontainer_config.get("dockerFile") or devcontainer_config.get("dockerfile"): + service.pop("image", None) + service["build"] = { + "context": devcontainer_config.get("context", "."), + "dockerfile": devcontainer_config.get("dockerFile") or devcontainer_config.get("dockerfile"), + } + + ports = _normalize_compose_ports(devcontainer_config.get("forwardPorts")) + if ports: + service["ports"] = ports + + environment = _normalize_compose_env(devcontainer_config.get("containerEnv")) + if environment: + service["environment"] = environment + + compose = { + "services": { + _service_name(devcontainer_config, repo_url): service + } + } + if yaml: + return yaml.safe_dump(compose, sort_keys=False) + return "\n".join(_dump_yaml(compose)) + "\n" diff --git a/main.py b/main.py index 2e6410d..be8c432 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ from helpers.openai_helpers import setup_azure_openai, setup_instructor from helpers.github_helpers import fetch_repo_context, check_url_exists from helpers.devcontainer_helpers import generate_devcontainer_json, validate_devcontainer_json +from helpers.docker_compose_helpers import generate_docker_compose_yml from helpers.token_helpers import count_tokens, truncate_to_token_limit from models import DevContainer from schemas import DevContainerModel @@ -127,6 +128,7 @@ async def post(repo_url: str, regenerate: bool = False): generated = True source = "generated" if url is None else "repository" + docker_compose_yml = generate_docker_compose_yml(devcontainer_json, repo_url) if not exists or regenerate: logging.info("Saving to database...") @@ -185,6 +187,19 @@ async def post(repo_url: str, regenerate: bool = False): cls="button-group" ), cls="code-container relative" + ), + Article("Docker Compose generated"), + Pre( + Code(docker_compose_yml, id="docker-compose-code", cls="overflow-auto"), + Div( + Button( + Img(cls="w-4 h-4", src="assets/icons/copy-icon.svg", alt="Copy"), + cls="icon-button copy-button", + title="Copy docker-compose.yml to clipboard", + ), + cls="button-group" + ), + cls="code-container relative" ) ) except Exception as e: @@ -207,4 +222,4 @@ async def get(fname:str, ext:str): if __name__ == "__main__": logging.info("Starting FastHTML app...") - serve() \ No newline at end of file + serve() diff --git a/tests/test_docker_compose_generation.py b/tests/test_docker_compose_generation.py new file mode 100644 index 0000000..0cfaa44 --- /dev/null +++ b/tests/test_docker_compose_generation.py @@ -0,0 +1,72 @@ +import unittest + +import helpers.docker_compose_helpers as docker_compose_helpers + + +class DockerComposeGenerationTest(unittest.TestCase): + def setUp(self): + self.original_yaml = docker_compose_helpers.yaml + docker_compose_helpers.yaml = None + + def tearDown(self): + docker_compose_helpers.yaml = self.original_yaml + + def test_generates_compose_from_image_devcontainer(self): + compose_yml = docker_compose_helpers.generate_docker_compose_yml( + """ + { + "name": "Python API", + "image": "mcr.microsoft.com/devcontainers/python:3.12-bookworm", + "forwardPorts": [8000, "5173"], + "containerEnv": { + "API_KEY": "${localEnv:API_KEY}", + "ENVIRONMENT": "development" + } + } + """, + "https://github.com/example/python-api", + ) + + self.assertIn("python-api:", compose_yml) + self.assertIn('image: "mcr.microsoft.com/devcontainers/python:3.12-bookworm"', compose_yml) + self.assertIn(".:/workspaces/python-api:cached", compose_yml) + self.assertIn('working_dir: "/workspaces/python-api"', compose_yml) + self.assertIn("8000:8000", compose_yml) + self.assertIn("5173:5173", compose_yml) + self.assertIn('API_KEY: "${API_KEY}"', compose_yml) + self.assertIn('ENVIRONMENT: "development"', compose_yml) + + def test_generates_compose_build_from_dockerfile_devcontainer(self): + compose_yml = docker_compose_helpers.generate_docker_compose_yml( + """ + { + "name": "Dockerfile App", + "build": { + "context": ".devcontainer", + "dockerfile": "Dockerfile" + } + } + """, + "https://github.com/example/dockerfile-app", + ) + + self.assertIn("dockerfile-app:", compose_yml) + self.assertNotIn("image:", compose_yml) + self.assertIn("build:", compose_yml) + self.assertIn('context: ".devcontainer"', compose_yml) + self.assertIn('dockerfile: "Dockerfile"', compose_yml) + + def test_invalid_devcontainer_json_still_produces_usable_compose(self): + with self.assertLogs(level="WARNING"): + compose_yml = docker_compose_helpers.generate_docker_compose_yml( + "{ not json }", + "https://github.com/example/fallback-app", + ) + + self.assertIn("fallback-app:", compose_yml) + self.assertIn('image: "mcr.microsoft.com/devcontainers/base:ubuntu"', compose_yml) + self.assertIn(".:/workspaces/fallback-app:cached", compose_yml) + + +if __name__ == "__main__": + unittest.main()