Skip to content

Experiment: resource dependency graph as YAML during build (alpha flag) - #3050

Open
ronpal wants to merge 5 commits into
mainfrom
feat/build-dependency-graph-export
Open

Experiment: resource dependency graph as YAML during build (alpha flag)#3050
ronpal wants to merge 5 commits into
mainfrom
feat/build-dependency-graph-export

Conversation

@ronpal

@ronpal ronpal commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Description

Adds an alpha-gated --dependency-graph option to cdf build that writes the instance-level resource dependency graph as YAML after a successful build.

The output includes:

  • topological_order — built resources in dependency order (cycles broken and listed under cycles when present)
  • modules — per-module resources with depends_on edges; references outside the build are marked missing: true

Enable in cdf.toml:

[alpha_flags]
dependencygraph = true

Example:

cdf build --dependency-graph build/dependency-graph.yaml

Bump

  • Patch
  • Skip

Changelog

Added

  • Alpha flag DEPENDENCY_GRAPH and --dependency-graph on cdf build to export the resource dependency graph as YAML.

Export the instance-level resource dependency graph (topological order,
per-module edges, missing refs, and cycles) as YAML after build.
@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

@ronpal

ronpal commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new feature to export instance-level resource dependency graphs as YAML during the build process, controlled by a feature flag. Feedback highlights a style guide violation regarding the use of untyped dictionaries instead of Pydantic models, as well as a bug in the cycle-breaking logic that entirely omits cyclic nodes from the topological order.

Comment on lines +18 to +117
Node = tuple[type[ResourceIO], Identifier]


def _node_dict(crud_cls: type[ResourceIO], identifier: Identifier) -> dict[str, Any]:
return {"type": crud_cls.kind, "id": str(identifier)}


def _sort_key(node: Node) -> tuple[str, str]:
crud_cls, identifier = node
return crud_cls.kind, str(identifier)


def _topological_order(graph: dict[Node, set[Node]], built_nodes: set[Node]) -> tuple[list[Node], list[list[Node]]]:
"""Topologically sort the built resources.

Returns the ordered built nodes and any detected cycles. Cycles are broken so that a
partial ordering can still be produced (mirrors the view-sorting behavior elsewhere).
"""
cycles: list[list[Node]] = []
working = {node: set(deps) for node, deps in graph.items()}
while True:
try:
ordered = list(TopologicalSorter(working).static_order())
break
except CycleError as e:
cycle_nodes: list[Node] = list(e.args[1])
cycles.append(cycle_nodes)
cycle_set = set(cycle_nodes)
working = {n: (d - cycle_set) for n, d in working.items() if n not in cycle_set}
return [node for node in ordered if node in built_nodes], cycles


def build_dependency_graph(build_folder: BuildFolder) -> dict[str, Any]:
"""Build the serializable instance-level dependency graph for a built folder."""
built_nodes: set[Node] = {
(resource.crud_cls, resource.identifier)
for module in build_folder.built_modules
for resource in module.resources
}

graph: dict[Node, set[Node]] = {}
for module in build_folder.built_modules:
for resource in module.resources:
node: Node = (resource.crud_cls, resource.identifier)
graph.setdefault(node, set()).update(resource.dependencies)

ordered, cycles = _topological_order(graph, built_nodes)

modules_output: list[dict[str, Any]] = []
for module in build_folder.built_modules:
if not module.resources:
continue
resources_output: list[dict[str, Any]] = []
for resource in sorted(module.resources, key=lambda r: _sort_key((r.crud_cls, r.identifier))):
entry: dict[str, Any] = {
**_node_dict(resource.crud_cls, resource.identifier),
"source": relative_to_if_possible(resource.source_path).as_posix(),
}
depends_on = _depends_on(resource, built_nodes)
if depends_on:
entry["depends_on"] = depends_on
resources_output.append(entry)
modules_output.append(
{
"module": str(module.module_id),
"resources": resources_output,
}
)

output: dict[str, Any] = {
"topological_order": [
{
**_node_dict(crud_cls, identifier),
}
for crud_cls, identifier in ordered
],
"modules": modules_output,
}
if cycles:
output["cycles"] = [[_node_dict(crud_cls, identifier) for crud_cls, identifier in cycle] for cycle in cycles]
return output


def _depends_on(resource: BuiltResource, built_nodes: set[Node]) -> list[dict[str, Any]]:
depends_on: list[dict[str, Any]] = []
for crud_cls, identifier in sorted(resource.dependencies, key=_sort_key):
dependency = _node_dict(crud_cls, identifier)
if (crud_cls, identifier) not in built_nodes:
dependency["missing"] = True
depends_on.append(dependency)
return depends_on


def write_dependency_graph(build_folder: BuildFolder, output_path: Path) -> None:
"""Serialize the instance-level dependency graph to a YAML file."""
graph = build_dependency_graph(build_folder)
safe_write(output_path, yaml_safe_dump(graph, sort_keys=False))


__all__ = ["build_dependency_graph", "write_dependency_graph"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Issues Identified:

  1. Style Guide Violation (Type Safety & Strong Typing): The current implementation uses untyped dictionaries (dict[str, Any]) for representing the dependency graph nodes, modules, and the graph itself. The Cognite Python Style Guide explicitly requires using Pydantic models or dataclasses instead of untyped dictionaries to ensure type safety and strong typing.
  2. Bug in Cycle-Breaking Logic: In _topological_order, when a cycle is detected, the nodes in cycle_set are completely removed from the working dictionary (if n not in cycle_set). This means any resource involved in a cycle will be entirely omitted from the final topological_order list in the exported YAML, even though they are built resources that need to be deployed.

Solution:

We can resolve both issues by:

  • Defining Pydantic models (ResourceNode, DependencyNode, ModuleResourceNode, ModuleDependency, DependencyGraph) to represent the graph structure.
  • Modifying the cycle-breaking logic to keep the nodes in the graph but break the cycle by removing only the dependencies between the cycle nodes (working = {n: (d - cycle_set) if n in cycle_set else d for n, d in working.items()}).
from pydantic import BaseModel, Field

Node = tuple[type[ResourceIO], Identifier]


class ResourceNode(BaseModel):
    type: str
    id: str


class DependencyNode(ResourceNode):
    missing: bool | None = None


class ModuleResourceNode(ResourceNode):
    source: str
    depends_on: list[DependencyNode] | None = None


class ModuleDependency(BaseModel):
    module: str
    resources: list[ModuleResourceNode]


class DependencyGraph(BaseModel):
    topological_order: list[ResourceNode]
    modules: list[ModuleDependency]
    cycles: list[list[ResourceNode]] | None = None


def _sort_key(node: Node) -> tuple[str, str]:
    crud_cls, identifier = node
    return crud_cls.kind, str(identifier)


def _topological_order(graph: dict[Node, set[Node]], built_nodes: set[Node]) -> tuple[list[Node], list[list[Node]]]:
    """Topologically sort the built resources.

    Returns the ordered built nodes and any detected cycles. Cycles are broken so that a
    partial ordering can still be produced (mirrors the view-sorting behavior elsewhere).
    """
    cycles: list[list[Node]] = []
    working = {node: set(deps) for node, deps in graph.items()}
    while True:
        try:
            ordered = list(TopologicalSorter(working).static_order())
            break
        except CycleError as e:
            cycle_nodes: list[Node] = list(e.args[1])
            cycles.append(cycle_nodes)
            cycle_set = set(cycle_nodes)
            working = {n: (d - cycle_set) if n in cycle_set else d for n, d in working.items()}
    return [node for node in ordered if node in built_nodes], cycles


def build_dependency_graph(build_folder: BuildFolder) -> DependencyGraph:
    """Build the serializable instance-level dependency graph for a built folder."""
    built_nodes: set[Node] = {
        (resource.crud_cls, resource.identifier)
        for module in build_folder.built_modules
        for resource in module.resources
    }

    graph: dict[Node, set[Node]] = {}
    for module in build_folder.built_modules:
        for resource in module.resources:
            node: Node = (resource.crud_cls, resource.identifier)
            graph.setdefault(node, set()).update(resource.dependencies)

    ordered, cycles = _topological_order(graph, built_nodes)

    modules_output: list[ModuleDependency] = []
    for module in build_folder.built_modules:
        if not module.resources:
            continue
        resources_output: list[ModuleResourceNode] = []
        for resource in sorted(module.resources, key=lambda r: _sort_key((r.crud_cls, r.identifier))):
            depends_on = _depends_on(resource, built_nodes)
            resources_output.append(
                ModuleResourceNode(
                    type=resource.crud_cls.kind,
                    id=str(resource.identifier),
                    source=relative_to_if_possible(resource.source_path).as_posix(),
                    depends_on=depends_on or None,
                )
            )
        modules_output.append(
            ModuleDependency(
                module=str(module.module_id),
                resources=resources_output,
            )
        )

    output = DependencyGraph(
        topological_order=[
            ResourceNode(type=crud_cls.kind, id=str(identifier))
            for crud_cls, identifier in ordered
        ],
        modules=modules_output,
    )
    if cycles:
        output.cycles = [
            [ResourceNode(type=crud_cls.kind, id=str(identifier)) for crud_cls, identifier in cycle]
            for cycle in cycles
        ]
    return output


def _depends_on(resource: BuiltResource, built_nodes: set[Node]) -> list[DependencyNode]:
    depends_on: list[DependencyNode] = []
    for crud_cls, identifier in sorted(resource.dependencies, key=_sort_key):
        is_missing = (crud_cls, identifier) not in built_nodes
        depends_on.append(
            DependencyNode(
                type=crud_cls.kind,
                id=str(identifier),
                missing=True if is_missing else None,
            )
        )
    return depends_on


def write_dependency_graph(build_folder: BuildFolder, output_path: Path) -> None:
    """Serialize the instance-level dependency graph to a YAML file."""
    graph = build_dependency_graph(build_folder)
    safe_write(output_path, yaml_safe_dump(graph.model_dump(exclude_none=True), sort_keys=False))


__all__ = ["build_dependency_graph", "write_dependency_graph"]
References
  1. Use dataclasses and Pydantic models for complex data structures instead of untyped dictionaries (dict[str, Any]). (link)

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

☂️ Code Coverage

current status: ✅

Overall Coverage

Statements Covered Coverage Threshold Status
43683 37506 86% 80% 🟢

New Files

File Coverage Status
cognite_toolkit/_cdf_tk/commands/build_v2/_dependency_graph.py 27% 🟢
TOTAL 27% 🟢

Modified Files

File Coverage Status
cognite_toolkit/_cdf_tk/commands/build_v2/build_v2.py 91% 🟢
cognite_toolkit/_cdf_tk/commands/build_v2/data_classes/_build.py 94% 🟢
cognite_toolkit/_cdf_tk/data_classes/_base.py 80% 🟢
cognite_toolkit/_cdf_tk/feature_flags.py 100% 🟢
TOTAL 91% 🟢

updated for commit: 6687b67 by action🐍

@ronpal
ronpal marked this pull request as ready for review June 3, 2026 14:02
@ronpal
ronpal requested review from a team as code owners June 3, 2026 14:02
@codecov

codecov Bot commented Jun 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 31.08108% with 51 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.85%. Comparing base (7c74d87) to head (6687b67).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...kit/_cdf_tk/commands/build_v2/_dependency_graph.py 26.86% 49 Missing ⚠️
...nite_toolkit/_cdf_tk/commands/build_v2/build_v2.py 50.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3050      +/-   ##
==========================================
- Coverage   85.93%   85.85%   -0.08%     
==========================================
  Files         474      475       +1     
  Lines       43567    43683     +116     
==========================================
+ Hits        37440    37506      +66     
- Misses       6127     6177      +50     
Files with missing lines Coverage Δ
...t/_cdf_tk/commands/build_v2/data_classes/_build.py 93.75% <100.00%> (+0.05%) ⬆️
cognite_toolkit/_cdf_tk/data_classes/_base.py 80.48% <100.00%> (ø)
cognite_toolkit/_cdf_tk/feature_flags.py 100.00% <100.00%> (ø)
...nite_toolkit/_cdf_tk/commands/build_v2/build_v2.py 91.32% <50.00%> (-0.30%) ⬇️
...kit/_cdf_tk/commands/build_v2/_dependency_graph.py 26.86% <26.86%> (ø)

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Use TOPOLOGY / topology in cdf.toml and --topology on cdf build.
@ronpal ronpal added the do-no-merge PR that should not be merged. label Jun 19, 2026
@ronpal ronpal changed the title Export resource dependency graph as YAML during build (alpha flag) Experiment: resource dependency graph as YAML during build (alpha flag) Jun 19, 2026
ronpal added 3 commits June 19, 2026 13:50
…uilds

- Add print_dependency_graph_json() to output the dependency graph as JSON
  to stdout instead of writing YAML to a file
- Skip the module/CLI version mismatch check when __version__ is 0.0.0
  (local development builds)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-no-merge PR that should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant