Source code for eclypse.io.defaults.docker_compose

"""Docker Compose importers and exporters for ECLYPSE applications."""

from __future__ import annotations

from typing import (
    TYPE_CHECKING,
    Any,
    cast,
)

import networkx as nx

from eclypse.graph import Application
from eclypse.io._helpers import (
    normalize_json_value,
    read_yaml_data,
    write_yaml_data,
)
from eclypse.io.base import (
    GraphExporter,
    GraphImporter,
)
from eclypse.io.context import DockerComposeContext
from eclypse.io.graphs import graph_from_networkx

if TYPE_CHECKING:
    from pathlib import Path

    from eclypse.io.context import IOContext
    from eclypse.utils.types import GraphKind


[docs] class DockerComposeExporter(GraphExporter[Application, dict[str, Any]]): """Exporter for Docker Compose application files."""
[docs] def to_data( self, graph: Application, *, context: IOContext | None = None, ) -> dict[str, Any]: """Convert an application into Docker Compose data. Args: graph (Application): The application to export. context (IOContext | None): Optional import/export customisation. Returns: dict[str, Any]: Docker Compose-compatible data. """ compose_context = _compose_context(context) services = {} for node, attrs in graph.nodes(data=True): service = _service_from_node(str(node), dict(attrs), compose_context) dependencies = list(graph.successors(node)) if dependencies: service["depends_on"] = [str(target) for target in dependencies] service["x-eclypse-edges"] = { str(target): normalize_json_value(dict(graph.edges[node, target])) for target in dependencies } services[str(node)] = service return { "name": graph.id, "services": services, "x-eclypse": { "kind": "application", "id": graph.id, "graph": normalize_json_value(dict(graph.graph)), "flows": normalize_json_value(graph.flows), }, }
[docs] def write_data( self, data: dict[str, Any], target: str | Path, *, context: IOContext | None = None, # noqa: ARG002 ) -> None: """Write Docker Compose data to a YAML file. Args: data (dict[str, Any]): Docker Compose-compatible data. target (str | Path): The target YAML path. context (IOContext | None): Optional import/export customisation. """ write_yaml_data(data, target)
[docs] class DockerComposeImporter(GraphImporter[Application, dict[str, Any]]): """Importer for Docker Compose application files."""
[docs] def read_data( self, source: str | Path, *, context: IOContext | None = None, # noqa: ARG002 ) -> dict[str, Any]: """Read Docker Compose data from a YAML file. Args: source (str | Path): The source YAML path. context (IOContext | None): Optional import/export customisation. Returns: dict[str, Any]: The decoded Docker Compose data. """ return read_yaml_data(source)
[docs] def from_data( self, data: dict[str, Any], *, kind: GraphKind | None = None, context: IOContext | None = None, ) -> Application: """Convert Docker Compose data into an application. Args: data (dict[str, Any]): Docker Compose-compatible data. kind (GraphKind | None): Optional graph kind requested by the caller. context (IOContext | None): Optional import/export customisation. Returns: Application: The imported application. Raises: ValueError: If a non-application graph kind is requested. """ if kind not in (None, "application"): raise ValueError("Docker Compose importers only support applications.") compose_context = _compose_context(context) _validate_compose_data(data, compose_context) metadata = data.get("x-eclypse", {}) graph = nx.DiGraph() graph.graph["id"] = metadata.get("id", data.get("name", "Application")) graph.graph.update(metadata.get("graph", {})) for service_name, service in data.get("services", {}).items(): graph.add_node(service_name, **_node_from_service(service)) for service_name, service in data.get("services", {}).items(): edge_attrs = service.get("x-eclypse-edges", {}) for target in _dependencies(service.get("depends_on")): graph.add_edge(service_name, target, **edge_attrs.get(target, {})) application = graph_from_networkx(graph, kind="application", context=context) application = cast("Application", application) application.flows = metadata.get("flows", []) return application
def _service_from_node( node: str, attrs: dict[str, Any], context: DockerComposeContext, ) -> dict[str, Any]: """Return a Docker Compose service mapping from node attributes. Args: node (str): The node id. attrs (dict[str, Any]): Node attributes. context (DockerComposeContext): Docker Compose import/export options. Returns: dict[str, Any]: Docker Compose service mapping. Raises: ValueError: If the service cannot be represented as Docker Compose. """ service = {} image = attrs.pop("image", attrs.pop("container_image", None)) build = attrs.pop("build", None) if image is not None: service["image"] = str(image) if build is not None: service["build"] = normalize_json_value(build) if not service and context.allow_image_fallback_to_node: service["image"] = node if context.require_service_image_or_build and not _has_image_or_build(service): raise ValueError( "Docker Compose export requires 'image' or 'build' for service " f"{node!r}. Set node attribute 'image', 'container_image', or 'build'.", ) if attrs: service["x-eclypse-assets"] = normalize_json_value(attrs) return service def _node_from_service(service: dict[str, Any]) -> dict[str, Any]: """Return node attributes from a Docker Compose service mapping. Args: service (dict[str, Any]): Docker Compose service mapping. Returns: dict[str, Any]: Node attributes. """ attrs = dict(service.get("x-eclypse-assets", {})) if "image" in service: attrs["image"] = service["image"] if "build" in service: attrs["build"] = service["build"] return attrs def _dependencies(depends_on: Any) -> list[str]: """Normalise Docker Compose dependencies to a list. Args: depends_on (Any): A Docker Compose ``depends_on`` declaration. Returns: list[str]: Dependency service names. """ if depends_on is None: return [] if isinstance(depends_on, dict): return [str(name) for name in depends_on] if isinstance(depends_on, list): return [str(name) for name in depends_on] return [str(depends_on)] def _compose_context(context: IOContext | None) -> DockerComposeContext: """Return Docker Compose context options. Args: context (IOContext | None): Optional import/export context. Returns: DockerComposeContext: Docker Compose context options. """ return ( context if isinstance(context, DockerComposeContext) else DockerComposeContext() ) def _validate_compose_data( data: dict[str, Any], context: DockerComposeContext, ) -> None: """Validate Docker Compose data required by the codec. Args: data (dict[str, Any]): Docker Compose data. context (DockerComposeContext): Docker Compose import/export options. Raises: ValueError: If required Docker Compose data is missing. """ services = data.get("services") if context.require_services and not isinstance(services, dict): raise ValueError( "Docker Compose import requires a top-level 'services' mapping." ) if not isinstance(services, dict): return for service_name, service in services.items(): if not isinstance(service, dict): raise ValueError( f"Docker Compose service {service_name!r} must be a mapping.", ) if context.require_service_image_or_build and not _has_image_or_build(service): raise ValueError( "Docker Compose import requires 'image' or 'build' for service " f"{service_name!r}.", ) def _has_image_or_build(service: dict[str, Any]) -> bool: """Return whether a Docker Compose service defines a container source. Args: service (dict[str, Any]): Docker Compose service mapping. Returns: bool: Whether the service has ``image`` or ``build``. """ return "image" in service or "build" in service