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 creationEdgeException: invalid edge creationGraphException: 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.tomlCHANGELOG.mdREADME.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:
MAJORchanges may introduce breaking changes.MINORchanges add functionality in a backward-compatible way.PATCHchanges 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
104caa5f3db1b0eb2080caa669f02329da2904503291ada7eaed7f817080cdc0
|
|
| MD5 |
843d8ba4ec07da7d32a4ad784508671a
|
|
| BLAKE2b-256 |
12b3d310f7ba99f0319f089c39bd0398813585afb893f49cda00106b9129272c
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e98ed89836afa0b5a59c5e9696cf12353de5ad71d59e0dd91e346fc39e15d1a6
|
|
| MD5 |
d630fa72eaab7099002e796087c491fc
|
|
| BLAKE2b-256 |
9540f70173daf0051852d7b7c4a78e5fc60c80fcb89d705f6e2c5f6fdfd12982
|