IO round trip#

This example focuses on the eclypse.io package. It is not a simulation scenario. Instead, it shows how ECLYPSE graph objects can move between external files and the in-memory Application and Infrastructure classes.

Use it when you want to inspect the default importers and exporters side by side, compare what each format preserves, or create a starting point for your own graph exchange workflow.

The full code lives in the examples/io_round_trip directory.

Run it from the repository root with:

uv run io-round-trip

What it shows#

The example ships small input files under examples/io_round_trip/input and loads each one with the matching default importer:

  • application inputs: ECLYPSE JSON, Docker Compose, GML, GraphML, node-link JSON, and TOSCA

  • infrastructure inputs: ECLYPSE JSON, GML, GraphML, node-link JSON, and TOSCA

Each loaded graph is then exported to every compatible default output format. Docker Compose is only available for applications, so the grid contains:

  • 6 x 6 application conversions

  • 5 x 5 infrastructure conversions

  • 61 generated output files in total

The generated files are written under examples/io_round_trip/output using a path that makes each conversion explicit:

output/<kind>/from-<input-format>/to-<output-format>.<ext>

For example:

output/application/from-gml/to-tosca.yaml
output/application/from-eclypse_json/to-docker_compose.yaml
output/infrastructure/from-graphml/to-node_link_json.json
output/infrastructure/from-tosca/to-eclypse_json.json

The example also writes output/manifest.json with one row per generated conversion.

Input cases#

The input cases are declared once and reused by the grid runner. The case name is the input format in snake case, while the parent folder carries the graph kind.

Input case definitions
"""Input cases and format metadata for the IO round-trip example."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

from eclypse.io import (
    ApplicationContext,
    DockerComposeContext,
    InfrastructureContext,
    TOSCAApplicationContext,
    TOSCAInfrastructureContext,
)
from eclypse.utils.types import GraphKind

ROOT = Path(__file__).resolve().parent
INPUT_DIR = ROOT / "input"
OUTPUT_DIR = ROOT / "output"
SEED = 42

OUTPUT_EXTENSIONS = {
    "docker-compose": "yaml",
    "eclypse-json": "json",
    "gml": "gml",
    "graphml": "graphml",
    "node-link-json": "json",
    "tosca": "yaml",
}


@dataclass(frozen=True, slots=True)
class IOCase:
    """Input case handled by the IO grid.

    Args:
        name (str): Stable input case name used for output folders.
        kind (GraphKind): Graph kind expected from the importer.
        format (str): Built-in IO format name.
        path (Path): Input path.
    """

    name: str
    kind: GraphKind
    format: str
    path: Path


INPUT_CASE_SPECS = {
    "application": {
        "eclypse-json": "eclypse.json",
        "docker-compose": "docker-compose.yaml",
        "gml": "gml.gml",
        "graphml": "graphml.graphml",
        "node-link-json": "node-link.json",
        "tosca": "tosca.yaml",
    },
    "infrastructure": {
        "eclypse-json": "eclypse.json",
        "gml": "gml.gml",
        "graphml": "graphml.graphml",
        "node-link-json": "node-link.json",
        "tosca": "tosca.yaml",
    },
}

INPUT_CASES = [
    IOCase(
        format_.replace("-", "_"),
        kind,
        format_,
        INPUT_DIR / kind / filename,
    )
    for kind, formats in INPUT_CASE_SPECS.items()
    for format_, filename in formats.items()
]


def context_for(kind: GraphKind, format: str, *, direction: str):
    """Return an IO context suitable for an example conversion.

    Args:
        kind (GraphKind): Graph kind being imported or exported.
        format (str): Built-in IO format name.
        direction (str): Either ``"import"`` or ``"export"``.

    Returns:
        ApplicationContext | InfrastructureContext: Format-aware IO context.
    """
    if kind == "application":
        if format == "docker-compose":
            return DockerComposeContext(
                strict=False,
                seed=SEED,
                allow_image_fallback_to_node=direction == "export",
            )
        if format == "tosca":
            return TOSCAApplicationContext(strict=False, seed=SEED)
        return ApplicationContext(strict=False, seed=SEED)

    if format == "tosca":
        return TOSCAInfrastructureContext(strict=False, seed=SEED)
    return InfrastructureContext(strict=False, seed=SEED)

Conversion grid#

The grid does the same thing for every case:

  1. load the source file through load_application() or load_infrastructure()

  2. ask default_registry for compatible output formats

  3. dump the loaded graph through dump_application() or dump_infrastructure()

The example uses only default importers and exporters. It does not register custom handlers.

Grid code
"""Conversion grid for all default ECLYPSE IO formats."""

from __future__ import annotations

import json
from typing import Any

from eclypse.io import (
    default_registry,
    dump_application,
    dump_infrastructure,
    load_application,
    load_infrastructure,
)

from .cases import (
    OUTPUT_DIR,
    OUTPUT_EXTENSIONS,
    IOCase,
    context_for,
)


def load_case(case: IOCase):
    """Load one input case using the matching default importer.

    Args:
        case (IOCase): Input case to load.

    Returns:
        Application | Infrastructure: Loaded graph object.
    """
    context = context_for(case.kind, case.format, direction="import")
    if case.kind == "application":
        return load_application(
            case.path,
            using=case.format,
            application_context=context,
        )
    return load_infrastructure(
        case.path,
        using=case.format,
        infrastructure_context=context,
    )


def dump_case(graph, case: IOCase, output_format: str) -> dict[str, Any]:
    """Dump one loaded input to one output format.

    Args:
        graph (Application | Infrastructure): Graph object to export.
        case (IOCase): Source input case.
        output_format (str): Built-in output format name.

    Returns:
        dict[str, Any]: Manifest row for the generated output.
    """
    output_name = output_format.replace("-", "_")
    extension = OUTPUT_EXTENSIONS[output_format]
    target = (
        OUTPUT_DIR
        / case.kind
        / f"from-{case.name}"
        / f"to-{output_name}.{extension}"
    )
    target.parent.mkdir(parents=True, exist_ok=True)
    context = context_for(case.kind, output_format, direction="export")

    if case.kind == "application":
        dump_application(
            graph,
            target,
            using=output_format,
            application_context=context,
        )
    else:
        dump_infrastructure(
            graph,
            target,
            using=output_format,
            infrastructure_context=context,
        )

    return {
        "input": case.name,
        "kind": case.kind,
        "input_format": case.format,
        "output_format": output_format,
        "path": str(target.relative_to(OUTPUT_DIR)),
    }


def run_grid(cases: list[IOCase]) -> list[dict[str, Any]]:
    """Run all input cases through all compatible default exporters.

    Args:
        cases (list[IOCase]): Input cases to convert.

    Returns:
        list[dict[str, Any]]: Manifest rows for every generated output.
    """
    rows = []
    for case in cases:
        graph = load_case(case)
        for output_format in default_registry.formats(case.kind):
            rows.append(dump_case(graph, case, output_format))
    return rows


def write_manifest(rows: list[dict[str, Any]]) -> None:
    """Write a JSON manifest describing generated output files.

    Args:
        rows (list[dict[str, Any]]): Output rows produced by :func:`run_grid`.
    """
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    manifest = {
        "application_export_formats": default_registry.formats("application"),
        "infrastructure_export_formats": default_registry.formats("infrastructure"),
        "outputs": rows,
    }
    with (OUTPUT_DIR / "manifest.json").open("w", encoding="utf-8") as handle:
        json.dump(manifest, handle, indent=2, sort_keys=True)

Entrypoint#

The entrypoint runs the grid, writes the manifest, and prints a short summary of the available formats and generated files.

Entrypoint code
"""Run the default ECLYPSE IO importer/exporter conversion grid."""

from __future__ import annotations

from eclypse.io import default_registry

from .cases import (
    INPUT_CASES,
    OUTPUT_DIR,
)
from .grid import (
    run_grid,
    write_manifest,
)


def main() -> None:
    """Load every example input and export it to every compatible format."""
    rows = run_grid(INPUT_CASES)
    write_manifest(rows)

    print("Application formats:", default_registry.formats("application"))
    print("Infrastructure formats:", default_registry.formats("infrastructure"))
    print("Inputs converted:", len(INPUT_CASES))
    print("Outputs written:", len(rows))
    print("Output directory:", OUTPUT_DIR)


if __name__ == "__main__":
    main()

What to inspect#

Start from output/manifest.json to see the full conversion matrix. Then compare the outputs for the same input format, for example output/application/from-tosca or output/infrastructure/from-graphml.

The canonical ECLYPSE JSON output is the most complete round-trip format. Graph formats such as GML, GraphML, and node-link JSON are useful for graph tooling and topology exchange. Docker Compose and TOSCA show how ECLYPSE applications and infrastructures can be projected into cloud-edge oriented formats.

Related concepts: