Compare commits

...

2 Commits

3 changed files with 165 additions and 45 deletions

View File

@ -6,38 +6,53 @@ import logging
from compose import DockerComposeManager from compose import DockerComposeManager
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.INFO)
logger: logging.Logger = logging.getLogger("docker-compose-example") logger: logging.Logger = logging.getLogger("docker-compose-example")
if __name__ == "__main__": if __name__ == "__main__":
# Path to the compose file to generate # Path to the compose file to generate
compose_path = "docker-compose.yaml" compose_path = "docker-compose.yaml"
# Create a DockerComposeManager instance # Using DockerComposeManager as a context manager
manager = DockerComposeManager(compose_path) with DockerComposeManager(compose_path) as manager:
# Add top-level networks, volumes, configs, and secrets
manager.add_network("my_network")
manager.add_volume("db_data")
manager.add_config("my_config", config={"file": "./config.json"})
manager.add_secret("my_secret", config={"file": "./secret.txt"})
# Add a simple web service # Add a simple web service
manager.create_service( manager.create_service(
name="web", name="web",
image="nginx:alpine", image="nginx:alpine",
ports=["8080:80"], ports=["8080:80"],
environment={"NGINX_HOST": "localhost"}, environment={"NGINX_HOST": "localhost"},
) networks=["my_network"],
)
# Add a database service # Add a database service that depends on the web service
manager.create_service( manager.create_service(
name="db", name="db",
image="postgres:15-alpine", image="postgres:15-alpine",
environment={ environment={
"POSTGRES_USER": "user", "POSTGRES_USER": "user",
"POSTGRES_PASSWORD": "password", "POSTGRES_PASSWORD": "password",
"POSTGRES_DB": "example_db", "POSTGRES_DB": "example_db",
}, },
ports=["5432:5432"], ports=["5432:5432"],
volumes=["db_data:/var/lib/postgresql/data"], volumes=["db_data:/var/lib/postgresql/data"],
) networks=["my_network"],
depends_on={"web": {"condition": "service_started"}},
)
# Save the compose file # Modify the web service
manager.save() manager.modify_service("web", ports=["8081:80"])
# Add another service and then remove it
manager.create_service("temp_service", image="alpine:latest")
manager.remove_service("temp_service")
# Remove a network
manager.remove_network("my_network")
logger.info("docker-compose.yaml generated at %s", compose_path) logger.info("docker-compose.yaml generated at %s", compose_path)

View File

@ -1,6 +1,3 @@
# Copyright (c) 2023, the Compose project contributors.
# SPDX-License-Identifier: GPL-3.0-or-later
"""Docker Compose YAML file generator. """Docker Compose YAML file generator.
This package provides utilities for programmatically creating and managing Docker Compose This package provides utilities for programmatically creating and managing Docker Compose
@ -29,7 +26,7 @@ class ServiceConfig(BaseModel):
healthcheck: dict[str, Any] | None = None healthcheck: dict[str, Any] | None = None
restart: str | None = None restart: str | None = None
labels: dict[str, str] | list[str] | None = None labels: dict[str, str] | list[str] | None = None
depends_on: list[str] | None = None depends_on: list[str] | dict[str, dict[str, str]] | None = None
configs: list[dict[str, Any]] | None = None configs: list[dict[str, Any]] | None = None
secrets: list[dict[str, Any]] | None = None secrets: list[dict[str, Any]] | None = None
deploy: dict[str, Any] | None = None deploy: dict[str, Any] | None = None
@ -51,6 +48,9 @@ class VolumeConfig(BaseModel):
labels: dict[str, str] | list[str] | None = None labels: dict[str, str] | list[str] | None = None
name: str | None = None name: str | None = None
# Allow extra fields for flexibility and to support arbitrary Docker Compose extensions.
model_config = {"extra": "allow"}
class NetworkConfig(BaseModel): class NetworkConfig(BaseModel):
"""Represents a network configuration for a Docker Compose file.""" """Represents a network configuration for a Docker Compose file."""
@ -66,6 +66,28 @@ class NetworkConfig(BaseModel):
model_config = {"extra": "allow"} model_config = {"extra": "allow"}
class ConfigConfig(BaseModel):
"""Represents a config configuration for a Docker Compose file."""
file: str | None = None
external: bool | None = None
name: str | None = None
# Allow extra fields for flexibility and to support arbitrary Docker Compose extensions.
model_config = {"extra": "allow"}
class SecretConfig(BaseModel):
"""Represents a secret configuration for a Docker Compose file."""
file: str | None = None
external: bool | None = None
name: str | None = None
# Allow extra fields for flexibility and to support arbitrary Docker Compose extensions.
model_config = {"extra": "allow"}
if TYPE_CHECKING: if TYPE_CHECKING:
from types import TracebackType from types import TracebackType
@ -98,10 +120,17 @@ class DockerComposeManager:
Returns: Returns:
DockerComposeManager: self (for chaining) DockerComposeManager: self (for chaining)
Raises:
KeyError: If the volume does not exist.
""" """
if "volumes" in self._data and name in self._data["volumes"]: if "volumes" not in self._data or name not in self._data["volumes"]:
del self._data["volumes"][name] msg = f"Volume '{name}' not found."
self._dirty = True raise KeyError(msg)
del self._data["volumes"][name]
if not self._data["volumes"]:
del self._data["volumes"]
self._dirty = True
return self return self
def add_network(self, name: str, config: NetworkConfig | dict[str, Any] | None = None) -> DockerComposeManager: def add_network(self, name: str, config: NetworkConfig | dict[str, Any] | None = None) -> DockerComposeManager:
@ -126,10 +155,87 @@ class DockerComposeManager:
Returns: Returns:
DockerComposeManager: self (for chaining) DockerComposeManager: self (for chaining)
Raises:
KeyError: If the network does not exist.
""" """
if "networks" in self._data and name in self._data["networks"]: if "networks" not in self._data or name not in self._data["networks"]:
del self._data["networks"][name] msg = f"Network '{name}' not found."
self._dirty = True raise KeyError(msg)
del self._data["networks"][name]
if not self._data["networks"]:
del self._data["networks"]
self._dirty = True
return self
def add_config(self, name: str, config: ConfigConfig | dict[str, Any] | None = None) -> DockerComposeManager:
"""Add a top-level config definition.
Returns:
DockerComposeManager: self (for chaining)
"""
if "configs" not in self._data or not isinstance(self._data["configs"], dict):
self._data["configs"] = {}
if config is None:
self._data["configs"][name] = {}
else:
if isinstance(config, dict):
config = ConfigConfig(**config)
self._data["configs"][name] = config.model_dump(exclude_none=True)
self._dirty = True
return self
def remove_config(self, name: str) -> DockerComposeManager:
"""Remove a top-level config definition.
Returns:
DockerComposeManager: self (for chaining)
Raises:
KeyError: If the config does not exist.
"""
if "configs" not in self._data or name not in self._data["configs"]:
msg = f"Config '{name}' not found."
raise KeyError(msg)
del self._data["configs"][name]
if not self._data["configs"]:
del self._data["configs"]
self._dirty = True
return self
def add_secret(self, name: str, config: SecretConfig | dict[str, Any] | None = None) -> DockerComposeManager:
"""Add a top-level secret definition.
Returns:
DockerComposeManager: self (for chaining)
"""
if "secrets" not in self._data or not isinstance(self._data["secrets"], dict):
self._data["secrets"] = {}
if config is None:
self._data["secrets"][name] = {}
else:
if isinstance(config, dict):
config = SecretConfig(**config)
self._data["secrets"][name] = config.model_dump(exclude_none=True)
self._dirty = True
return self
def remove_secret(self, name: str) -> DockerComposeManager:
"""Remove a top-level secret definition.
Returns:
DockerComposeManager: self (for chaining)
Raises:
KeyError: If the secret does not exist.
"""
if "secrets" not in self._data or name not in self._data["secrets"]:
msg = f"Secret '{name}' not found."
raise KeyError(msg)
del self._data["secrets"][name]
if not self._data["secrets"]:
del self._data["secrets"]
self._dirty = True
return self return self
def __init__(self, path: str, version: str = "3.8") -> None: def __init__(self, path: str, version: str = "3.8") -> None:
@ -167,7 +273,7 @@ class DockerComposeManager:
healthcheck: dict[str, Any] | None = None, healthcheck: dict[str, Any] | None = None,
restart: str | None = None, restart: str | None = None,
labels: dict[str, str] | list[str] | None = None, labels: dict[str, str] | list[str] | None = None,
depends_on: list[str] | None = None, depends_on: list[str] | dict[str, dict[str, str]] | None = None,
configs: list[dict[str, Any]] | None = None, configs: list[dict[str, Any]] | None = None,
secrets: list[dict[str, Any]] | None = None, secrets: list[dict[str, Any]] | None = None,
deploy: dict[str, Any] | None = None, deploy: dict[str, Any] | None = None,
@ -244,11 +350,15 @@ class DockerComposeManager:
Returns: Returns:
DockerComposeManager: self (for chaining) DockerComposeManager: self (for chaining)
Raises:
KeyError: If the service does not exist.
""" """
services: dict[str, dict[str, Any]] = self._data["services"] services: dict[str, dict[str, Any]] = self._data["services"]
if name in services: if name not in services:
del services[name] msg: str = f"Service '{name}' not found."
self._dirty = True raise KeyError(msg)
del services[name]
self._dirty = True
return self return self
def save(self) -> None: def save(self) -> None:
@ -256,7 +366,6 @@ class DockerComposeManager:
with Path(self.path).open("w", encoding="utf-8") as f: with Path(self.path).open("w", encoding="utf-8") as f:
yaml.dump(self._data, f, sort_keys=False, indent=2, default_flow_style=False) yaml.dump(self._data, f, sort_keys=False, indent=2, default_flow_style=False)
self._dirty = False self._dirty = False
self._dirty = False
def __enter__(self) -> Self: def __enter__(self) -> Self:
"""Enter the context manager and return self. """Enter the context manager and return self.

View File

@ -71,12 +71,8 @@ def test_modify_nonexistent_service(tmp_path: Path) -> None:
def test_remove_nonexistent_service(tmp_path: Path) -> None: def test_remove_nonexistent_service(tmp_path: Path) -> None:
compose_file: Path = tmp_path / "docker-compose.yml" compose_file: Path = tmp_path / "docker-compose.yml"
manager = DockerComposeManager(str(compose_file)) manager = DockerComposeManager(str(compose_file))
# Should not raise with pytest.raises(KeyError):
manager.remove_service("notfound").save() manager.remove_service("notfound")
with compose_file.open() as f:
data = yaml.safe_load(f)
assert "services" in data
assert data["services"] == {}
def test_create_service_with_extra_kwargs(tmp_path: Path) -> None: def test_create_service_with_extra_kwargs(tmp_path: Path) -> None: