Compare commits

..

6 Commits

11 changed files with 714 additions and 87 deletions

88
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,88 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "latest"
- name: Cache dependencies
uses: actions/cache@v3
with:
path: |
.venv
.cache/uv
key: ${{ runner.os }}-uv-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-uv-
- name: Install dependencies
run: |
uv pip install -e ".[dev]"
- name: Run linting
run: |
uv run ruff check src/ tests/ example/
- name: Run formatting check
run: |
uv run ruff format --check src/ tests/ example/
- name: Run type checking
run: |
uv run mypy src/
- name: Run tests
run: |
uv run pytest --cov=src --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "latest"
- name: Install dependencies
run: |
uv pip install -e ".[dev]"
- name: Run pre-commit
run: |
uv run pre-commit run --all-files

36
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,36 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
- id: check-case-conflict
- id: check-docstring-first
- id: debug-statements
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies: [types-PyYAML]
args: [--ignore-missing-imports]
- repo: local
hooks:
- id: pytest
name: pytest
entry: pytest
language: system
pass_filenames: false
always_run: true
stages: [manual]

12
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"cSpell.words": [
"docstrings",
"healthcheck",
"isort",
"pycodestyle",
"pydantic",
"pydocstyle",
"pytest",
"pyyaml"
]
}

42
CHANGELOG.md Normal file
View File

@ -0,0 +1,42 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Initial project structure
- DockerComposeManager class for programmatic Docker Compose file management
- Support for services, volumes, networks, configs, and secrets
- Basic example demonstrating usage
- Comprehensive test suite
- Development tools configuration (Ruff, MyPy, Pre-commit)
- Makefile for common development commands
- Contributing guidelines
### Changed
- Lowered Python version requirement from 3.13+ to 3.9+ for broader compatibility
### Fixed
- None yet
## [0.1.0] - 2024-01-XX
### Added
- Initial release
- Core DockerComposeManager functionality
- Support for basic Docker Compose features:
- Services with full configuration options
- Volumes
- Networks
- Configs
- Secrets
- Context manager support for auto-saving
- Pydantic models for type safety
- YAML file generation and parsing
[Unreleased]: https://github.com/yourusername/compose/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/yourusername/compose/releases/tag/v0.1.0

146
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,146 @@
# Contributing to Compose
Thank you for your interest in contributing to the Compose project! This document provides guidelines and information for contributors.
## Development Setup
1. **Clone the repository:**
```bash
git clone <repository-url>
cd compose
```
2. **Install development dependencies:**
```bash
uv pip install -e ".[dev]"
```
3. **Install pre-commit hooks:**
```bash
make pre-commit-install
```
## Development Workflow
### Code Quality
We use several tools to maintain code quality:
- **Ruff**: For linting and formatting
- **MyPy**: For type checking
- **Pre-commit**: For automated checks on commit
### Running Checks
```bash
# Run all checks
make check-all
# Run individual checks
make lint # Linting
make format # Code formatting
make type-check # Type checking
make test # Tests
make test-cov # Tests with coverage
```
### Pre-commit Hooks
Pre-commit hooks will automatically run on each commit. To run them manually:
```bash
make pre-commit-run
```
## Testing
### Running Tests
```bash
# Run all tests
make test
# Run tests with coverage
make test-cov
# Run specific test file
uv run pytest tests/test_specific.py
# Run tests with verbose output
uv run pytest -v
```
### Writing Tests
- Place all tests in the `tests/` directory
- Use descriptive test names
- Follow the existing test patterns
- Include both positive and negative test cases
- Test edge cases and error conditions
## Code Style
### Python Code
- Follow PEP 8 style guidelines
- Use type hints for all function parameters and return values
- Write docstrings for all public functions and classes
- Use Google-style docstrings
### Commit Messages
- Use clear, descriptive commit messages
- Start with a verb in present tense (e.g., "Add", "Fix", "Update")
- Keep the first line under 50 characters
- Add more details in the body if needed
Example:
```
Add support for Docker Compose volumes
- Implement VolumeConfig class
- Add add_volume and remove_volume methods
- Include comprehensive tests
```
## Pull Request Process
1. **Create a feature branch** from the main branch
2. **Make your changes** following the coding guidelines
3. **Write tests** for new functionality
4. **Run all checks** to ensure code quality
5. **Update documentation** if needed
6. **Submit a pull request** with a clear description
### Pull Request Checklist
- [ ] Code follows the project's style guidelines
- [ ] Tests pass and coverage is maintained
- [ ] Documentation is updated
- [ ] Pre-commit hooks pass
- [ ] Type checking passes
- [ ] Linting passes
## Reporting Issues
When reporting issues, please include:
- A clear description of the problem
- Steps to reproduce the issue
- Expected behavior
- Actual behavior
- Environment details (Python version, OS, etc.)
- Any relevant error messages or logs
## Getting Help
If you need help or have questions:
- Check the existing documentation
- Look at existing issues and pull requests
- Create a new issue for bugs or feature requests
- Ask questions in discussions
## License
By contributing to this project, you agree that your contributions will be licensed under the same license as the project.

49
Makefile Normal file
View File

@ -0,0 +1,49 @@
.PHONY: help install install-dev test test-cov lint format type-check clean build publish
help: ## Show this help message
@echo "Available commands:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
install: ## Install the package in development mode
uv pip install -e .
install-dev: ## Install the package with development dependencies
uv pip install -e ".[dev]"
test: ## Run tests
uv run pytest
test-cov: ## Run tests with coverage
uv run pytest --cov=src --cov-report=html --cov-report=term-missing
lint: ## Run linting checks
uv run ruff check src/ tests/ example/
format: ## Format code
uv run ruff format src/ tests/ example/
type-check: ## Run type checking
uv run mypy src/
clean: ## Clean up build artifacts
rm -rf build/
rm -rf dist/
rm -rf *.egg-info/
rm -rf .pytest_cache/
rm -rf .coverage
rm -rf htmlcov/
rm -rf .mypy_cache/
build: ## Build the package
uv run python -m build
publish: ## Publish to PyPI (requires authentication)
uv run python -m twine upload dist/*
pre-commit-install: ## Install pre-commit hooks
uv run pre-commit install
pre-commit-run: ## Run pre-commit on all files
uv run pre-commit run --all-files
check-all: lint type-check test ## Run all checks (lint, type-check, test)

View File

@ -1,36 +1,58 @@
"""Example usage of DockerComposeManager to generate a docker-compose.yaml file.""" """Example usage of DockerComposeManager to generate a docker-compose.yaml file."""
from __future__ import annotations
import logging
from compose import DockerComposeManager from compose import DockerComposeManager
logging.basicConfig(level=logging.INFO)
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": "exampledb", "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"])
print(f"docker-compose.yaml generated at {compose_path}") # 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)

View File

@ -3,8 +3,23 @@ name = "compose"
version = "0.1.0" version = "0.1.0"
description = "A simple Python package for managing Docker Compose files" description = "A simple Python package for managing Docker Compose files"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.9"
dependencies = ["pytest>=8.4.0", "pyyaml>=6.0.2"] dependencies = [
"pydantic>=2.11.7",
"pytest>=8.4.0",
"pyyaml>=6.0.2",
"ruff>=0.12.0",
]
[project.optional-dependencies]
dev = [
"mypy>=1.8.0",
"pre-commit>=3.6.0",
"pytest-cov>=4.1.0",
"pytest-mock>=3.12.0",
"hypothesis>=6.98.0",
"types-PyYAML>=6.0.12",
]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
@ -61,3 +76,53 @@ lint.ignore = [
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "D103", "PLR2004"] "tests/*" = ["S101", "D103", "PLR2004"]
[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"--strict-markers",
"--strict-config",
"--cov=src",
"--cov-report=term-missing",
"--cov-report=html",
"--cov-report=xml",
]
[tool.coverage.run]
source = ["src"]
omit = [
"*/tests/*",
"*/test_*",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if settings.DEBUG",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if __name__ == .__main__.:",
"class .*\\bProtocol\\):",
"@(abc\\.)?abstractmethod",
]

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
@ -14,13 +11,94 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Self from typing import TYPE_CHECKING, Any, Self
import yaml import yaml
from pydantic import BaseModel, ValidationError
class ServiceConfig(BaseModel):
image: str = ""
ports: list[str] | None = None
environment: dict[str, str] | None = None
volumes: list[str] | None = None
networks: list[str] | None = None
command: str | list[str] | None = None
entrypoint: str | list[str] | None = None
build: dict[str, Any] | str | None = None
healthcheck: dict[str, Any] | None = None
restart: str | None = None
labels: dict[str, str] | list[str] | None = None
depends_on: list[str] | dict[str, dict[str, str]] | None = None
configs: list[dict[str, Any]] | None = None
secrets: list[dict[str, Any]] | None = None
deploy: dict[str, Any] | None = None
resources: dict[str, Any] | None = None
# Allow extra fields for flexibility and to support arbitrary Docker Compose extensions.
model_config = {"extra": "allow"}
class VolumeConfig(BaseModel):
"""Configuration for a Docker Compose volume.
Represents the configuration options for defining a volume in a Docker Compose file.
"""
driver: str | None = None
driver_opts: dict[str, Any] | None = None
external: bool | dict[str, Any] | None = None
labels: dict[str, str] | list[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):
"""Represents a network configuration for a Docker Compose file."""
driver: str | None = None
driver_opts: dict[str, Any] | None = None
attachable: bool | None = None
external: bool | dict[str, Any] | None = None
labels: dict[str, str] | list[str] | None = None
name: str | None = None
# Allow extra fields for flexibility and to support arbitrary Docker Compose extensions.
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
class DockerComposeManager: class DockerComposeManager:
def add_volume(self, name: str, config: dict[str, Any] | None = None) -> DockerComposeManager: """A class to create and modify Docker Compose YAML files programmatically.
Supports context manager usage for auto-saving.
"""
def add_volume(self, name: str, config: VolumeConfig | dict[str, Any] | None = None) -> DockerComposeManager:
"""Add a top-level volume definition. """Add a top-level volume definition.
Returns: Returns:
@ -28,7 +106,12 @@ class DockerComposeManager:
""" """
if "volumes" not in self._data or not isinstance(self._data["volumes"], dict): if "volumes" not in self._data or not isinstance(self._data["volumes"], dict):
self._data["volumes"] = {} self._data["volumes"] = {}
self._data["volumes"][name] = config or {} if config is None:
self._data["volumes"][name] = {}
else:
if isinstance(config, dict):
config = VolumeConfig(**config)
self._data["volumes"][name] = config.model_dump(exclude_none=True)
self._dirty = True self._dirty = True
return self return self
@ -37,13 +120,20 @@ 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: dict[str, Any] | None = None) -> DockerComposeManager: def add_network(self, name: str, config: NetworkConfig | dict[str, Any] | None = None) -> DockerComposeManager:
"""Add a top-level network definition. """Add a top-level network definition.
Returns: Returns:
@ -51,7 +141,12 @@ class DockerComposeManager:
""" """
if "networks" not in self._data or not isinstance(self._data["networks"], dict): if "networks" not in self._data or not isinstance(self._data["networks"], dict):
self._data["networks"] = {} self._data["networks"] = {}
self._data["networks"][name] = config or {} if config is None:
self._data["networks"][name] = {}
else:
if isinstance(config, dict):
config = NetworkConfig(**config)
self._data["networks"][name] = config.model_dump(exclude_none=True)
self._dirty = True self._dirty = True
return self return self
@ -60,16 +155,88 @@ 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 return self
"""A class to create and modify Docker Compose YAML files programmatically. def add_config(self, name: str, config: ConfigConfig | dict[str, Any] | None = None) -> DockerComposeManager:
"""Add a top-level config definition.
Supports context manager usage for auto-saving. 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
def __init__(self, path: str, version: str = "3.8") -> None: 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.""" """Initialize the manager with a YAML file path. Loads existing file or creates a new one."""
@ -93,6 +260,8 @@ class DockerComposeManager:
def create_service( def create_service(
self, self,
name: str, name: str,
*,
config: ServiceConfig | dict[str, Any] | None = None,
image: str = "", image: str = "",
ports: list[str] | None = None, ports: list[str] | None = None,
environment: dict[str, str] | None = None, environment: dict[str, str] | None = None,
@ -104,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,
@ -116,42 +285,39 @@ class DockerComposeManager:
Returns: Returns:
DockerComposeManager: self (for chaining) DockerComposeManager: self (for chaining)
Raises:
ValueError: If the service config is invalid.
""" """
services = self._data["services"] services = self._data["services"]
service: dict[str, Any] = {} if config is not None:
if image: if isinstance(config, dict):
service["image"] = image config = ServiceConfig(**config)
if ports is not None: service = config.model_dump(exclude_none=True)
service["ports"] = ports service.update(kwargs)
if environment is not None: else:
service["environment"] = environment try:
if volumes is not None: service = ServiceConfig(
service["volumes"] = volumes image=image,
if networks is not None: ports=ports,
service["networks"] = networks environment=environment,
if command is not None: volumes=volumes,
service["command"] = command networks=networks,
if entrypoint is not None: command=command,
service["entrypoint"] = entrypoint entrypoint=entrypoint,
if build is not None: build=build,
service["build"] = build healthcheck=healthcheck,
if healthcheck is not None: restart=restart,
service["healthcheck"] = healthcheck labels=labels,
if restart is not None: depends_on=depends_on,
service["restart"] = restart configs=configs,
if labels is not None: secrets=secrets,
service["labels"] = labels deploy=deploy,
if depends_on is not None: resources=resources,
service["depends_on"] = depends_on **kwargs,
if configs is not None: ).model_dump(exclude_none=True)
service["configs"] = configs except ValidationError as e:
if secrets is not None: msg = f"Invalid service config: {e}"
service["secrets"] = secrets raise ValueError(msg) from e
if deploy is not None:
service["deploy"] = deploy
if resources is not None:
service["resources"] = resources
service.update(kwargs)
services[name] = service services[name] = service
self._dirty = True self._dirty = True
return self return self
@ -184,17 +350,21 @@ 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:
"""Save the current state to the YAML file.""" """Save the current state to the YAML file."""
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) yaml.dump(self._data, f, sort_keys=False, indent=2, default_flow_style=False)
self._dirty = False self._dirty = False
def __enter__(self) -> Self: def __enter__(self) -> Self:

View File

@ -0,0 +1 @@
"""Test package for the compose library."""

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: