fromscratch-pygraphdb 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fromscratch_pygraphdb-0.4.0.dist-info/METADATA +145 -0
- fromscratch_pygraphdb-0.4.0.dist-info/RECORD +17 -0
- fromscratch_pygraphdb-0.4.0.dist-info/WHEEL +4 -0
- fromscratch_pygraphdb-0.4.0.dist-info/licenses/LICENSE +21 -0
- pygraphdb/__init__.py +72 -0
- pygraphdb/_version.py +24 -0
- pygraphdb/atomic.py +66 -0
- pygraphdb/edge.py +177 -0
- pygraphdb/errors.py +63 -0
- pygraphdb/graph.py +509 -0
- pygraphdb/matching.py +111 -0
- pygraphdb/node.py +149 -0
- pygraphdb/pathfinding.py +147 -0
- pygraphdb/persistence.py +129 -0
- pygraphdb/py.typed +0 -0
- pygraphdb/query.py +241 -0
- pygraphdb/traversal.py +105 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fromscratch-pygraphdb
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: An educational graph database library built from scratch in Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/robcox2910/pygraphdb
|
|
6
|
+
Project-URL: Repository, https://github.com/robcox2910/pygraphdb
|
|
7
|
+
Project-URL: Issues, https://github.com/robcox2910/pygraphdb/issues
|
|
8
|
+
Project-URL: Documentation, https://robcox2910.github.io/pygraphdb/
|
|
9
|
+
Author: Rob Cox
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: database,education,graph,neo4j
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Education
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.14
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: commitizen>=4.4.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: hypothesis>=6.170; extra == 'dev'
|
|
25
|
+
Requires-Dist: pip-audit>=2.10.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pre-commit>=4.0.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pyright>=1.1.400; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.3.4; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.9.3; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# PyGraphDB
|
|
34
|
+
|
|
35
|
+
An educational graph database library built from scratch in Python. Part of the **PyLearn** educational platform for kids aged 12+.
|
|
36
|
+
|
|
37
|
+
A graph database stores data as **nodes** (entities) and **edges** (relationships) -- like a social network map where people are dots and friendships are lines connecting them.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Nodes & Edges** -- Create entities with labels and properties, connect them with typed relationships
|
|
42
|
+
- **CRUD Operations** -- Add, get, update, and delete nodes and edges
|
|
43
|
+
- **Graph Traversal** -- Explore your graph with BFS (breadth-first) and DFS (depth-first) algorithms
|
|
44
|
+
- **Shortest Path** -- Find the quickest route between two nodes (like GPS directions)
|
|
45
|
+
- **Pattern Matching** -- Search for nodes and edges by label, type, or properties
|
|
46
|
+
- **Cypher-like Queries** -- Ask questions with a simple query language inspired by Neo4j's Cypher
|
|
47
|
+
- **Persistence** -- Save and load your graph to/from JSON files
|
|
48
|
+
- **Neighbor & Degree** -- Find connected nodes and count connections
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from pygraphdb import Graph, bfs, shortest_path, execute_query, find_nodes
|
|
54
|
+
|
|
55
|
+
# Create a social network
|
|
56
|
+
g = Graph()
|
|
57
|
+
g.add_node("alice", label="Person", properties={"name": "Alice", "age": 13})
|
|
58
|
+
g.add_node("bob", label="Person", properties={"name": "Bob", "age": 14})
|
|
59
|
+
g.add_node("charlie", label="Person", properties={"name": "Charlie", "age": 13})
|
|
60
|
+
|
|
61
|
+
g.add_edge("e1", "alice", "bob", rel_type="KNOWS")
|
|
62
|
+
g.add_edge("e2", "bob", "charlie", rel_type="KNOWS")
|
|
63
|
+
|
|
64
|
+
# Find Alice's friends
|
|
65
|
+
friends = g.get_neighbors("alice", direction="outgoing")
|
|
66
|
+
print([f.properties["name"] for f in friends]) # ['Bob']
|
|
67
|
+
|
|
68
|
+
# Traverse the whole network
|
|
69
|
+
visited = bfs(g, "alice")
|
|
70
|
+
print([n.properties["name"] for n in visited]) # ['Alice', 'Bob', 'Charlie']
|
|
71
|
+
|
|
72
|
+
# Find shortest path
|
|
73
|
+
path = shortest_path(g, "alice", "charlie")
|
|
74
|
+
print([n.properties["name"] for n in path]) # ['Alice', 'Bob', 'Charlie']
|
|
75
|
+
|
|
76
|
+
# Query with Cypher-like syntax
|
|
77
|
+
results = execute_query(g, 'MATCH (n:Person) WHERE n.name = "Alice" RETURN n')
|
|
78
|
+
print(results[0].properties["name"]) # 'Alice'
|
|
79
|
+
|
|
80
|
+
# Pattern matching
|
|
81
|
+
teenagers = find_nodes(g, label="Person")
|
|
82
|
+
print(len(teenagers)) # 3
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Installation
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Clone and install
|
|
89
|
+
git clone https://github.com/robcox2910/pygraphdb.git
|
|
90
|
+
cd pygraphdb
|
|
91
|
+
uv sync --all-extras
|
|
92
|
+
|
|
93
|
+
# Run tests
|
|
94
|
+
uv run pytest
|
|
95
|
+
|
|
96
|
+
# Lint & type check
|
|
97
|
+
uv run ruff check src/ tests/
|
|
98
|
+
uv run pyright src tests
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Releasing
|
|
102
|
+
|
|
103
|
+
Other projects in the series ([PyStack](https://github.com/robcox2910/pystack)) use a pinned release of this one,
|
|
104
|
+
not whatever is on `main`. To publish a change for them:
|
|
105
|
+
|
|
106
|
+
1. Merge the change to `main`.
|
|
107
|
+
2. Tag it: `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`.
|
|
108
|
+
A change to the public API bumps the minor version (`v0.1.0` → `v0.2.0`);
|
|
109
|
+
anything else bumps the patch (`v0.1.0` → `v0.1.1`).
|
|
110
|
+
3. In each project that uses it, edit the tag in `[tool.uv.sources]`, run
|
|
111
|
+
`uv lock --upgrade-package <name>`, and open a pull request so CI checks
|
|
112
|
+
the pair together.
|
|
113
|
+
|
|
114
|
+
<!-- series:start -->
|
|
115
|
+
## Related Projects
|
|
116
|
+
|
|
117
|
+
This project is one of a series that builds real systems "from scratch in Python" to teach how they work. See the [series home](https://robcox2910.github.io/) for the full story.
|
|
118
|
+
|
|
119
|
+
| Project | What it teaches | Repository | Docs |
|
|
120
|
+
| --- | --- | --- | --- |
|
|
121
|
+
| PyOS | Operating systems | [py-os](https://github.com/robcox2910/py-os) | [Docs](https://robcox2910.github.io/py-os/) |
|
|
122
|
+
| Pebble | Compilers and programming languages | [pebble-lang](https://github.com/robcox2910/pebble-lang) | [Docs](https://robcox2910.github.io/pebble-lang/) |
|
|
123
|
+
| PyDB | Relational databases | [pydb](https://github.com/robcox2910/pydb) | [Docs](https://robcox2910.github.io/pydb/) |
|
|
124
|
+
| PyKV | Key-value stores | [pykv](https://github.com/robcox2910/pykv) | [Docs](https://robcox2910.github.io/pykv/) |
|
|
125
|
+
| PyDocDB | Document databases | [pydoc-db](https://github.com/robcox2910/pydoc-db) | [Docs](https://robcox2910.github.io/pydoc-db/) |
|
|
126
|
+
| **PyGraphDB** | **Graph databases** | [pygraphdb](https://github.com/robcox2910/pygraphdb) | [Docs](https://robcox2910.github.io/pygraphdb/) |
|
|
127
|
+
| PyTSDB | Time-series databases | [pytsdb](https://github.com/robcox2910/pytsdb) | [Docs](https://robcox2910.github.io/pytsdb/) |
|
|
128
|
+
| PyVecDB | Vector databases | [pyvecdb](https://github.com/robcox2910/pyvecdb) | [Docs](https://robcox2910.github.io/pyvecdb/) |
|
|
129
|
+
| PyColDB | Column-family databases | [pycoldb](https://github.com/robcox2910/pycoldb) | [Docs](https://robcox2910.github.io/pycoldb/) |
|
|
130
|
+
| PyCrypt | Cryptography | [pycrypt](https://github.com/robcox2910/pycrypt) | [Docs](https://robcox2910.github.io/pycrypt/) |
|
|
131
|
+
| PyNet | Computer networking | [pynet](https://github.com/robcox2910/pynet) | [Docs](https://robcox2910.github.io/pynet/) |
|
|
132
|
+
| PyWeb | HTTP web servers | [pyweb](https://github.com/robcox2910/pyweb) | [Docs](https://robcox2910.github.io/pyweb/) |
|
|
133
|
+
| PyGit | Version control | [pygit](https://github.com/robcox2910/pygit) | [Docs](https://robcox2910.github.io/pygit/) |
|
|
134
|
+
| PySearch | Search engines | [pysearch](https://github.com/robcox2910/pysearch) | [Docs](https://robcox2910.github.io/pysearch/) |
|
|
135
|
+
| PyMQ | Message queues | [pymq](https://github.com/robcox2910/pymq) | [Docs](https://robcox2910.github.io/pymq/) |
|
|
136
|
+
| PyLLM | Language models (the brain) | [pyllm](https://github.com/robcox2910/pyllm) | [Docs](https://robcox2910.github.io/pyllm/) |
|
|
137
|
+
| PyScheduler | Job scheduling | [pyscheduler](https://github.com/robcox2910/pyscheduler) | [Docs](https://robcox2910.github.io/pyscheduler/) |
|
|
138
|
+
| PyStack | Full-stack integration | [pystack](https://github.com/robcox2910/pystack) | [Docs](https://robcox2910.github.io/pystack/) |
|
|
139
|
+
<!-- series:end -->
|
|
140
|
+
|
|
141
|
+
<!-- docs:start -->
|
|
142
|
+
## Documentation
|
|
143
|
+
|
|
144
|
+
Full docs (with kid-friendly explanations of every idea): https://robcox2910.github.io/pygraphdb/
|
|
145
|
+
<!-- docs:end -->
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
pygraphdb/__init__.py,sha256=yOCxmfp_KBoZis4aco7gYr_FzkDFzruvF4ZWt7i8zSw,2027
|
|
2
|
+
pygraphdb/_version.py,sha256=HGBlVvZpQ8N2kh1n_LY5PM4s9ljcIgdbOCgXN-uFje4,520
|
|
3
|
+
pygraphdb/atomic.py,sha256=QLLPGZqpA-TgVV5lDEKCLB7vVr05bxj97_Fwykz_gi4,2345
|
|
4
|
+
pygraphdb/edge.py,sha256=7Z9je6Mm7MaHHg4pjeL-vFs90BqxxqB3fvcv27_ZP8k,5813
|
|
5
|
+
pygraphdb/errors.py,sha256=Y7kvfgYSt0cL9F6WaxN8lDYuvThkZGHkF8QivAi60ws,1928
|
|
6
|
+
pygraphdb/graph.py,sha256=HifGuGb9BIYgWeNRHy2cLCnb0znc7W-jzM8K2eReCos,16136
|
|
7
|
+
pygraphdb/matching.py,sha256=-vfLY8vu9fpxS8J3ZKB_UgVSGYeJ72-s0GtZLpwU850,3575
|
|
8
|
+
pygraphdb/node.py,sha256=aNDIxIKxRs6BTzrk1hB6UnqgkYdBZLNN-Sk-zyVkn8w,4763
|
|
9
|
+
pygraphdb/pathfinding.py,sha256=3Nzq4sqY2xoeip-IzWuLRgk1DCGjAPkqAPXVkvXr3F4,4486
|
|
10
|
+
pygraphdb/persistence.py,sha256=VY0jst6WDUWqOMHvuKFduoU4rGRs_iIM3HZqnnNUUWM,4368
|
|
11
|
+
pygraphdb/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
pygraphdb/query.py,sha256=M8O6BP6lz_EjavugomgAA3jnuTw3sycvuHGWtvqYyKQ,7629
|
|
13
|
+
pygraphdb/traversal.py,sha256=KfoWw92xwNLxECFrkq_I6K0H9aTbvgx8HvU-vtW6fZU,3197
|
|
14
|
+
fromscratch_pygraphdb-0.4.0.dist-info/METADATA,sha256=lAgyAlScMdPlo30u_-cXaGzVJuWRIFx1bwp-lQyDraA,7142
|
|
15
|
+
fromscratch_pygraphdb-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
fromscratch_pygraphdb-0.4.0.dist-info/licenses/LICENSE,sha256=NtHM2ak--oE71VCcY50kcCoC1cKS0LRUsxq_xA89RNc,1064
|
|
17
|
+
fromscratch_pygraphdb-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Cox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pygraphdb/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""PyGraphDB -- an educational graph database built from scratch in Python.
|
|
2
|
+
|
|
3
|
+
A graph database stores data as nodes (entities) and edges (relationships),
|
|
4
|
+
like a social network map. This library teaches you how graph databases
|
|
5
|
+
work by building one step by step.
|
|
6
|
+
|
|
7
|
+
Think of it like this:
|
|
8
|
+
- **Nodes** are people at a party
|
|
9
|
+
- **Edges** are the friendships between them
|
|
10
|
+
- **The Graph** is the party itself -- it keeps track of everyone and
|
|
11
|
+
who knows whom
|
|
12
|
+
- **Traversal** is walking around the party visiting people
|
|
13
|
+
- **Queries** are questions you ask, like "Who are Alice's friends?"
|
|
14
|
+
|
|
15
|
+
Quick start:
|
|
16
|
+
>>> from pygraphdb import Graph
|
|
17
|
+
>>> g = Graph()
|
|
18
|
+
>>> g.add_node("alice", label="Person", properties={"name": "Alice"})
|
|
19
|
+
>>> g.add_node("bob", label="Person", properties={"name": "Bob"})
|
|
20
|
+
>>> g.add_edge("e1", "alice", "bob", rel_type="KNOWS")
|
|
21
|
+
>>> friends = g.get_neighbors("alice")
|
|
22
|
+
>>> friends[0].properties["name"]
|
|
23
|
+
'Bob'
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from pygraphdb.edge import Edge
|
|
27
|
+
from pygraphdb.errors import (
|
|
28
|
+
DuplicateEdgeError,
|
|
29
|
+
DuplicateNodeError,
|
|
30
|
+
EdgeNotFoundError,
|
|
31
|
+
GraphError,
|
|
32
|
+
NodeNotFoundError,
|
|
33
|
+
PersistenceError,
|
|
34
|
+
QuerySyntaxError,
|
|
35
|
+
)
|
|
36
|
+
from pygraphdb.graph import Graph
|
|
37
|
+
from pygraphdb.matching import find_edges, find_nodes
|
|
38
|
+
from pygraphdb.node import Node
|
|
39
|
+
from pygraphdb.pathfinding import all_paths, shortest_path
|
|
40
|
+
from pygraphdb.persistence import graph_from_json, graph_to_json, load_graph, save_graph
|
|
41
|
+
from pygraphdb.query import execute_query
|
|
42
|
+
from pygraphdb.traversal import bfs, dfs
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"DuplicateEdgeError",
|
|
46
|
+
"DuplicateNodeError",
|
|
47
|
+
"Edge",
|
|
48
|
+
"EdgeNotFoundError",
|
|
49
|
+
"Graph",
|
|
50
|
+
"GraphError",
|
|
51
|
+
"Node",
|
|
52
|
+
# Errors
|
|
53
|
+
"NodeNotFoundError",
|
|
54
|
+
"PersistenceError",
|
|
55
|
+
"QuerySyntaxError",
|
|
56
|
+
"all_paths",
|
|
57
|
+
# Traversal
|
|
58
|
+
"bfs",
|
|
59
|
+
"dfs",
|
|
60
|
+
# Query
|
|
61
|
+
"execute_query",
|
|
62
|
+
"find_edges",
|
|
63
|
+
# Matching
|
|
64
|
+
"find_nodes",
|
|
65
|
+
"graph_from_json",
|
|
66
|
+
"graph_to_json",
|
|
67
|
+
"load_graph",
|
|
68
|
+
# Persistence
|
|
69
|
+
"save_graph",
|
|
70
|
+
# Pathfinding
|
|
71
|
+
"shortest_path",
|
|
72
|
+
]
|
pygraphdb/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
pygraphdb/atomic.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Safe saving -- never leave a half-written file behind.
|
|
2
|
+
|
|
3
|
+
The usual way to save a file is: open it, wipe it, write the new
|
|
4
|
+
contents. If the computer crashes after the wipe but before the writing
|
|
5
|
+
finishes, your data is gone -- the old version was wiped and the new one
|
|
6
|
+
never arrived.
|
|
7
|
+
|
|
8
|
+
So we save like a careful student copying out their homework:
|
|
9
|
+
|
|
10
|
+
1. Write the new version on a **fresh sheet** (a temporary file sitting
|
|
11
|
+
right next to the real one).
|
|
12
|
+
2. Make sure the **ink is dry** -- ``fsync`` forces the bytes out of the
|
|
13
|
+
computer's memory and onto the disk.
|
|
14
|
+
3. **Swap** the fresh sheet in for the old one in a single instant step
|
|
15
|
+
(``Path.replace``). There is never a moment with no complete file: you
|
|
16
|
+
see either the old version or the new one, never half of each.
|
|
17
|
+
|
|
18
|
+
Real databases (SQLite, PostgreSQL, Redis) all use this same trick.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import os
|
|
23
|
+
import stat
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# Permissions for a brand-new file: owner can read/write, others can read.
|
|
28
|
+
_NEW_FILE_MODE = 0o644
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
32
|
+
"""Replace the file at *path* with *text*, so a crash can never leave it half-written.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: The file to create or replace (its folder must already exist).
|
|
36
|
+
text: The new contents, saved as UTF-8.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else _NEW_FILE_MODE
|
|
40
|
+
fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
41
|
+
temp_path = Path(temp_name)
|
|
42
|
+
try:
|
|
43
|
+
with os.fdopen(fd, "w", encoding="utf-8") as temp_file:
|
|
44
|
+
temp_file.write(text)
|
|
45
|
+
temp_file.flush()
|
|
46
|
+
os.fsync(temp_file.fileno())
|
|
47
|
+
temp_path.chmod(mode)
|
|
48
|
+
temp_path.replace(path)
|
|
49
|
+
except BaseException:
|
|
50
|
+
temp_path.unlink(missing_ok=True) # don't leave the scrap sheet lying around
|
|
51
|
+
raise
|
|
52
|
+
_sync_folder(path.parent)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _sync_folder(folder: Path) -> None:
|
|
56
|
+
"""Make the swap itself durable by syncing the folder's list of files.
|
|
57
|
+
|
|
58
|
+
Some systems (like Windows) can't sync a folder; there the swap is
|
|
59
|
+
still safe, just not guaranteed to survive a power cut.
|
|
60
|
+
"""
|
|
61
|
+
with contextlib.suppress(OSError):
|
|
62
|
+
fd = os.open(folder, os.O_RDONLY)
|
|
63
|
+
try:
|
|
64
|
+
os.fsync(fd)
|
|
65
|
+
finally:
|
|
66
|
+
os.close(fd)
|
pygraphdb/edge.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Edge -- a relationship between two nodes in a graph database.
|
|
2
|
+
|
|
3
|
+
Picture two people at a party shaking hands. That handshake is an edge!
|
|
4
|
+
It connects one person (the source) to another (the target) and has a
|
|
5
|
+
type that describes the relationship ("FRIENDS_WITH", "KNOWS", "WORKS_AT").
|
|
6
|
+
|
|
7
|
+
Edges can also carry properties, just like friendships have qualities --
|
|
8
|
+
maybe you became friends in 2024, or you're "best friends" vs "acquaintances."
|
|
9
|
+
|
|
10
|
+
Edges in our graph are *directed* -- like a one-way street. "Alice FOLLOWS Bob"
|
|
11
|
+
doesn't automatically mean "Bob FOLLOWS Alice." If you want both directions,
|
|
12
|
+
you add two separate edges.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> from pygraphdb.edge import Edge
|
|
16
|
+
>>> friendship = Edge(
|
|
17
|
+
... edge_id="e1",
|
|
18
|
+
... source_id="alice",
|
|
19
|
+
... target_id="bob",
|
|
20
|
+
... rel_type="FRIENDS_WITH",
|
|
21
|
+
... properties={"since": 2024},
|
|
22
|
+
... )
|
|
23
|
+
>>> friendship.rel_type
|
|
24
|
+
'FRIENDS_WITH'
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
|
|
30
|
+
# The fields the graph files an edge under; they can't change once set.
|
|
31
|
+
_FIXED_FIELDS = frozenset({"edge_id", "source_id", "target_id"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Edge:
|
|
36
|
+
"""A directed relationship between two nodes.
|
|
37
|
+
|
|
38
|
+
Think of an edge like an arrow drawn between two dots on a whiteboard:
|
|
39
|
+
- ``edge_id`` is a unique label for this arrow
|
|
40
|
+
- ``source_id`` is where the arrow starts (the "from" node)
|
|
41
|
+
- ``target_id`` is where the arrow points (the "to" node)
|
|
42
|
+
- those three can't be changed once the edge is made
|
|
43
|
+
- ``rel_type`` is what the arrow means ("FRIENDS_WITH", "LIKES", etc.)
|
|
44
|
+
- ``properties`` is extra info about the relationship
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
edge_id: A unique identifier for this edge.
|
|
48
|
+
source_id: The ID of the node where this edge starts.
|
|
49
|
+
target_id: The ID of the node where this edge ends.
|
|
50
|
+
rel_type: The relationship type (e.g. "KNOWS", "WORKS_AT").
|
|
51
|
+
properties: A dictionary of key-value attributes.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
edge_id: str
|
|
56
|
+
source_id: str
|
|
57
|
+
target_id: str
|
|
58
|
+
rel_type: str = ""
|
|
59
|
+
properties: dict[str, object] = field(default_factory=lambda: {})
|
|
60
|
+
|
|
61
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
62
|
+
"""Set an attribute, except the id or either end once they've been set.
|
|
63
|
+
|
|
64
|
+
The graph files every edge under its id, and in the road lists of
|
|
65
|
+
the two nodes it joins. Changing any of those afterwards would leave
|
|
66
|
+
the edge filed in the wrong places -- so it isn't allowed.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
AttributeError: If ``edge_id``, ``source_id`` or ``target_id`` is
|
|
70
|
+
set a second time.
|
|
71
|
+
|
|
72
|
+
"""
|
|
73
|
+
if name in _FIXED_FIELDS and name in self.__dict__:
|
|
74
|
+
msg = f"an edge's {name} can't be changed: it files the edge in the graph"
|
|
75
|
+
raise AttributeError(msg)
|
|
76
|
+
super().__setattr__(name, value)
|
|
77
|
+
|
|
78
|
+
def has_property(self, key: str) -> bool:
|
|
79
|
+
"""Check whether this edge has a given property key.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
key: The property name to look for.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
True if the property exists, False otherwise.
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
return key in self.properties
|
|
89
|
+
|
|
90
|
+
def get_property(self, key: str, default: object = None) -> object:
|
|
91
|
+
"""Get a property value by key, with an optional default.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
key: The property name to retrieve.
|
|
95
|
+
default: Value to return if the key is missing.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The property value, or the default.
|
|
99
|
+
|
|
100
|
+
"""
|
|
101
|
+
return self.properties.get(key, default)
|
|
102
|
+
|
|
103
|
+
def set_property(self, key: str, value: object) -> None:
|
|
104
|
+
"""Set a property on this edge.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
key: The property name.
|
|
108
|
+
value: The property value.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
self.properties[key] = value
|
|
112
|
+
|
|
113
|
+
def remove_property(self, key: str) -> object:
|
|
114
|
+
"""Remove and return a property from this edge.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
key: The property name to remove.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The removed value.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
KeyError: If the property does not exist.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
return self.properties.pop(key)
|
|
127
|
+
|
|
128
|
+
def to_dict(self) -> dict[str, object]:
|
|
129
|
+
"""Write everything about this edge onto an index card.
|
|
130
|
+
|
|
131
|
+
Like noting on a card who shook hands with whom, what kind of
|
|
132
|
+
relationship it was, and when -- turns the edge into a plain
|
|
133
|
+
dictionary you can save to a file.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
A dictionary representation of this edge.
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
return {
|
|
140
|
+
"edge_id": self.edge_id,
|
|
141
|
+
"source_id": self.source_id,
|
|
142
|
+
"target_id": self.target_id,
|
|
143
|
+
"rel_type": self.rel_type,
|
|
144
|
+
"properties": dict(self.properties),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def from_dict(cls, data: dict[str, object]) -> Edge:
|
|
149
|
+
"""Rebuild an edge from the index card that to_dict wrote.
|
|
150
|
+
|
|
151
|
+
Like reading a filed-away card and re-drawing that handshake
|
|
152
|
+
(arrow) between two people -- turns a plain dictionary back into
|
|
153
|
+
a real Edge.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
data: A dictionary with edge fields.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
A new Edge instance.
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
edge_id = str(data.get("edge_id", ""))
|
|
163
|
+
source_id = str(data.get("source_id", ""))
|
|
164
|
+
target_id = str(data.get("target_id", ""))
|
|
165
|
+
rel_type = str(data.get("rel_type", ""))
|
|
166
|
+
raw_props = data.get("properties")
|
|
167
|
+
props: dict[str, object] = {}
|
|
168
|
+
if isinstance(raw_props, dict):
|
|
169
|
+
for k, v in raw_props.items(): # pyright: ignore[reportUnknownVariableType]
|
|
170
|
+
props[str(k)] = v # pyright: ignore[reportUnknownArgumentType]
|
|
171
|
+
return cls(
|
|
172
|
+
edge_id=edge_id,
|
|
173
|
+
source_id=source_id,
|
|
174
|
+
target_id=target_id,
|
|
175
|
+
rel_type=rel_type,
|
|
176
|
+
properties=props,
|
|
177
|
+
)
|
pygraphdb/errors.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Custom exceptions for PyGraphDB.
|
|
2
|
+
|
|
3
|
+
Think of these like warning signs at a playground -- they tell you when
|
|
4
|
+
something went wrong so you can fix it instead of getting confused by
|
|
5
|
+
a mysterious crash.
|
|
6
|
+
|
|
7
|
+
Each exception type describes a specific kind of problem:
|
|
8
|
+
- A node you asked for doesn't exist
|
|
9
|
+
- An edge you asked for doesn't exist
|
|
10
|
+
- You tried to add something that's already there
|
|
11
|
+
- A query you wrote has a mistake in it
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GraphError(Exception):
|
|
16
|
+
"""Base exception for all graph database errors."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NodeNotFoundError(GraphError):
|
|
20
|
+
"""Raise when a requested node does not exist in the graph.
|
|
21
|
+
|
|
22
|
+
Like looking for a person at a party who never showed up.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EdgeNotFoundError(GraphError):
|
|
27
|
+
"""Raise when a requested edge does not exist in the graph.
|
|
28
|
+
|
|
29
|
+
Like looking for a friendship between two people who don't know each other.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DuplicateNodeError(GraphError):
|
|
34
|
+
"""Raise when trying to add a node with an ID that already exists.
|
|
35
|
+
|
|
36
|
+
Like trying to give two different people the exact same name tag at a party.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DuplicateEdgeError(GraphError):
|
|
41
|
+
"""Raise when trying to add an edge whose id is already taken.
|
|
42
|
+
|
|
43
|
+
Like giving two different roads the exact same street sign -- an edge
|
|
44
|
+
with this id already exists. (Two separate roads between the same pair
|
|
45
|
+
of buildings are fine, as long as each has its own sign.)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class QuerySyntaxError(GraphError):
|
|
50
|
+
"""Raise when a query string has invalid syntax.
|
|
51
|
+
|
|
52
|
+
Like writing a sentence with the words in the wrong order --
|
|
53
|
+
the computer can't understand what you're asking.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PersistenceError(GraphError):
|
|
58
|
+
"""Raise when a saved graph can't be read back from disk.
|
|
59
|
+
|
|
60
|
+
Like opening your saved game and finding the file missing or
|
|
61
|
+
scrambled -- rather than starting an empty game and pretending all is
|
|
62
|
+
well, we stop and say which file is the problem.
|
|
63
|
+
"""
|