Skip to main content

pygraph-tool is a module to create and manipulate graphs.

Project description

pygraph-tool

pygraph-tool is a lightweight Python library to create and manipulate object-oriented graphs.

It provides a simple and explicit API for working with nodes and edges. Nodes and edges have stable identifiers, can store user-defined Python values, and can be annotated with metadata for filtering, categorization, layering, or application-specific usage.

The library is designed for developers who need a small, readable, in-memory graph toolkit without requiring a graph database server.

Features

  • Directed and bidirectional edges
  • Automatic UUID-based identifiers when no id is provided
  • User-defined values on nodes and edges
  • Mutable node and edge values
  • Optional metadata on nodes and edges
  • Fast lookup by node and edge identifiers
  • Successor and predecessor traversal
  • Neighbor retrieval by direction
  • Reachability traversal with maximum depth
  • Unweighted shortest path search
  • Incoming, outgoing, and incident edge retrieval
  • Subgraph extraction from selected nodes
  • Generic filtering with predicates
  • Metadata-based filtering
  • Dedicated exceptions for graph, node, and edge errors

Use cases

pygraph-tool can be used for:

  • Knowledge graphs
  • Dependency graphs
  • Workflow graphs
  • Concept maps
  • Graph-based prototypes
  • In-memory graph manipulation
  • Educational examples
  • AI agent memory experiments
  • Object graphs with rich Python values

It is not intended to replace a full graph database such as Neo4j. Instead, it focuses on lightweight local graph manipulation with Python objects.

Installation

Install from PyPI:

pip install pygraph-tool

With uv:

uv add pygraph-tool

Getting started

Import modules

from pygraph_tool import Graph

You can also import the main public classes:

from pygraph_tool import Edge, Metadata, Node

And the exceptions:

from pygraph_tool import EdgeException, GraphException, NodeException

Create a graph

A new graph starts empty.

graph = Graph()

Add nodes

A node stores a user-defined value. The value can be any Python object.

graph.add_node(value="I'm n1", node_id="n1")
graph.add_node(value="I'm n2", node_id="n2")
graph.add_node(value="I'm n3", node_id="n3")

If no node_id is provided, a UUID-based identifier is generated automatically:

node = graph.add_node(value="Generated id node")

print(node.node_id)

The created node is returned by add_node():

node = graph.add_node(value={"name": "Python"}, node_id="python")

print(node.node_id)
print(node.value)

If a node with the same identifier already exists, a GraphException is raised:

try:
    graph.add_node(value="I'm n1 again", node_id="n1")
except GraphException as error:
    print(error)

Add metadata to nodes

Metadata can be used for tags, categories, layers, flags, or custom properties.

metadata = Metadata(
    tags={"python", "graph"},
    categories={"concept"},
    layers={"knowledge_base"},
    flags={"visible"},
    properties={"priority": 1},
)

graph.add_node(
    value="Python graph concept",
    node_id="python-graph",
    metadata=metadata,
)

Metadata can also be copied safely:

metadata_copy = metadata.copy()

This is useful when extracting subgraphs or duplicating graph elements without sharing the same metadata collections.

Add unidirectional edges

A unidirectional edge connects a start node to an end node.

graph.add_unidirectional_edge(
    node_id_start="n1",
    node_id_end="n2",
    edge_id="e1",
    weight=1.5,
)

graph.add_unidirectional_edge(
    node_id_start="n3",
    node_id_end="n2",
    edge_id="e2",
)

graph.add_unidirectional_edge(
    node_id_start="n1",
    node_id_end="n3",
    edge_id="e3",
)

This creates:

n1 -> n2
n3 -> n2
n1 -> n3

If no edge_id is provided, a UUID-based identifier is generated automatically:

edge = graph.add_unidirectional_edge(
    node_id_start="n1",
    node_id_end="n2",
)

print(edge.edge_id)

If an edge with the same identifier already exists, a GraphException is raised:

try:
    graph.add_unidirectional_edge(
        node_id_start="n2",
        node_id_end="n3",
        edge_id="e1",
    )
except GraphException as error:
    print(error)

Add bidirectional edges

A bidirectional edge connects two nodes in both directions.

graph.add_bidirectional_edge(
    node_id_start="n2",
    node_id_end="n3",
    edge_id="e4",
)

This creates a logical bidirectional relationship:

n2 <-> n3

A bidirectional edge is considered both incoming and outgoing for each of its connected nodes.

Add values to edges

Edges can also store user-defined values.

This is useful when an edge represents a meaningful relationship, such as a dependency, a semantic link, a knowledge relation, or a similarity score.

edge = graph.add_unidirectional_edge(
    node_id_start="n1",
    node_id_end="n2",
    edge_id="relation-1",
    value={
        "type": "supports",
        "confidence": 0.85,
    },
)

print(edge.value)

Edge values can be updated without removing the edge:

edge.value = {
    "type": "supports",
    "confidence": 0.95,
}

Add metadata to edges

Edges can also have metadata.

edge_metadata = Metadata(
    tags={"semantic", "verified"},
    categories={"relation"},
    layers={"knowledge_base"},
)

graph.add_unidirectional_edge(
    node_id_start="n1",
    node_id_end="n2",
    edge_id="semantic-link",
    value="supports",
    metadata=edge_metadata,
)

Access nodes and edges

node = graph.get_node("n1")
edge = graph.get_edge("e1")

print(node.value)
print(edge.weight)

You can check whether a node or edge exists:

print(graph.is_node("n1"))
print(graph.is_edge("e1"))

You can also access all nodes and edges:

for node in graph.nodes:
    print(node.node_id, node.value)

for edge in graph.edges:
    print(edge.edge_id, edge.node_start.node_id, edge.node_end.node_id)

Traverse the graph

Successors

Successors are nodes reachable from a given node by following outgoing edges.

successors = graph.get_successors("n1")

for node in successors:
    print(node.node_id)

For bidirectional edges, the opposite endpoint is considered a successor.

Predecessors

Predecessors are nodes that point to a given node.

predecessors = graph.get_predecessors("n2")

for node in predecessors:
    print(node.node_id)

For bidirectional edges, the opposite endpoint is considered a predecessor.

Retrieve neighbors

Neighbors are nodes directly connected to a given node.

By default, both incoming and outgoing directions are considered:

neighbors = graph.get_neighbors("n1")

for node in neighbors:
    print(node.node_id)

You can restrict the direction:

successors = graph.get_neighbors("n1", direction="outgoing")
predecessors = graph.get_neighbors("n1", direction="incoming")
all_neighbors = graph.get_neighbors("n1", direction="both")

Supported direction values are:

outgoing
incoming
both

Retrieve reachable nodes

You can retrieve all nodes reachable from a start node up to a maximum depth.

reachable_nodes = graph.get_reachable_nodes(
    node_id="n1",
    max_depth=2,
    direction="outgoing",
)

for node in reachable_nodes:
    print(node.node_id)

You can include the start node in the result:

reachable_nodes = graph.get_reachable_nodes(
    node_id="n1",
    max_depth=2,
    direction="outgoing",
    include_start=True,
)

You can also traverse in both directions:

reachable_nodes = graph.get_reachable_nodes(
    node_id="n1",
    max_depth=2,
    direction="both",
)

This is useful when extracting a local context around a node.

For example:

context_nodes = graph.get_reachable_nodes(
    node_id="python",
    max_depth=2,
    direction="both",
    include_start=True,
)

Find shortest paths

You can find an unweighted shortest path between two nodes.

path = graph.get_shortest_path(
    node_id_start="n1",
    node_id_end="n3",
)

if path is not None:
    print([node.node_id for node in path])
else:
    print("No path found.")

The shortest path search is breadth-first and does not take edge weights into account.

You can also control the traversal direction:

path = graph.get_shortest_path(
    node_id_start="n1",
    node_id_end="n3",
    direction="both",
)

If the start and end nodes are the same, the returned path contains only that node:

path = graph.get_shortest_path("n1", "n1")

print([node.node_id for node in path])

Output:

['n1']

Retrieve connected edges

Outgoing edges

Outgoing edges start from the given node.

outgoing_edges = graph.get_outgoing_edges("n1")

for edge in outgoing_edges:
    print(edge.edge_id)

For bidirectional edges, an edge connected to the node is considered outgoing even when the node is the edge end.

Incoming edges

Incoming edges end at the given node.

incoming_edges = graph.get_incoming_edges("n2")

for edge in incoming_edges:
    print(edge.edge_id)

For bidirectional edges, an edge connected to the node is considered incoming even when the node is the edge start.

Incident edges

Incident edges are all edges connected to a node, regardless of direction.

incident_edges = graph.get_incident_edges("n2")

for edge in incident_edges:
    print(edge.edge_id)

Extract subgraphs

You can create a new graph from a selected set of node identifiers.

subgraph = graph.extract_subgraph(["n1", "n2", "n3"])

By default, edges are included only when both their start and end nodes are part of the selected nodes.

print([node.node_id for node in subgraph.nodes])
print([edge.edge_id for edge in subgraph.edges])

You can extract nodes without edges:

subgraph = graph.extract_subgraph(
    ["n1", "n2", "n3"],
    include_edges=False,
)

Subgraph extraction reuses node and edge values by reference, but metadata objects are copied.

This means:

  • The selected node values are the same Python objects as in the original graph.
  • The selected edge values are the same Python objects as in the original graph.
  • Metadata collections are copied to avoid accidental metadata mutation across graphs.

A common pattern is to combine reachability traversal and subgraph extraction:

context_nodes = graph.get_reachable_nodes(
    node_id="n1",
    max_depth=2,
    direction="both",
    include_start=True,
)

context_subgraph = graph.extract_subgraph(
    node.node_id for node in context_nodes
)

This is useful for building local graph contexts.

Filter nodes and edges

Filter with predicates

You can filter nodes with any custom predicate:

nodes = graph.filter_nodes(
    lambda node: node.node_id.startswith("n")
)

You can also filter edges:

edges = graph.filter_edges(
    lambda edge: edge.weight > 1.0
)

Since node and edge values can be any Python object, predicates can inspect custom attributes.

nodes = graph.filter_nodes(
    lambda node: hasattr(node.value, "confidence")
    and node.value.confidence > 0.8
)

Filter by metadata

You can filter nodes by tags, categories, layers, and flags:

nodes = graph.filter_nodes_by_metadata(
    tags=["python"],
    layers=["knowledge_base"],
)

By default, all expected values within each criterion must be present.

nodes = graph.filter_nodes_by_metadata(
    tags=["python", "graph"],
    match_all=True,
)

Use match_all=False to require at least one value within each criterion:

nodes = graph.filter_nodes_by_metadata(
    tags=["python", "java"],
    match_all=False,
)

Different criteria are always combined with AND.

For example:

nodes = graph.filter_nodes_by_metadata(
    tags=["python", "java"],
    layers=["knowledge_base"],
    match_all=False,
)

This means:

(tags contains "python" OR "java")
AND
(layers contains "knowledge_base")

Edges can be filtered the same way:

edges = graph.filter_edges_by_metadata(
    tags=["semantic"],
    categories=["relation"],
)

Remove edges

removed_edge = graph.remove_edge("e3")

print(removed_edge.edge_id)

If the edge does not exist, a GraphException is raised.

try:
    graph.remove_edge("unknown-edge")
except GraphException as error:
    print(error)

Removing an edge updates the graph internal adjacency indexes.

Remove nodes

Removing a node also removes all edges connected to it.

removed_node = graph.remove_node("n2")

print(removed_node.node_id)

If the node does not exist, a GraphException is raised.

try:
    graph.remove_node("unknown-node")
except GraphException as error:
    print(error)

Removing a node updates the graph internal adjacency indexes and removes all incident edges.

Display a graph

Here is a simple display helper:

def display_graph(graph: Graph) -> None:
    """Display a simple text representation of a graph."""
    print("Nodes:")
    for node in graph.nodes:
        print(f"- {node.node_id}: {node.value}")

    print("Edges:")
    for edge in graph.edges:
        arrow = "<->" if edge.bidirectional else "->"
        print(
            f"- {edge.node_start.node_id} "
            f"{arrow} "
            f"{edge.node_end.node_id} "
            f"({edge.edge_id}, weight={edge.weight}, value={edge.value})"
        )


display_graph(graph)

Example output:

Nodes:
- n1: I'm n1
- n2: I'm n2
- n3: I'm n3

Edges:
- n1 -> n2 (e1, weight=1.5, value=None)
- n3 -> n2 (e2, weight=1.0, value=None)
- n1 -> n3 (e3, weight=1.0, value=None)

Example: knowledge graph

Because nodes and edges can store Python values, pygraph-tool can be used to build simple knowledge graphs.

from dataclasses import dataclass

from pygraph_tool import Graph, Metadata


@dataclass
class Concept:
    name: str
    definition: str
    confidence: float


@dataclass
class Relation:
    relation_type: str
    confidence: float


graph: Graph[Concept, Relation] = Graph()

graph.add_node(
    node_id="concept:tree",
    value=Concept(
        name="tree",
        definition="A perennial plant with a trunk and branches.",
        confidence=0.95,
    ),
    metadata=Metadata(tags={"biology"}, categories={"concept"}),
)

graph.add_node(
    node_id="concept:leaf",
    value=Concept(
        name="leaf",
        definition="A plant organ often involved in photosynthesis.",
        confidence=0.9,
    ),
    metadata=Metadata(tags={"biology"}, categories={"concept"}),
)

graph.add_unidirectional_edge(
    node_id_start="concept:tree",
    node_id_end="concept:leaf",
    edge_id="tree-has-leaf",
    value=Relation(
        relation_type="HAS_PART",
        confidence=0.92,
    ),
    metadata=Metadata(tags={"semantic"}, categories={"relation"}),
)

You can then extract a local context around a concept:

context_nodes = graph.get_reachable_nodes(
    node_id="concept:tree",
    max_depth=2,
    direction="both",
    include_start=True,
)

context_graph = graph.extract_subgraph(
    node.node_id for node in context_nodes
)

Error handling

pygraph-tool exposes dedicated exceptions:

from pygraph_tool import EdgeException, GraphException, NodeException

Typical cases:

  • NodeException: invalid node creation
  • EdgeException: invalid edge creation
  • GraphException: invalid graph operation, such as duplicate identifiers or missing nodes/edges

Example:

try:
    graph.get_node("missing-node")
except GraphException as error:
    print(error)

API overview

Main classes:

from pygraph_tool import Edge, Graph, Metadata, Node

Main exceptions:

from pygraph_tool import EdgeException, GraphException, NodeException

Common graph methods:

graph.add_node(...)
graph.add_unidirectional_edge(...)
graph.add_bidirectional_edge(...)

graph.get_node(...)
graph.get_edge(...)

graph.is_node(...)
graph.is_edge(...)

graph.get_successors(...)
graph.get_predecessors(...)
graph.get_neighbors(...)
graph.get_reachable_nodes(...)
graph.get_shortest_path(...)

graph.get_outgoing_edges(...)
graph.get_incoming_edges(...)
graph.get_incident_edges(...)

graph.extract_subgraph(...)

graph.filter_nodes(...)
graph.filter_edges(...)
graph.filter_nodes_by_metadata(...)
graph.filter_edges_by_metadata(...)

graph.remove_edge(...)
graph.remove_node(...)

Development

Install development dependencies:

uv sync --dev

Run tests:

uv run pytest

Run tests with coverage:

uv run coverage run -m pytest
uv run coverage report

Run linting:

uv run ruff check .

Run formatting:

uv run ruff format .

Run type checking:

uv run mypy pygraph_tool

Build the package:

uv build --no-sources

Release checklist

Before publishing a new version:

uv run ruff format .
uv run ruff check .
uv run mypy pygraph_tool
uv run coverage run -m pytest
uv run coverage report
uv build --no-sources

Then update:

  • pyproject.toml
  • CHANGELOG.md
  • README.md

Create a Git tag:

git tag v1.1.0
git push origin v1.1.0

Publish to PyPI:

uv publish

Versioning

pygraph-tool follows semantic versioning.

Given a version number MAJOR.MINOR.PATCH:

  • MAJOR changes may introduce breaking changes.
  • MINOR changes add functionality in a backward-compatible way.
  • PATCH changes fix bugs in a backward-compatible way.

Roadmap

Potential future improvements:

  • Fluent query API
  • JSON serialization
  • SQLite storage backend
  • Public indexing API
  • Weighted shortest path search
  • Mermaid export
  • Graph visualization helpers
  • Optional Neo4j adapter
  • AI memory-oriented extension package

Author

Created and maintained by David BEL AICH.

For questions or suggestions, please contact: belaich.david@outlook.fr.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pygraph_tool-1.1.0.tar.gz (14.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pygraph_tool-1.1.0-py3-none-any.whl (15.9 kB view details)

Uploaded Python 3

File details

Details for the file pygraph_tool-1.1.0.tar.gz.

File metadata

  • Download URL: pygraph_tool-1.1.0.tar.gz
  • Upload date:
  • Size: 14.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pygraph_tool-1.1.0.tar.gz
Algorithm Hash digest
SHA256 104caa5f3db1b0eb2080caa669f02329da2904503291ada7eaed7f817080cdc0
MD5 843d8ba4ec07da7d32a4ad784508671a
BLAKE2b-256 12b3d310f7ba99f0319f089c39bd0398813585afb893f49cda00106b9129272c

See more details on using hashes here.

File details

Details for the file pygraph_tool-1.1.0-py3-none-any.whl.

File metadata

  • Download URL: pygraph_tool-1.1.0-py3-none-any.whl
  • Upload date:
  • Size: 15.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.3 {"installer":{"name":"uv","version":"0.11.3","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pygraph_tool-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e98ed89836afa0b5a59c5e9696cf12353de5ad71d59e0dd91e346fc39e15d1a6
MD5 d630fa72eaab7099002e796087c491fc
BLAKE2b-256 9540f70173daf0051852d7b7c4a78e5fc60c80fcb89d705f6e2c5f6fdfd12982

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page