Contributing

Development environment setup

Clone the repository:

git clone https://github.com/Teal-Insights/excel-grapher.git
cd excel-grapher

Install uv if you don’t have it already:

curl -LsSf https://astral.sh/uv/install.sh | sh

Install the dependencies:

uv sync

Install pre-commit hooks:

uv run pre-commit install

To render the documentation, install Quarto if you don’t have it already:

curl -LsSf https://quarto.org/install.sh | sh

Extension-point conventions

A key goal of excel-grapher is to make it easy to extend the library with new functionality. We achieve this by exposing a rich set of extension points that allow users to customize library behaviors, especially during graph extraction, visualization, and code generation.

Prefer explicit, typed extension points over ad hoc keyword flags when users need to customize behavior. The core API should make simple Python usage direct, while still supporting named plugins when a choice needs to be stored in configuration, selected from a CLI, or reused across a project.

Current patterns in the codebase

Extension point Customization forms Registry helpers Notes
Graph build hooks (NodeHook) Direct callables only None Passed to create_dependency_graph(..., hooks=...) or DependencyGraph.register_hook. Local instrumentation only.
Series docstring callbacks SeriesBindingDocstringCallbackSpec register_*, list_*, resolve_*, run_*, unregister_* Direct callback or registered name for codegen.
Series docstring renderers SeriesDocstringRendererSpec resolve_* only Built-in names plus renderer objects or callables.
Web visualization layouts WebVizLayoutSpec register_*, list_*, resolve_*, run_*, unregister_* Layout ID string or direct WebVizLayoutPlugin callable.

Direct customization first

For local Python usage, accept direct objects or callables when they are easy to type and configure:

code = CodeGenerator(graph).generate(
    targets,
    series_bindings=bindings,
    bindings_workbook=workbook_path,
    series_docstring_callback=my_callback,
)

payload = to_web_viz_payload(graph, layout=my_layout_plugin)

Use a Protocol for non-trivial callables so contributors know the expected context and return type.

Named plugins for reusable choices

Use registries when an extension should be selectable by a stable string name. Registry-backed APIs should expose:

  • register_*
  • list_*
  • resolve_* for lookup without execution
  • run_* only when the helper executes the plugin immediately
  • unregister_* for test and notebook cleanup (optional but recommended)

Duplicate registration should fail by default. Support replace=True as an explicit opt-in when replacement is useful in tests, notebooks, or project bootstrap code:

register_series_docstring_callback("project_docs", my_callback)

code = CodeGenerator(graph).generate(
    targets,
    series_bindings=bindings,
    bindings_workbook=workbook_path,
    series_docstring_callback="project_docs",
)

# In tests or notebooks, re-register or clean up:
register_series_docstring_callback("project_docs", updated_callback, replace=True)
unregister_series_docstring_callback("project_docs")

Built-ins and specs

When an API accepts more than one customization form, define a *Spec type alias. Built-ins should usually be available both as importable Python objects/classes and as stable string IDs if they are useful in config-driven workflows.

Examples in the current API:

Scope guidelines

Use direct callables only for local instrumentation hooks that are not reusable plugin choices, such as NodeHook graph build hooks. Use named registries for behavior that users may want to list, document, select from config, or share across workflows.

Graph projection for export

Projections build artifact-specific views of the canonical graph without mutating it. A projection step implements the ProjectionStep protocol (project(graph) -> ProjectionResult) and returns a ProjectionResult facade that reads like the projected graph and carries a durable ProjectionManifest. The facade satisfies the read-only grapher.GraphReadView protocol, the same surface DependencyGraph exposes, so read-only consumers such as to_networkx and CodeGenerator accept a graph or a projection interchangeably. IdentityTransitCompression is the built-in reference step:

projection = IdentityTransitCompression().project(graph)
CodeGenerator(projection).generate_modules(...)
to_web_viz_payload(to_networkx(projection))

The manifest contract

ProjectionManifest is a protocol; codegen depends only on its small address-projection surface (kind, map_to_projected, to_dict). BaseProjectionManifest is a ready-to-use implementation that also carries collapse lineage. It deliberately separates two concerns:

  • Forwarding map (forwarding_map): removed addresses that are value-equivalent to a retained computation. Codegen queries this through map_to_projected only for public targets and series binding ranges, so internal-only collapses should leave it empty.
  • Collapse lineage (retained_to_collapsed_sources, removed_node_snapshots, collapsed_groups, formula_rewrites): which originals folded into each retained node, in dependency order, with original formulas and metadata. This is for audit and downstream refactoring and does not imply value-equivalence.

Writing a custom projection

Build the projected graph from a copy and record lineage on the manifest:

from excel_grapher.exporter import (
    BaseProjectionManifest, ProjectedNodeSnapshot, ProjectionResult,
    register_projection_manifest,
)

class SubgraphCollapse:
    def project(self, graph):
        projected = graph.copy()                      # never mutate the canonical graph
        # ... select a single-exit subgraph, then on `projected`:
        #     set_node_formula(root, inlined_formula, normalized_formula)  # substitute bodies
        #     add_edge(root, external_dep, ...)              # rewire to surviving deps
        #     remove_node(internal)                          # delete collapsed internals
        #     set_node_metadata(root, {"collapsed_from": [...]})  # tag the condensed node
        manifest = BaseProjectionManifest(
            kind="subgraph_collapse",
            forwarding_map={},                         # internal nodes are not forwarded
            retained_to_collapsed_sources={root: tuple(internals)},
            removed_node_snapshots={addr: ProjectedNodeSnapshot(...) for addr in internals},
            formula_rewrites=(),
            collapsed_groups=(),
        )
        return ProjectionResult(graph, projected, manifest)

# Enable serialization round-trips for the custom kind:
register_projection_manifest("subgraph_collapse", BaseProjectionManifest.from_dict)

Public graph-mutation primitives for projection authors: DependencyGraph.copy, set_node_formula, remove_node, set_node_metadata, add_edge. Compose steps with apply_projection(graph, [step_a, step_b]); multiple steps fold into a CompositeProjectionManifest that chains the forwarding map and preserves each component manifest (heterogeneous kinds supported).

A parity-tested formula-inlining/subgraph-collapse engine is a planned follow-up; until then, authors own the substitution logic in their projection step. A config-driven registry for selecting projections (mirroring web viz layouts) may follow when needed.

Alignment status

Changes aligned in this release:

Deferred (no change needed yet):

  • Graph build hooks remain direct-only; a registry would add no value for one-off instrumentation.
  • Series docstring renderers stay lookup-only (no global registration); built-ins are fixed and custom renderers are passed inline.

Roadmap

  • Continue expanding three-way parity coverage: evaluator ↔︎ export runtime, evaluator ↔︎ Excel (cache and live automation where available), especially for representation-sensitive areas such as OFFSET, INDIRECT, LOOKUP, MATCH, and INDEX.
  • Refine the dynamic-reference configuration API and constraints tooling (e.g., validation helpers) as more real-world models and templates are integrated.
  • Live workflow graph