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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mygraph-client = mygraph.cli:main
@@ -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