diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..11c12f2 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,7 @@ +This Python library is used to create Docker compose.yaml files from Python classes. + +It is designed to simplify the process of defining and managing Docker Compose configurations programmatically. + +Uses uv for Python dependency management. `uv sync`, `uv add `. + +Ruff is used for linting and formatting. Use `ruff check .` to check the code and `ruff format .` to format it. diff --git a/example/__init__.py b/example/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/basic_example.py b/example/basic_example.py new file mode 100644 index 0000000..ecf4a83 --- /dev/null +++ b/example/basic_example.py @@ -0,0 +1,36 @@ +"""Example usage of DockerComposeManager to generate a docker-compose.yaml file.""" + +from compose import DockerComposeManager + +if __name__ == "__main__": + # Path to the compose file to generate + compose_path = "docker-compose.yaml" + + # Create a DockerComposeManager instance + manager = DockerComposeManager(compose_path) + + # Add a simple web service + manager.create_service( + name="web", + image="nginx:alpine", + ports=["8080:80"], + environment={"NGINX_HOST": "localhost"}, + ) + + # Add a database service + manager.create_service( + name="db", + image="postgres:15-alpine", + environment={ + "POSTGRES_USER": "user", + "POSTGRES_PASSWORD": "password", + "POSTGRES_DB": "exampledb", + }, + ports=["5432:5432"], + volumes=["db_data:/var/lib/postgresql/data"], + ) + + # Save the compose file + manager.save() + + print(f"docker-compose.yaml generated at {compose_path}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..21f4fb9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "compose" +version = "0.1.0" +description = "A simple Python package for managing Docker Compose files" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "pytest>=8.4.0", + "pyyaml>=6.0.2", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/src/compose/__init__.py b/src/compose/__init__.py new file mode 100644 index 0000000..5d578f0 --- /dev/null +++ b/src/compose/__init__.py @@ -0,0 +1,133 @@ +# Copyright (c) 2023, the Compose project contributors. +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Docker Compose YAML file generator. + +This package provides utilities for programmatically creating and managing Docker Compose +configuration files through Python classes, simplifying the process of defining +and managing Docker Compose configurations. +""" + +from pathlib import Path +from types import TracebackType +from typing import Any + +import yaml + + +class DockerComposeManager: + """A class to create and modify Docker Compose YAML files programmatically. + + Supports context manager usage for auto-saving. + """ + + def __init__(self, path: str, version: str = "3.8") -> None: + """Initialize the manager with a YAML file path. Loads existing file or creates a new one.""" + self.path: str = path + self.version: str = version + self._data: dict[str, Any] = {} + self._dirty: bool = False + self._load() + + def _load(self) -> None: + if Path(self.path).exists(): + with Path(self.path).open("r", encoding="utf-8") as f: + self._data = yaml.safe_load(f) or {} + if not self._data: + self._data = {"version": self.version, "services": {}} + if "services" not in self._data: + self._data["services"] = {} + if not isinstance(self._data["services"], dict): + self._data["services"] = {} + + def create_service( + self, + name: str, + image: str, + ports: list[str] | None = None, + environment: dict[str, str] | None = None, + **kwargs: object, + ) -> "DockerComposeManager": + """Create a new service in the compose file. + + Returns: + DockerComposeManager: self (for chaining) + + """ + services = self._data["services"] + service: dict[str, Any] = {"image": image} + if ports is not None: + service["ports"] = ports + if environment is not None: + service["environment"] = environment + service.update(kwargs) + services[name] = service + self._dirty = True + return self + + def modify_service(self, name: str, **kwargs: object) -> "DockerComposeManager": + """Modify an existing service. Raises KeyError if not found. + + Args: + name (str): Name of the service to modify. + **kwargs: Key-value pairs to update in the service configuration. + + Raises: + KeyError: If the service with the given name does not exist. + + Returns: + DockerComposeManager: self (for chaining) + + """ + services: dict[str, dict[str, Any]] = self._data["services"] + if name not in services: + msg: str = f"Service '{name}' not found." + raise KeyError(msg) + services[name].update(kwargs) + self._dirty = True + return self + + def remove_service(self, name: str) -> "DockerComposeManager": + """Remove a service from the compose file. + + Returns: + DockerComposeManager: self (for chaining) + + """ + services: dict[str, dict[str, Any]] = self._data["services"] + if name in services: + del services[name] + self._dirty = True + return self + + def save(self) -> None: + """Save the current state to the YAML file.""" + with Path(self.path).open("w", encoding="utf-8") as f: + yaml.dump(self._data, f, sort_keys=False, indent=2) + self._dirty = False + + def __enter__(self) -> "DockerComposeManager": + """Enter the context manager and return self. + + Returns: + DockerComposeManager: The instance itself for context management. + + """ + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit the context manager and save the file if changes were made. + + Args: + exc_type: The exception type if an exception was raised, None otherwise. + exc_val: The exception value if an exception was raised, None otherwise. + exc_tb: The traceback if an exception was raised, None otherwise. + + """ + if self._dirty: + self.save() diff --git a/src/compose/py.typed b/src/compose/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_compose_manager.py b/tests/test_compose_manager.py new file mode 100644 index 0000000..309cd8f --- /dev/null +++ b/tests/test_compose_manager.py @@ -0,0 +1,62 @@ +from pathlib import Path + +import pytest +import yaml + +from compose import DockerComposeManager + + +def test_create_and_save_service(tmp_path: Path) -> None: + compose_file: Path = tmp_path / "docker-compose.yml" + manager = DockerComposeManager(str(compose_file)) + manager.create_service( + name="web", + image="nginx:latest", + ports=["80:80"], + environment={"ENV_VAR": "value"}, + ).save() + # Check file exists and content + assert compose_file.exists() + with compose_file.open() as f: + data = yaml.safe_load(f) + assert "web" in data["services"] + assert data["services"]["web"]["image"] == "nginx:latest" + assert data["services"]["web"]["ports"] == ["80:80"] + assert data["services"]["web"]["environment"] == {"ENV_VAR": "value"} + + +def test_modify_service(tmp_path: Path) -> None: + compose_file: Path = tmp_path / "docker-compose.yml" + manager = DockerComposeManager(str(compose_file)) + manager.create_service(name="web", image="nginx:latest").save() + manager.modify_service(name="web", image="nginx:1.19", ports=["8080:80"]).save() + with compose_file.open() as f: + data = yaml.safe_load(f) + assert data["services"]["web"]["image"] == "nginx:1.19" + assert data["services"]["web"]["ports"] == ["8080:80"] + + +def test_remove_service(tmp_path: Path) -> None: + compose_file: Path = tmp_path / "docker-compose.yml" + manager = DockerComposeManager(str(compose_file)) + manager.create_service(name="web", image="nginx:latest").save() + manager.remove_service("web").save() + with compose_file.open() as f: + data = yaml.safe_load(f) + assert "web" not in data["services"] + + +def test_context_manager(tmp_path: Path) -> None: + compose_file: Path = tmp_path / "docker-compose.yml" + with DockerComposeManager(str(compose_file)) as manager: + manager.create_service(name="web", image="nginx:latest") + with compose_file.open() as f: + data = yaml.safe_load(f) + assert "web" in data["services"] + + +def test_modify_nonexistent_service(tmp_path: Path) -> None: + compose_file: Path = tmp_path / "docker-compose.yml" + manager = DockerComposeManager(str(compose_file)) + with pytest.raises(KeyError): + manager.modify_service("notfound", image="nginx:latest")