mygraph-client 0.1.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.
- mygraph/__init__.py +46 -0
- mygraph/__main__.py +4 -0
- mygraph/_ops.py +195 -0
- mygraph/_protocol.py +222 -0
- mygraph/cli.py +78 -0
- mygraph/client.py +179 -0
- mygraph/errors.py +62 -0
- mygraph/py.typed +0 -0
- mygraph/transaction.py +67 -0
- mygraph/types.py +103 -0
- mygraph_client-0.1.0.dist-info/METADATA +167 -0
- mygraph_client-0.1.0.dist-info/RECORD +16 -0
- mygraph_client-0.1.0.dist-info/WHEEL +5 -0
- mygraph_client-0.1.0.dist-info/entry_points.txt +2 -0
- mygraph_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- mygraph_client-0.1.0.dist-info/top_level.txt +1 -0
mygraph/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Python client for the MyGraph server.
|
|
2
|
+
|
|
3
|
+
from mygraph import connect
|
|
4
|
+
|
|
5
|
+
with connect("localhost", 6660) as db:
|
|
6
|
+
alice = db.create_node("User", {"name": "Alice"})
|
|
7
|
+
for user in db.find_nodes("User", {"name": "Alice"}):
|
|
8
|
+
print(user.id, user.properties)
|
|
9
|
+
|
|
10
|
+
The client speaks MyGraph protocol v1 (``docs/PROTOCOL.md``) over TCP and
|
|
11
|
+
needs nothing outside the Python standard library.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .client import Client, connect
|
|
15
|
+
from .errors import (
|
|
16
|
+
AlreadyExistsError,
|
|
17
|
+
ConnectionClosedError,
|
|
18
|
+
CypherSyntaxError,
|
|
19
|
+
MyGraphError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
ProtocolError,
|
|
22
|
+
QueryError,
|
|
23
|
+
TransactionError,
|
|
24
|
+
)
|
|
25
|
+
from .transaction import Transaction
|
|
26
|
+
from .types import Node, Relationship, Result, Stats
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AlreadyExistsError",
|
|
32
|
+
"Client",
|
|
33
|
+
"ConnectionClosedError",
|
|
34
|
+
"CypherSyntaxError",
|
|
35
|
+
"MyGraphError",
|
|
36
|
+
"Node",
|
|
37
|
+
"NotFoundError",
|
|
38
|
+
"ProtocolError",
|
|
39
|
+
"QueryError",
|
|
40
|
+
"Relationship",
|
|
41
|
+
"Result",
|
|
42
|
+
"Stats",
|
|
43
|
+
"Transaction",
|
|
44
|
+
"TransactionError",
|
|
45
|
+
"connect",
|
|
46
|
+
]
|
mygraph/__main__.py
ADDED
mygraph/_ops.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Node and relationship helpers built on ``run``.
|
|
2
|
+
|
|
3
|
+
The helpers generate Cypher. Values always travel as parameters. Labels,
|
|
4
|
+
relationship types and property keys are written as backtick-quoted
|
|
5
|
+
identifiers, so any name is safe to pass.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
from .types import Node, Relationship, Result
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def quote(name: str) -> str:
|
|
16
|
+
"""Backtick-quotes an identifier (label, type or property key)."""
|
|
17
|
+
if not isinstance(name, str) or not name:
|
|
18
|
+
raise ValueError(f"invalid identifier: {name!r}")
|
|
19
|
+
return "`" + name.replace("`", "``") + "`"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _labels(labels: str | Sequence[str] | None) -> str:
|
|
23
|
+
if labels is None:
|
|
24
|
+
return ""
|
|
25
|
+
if isinstance(labels, str):
|
|
26
|
+
labels = [labels]
|
|
27
|
+
return "".join(":" + quote(label) for label in labels)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _where(var: str, properties: Mapping[str, Any] | None, params: dict, extra: Iterable[str] = ()) -> str:
|
|
31
|
+
conds = list(extra)
|
|
32
|
+
for i, (key, value) in enumerate((properties or {}).items()):
|
|
33
|
+
if value is None:
|
|
34
|
+
conds.append(f"{var}.{quote(key)} IS NULL")
|
|
35
|
+
else:
|
|
36
|
+
params[f"p{i}"] = value
|
|
37
|
+
conds.append(f"{var}.{quote(key)} = $p{i}")
|
|
38
|
+
return " WHERE " + " AND ".join(conds) if conds else ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _paging(skip: int | None, limit: int | None) -> str:
|
|
42
|
+
out = ""
|
|
43
|
+
for word, n in (("SKIP", skip), ("LIMIT", limit)):
|
|
44
|
+
if n is not None:
|
|
45
|
+
if isinstance(n, bool) or not isinstance(n, int) or n < 0:
|
|
46
|
+
raise ValueError(f"{word.lower()} must be a non-negative int")
|
|
47
|
+
out += f" {word} {n}"
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CypherOps:
|
|
52
|
+
"""CRUD helpers. Subclasses provide ``run``."""
|
|
53
|
+
|
|
54
|
+
def run(self, query: str, params: Mapping[str, Any] | None = None, **kwargs: Any) -> Result:
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------ nodes
|
|
58
|
+
|
|
59
|
+
def create_node(self, labels: str | Sequence[str] | None = None, properties: Mapping[str, Any] | None = None) -> Node:
|
|
60
|
+
"""Creates a node and returns it."""
|
|
61
|
+
r = self.run(f"CREATE (n{_labels(labels)}) SET n = $props RETURN n", {"props": dict(properties or {})})
|
|
62
|
+
return r.value()
|
|
63
|
+
|
|
64
|
+
def get_node(self, node_id: int) -> Node | None:
|
|
65
|
+
"""The node with that id, or ``None``."""
|
|
66
|
+
return self.run("MATCH (n) WHERE id(n) = $id RETURN n", {"id": node_id}).value()
|
|
67
|
+
|
|
68
|
+
def find_nodes(
|
|
69
|
+
self,
|
|
70
|
+
labels: str | Sequence[str] | None = None,
|
|
71
|
+
properties: Mapping[str, Any] | None = None,
|
|
72
|
+
*,
|
|
73
|
+
skip: int | None = None,
|
|
74
|
+
limit: int | None = None,
|
|
75
|
+
order_by: str | None = None,
|
|
76
|
+
descending: bool = False,
|
|
77
|
+
) -> list[Node]:
|
|
78
|
+
"""Nodes with all the given labels whose properties equal the given
|
|
79
|
+
values (``None`` matches a missing property)."""
|
|
80
|
+
params: dict[str, Any] = {}
|
|
81
|
+
q = f"MATCH (n{_labels(labels)}){_where('n', properties, params)} RETURN n"
|
|
82
|
+
if order_by is not None:
|
|
83
|
+
q += f" ORDER BY n.{quote(order_by)}" + (" DESC" if descending else "")
|
|
84
|
+
return self.run(q + _paging(skip, limit), params).values(0)
|
|
85
|
+
|
|
86
|
+
def count_nodes(self, labels: str | Sequence[str] | None = None, properties: Mapping[str, Any] | None = None) -> int:
|
|
87
|
+
params: dict[str, Any] = {}
|
|
88
|
+
q = f"MATCH (n{_labels(labels)}){_where('n', properties, params)} RETURN count(n)"
|
|
89
|
+
return self.run(q, params).value()
|
|
90
|
+
|
|
91
|
+
def update_node(
|
|
92
|
+
self,
|
|
93
|
+
node_id: int,
|
|
94
|
+
properties: Mapping[str, Any] | None = None,
|
|
95
|
+
*,
|
|
96
|
+
replace: bool = False,
|
|
97
|
+
add_labels: str | Sequence[str] | None = None,
|
|
98
|
+
remove_labels: str | Sequence[str] | None = None,
|
|
99
|
+
) -> Node | None:
|
|
100
|
+
"""Updates a node and returns it, or ``None`` if it does not exist.
|
|
101
|
+
|
|
102
|
+
By default ``properties`` are merged (``SET n += $props``; a ``None``
|
|
103
|
+
value removes that property). With ``replace=True`` they replace all
|
|
104
|
+
existing properties (``SET n = $props``).
|
|
105
|
+
"""
|
|
106
|
+
clauses = []
|
|
107
|
+
params: dict[str, Any] = {"id": node_id}
|
|
108
|
+
if properties is not None or replace:
|
|
109
|
+
params["props"] = dict(properties or {})
|
|
110
|
+
clauses.append("SET n = $props" if replace else "SET n += $props")
|
|
111
|
+
if add_labels:
|
|
112
|
+
clauses.append(f"SET n{_labels(add_labels)}")
|
|
113
|
+
if remove_labels:
|
|
114
|
+
clauses.append(f"REMOVE n{_labels(remove_labels)}")
|
|
115
|
+
q = "MATCH (n) WHERE id(n) = $id " + " ".join(clauses) + " RETURN n"
|
|
116
|
+
return self.run(q, params).value()
|
|
117
|
+
|
|
118
|
+
def delete_node(self, node_id: int, *, detach: bool = True) -> bool:
|
|
119
|
+
"""Deletes a node. With ``detach=True`` (default) its relationships
|
|
120
|
+
are deleted too; otherwise a node with relationships raises.
|
|
121
|
+
Returns whether a node was deleted."""
|
|
122
|
+
verb = "DETACH DELETE" if detach else "DELETE"
|
|
123
|
+
r = self.run(f"MATCH (n) WHERE id(n) = $id {verb} n", {"id": node_id})
|
|
124
|
+
return r.stats.nodes_deleted > 0
|
|
125
|
+
|
|
126
|
+
def delete_nodes(
|
|
127
|
+
self,
|
|
128
|
+
labels: str | Sequence[str] | None = None,
|
|
129
|
+
properties: Mapping[str, Any] | None = None,
|
|
130
|
+
*,
|
|
131
|
+
detach: bool = True,
|
|
132
|
+
) -> int:
|
|
133
|
+
"""Deletes every matching node. Returns how many were deleted."""
|
|
134
|
+
params: dict[str, Any] = {}
|
|
135
|
+
verb = "DETACH DELETE" if detach else "DELETE"
|
|
136
|
+
r = self.run(f"MATCH (n{_labels(labels)}){_where('n', properties, params)} {verb} n", params)
|
|
137
|
+
return r.stats.nodes_deleted
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------ relationships
|
|
140
|
+
|
|
141
|
+
def create_relationship(
|
|
142
|
+
self,
|
|
143
|
+
start_id: int,
|
|
144
|
+
end_id: int,
|
|
145
|
+
rel_type: str,
|
|
146
|
+
properties: Mapping[str, Any] | None = None,
|
|
147
|
+
) -> Relationship | None:
|
|
148
|
+
"""Creates ``(start)-[:rel_type]->(end)`` and returns it, or ``None``
|
|
149
|
+
if either node does not exist."""
|
|
150
|
+
q = (
|
|
151
|
+
"MATCH (a) WHERE id(a) = $start MATCH (b) WHERE id(b) = $end "
|
|
152
|
+
f"CREATE (a)-[r:{quote(rel_type)}]->(b) SET r = $props RETURN r"
|
|
153
|
+
)
|
|
154
|
+
params = {"start": start_id, "end": end_id, "props": dict(properties or {})}
|
|
155
|
+
return self.run(q, params).value()
|
|
156
|
+
|
|
157
|
+
def get_relationship(self, rel_id: int) -> Relationship | None:
|
|
158
|
+
return self.run("MATCH ()-[r]->() WHERE id(r) = $id RETURN r", {"id": rel_id}).value()
|
|
159
|
+
|
|
160
|
+
def find_relationships(
|
|
161
|
+
self,
|
|
162
|
+
rel_type: str | None = None,
|
|
163
|
+
properties: Mapping[str, Any] | None = None,
|
|
164
|
+
*,
|
|
165
|
+
start_id: int | None = None,
|
|
166
|
+
end_id: int | None = None,
|
|
167
|
+
skip: int | None = None,
|
|
168
|
+
limit: int | None = None,
|
|
169
|
+
) -> list[Relationship]:
|
|
170
|
+
"""Relationships of a type (any if ``None``) whose properties equal
|
|
171
|
+
the given values, optionally from/to specific nodes."""
|
|
172
|
+
params: dict[str, Any] = {}
|
|
173
|
+
extra = []
|
|
174
|
+
if start_id is not None:
|
|
175
|
+
params["start"] = start_id
|
|
176
|
+
extra.append("id(a) = $start")
|
|
177
|
+
if end_id is not None:
|
|
178
|
+
params["end"] = end_id
|
|
179
|
+
extra.append("id(b) = $end")
|
|
180
|
+
t = f":{quote(rel_type)}" if rel_type else ""
|
|
181
|
+
q = f"MATCH (a)-[r{t}]->(b){_where('r', properties, params, extra)} RETURN r"
|
|
182
|
+
return self.run(q + _paging(skip, limit), params).values(0)
|
|
183
|
+
|
|
184
|
+
def update_relationship(
|
|
185
|
+
self, rel_id: int, properties: Mapping[str, Any], *, replace: bool = False
|
|
186
|
+
) -> Relationship | None:
|
|
187
|
+
"""Merges (or with ``replace=True`` replaces) a relationship's
|
|
188
|
+
properties. Returns it, or ``None`` if it does not exist."""
|
|
189
|
+
op = "=" if replace else "+="
|
|
190
|
+
q = f"MATCH ()-[r]->() WHERE id(r) = $id SET r {op} $props RETURN r"
|
|
191
|
+
return self.run(q, {"id": rel_id, "props": dict(properties)}).value()
|
|
192
|
+
|
|
193
|
+
def delete_relationship(self, rel_id: int) -> bool:
|
|
194
|
+
r = self.run("MATCH ()-[r]->() WHERE id(r) = $id DELETE r", {"id": rel_id})
|
|
195
|
+
return r.stats.relationships_deleted > 0
|
mygraph/_protocol.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""MyGraph protocol v1: framing and value codec (see docs/PROTOCOL.md).
|
|
2
|
+
|
|
3
|
+
Frame header, little-endian: u8 version, u8 message type, u32 request id,
|
|
4
|
+
u32 payload length. The payload is one encoded MAP (count + key/value pairs,
|
|
5
|
+
no tag).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import socket
|
|
11
|
+
import struct
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .errors import ConnectionClosedError, ProtocolError
|
|
15
|
+
from .types import Node, Relationship
|
|
16
|
+
|
|
17
|
+
PROTOCOL_VERSION = 1
|
|
18
|
+
MAX_PAYLOAD = 64 * 1024 * 1024
|
|
19
|
+
MAX_DEPTH = 64
|
|
20
|
+
|
|
21
|
+
_HEADER = struct.Struct("<BBII")
|
|
22
|
+
_U32 = struct.Struct("<I")
|
|
23
|
+
_U64 = struct.Struct("<Q")
|
|
24
|
+
_I64 = struct.Struct("<q")
|
|
25
|
+
_F64 = struct.Struct("<d")
|
|
26
|
+
|
|
27
|
+
# Message types
|
|
28
|
+
HELLO = 0x01
|
|
29
|
+
AUTH = 0x02
|
|
30
|
+
QUERY = 0x10
|
|
31
|
+
QUERY_RESULT = 0x11
|
|
32
|
+
BEGIN = 0x20
|
|
33
|
+
COMMIT = 0x21
|
|
34
|
+
ROLLBACK = 0x22
|
|
35
|
+
OK = 0x23
|
|
36
|
+
PING = 0x30
|
|
37
|
+
PONG = 0x31
|
|
38
|
+
GOODBYE = 0x40
|
|
39
|
+
ERROR = 0x7F
|
|
40
|
+
|
|
41
|
+
# Value tags
|
|
42
|
+
T_NULL, T_BOOL, T_INT, T_FLOAT, T_STRING, T_BYTES, T_LIST, T_MAP, T_NODE, T_REL = range(10)
|
|
43
|
+
|
|
44
|
+
_I64_MIN, _I64_MAX = -(2**63), 2**63 - 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------- encoding
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _len(buf: bytearray, n: int) -> None:
|
|
51
|
+
if n > 0xFFFFFFFF:
|
|
52
|
+
raise ValueError("value too large to encode")
|
|
53
|
+
buf += _U32.pack(n)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _bytes(buf: bytearray, b: bytes) -> None:
|
|
57
|
+
_len(buf, len(b))
|
|
58
|
+
buf += b
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def encode_value(buf: bytearray, v: Any, depth: int = 0) -> None:
|
|
62
|
+
if depth > MAX_DEPTH:
|
|
63
|
+
raise ValueError("value nesting too deep")
|
|
64
|
+
if v is None:
|
|
65
|
+
buf.append(T_NULL)
|
|
66
|
+
elif isinstance(v, bool): # before int: bool is a subclass of int
|
|
67
|
+
buf.append(T_BOOL)
|
|
68
|
+
buf.append(1 if v else 0)
|
|
69
|
+
elif isinstance(v, int):
|
|
70
|
+
if not _I64_MIN <= v <= _I64_MAX:
|
|
71
|
+
raise ValueError(f"integer {v} does not fit in 64 bits")
|
|
72
|
+
buf.append(T_INT)
|
|
73
|
+
buf += _I64.pack(v)
|
|
74
|
+
elif isinstance(v, float):
|
|
75
|
+
buf.append(T_FLOAT)
|
|
76
|
+
buf += _F64.pack(v)
|
|
77
|
+
elif isinstance(v, str):
|
|
78
|
+
buf.append(T_STRING)
|
|
79
|
+
_bytes(buf, v.encode("utf-8"))
|
|
80
|
+
elif isinstance(v, (bytes, bytearray, memoryview)):
|
|
81
|
+
buf.append(T_BYTES)
|
|
82
|
+
_bytes(buf, bytes(v))
|
|
83
|
+
elif isinstance(v, (list, tuple)):
|
|
84
|
+
buf.append(T_LIST)
|
|
85
|
+
_len(buf, len(v))
|
|
86
|
+
for item in v:
|
|
87
|
+
encode_value(buf, item, depth + 1)
|
|
88
|
+
elif isinstance(v, dict):
|
|
89
|
+
buf.append(T_MAP)
|
|
90
|
+
encode_map(buf, v, depth + 1)
|
|
91
|
+
elif isinstance(v, (Node, Relationship)):
|
|
92
|
+
raise TypeError(f"{type(v).__name__} values cannot be sent as parameters; pass its id instead")
|
|
93
|
+
else:
|
|
94
|
+
raise TypeError(f"cannot send a value of type {type(v).__name__}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def encode_map(buf: bytearray, m: dict, depth: int = 0) -> None:
|
|
98
|
+
_len(buf, len(m))
|
|
99
|
+
for k, v in m.items():
|
|
100
|
+
if not isinstance(k, str):
|
|
101
|
+
raise TypeError(f"map keys must be str, got {type(k).__name__}")
|
|
102
|
+
_bytes(buf, k.encode("utf-8"))
|
|
103
|
+
encode_value(buf, v, depth)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def encode_payload(payload: dict) -> bytes:
|
|
107
|
+
buf = bytearray()
|
|
108
|
+
encode_map(buf, payload)
|
|
109
|
+
return bytes(buf)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------- decoding
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Decoder:
|
|
116
|
+
def __init__(self, data: bytes):
|
|
117
|
+
self.data = memoryview(data)
|
|
118
|
+
self.pos = 0
|
|
119
|
+
|
|
120
|
+
def _take(self, n: int) -> memoryview:
|
|
121
|
+
if len(self.data) - self.pos < n:
|
|
122
|
+
raise ProtocolError("unexpected end of payload")
|
|
123
|
+
s = self.data[self.pos : self.pos + n]
|
|
124
|
+
self.pos += n
|
|
125
|
+
return s
|
|
126
|
+
|
|
127
|
+
def _u8(self) -> int:
|
|
128
|
+
return self._take(1)[0]
|
|
129
|
+
|
|
130
|
+
def _u32(self) -> int:
|
|
131
|
+
return _U32.unpack(self._take(4))[0]
|
|
132
|
+
|
|
133
|
+
def _u64(self) -> int:
|
|
134
|
+
return _U64.unpack(self._take(8))[0]
|
|
135
|
+
|
|
136
|
+
def _count(self) -> int:
|
|
137
|
+
n = self._u32()
|
|
138
|
+
if n > len(self.data) - self.pos:
|
|
139
|
+
raise ProtocolError("element count exceeds payload size")
|
|
140
|
+
return n
|
|
141
|
+
|
|
142
|
+
def _str(self) -> str:
|
|
143
|
+
n = self._u32()
|
|
144
|
+
try:
|
|
145
|
+
return str(self._take(n), "utf-8")
|
|
146
|
+
except UnicodeDecodeError as e:
|
|
147
|
+
raise ProtocolError("invalid UTF-8 string") from e
|
|
148
|
+
|
|
149
|
+
def value(self, depth: int = 0) -> Any:
|
|
150
|
+
if depth > MAX_DEPTH:
|
|
151
|
+
raise ProtocolError("value nesting too deep")
|
|
152
|
+
t = self._u8()
|
|
153
|
+
if t == T_NULL:
|
|
154
|
+
return None
|
|
155
|
+
if t == T_BOOL:
|
|
156
|
+
return self._u8() != 0
|
|
157
|
+
if t == T_INT:
|
|
158
|
+
return _I64.unpack(self._take(8))[0]
|
|
159
|
+
if t == T_FLOAT:
|
|
160
|
+
return _F64.unpack(self._take(8))[0]
|
|
161
|
+
if t == T_STRING:
|
|
162
|
+
return self._str()
|
|
163
|
+
if t == T_BYTES:
|
|
164
|
+
return bytes(self._take(self._u32()))
|
|
165
|
+
if t == T_LIST:
|
|
166
|
+
return [self.value(depth + 1) for _ in range(self._count())]
|
|
167
|
+
if t == T_MAP:
|
|
168
|
+
return self.map(depth + 1)
|
|
169
|
+
if t == T_NODE:
|
|
170
|
+
node_id = self._u64()
|
|
171
|
+
labels = [self._str() for _ in range(self._count())]
|
|
172
|
+
return Node(node_id, labels, self.map(depth + 1))
|
|
173
|
+
if t == T_REL:
|
|
174
|
+
rel_id, start, end = self._u64(), self._u64(), self._u64()
|
|
175
|
+
return Relationship(rel_id, start, end, self._str(), self.map(depth + 1))
|
|
176
|
+
raise ProtocolError(f"unknown value tag {t}")
|
|
177
|
+
|
|
178
|
+
def map(self, depth: int = 0) -> dict:
|
|
179
|
+
out = {}
|
|
180
|
+
for _ in range(self._count()):
|
|
181
|
+
k = self._str()
|
|
182
|
+
out[k] = self.value(depth)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def decode_payload(data: bytes) -> dict:
|
|
187
|
+
d = Decoder(data)
|
|
188
|
+
m = d.map()
|
|
189
|
+
if d.pos != len(d.data):
|
|
190
|
+
raise ProtocolError("trailing bytes after payload")
|
|
191
|
+
return m
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------- framing
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def encode_frame(message_type: int, request_id: int, payload: bytes) -> bytes:
|
|
198
|
+
if len(payload) > MAX_PAYLOAD:
|
|
199
|
+
raise ValueError("payload too large")
|
|
200
|
+
return _HEADER.pack(PROTOCOL_VERSION, message_type, request_id, len(payload)) + payload
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
|
204
|
+
chunks = []
|
|
205
|
+
while n:
|
|
206
|
+
chunk = sock.recv(min(n, 1 << 20))
|
|
207
|
+
if not chunk:
|
|
208
|
+
raise ConnectionClosedError("connection closed by the server")
|
|
209
|
+
chunks.append(chunk)
|
|
210
|
+
n -= len(chunk)
|
|
211
|
+
return b"".join(chunks)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def read_frame(sock: socket.socket) -> tuple[int, int, bytes]:
|
|
215
|
+
"""Reads one frame: ``(message_type, request_id, payload)``."""
|
|
216
|
+
version, message_type, request_id, length = _HEADER.unpack(_recv_exact(sock, _HEADER.size))
|
|
217
|
+
if version != PROTOCOL_VERSION:
|
|
218
|
+
raise ProtocolError(f"server answered with protocol version {version}, expected {PROTOCOL_VERSION}")
|
|
219
|
+
if length > MAX_PAYLOAD:
|
|
220
|
+
raise ProtocolError(f"payload of {length} bytes exceeds the limit")
|
|
221
|
+
return message_type, request_id, _recv_exact(sock, length) if length else b""
|
|
222
|
+
|
mygraph/cli.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""``mygraph-client`` command: run Cypher against a MyGraph server.
|
|
2
|
+
|
|
3
|
+
mygraph-client [--host HOST] [--port PORT] [--json] "MATCH (n) RETURN n;"
|
|
4
|
+
echo "MATCH (n) RETURN n;" | mygraph-client
|
|
5
|
+
|
|
6
|
+
Without a query argument the script is read from standard input.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import dataclasses
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .client import DEFAULT_PORT, connect
|
|
18
|
+
from .errors import MyGraphError
|
|
19
|
+
from .types import Node, Relationship
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _plain(value: Any) -> Any:
|
|
23
|
+
if isinstance(value, (Node, Relationship)):
|
|
24
|
+
return dataclasses.asdict(value)
|
|
25
|
+
if isinstance(value, list):
|
|
26
|
+
return [_plain(v) for v in value]
|
|
27
|
+
if isinstance(value, dict):
|
|
28
|
+
return {k: _plain(v) for k, v in value.items()}
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _show(value: Any) -> str:
|
|
33
|
+
if isinstance(value, Node):
|
|
34
|
+
labels = "".join(f":{label}" for label in value.labels)
|
|
35
|
+
return f"({value.id}{labels} {json.dumps(value.properties, ensure_ascii=False)})"
|
|
36
|
+
if isinstance(value, Relationship):
|
|
37
|
+
props = json.dumps(value.properties, ensure_ascii=False)
|
|
38
|
+
return f"({value.start_node})-[{value.id}:{value.type} {props}]->({value.end_node})"
|
|
39
|
+
if isinstance(value, str):
|
|
40
|
+
return value
|
|
41
|
+
return json.dumps(_plain(value), ensure_ascii=False)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str] | None = None) -> int:
|
|
45
|
+
parser = argparse.ArgumentParser(
|
|
46
|
+
prog="mygraph-client", description="Run Cypher against a MyGraph server."
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument("query", nargs="?", help="Cypher script (default: read stdin)")
|
|
49
|
+
parser.add_argument("--host", default="localhost")
|
|
50
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
51
|
+
parser.add_argument("--json", action="store_true", help="print records as JSON lines")
|
|
52
|
+
args = parser.parse_args(argv)
|
|
53
|
+
|
|
54
|
+
query = args.query if args.query is not None else sys.stdin.read()
|
|
55
|
+
if not query.strip():
|
|
56
|
+
parser.error("empty query")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
with connect(args.host, args.port) as db:
|
|
60
|
+
result = db.run(query)
|
|
61
|
+
except MyGraphError as e:
|
|
62
|
+
print(f"error: {e}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
if args.json:
|
|
66
|
+
for record in result:
|
|
67
|
+
print(json.dumps(_plain(record), ensure_ascii=False))
|
|
68
|
+
else:
|
|
69
|
+
if result.columns:
|
|
70
|
+
print("\t".join(result.columns))
|
|
71
|
+
for row in result.rows:
|
|
72
|
+
print("\t".join(_show(v) for v in row))
|
|
73
|
+
print(f"({len(result)} rows)", file=sys.stderr)
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
sys.exit(main())
|
mygraph/client.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Connection to a MyGraph server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from . import _protocol as p
|
|
10
|
+
from ._ops import CypherOps, quote
|
|
11
|
+
from .errors import ConnectionClosedError, ProtocolError, TransactionError, error_from_payload
|
|
12
|
+
from .transaction import Transaction
|
|
13
|
+
from .types import Result, Stats
|
|
14
|
+
|
|
15
|
+
DEFAULT_PORT = 6660
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Client(CypherOps):
|
|
19
|
+
"""One TCP connection to a MyGraph server.
|
|
20
|
+
|
|
21
|
+
Requests on one client are sent one at a time (a lock serializes them),
|
|
22
|
+
so a client can be shared between threads, but they all share its current
|
|
23
|
+
database and its open transaction. Use one client per thread for
|
|
24
|
+
independent work.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
host: str = "localhost",
|
|
30
|
+
port: int = DEFAULT_PORT,
|
|
31
|
+
*,
|
|
32
|
+
user: str | None = None,
|
|
33
|
+
password: str | None = None,
|
|
34
|
+
timeout: float | None = 30.0,
|
|
35
|
+
client_name: str = "mygraph-python/0.1.0",
|
|
36
|
+
):
|
|
37
|
+
self.host, self.port = host, port
|
|
38
|
+
self._lock = threading.Lock()
|
|
39
|
+
self._next_id = 1
|
|
40
|
+
self._transaction: Transaction | None = None
|
|
41
|
+
try:
|
|
42
|
+
self._sock: socket.socket | None = socket.create_connection((host, port), timeout=timeout)
|
|
43
|
+
except OSError as e:
|
|
44
|
+
raise ConnectionClosedError(f"cannot connect to {host}:{port}: {e}") from e
|
|
45
|
+
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
46
|
+
try:
|
|
47
|
+
hello = self._request(p.HELLO, {"client": client_name}, expect=p.HELLO)
|
|
48
|
+
self.server_info: dict[str, Any] = hello
|
|
49
|
+
if user is not None:
|
|
50
|
+
self._request(p.AUTH, {"user": user, "password": password or ""})
|
|
51
|
+
except BaseException:
|
|
52
|
+
self._close_socket()
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------ transport
|
|
56
|
+
|
|
57
|
+
def _request(self, message_type: int, payload: dict, expect: int = p.OK) -> dict:
|
|
58
|
+
with self._lock:
|
|
59
|
+
if self._sock is None:
|
|
60
|
+
raise ConnectionClosedError("the client is closed")
|
|
61
|
+
request_id = self._next_id
|
|
62
|
+
self._next_id = (self._next_id + 1) & 0xFFFFFFFF or 1
|
|
63
|
+
try:
|
|
64
|
+
self._sock.sendall(p.encode_frame(message_type, request_id, p.encode_payload(payload)))
|
|
65
|
+
kind, response_id, body = p.read_frame(self._sock)
|
|
66
|
+
except (OSError, ConnectionClosedError) as e:
|
|
67
|
+
# The stream position is unknown now; the connection cannot be reused.
|
|
68
|
+
self._close_socket()
|
|
69
|
+
if isinstance(e, ConnectionClosedError):
|
|
70
|
+
raise
|
|
71
|
+
raise ConnectionClosedError(f"connection lost: {e}") from e
|
|
72
|
+
except ProtocolError:
|
|
73
|
+
self._close_socket()
|
|
74
|
+
raise
|
|
75
|
+
if response_id != request_id:
|
|
76
|
+
self.close()
|
|
77
|
+
raise ProtocolError(f"response id {response_id} does not match request id {request_id}")
|
|
78
|
+
data = p.decode_payload(body)
|
|
79
|
+
if kind == p.ERROR:
|
|
80
|
+
raise error_from_payload(data)
|
|
81
|
+
if kind != expect:
|
|
82
|
+
raise ProtocolError(f"unexpected message type 0x{kind:02x} (expected 0x{expect:02x})")
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
def _close_socket(self) -> None:
|
|
86
|
+
sock, self._sock = self._sock, None
|
|
87
|
+
if sock is not None:
|
|
88
|
+
try:
|
|
89
|
+
sock.close()
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------ queries
|
|
94
|
+
|
|
95
|
+
def run(self, query: str, params: Mapping[str, Any] | None = None, **kwargs: Any) -> Result:
|
|
96
|
+
"""Runs a Cypher script (one or more ``;``-separated statements) and
|
|
97
|
+
returns the last statement's rows. Parameters are referenced as
|
|
98
|
+
``$name`` and may be given as a dict, keyword arguments, or both."""
|
|
99
|
+
all_params = {**(params or {}), **kwargs}
|
|
100
|
+
data = self._request(p.QUERY, {"query": query, "params": all_params}, expect=p.QUERY_RESULT)
|
|
101
|
+
return Result(
|
|
102
|
+
columns=list(data.get("columns", [])),
|
|
103
|
+
rows=list(data.get("rows", [])),
|
|
104
|
+
stats=Stats.from_map(data.get("stats", {})),
|
|
105
|
+
database=data.get("database", ""),
|
|
106
|
+
schema=data.get("schema", ""),
|
|
107
|
+
in_transaction=bool(data.get("in_transaction", False)),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def transaction(self) -> Transaction:
|
|
111
|
+
"""Begins an explicit transaction (``BEGIN``)."""
|
|
112
|
+
if self._transaction is not None and self._transaction.is_open:
|
|
113
|
+
raise TransactionError("Transaction", "a transaction is already open on this client")
|
|
114
|
+
self._request(p.BEGIN, {})
|
|
115
|
+
self._transaction = Transaction(self)
|
|
116
|
+
return self._transaction
|
|
117
|
+
|
|
118
|
+
begin = transaction
|
|
119
|
+
|
|
120
|
+
def ping(self) -> None:
|
|
121
|
+
self._request(p.PING, {}, expect=p.PONG)
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------ databases and schemas
|
|
124
|
+
|
|
125
|
+
def use_database(self, name: str) -> None:
|
|
126
|
+
self.run(f"USE DATABASE {quote(name)}")
|
|
127
|
+
|
|
128
|
+
def use_schema(self, name: str) -> None:
|
|
129
|
+
self.run(f"USE SCHEMA {quote(name)}")
|
|
130
|
+
|
|
131
|
+
def create_database(self, name: str, *, if_not_exists: bool = True) -> None:
|
|
132
|
+
self.run(f"CREATE DATABASE {'IF NOT EXISTS ' if if_not_exists else ''}{quote(name)}")
|
|
133
|
+
|
|
134
|
+
def drop_database(self, name: str, *, if_exists: bool = True) -> None:
|
|
135
|
+
self.run(f"DROP DATABASE {'IF EXISTS ' if if_exists else ''}{quote(name)}")
|
|
136
|
+
|
|
137
|
+
def create_schema(self, name: str, *, if_not_exists: bool = True) -> None:
|
|
138
|
+
self.run(f"CREATE SCHEMA {'IF NOT EXISTS ' if if_not_exists else ''}{quote(name)}")
|
|
139
|
+
|
|
140
|
+
def drop_schema(self, name: str, *, if_exists: bool = True) -> None:
|
|
141
|
+
self.run(f"DROP SCHEMA {'IF EXISTS ' if if_exists else ''}{quote(name)}")
|
|
142
|
+
|
|
143
|
+
def databases(self) -> list[dict[str, Any]]:
|
|
144
|
+
return self.run("SHOW DATABASES").records()
|
|
145
|
+
|
|
146
|
+
def schemas(self) -> list[dict[str, Any]]:
|
|
147
|
+
return self.run("SHOW SCHEMAS").records()
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------ lifecycle
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def closed(self) -> bool:
|
|
153
|
+
return self._sock is None
|
|
154
|
+
|
|
155
|
+
def close(self) -> None:
|
|
156
|
+
"""Says GOODBYE and closes the socket. An open transaction is rolled
|
|
157
|
+
back by the server."""
|
|
158
|
+
if self._sock is None:
|
|
159
|
+
return
|
|
160
|
+
try:
|
|
161
|
+
self._request(p.GOODBYE, {})
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
self._close_socket()
|
|
165
|
+
|
|
166
|
+
def __enter__(self) -> "Client":
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
170
|
+
self.close()
|
|
171
|
+
|
|
172
|
+
def __repr__(self) -> str:
|
|
173
|
+
state = "closed" if self.closed else "open"
|
|
174
|
+
return f"<mygraph.Client {self.host}:{self.port} {state}>"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def connect(host: str = "localhost", port: int = DEFAULT_PORT, **kwargs: Any) -> Client:
|
|
178
|
+
"""Opens a connection. Same arguments as :class:`Client`."""
|
|
179
|
+
return Client(host, port, **kwargs)
|
mygraph/errors.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Exceptions raised by the client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MyGraphError(Exception):
|
|
7
|
+
"""Base class for every error raised by this library."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProtocolError(MyGraphError):
|
|
11
|
+
"""The server sent something that does not follow protocol v1, or
|
|
12
|
+
rejected a malformed request."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConnectionClosedError(MyGraphError):
|
|
16
|
+
"""The connection is closed or was lost."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class QueryError(MyGraphError):
|
|
20
|
+
"""The server could not run a statement. ``code`` is the engine error
|
|
21
|
+
kind (``Syntax``, ``NotFound``, ``Transaction``, ``Type``, ...)."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, code: str, message: str, line: int | None = None, column: int | None = None):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.code = code
|
|
26
|
+
self.message = message
|
|
27
|
+
self.line = line
|
|
28
|
+
self.column = column
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CypherSyntaxError(QueryError):
|
|
32
|
+
"""Invalid Cypher. ``line`` and ``column`` point at the problem."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotFoundError(QueryError):
|
|
36
|
+
"""A database, schema or other named object does not exist."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AlreadyExistsError(QueryError):
|
|
40
|
+
"""A database or schema with that name already exists."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TransactionError(QueryError):
|
|
44
|
+
"""Invalid transaction use, such as COMMIT with no open transaction."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_BY_CODE: dict[str, type[QueryError]] = {
|
|
48
|
+
"Syntax": CypherSyntaxError,
|
|
49
|
+
"NotFound": NotFoundError,
|
|
50
|
+
"AlreadyExists": AlreadyExistsError,
|
|
51
|
+
"Transaction": TransactionError,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def error_from_payload(payload: dict) -> MyGraphError:
|
|
56
|
+
"""Builds the exception for an ERROR message payload."""
|
|
57
|
+
code = str(payload.get("code", "Unknown"))
|
|
58
|
+
message = str(payload.get("message", "unknown error"))
|
|
59
|
+
if code == "Protocol":
|
|
60
|
+
return ProtocolError(message)
|
|
61
|
+
cls = _BY_CODE.get(code, QueryError)
|
|
62
|
+
return cls(code, message, payload.get("line"), payload.get("column"))
|
mygraph/py.typed
ADDED
|
File without changes
|
mygraph/transaction.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Explicit transactions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
|
6
|
+
|
|
7
|
+
from . import _protocol as p
|
|
8
|
+
from ._ops import CypherOps
|
|
9
|
+
from .errors import TransactionError
|
|
10
|
+
from .types import Result
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .client import Client
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Transaction(CypherOps):
|
|
17
|
+
"""A transaction on one connection. Use it as a context manager:
|
|
18
|
+
|
|
19
|
+
with db.transaction() as tx:
|
|
20
|
+
tx.create_node("User", {"name": "Carol"})
|
|
21
|
+
|
|
22
|
+
Leaving the block commits; an exception rolls back. While it is open,
|
|
23
|
+
every statement on the same client runs inside it. In MyGraph v0.1 an open
|
|
24
|
+
transaction blocks all other connections, so keep it short.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, client: "Client"):
|
|
28
|
+
self._client = client
|
|
29
|
+
self._open = True
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_open(self) -> bool:
|
|
33
|
+
return self._open
|
|
34
|
+
|
|
35
|
+
def run(self, query: str, params: Mapping[str, Any] | None = None, **kwargs: Any) -> Result:
|
|
36
|
+
self._check()
|
|
37
|
+
return self._client.run(query, params, **kwargs)
|
|
38
|
+
|
|
39
|
+
def commit(self) -> None:
|
|
40
|
+
self._finish(p.COMMIT)
|
|
41
|
+
|
|
42
|
+
def rollback(self) -> None:
|
|
43
|
+
self._finish(p.ROLLBACK)
|
|
44
|
+
|
|
45
|
+
def _check(self) -> None:
|
|
46
|
+
if not self._open:
|
|
47
|
+
raise TransactionError("Transaction", "the transaction is already finished")
|
|
48
|
+
|
|
49
|
+
def _finish(self, message_type: int) -> None:
|
|
50
|
+
self._check()
|
|
51
|
+
self._open = False
|
|
52
|
+
self._client._request(message_type, {})
|
|
53
|
+
self._client._transaction = None
|
|
54
|
+
|
|
55
|
+
def __enter__(self) -> "Transaction":
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
59
|
+
if not self._open:
|
|
60
|
+
return
|
|
61
|
+
if exc_type is None:
|
|
62
|
+
self.commit()
|
|
63
|
+
else:
|
|
64
|
+
try:
|
|
65
|
+
self.rollback()
|
|
66
|
+
except Exception:
|
|
67
|
+
pass # keep the original exception
|
mygraph/types.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Values returned by queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Iterator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Node:
|
|
11
|
+
id: int
|
|
12
|
+
labels: list[str] = field(default_factory=list)
|
|
13
|
+
properties: dict[str, Any] = field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
def __getitem__(self, key: str) -> Any:
|
|
16
|
+
return self.properties[key]
|
|
17
|
+
|
|
18
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
19
|
+
return self.properties.get(key, default)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Relationship:
|
|
24
|
+
id: int
|
|
25
|
+
start_node: int
|
|
26
|
+
end_node: int
|
|
27
|
+
type: str
|
|
28
|
+
properties: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
def __getitem__(self, key: str) -> Any:
|
|
31
|
+
return self.properties[key]
|
|
32
|
+
|
|
33
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
34
|
+
return self.properties.get(key, default)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Stats:
|
|
39
|
+
"""Update counters of a statement."""
|
|
40
|
+
|
|
41
|
+
nodes_created: int = 0
|
|
42
|
+
nodes_deleted: int = 0
|
|
43
|
+
relationships_created: int = 0
|
|
44
|
+
relationships_deleted: int = 0
|
|
45
|
+
properties_set: int = 0
|
|
46
|
+
labels_added: int = 0
|
|
47
|
+
labels_removed: int = 0
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_map(cls, m: dict[str, Any]) -> "Stats":
|
|
51
|
+
known = cls.__dataclass_fields__
|
|
52
|
+
return cls(**{k: int(v) for k, v in m.items() if k in known})
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def contains_updates(self) -> bool:
|
|
56
|
+
return self != Stats()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Result:
|
|
61
|
+
"""Rows of a statement plus its update counters.
|
|
62
|
+
|
|
63
|
+
``database`` and ``schema`` are the connection's current ones after the
|
|
64
|
+
statement ran (so ``USE DATABASE`` is visible here).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
columns: list[str]
|
|
68
|
+
rows: list[list[Any]]
|
|
69
|
+
stats: Stats
|
|
70
|
+
database: str = ""
|
|
71
|
+
schema: str = ""
|
|
72
|
+
in_transaction: bool = False
|
|
73
|
+
|
|
74
|
+
def __len__(self) -> int:
|
|
75
|
+
return len(self.rows)
|
|
76
|
+
|
|
77
|
+
def __iter__(self) -> Iterator[dict[str, Any]]:
|
|
78
|
+
return iter(self.records())
|
|
79
|
+
|
|
80
|
+
def records(self) -> list[dict[str, Any]]:
|
|
81
|
+
"""Rows as ``{column: value}`` dicts."""
|
|
82
|
+
return [dict(zip(self.columns, row)) for row in self.rows]
|
|
83
|
+
|
|
84
|
+
def single(self) -> dict[str, Any] | None:
|
|
85
|
+
"""The only record, ``None`` if there are no rows. Raises
|
|
86
|
+
``ValueError`` if there is more than one."""
|
|
87
|
+
if not self.rows:
|
|
88
|
+
return None
|
|
89
|
+
if len(self.rows) > 1:
|
|
90
|
+
raise ValueError(f"expected at most one row, got {len(self.rows)}")
|
|
91
|
+
return self.records()[0]
|
|
92
|
+
|
|
93
|
+
def value(self, column: str | int = 0, default: Any = None) -> Any:
|
|
94
|
+
"""A value of the first row, by column name or index."""
|
|
95
|
+
if not self.rows:
|
|
96
|
+
return default
|
|
97
|
+
idx = column if isinstance(column, int) else self.columns.index(column)
|
|
98
|
+
return self.rows[0][idx]
|
|
99
|
+
|
|
100
|
+
def values(self, column: str | int = 0) -> list[Any]:
|
|
101
|
+
"""One column of every row."""
|
|
102
|
+
idx = column if isinstance(column, int) else self.columns.index(column)
|
|
103
|
+
return [row[idx] for row in self.rows]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mygraph-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the MyGraph server (protocol v1)
|
|
5
|
+
Author: Julio Contreras
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/julioacontreras/my-graph
|
|
8
|
+
Project-URL: Repository, https://github.com/julioacontreras/my-graph
|
|
9
|
+
Project-URL: Issues, https://github.com/julioacontreras/my-graph/issues
|
|
10
|
+
Keywords: graph,database,client,mygraph
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Database
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# mygraph-client (Python)
|
|
22
|
+
|
|
23
|
+
Python client for the MyGraph server. It talks to the server over TCP using
|
|
24
|
+
MyGraph protocol v1 ([docs/PROTOCOL.md](https://github.com/julioacontreras/my-graph/blob/main/docs/PROTOCOL.md))
|
|
25
|
+
and needs only the standard library (Python ≥ 3.10).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install mygraph-client
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The import name is `mygraph`:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import mygraph
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For development, from the repository root:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e python/
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Start a server
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cargo run -p mygraph-server -- start # 127.0.0.1:6660, in memory
|
|
49
|
+
cargo run -p mygraph-server -- start --port 6660 --data ./data
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Command line client
|
|
53
|
+
|
|
54
|
+
`pip install` also installs the `mygraph-client` command. With a server on
|
|
55
|
+
port 6660 (for example `docker compose up -d`):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
mygraph-client "MATCH (n) RETURN n;"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
n
|
|
63
|
+
(0:User {"age": 25, "name": "Alice"})
|
|
64
|
+
(1:User {"name": "Bob"})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Without a query argument the script is read from stdin, so multi-line
|
|
68
|
+
queries work too:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
mygraph-client <<'CYPHER'
|
|
72
|
+
MATCH (n)
|
|
73
|
+
RETURN n;
|
|
74
|
+
CYPHER
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
| Option | Default | |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `--host HOST` | `localhost` | Server host |
|
|
80
|
+
| `--port PORT` | `6660` | Server port |
|
|
81
|
+
| `--json` | off | One JSON object per record |
|
|
82
|
+
|
|
83
|
+
Without installing, `python3 -m mygraph` does the same. On error it prints
|
|
84
|
+
the message to stderr and exits with status 1.
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import mygraph
|
|
90
|
+
|
|
91
|
+
with mygraph.connect("localhost", 6660) as db:
|
|
92
|
+
# Any Cypher, with parameters as a dict and/or keyword arguments
|
|
93
|
+
r = db.run("MATCH (u:User) WHERE u.age > $min RETURN u.name AS name", min=18)
|
|
94
|
+
for record in r: # records are dicts
|
|
95
|
+
print(record["name"])
|
|
96
|
+
r.value("name"); r.values("name"); r.single(); r.stats.nodes_created
|
|
97
|
+
|
|
98
|
+
# Create
|
|
99
|
+
alice = db.create_node("User", {"name": "Alice", "age": 25}) # -> Node
|
|
100
|
+
bob = db.create_node(["User", "Admin"], {"name": "Bob"})
|
|
101
|
+
knows = db.create_relationship(alice.id, bob.id, "KNOWS", {"since": 2020})
|
|
102
|
+
|
|
103
|
+
# Search
|
|
104
|
+
db.get_node(alice.id) # Node | None
|
|
105
|
+
db.find_nodes("User", {"name": "Alice"}, order_by="age", limit=10) # list[Node]
|
|
106
|
+
db.count_nodes("User")
|
|
107
|
+
db.find_relationships("KNOWS", start_id=alice.id) # list[Relationship]
|
|
108
|
+
|
|
109
|
+
# Update
|
|
110
|
+
db.update_node(alice.id, {"age": 26}) # merge: SET n += $props (None removes a key)
|
|
111
|
+
db.update_node(alice.id, {"name": "Alicia"}, replace=True) # SET n = $props
|
|
112
|
+
db.update_node(alice.id, add_labels="VIP", remove_labels="Admin")
|
|
113
|
+
db.update_relationship(knows.id, {"since": 2021})
|
|
114
|
+
|
|
115
|
+
# Delete
|
|
116
|
+
db.delete_relationship(knows.id)
|
|
117
|
+
db.delete_node(alice.id) # DETACH DELETE by default
|
|
118
|
+
db.delete_nodes("User", {"name": "Bob"}) # returns how many
|
|
119
|
+
|
|
120
|
+
# Transactions: commit on exit, rollback on exception
|
|
121
|
+
with db.transaction() as tx:
|
|
122
|
+
tx.create_node("User", {"name": "Carol"})
|
|
123
|
+
tx.run("MATCH (u:User {name: 'Carol'}) SET u.vip = true")
|
|
124
|
+
|
|
125
|
+
# Databases and schemas (the current one is per connection)
|
|
126
|
+
db.create_database("shop"); db.use_database("shop"); db.databases()
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`Node` has `id`, `labels`, `properties` (and `node["key"]`). `Relationship`
|
|
130
|
+
has `id`, `start_node`, `end_node`, `type`, `properties`.
|
|
131
|
+
|
|
132
|
+
Helpers write labels, types and property keys as backtick-quoted
|
|
133
|
+
identifiers, and always send values as parameters, so user input is never
|
|
134
|
+
spliced into the query text.
|
|
135
|
+
|
|
136
|
+
## Errors
|
|
137
|
+
|
|
138
|
+
| Exception | When |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `CypherSyntaxError` | Invalid Cypher (`.line`, `.column`) |
|
|
141
|
+
| `NotFoundError`, `AlreadyExistsError` | Unknown or duplicate database/schema |
|
|
142
|
+
| `TransactionError` | e.g. COMMIT with no open transaction |
|
|
143
|
+
| `QueryError` | Base class of the above; `.code` holds the engine error kind |
|
|
144
|
+
| `ProtocolError` | The server or the client broke protocol v1 |
|
|
145
|
+
| `ConnectionClosedError` | Cannot connect, or the connection was lost/closed |
|
|
146
|
+
|
|
147
|
+
An error in a statement does not close the connection.
|
|
148
|
+
|
|
149
|
+
## Notes for MyGraph v0.1
|
|
150
|
+
|
|
151
|
+
- An open transaction blocks every other connection until it commits or rolls
|
|
152
|
+
back. Keep transactions short.
|
|
153
|
+
- While a transaction is open, everything sent on that client runs inside it.
|
|
154
|
+
- A client serializes its requests with a lock. Threads that share one
|
|
155
|
+
client also share its current database and transaction, so use one client
|
|
156
|
+
per thread for independent work.
|
|
157
|
+
- `AUTH` is accepted but not checked yet (no users or roles on the server).
|
|
158
|
+
|
|
159
|
+
## Tests
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
cd python
|
|
163
|
+
python3 -m unittest discover -s tests -t .
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The integration tests build `mygraph-server` with cargo and start it on a
|
|
167
|
+
free port. Set `MYGRAPH_SERVER_BIN` to use an existing binary instead.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
mygraph/__init__.py,sha256=y9ItbM6lp7fLv4gD6sKAhYcyJ1ieVpvakFBzoIUQEKk,1060
|
|
2
|
+
mygraph/__main__.py,sha256=xs0ncFDq0tEcmLfLq5Gtp9GeSVyV5Mht4fmq8rIFHTg,51
|
|
3
|
+
mygraph/_ops.py,sha256=I-305JWdJamIFMzymECl7CivHZUOTsRo-AgQgl5z2iY,7988
|
|
4
|
+
mygraph/_protocol.py,sha256=w4hNEcmL-SsZsEXZoWzOpPwSG-FPLB0oCffnmcmzP2k,6706
|
|
5
|
+
mygraph/cli.py,sha256=FG_0lK2hieMNsTFB8LGJICeoRi33qSGdXyPKJ0hmg0g,2556
|
|
6
|
+
mygraph/client.py,sha256=a-FdfFp5RJWLSQmhYwy-TS3TggrAKzptfehe8qXV8F8,6924
|
|
7
|
+
mygraph/errors.py,sha256=ZuNi3t9zw351mZilU6uQawrH37hMj73bqfbMNK1Ir4k,1853
|
|
8
|
+
mygraph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
mygraph/transaction.py,sha256=bmaxkpRBSeGXX09GPbRgJ07BJcNY235q4GIGpxKctcQ,1876
|
|
10
|
+
mygraph/types.py,sha256=ndTaOKZFg3OH69sDSjyIKa5fGTpJT6AJR4tcIX2YR3A,2981
|
|
11
|
+
mygraph_client-0.1.0.dist-info/licenses/LICENSE,sha256=9IeScmE-6XAFux8NdJsLpTQ8cb4IfrqzEj8cVSrPfUE,1072
|
|
12
|
+
mygraph_client-0.1.0.dist-info/METADATA,sha256=pFr7x3VI4YeRiTtzDOwbZQ4YcfvwOYjEtiYIyux4N5w,5447
|
|
13
|
+
mygraph_client-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
14
|
+
mygraph_client-0.1.0.dist-info/entry_points.txt,sha256=2MIOzdJhMB0bcleRgslHNn73iPpE_eh7GSGn3PFmM28,52
|
|
15
|
+
mygraph_client-0.1.0.dist-info/top_level.txt,sha256=uSRT9RPr435_xnYwe6T1aYDGjTUD3JuWLH9bCtudRKo,8
|
|
16
|
+
mygraph_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Julio Contreras
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mygraph
|