strgraph 1.1.0__tar.gz

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.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: strgraph
3
+ Version: 1.1.0
4
+ Summary: Python client for StratiGraph — Yjs real-time collaboration via Node.js sidecar
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0; extra == "dev"
10
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
11
+
12
+ # stratigraph-sync
13
+
14
+ Python client for `@stratigraph/sync` — real-time multi-user editing powered by Yjs CRDTs.
15
+
16
+ Connects to the `sync-sidecar` Node.js binary over stdio JSON-RPC. Requires Node.js to be installed.
17
+
18
+ ## Quick Start
19
+
20
+ ```python
21
+ from stratigraph_sync import SyncClient, InitConfig
22
+
23
+ client = SyncClient()
24
+ snapshot = client.init(InitConfig(room_id="trench-5-west"))
25
+ print(snapshot.data) # {"contexts": {}, "observations": {}, ...}
26
+
27
+ # Register event handlers
28
+ def on_change(msg):
29
+ print(f"Remote change: {msg}")
30
+
31
+ client.on("remote_patch", on_change)
32
+
33
+ # Read and write data
34
+ client.add("contexts", {"id": "ctx-1", "type": "fill", "description": "Clay layer"})
35
+ client.patch("contexts", "ctx-1", {"description": "Sandy clay"})
36
+ all_contexts = client.query("contexts")
37
+ single = client.query("contexts", "ctx-1")
38
+ full = client.snapshot()
39
+
40
+ client.leave()
41
+ client.close()
42
+ ```
43
+
44
+ ## Events
45
+
46
+ | Event | Payload | Description |
47
+ |-------|---------|-------------|
48
+ | `remote_patch` | `{collection, id, fields, by}` | Remote document update |
49
+ | `remote_delete` | `{collection, id, by}` | Remote document deletion |
50
+ | `remote_add` | `{collection, document, by}` | Remote document addition |
51
+ | `awareness` | `{users: [...]}` | User presence update |
52
+ | `sync_status` | `{state, pending}` | Connection status change |
@@ -0,0 +1,41 @@
1
+ # stratigraph-sync
2
+
3
+ Python client for `@stratigraph/sync` — real-time multi-user editing powered by Yjs CRDTs.
4
+
5
+ Connects to the `sync-sidecar` Node.js binary over stdio JSON-RPC. Requires Node.js to be installed.
6
+
7
+ ## Quick Start
8
+
9
+ ```python
10
+ from stratigraph_sync import SyncClient, InitConfig
11
+
12
+ client = SyncClient()
13
+ snapshot = client.init(InitConfig(room_id="trench-5-west"))
14
+ print(snapshot.data) # {"contexts": {}, "observations": {}, ...}
15
+
16
+ # Register event handlers
17
+ def on_change(msg):
18
+ print(f"Remote change: {msg}")
19
+
20
+ client.on("remote_patch", on_change)
21
+
22
+ # Read and write data
23
+ client.add("contexts", {"id": "ctx-1", "type": "fill", "description": "Clay layer"})
24
+ client.patch("contexts", "ctx-1", {"description": "Sandy clay"})
25
+ all_contexts = client.query("contexts")
26
+ single = client.query("contexts", "ctx-1")
27
+ full = client.snapshot()
28
+
29
+ client.leave()
30
+ client.close()
31
+ ```
32
+
33
+ ## Events
34
+
35
+ | Event | Payload | Description |
36
+ |-------|---------|-------------|
37
+ | `remote_patch` | `{collection, id, fields, by}` | Remote document update |
38
+ | `remote_delete` | `{collection, id, by}` | Remote document deletion |
39
+ | `remote_add` | `{collection, document, by}` | Remote document addition |
40
+ | `awareness` | `{users: [...]}` | User presence update |
41
+ | `sync_status` | `{state, pending}` | Connection status change |
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "strgraph"
7
+ version = "1.1.0"
8
+ description = "Python client for StratiGraph — Yjs real-time collaboration via Node.js sidecar"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest>=8.0",
17
+ "pytest-asyncio>=0.24",
18
+ ]
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["."]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .client import SyncClient
2
+ from .models import InitConfig, SyncStatus, AwarenessUser, RemoteChange, StateSnapshot
3
+
4
+ __all__ = ["SyncClient", "InitConfig", "SyncStatus", "AwarenessUser", "RemoteChange", "StateSnapshot"]
@@ -0,0 +1,209 @@
1
+ """
2
+ SyncClient — spawns sync-sidecar Node.js binary, communicates via stdio JSON-RPC.
3
+
4
+ Usage:
5
+ client = SyncClient()
6
+ client.init(InitConfig(room_id="my-room"))
7
+ client.patch("contexts", "ctx-1", {"description": "new text"})
8
+ snapshot = client.snapshot()
9
+ client.leave()
10
+ """
11
+
12
+ import json
13
+ import subprocess
14
+ import threading
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from queue import Queue
18
+ from typing import Any, Callable, Optional
19
+
20
+ from .models import AwarenessUser, InitConfig, RemoteChange, StateSnapshot, SyncStatus
21
+
22
+
23
+ class SyncClient:
24
+ """Manages a sync-sidecar subprocess and provides JSON-RPC communication."""
25
+
26
+ def __init__(self, sidecar_path: Optional[str] = None):
27
+ self._proc: Optional[subprocess.Popen] = None
28
+ self._reader_thread: Optional[threading.Thread] = None
29
+ self._running = False
30
+ self._callbacks: dict[str, list[Callable]] = {
31
+ "remote_patch": [],
32
+ "remote_delete": [],
33
+ "remote_add": [],
34
+ "awareness": [],
35
+ "sync_status": [],
36
+ "error": [],
37
+ }
38
+ self._response_queue: Queue = Queue()
39
+ self._sidecar_path = sidecar_path or self._find_sidecar()
40
+
41
+ def _find_sidecar(self) -> str:
42
+ """Locate the sync-sidecar binary, searching common locations."""
43
+ # Check PATH first
44
+ import shutil
45
+ path = shutil.which("sync-sidecar")
46
+ if path:
47
+ return path
48
+
49
+ # Check relative to this package (monorepo development)
50
+ pkg_dir = Path(__file__).resolve().parent.parent.parent
51
+ candidates = [
52
+ pkg_dir / "sync-sidecar" / "bin" / "sync-sidecar",
53
+ pkg_dir / ".." / ".." / "sync-sidecar" / "bin" / "sync-sidecar",
54
+ ]
55
+ for c in candidates:
56
+ if c.exists():
57
+ return str(c)
58
+
59
+ # Check npm global
60
+ npm_path = Path.home() / "node_modules" / ".bin" / "sync-sidecar"
61
+ if npm_path.exists():
62
+ return str(npm_path)
63
+
64
+ raise FileNotFoundError(
65
+ "sync-sidecar binary not found. Install with: cd packages/sync-sidecar && npm link"
66
+ )
67
+
68
+ @property
69
+ def connected(self) -> bool:
70
+ return self._running and self._proc is not None and self._proc.poll() is None
71
+
72
+ def init(self, config: InitConfig, timeout: float = 10.0) -> StateSnapshot:
73
+ """Initialize a collaboration room. Returns the initial state snapshot."""
74
+ self._start_process()
75
+
76
+ params: dict[str, Any] = {
77
+ "roomId": config.room_id,
78
+ "userId": config.user_id,
79
+ "displayName": config.display_name,
80
+ }
81
+ if config.encryption_key:
82
+ params["encryptionKey"] = config.encryption_key
83
+ if config.sync_server:
84
+ params["providers"] = [{"type": "websocket", "url": config.sync_server}]
85
+
86
+ self._send({"method": "init", "params": params})
87
+ response = self._wait_for_response(timeout)
88
+ if response and response.get("type") == "state_snapshot":
89
+ return StateSnapshot(data=response.get("data", {}))
90
+ raise RuntimeError(f"init failed: {response}")
91
+
92
+ def patch(self, collection: str, doc_id: str, fields: dict[str, Any]) -> None:
93
+ """Update specific fields on a document."""
94
+ self._send({
95
+ "method": "patch",
96
+ "params": {"collection": collection, "id": doc_id, "fields": fields},
97
+ })
98
+
99
+ def add(self, collection: str, document: dict[str, Any]) -> None:
100
+ """Add a new document to a collection."""
101
+ self._send({
102
+ "method": "add",
103
+ "params": {"collection": collection, "document": document},
104
+ })
105
+
106
+ def delete(self, collection: str, doc_id: str) -> None:
107
+ """Remove a document from a collection."""
108
+ self._send({
109
+ "method": "delete",
110
+ "params": {"collection": collection, "id": doc_id},
111
+ })
112
+
113
+ def query(self, collection: str, doc_id: Optional[str] = None,
114
+ timeout: float = 5.0) -> dict[str, Any]:
115
+ """Query documents in a collection. Returns the state_snapshot data."""
116
+ params: dict[str, Any] = {"collection": collection}
117
+ if doc_id:
118
+ params["id"] = doc_id
119
+ self._send({"method": "query", "params": params})
120
+ response = self._wait_for_response(timeout)
121
+ if response and response.get("type") == "state_snapshot":
122
+ return response.get("data", {})
123
+ raise RuntimeError(f"query failed: {response}")
124
+
125
+ def snapshot(self, timeout: float = 5.0) -> dict[str, Any]:
126
+ """Get the full state snapshot."""
127
+ self._send({"method": "snapshot"})
128
+ response = self._wait_for_response(timeout)
129
+ if response and response.get("type") == "state_snapshot":
130
+ return response.get("data", {})
131
+ raise RuntimeError(f"snapshot failed: {response}")
132
+
133
+ def leave(self) -> None:
134
+ """Disconnect from the room but keep the subprocess alive."""
135
+ if self.connected:
136
+ self._send({"method": "leave"})
137
+
138
+ def close(self) -> None:
139
+ """Shut down the sidecar process."""
140
+ self._running = False
141
+ if self._proc:
142
+ try:
143
+ self._send({"method": "leave"})
144
+ self._proc.wait(timeout=3)
145
+ except Exception:
146
+ self._proc.kill()
147
+ self._proc = None
148
+
149
+ def on(self, event: str, callback: Callable) -> None:
150
+ """Register a callback for sidecar events.
151
+
152
+ Events: 'remote_patch', 'remote_delete', 'remote_add', 'awareness', 'sync_status', 'error'
153
+ """
154
+ if event in self._callbacks:
155
+ self._callbacks[event].append(callback)
156
+
157
+ # ── Internal ──────────────────────────────────────────────────────────
158
+
159
+ def _start_process(self) -> None:
160
+ if self._proc is not None:
161
+ return
162
+
163
+ self._proc = subprocess.Popen(
164
+ [self._sidecar_path],
165
+ stdin=subprocess.PIPE,
166
+ stdout=subprocess.PIPE,
167
+ stderr=subprocess.PIPE,
168
+ text=True,
169
+ bufsize=1,
170
+ )
171
+ self._running = True
172
+ self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
173
+ self._reader_thread.start()
174
+
175
+ def _send(self, msg: dict[str, Any]) -> None:
176
+ if not self.connected:
177
+ raise RuntimeError("Sidecar not connected")
178
+ line = json.dumps(msg, ensure_ascii=False) + "\n"
179
+ self._proc.stdin.write(line) # type: ignore
180
+ self._proc.stdin.flush() # type: ignore
181
+
182
+ def _reader_loop(self) -> None:
183
+ while self._running and self._proc and self._proc.stdout:
184
+ line = self._proc.stdout.readline()
185
+ if not line:
186
+ break
187
+ try:
188
+ msg = json.loads(line.strip())
189
+ except json.JSONDecodeError:
190
+ continue
191
+
192
+ msg_type = msg.get("type")
193
+ if msg_type == "state_snapshot":
194
+ self._response_queue.put(msg)
195
+ elif msg_type in self._callbacks:
196
+ for cb in self._callbacks[msg_type]:
197
+ cb(msg)
198
+
199
+ def _wait_for_response(self, timeout: float) -> Optional[dict[str, Any]]:
200
+ try:
201
+ return self._response_queue.get(timeout=timeout)
202
+ except Exception:
203
+ return None
204
+
205
+ def __enter__(self):
206
+ return self
207
+
208
+ def __exit__(self, *args):
209
+ self.close()
@@ -0,0 +1,38 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Optional
3
+
4
+
5
+ @dataclass
6
+ class SyncStatus:
7
+ state: str # 'disconnected' | 'connecting' | 'connected' | 'synced'
8
+ pending: int = 0
9
+
10
+
11
+ @dataclass
12
+ class AwarenessUser:
13
+ user_id: str
14
+ name: str
15
+ color: str
16
+
17
+
18
+ @dataclass
19
+ class RemoteChange:
20
+ type: str # 'add' | 'update' | 'delete'
21
+ collection: str
22
+ id: str
23
+ fields: dict[str, Any] = field(default_factory=dict)
24
+ by: str = ""
25
+
26
+
27
+ @dataclass
28
+ class StateSnapshot:
29
+ data: dict[str, Any] = field(default_factory=dict)
30
+
31
+
32
+ @dataclass
33
+ class InitConfig:
34
+ room_id: str
35
+ user_id: str = "python-client"
36
+ display_name: str = "Python Client"
37
+ encryption_key: Optional[str] = None
38
+ sync_server: Optional[str] = None
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: strgraph
3
+ Version: 1.1.0
4
+ Summary: Python client for StratiGraph — Yjs real-time collaboration via Node.js sidecar
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0; extra == "dev"
10
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
11
+
12
+ # stratigraph-sync
13
+
14
+ Python client for `@stratigraph/sync` — real-time multi-user editing powered by Yjs CRDTs.
15
+
16
+ Connects to the `sync-sidecar` Node.js binary over stdio JSON-RPC. Requires Node.js to be installed.
17
+
18
+ ## Quick Start
19
+
20
+ ```python
21
+ from stratigraph_sync import SyncClient, InitConfig
22
+
23
+ client = SyncClient()
24
+ snapshot = client.init(InitConfig(room_id="trench-5-west"))
25
+ print(snapshot.data) # {"contexts": {}, "observations": {}, ...}
26
+
27
+ # Register event handlers
28
+ def on_change(msg):
29
+ print(f"Remote change: {msg}")
30
+
31
+ client.on("remote_patch", on_change)
32
+
33
+ # Read and write data
34
+ client.add("contexts", {"id": "ctx-1", "type": "fill", "description": "Clay layer"})
35
+ client.patch("contexts", "ctx-1", {"description": "Sandy clay"})
36
+ all_contexts = client.query("contexts")
37
+ single = client.query("contexts", "ctx-1")
38
+ full = client.snapshot()
39
+
40
+ client.leave()
41
+ client.close()
42
+ ```
43
+
44
+ ## Events
45
+
46
+ | Event | Payload | Description |
47
+ |-------|---------|-------------|
48
+ | `remote_patch` | `{collection, id, fields, by}` | Remote document update |
49
+ | `remote_delete` | `{collection, id, by}` | Remote document deletion |
50
+ | `remote_add` | `{collection, document, by}` | Remote document addition |
51
+ | `awareness` | `{users: [...]}` | User presence update |
52
+ | `sync_status` | `{state, pending}` | Connection status change |
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ stratigraph_sync/__init__.py
4
+ stratigraph_sync/client.py
5
+ stratigraph_sync/models.py
6
+ strgraph.egg-info/PKG-INFO
7
+ strgraph.egg-info/SOURCES.txt
8
+ strgraph.egg-info/dependency_links.txt
9
+ strgraph.egg-info/requires.txt
10
+ strgraph.egg-info/top_level.txt
11
+ tests/__init__.py
12
+ tests/test_client.py
@@ -0,0 +1,4 @@
1
+
2
+ [dev]
3
+ pytest>=8.0
4
+ pytest-asyncio>=0.24
@@ -0,0 +1,3 @@
1
+ dist
2
+ stratigraph_sync
3
+ tests
File without changes
@@ -0,0 +1,99 @@
1
+ """Integration tests for the SyncClient.
2
+
3
+ These tests require the sync-sidecar binary to be available.
4
+ Run: pytest tests/ -v
5
+ """
6
+
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ from stratigraph_sync import SyncClient, InitConfig
15
+
16
+
17
+ def find_sidecar() -> str:
18
+ """Locate the sync-sidecar binary for testing."""
19
+ # Check common locations
20
+ candidates = [
21
+ Path(__file__).resolve().parent.parent.parent
22
+ / "sync-sidecar" / "bin" / "sync-sidecar",
23
+ Path(__file__).resolve().parent.parent.parent.parent
24
+ / "sync-sidecar" / "bin" / "sync-sidecar",
25
+ ]
26
+ for c in candidates:
27
+ if c.exists():
28
+ return str(c)
29
+ pytest.skip("sync-sidecar binary not found for integration tests")
30
+ return ""
31
+
32
+
33
+ class TestSyncClientIntegration:
34
+ def test_init_creates_room(self):
35
+ sidecar = find_sidecar()
36
+ client = SyncClient(sidecar_path=sidecar)
37
+ try:
38
+ snapshot = client.init(InitConfig(room_id="test-py-room"))
39
+ assert isinstance(snapshot.data, dict)
40
+ assert "contexts" in snapshot.data
41
+ assert "observations" in snapshot.data
42
+ assert snapshot.data["contexts"] == {}
43
+ finally:
44
+ client.close()
45
+
46
+ def test_patch_updates_field(self):
47
+ sidecar = find_sidecar()
48
+ client = SyncClient(sidecar_path=sidecar)
49
+ try:
50
+ client.init(InitConfig(room_id="test-py-patch"))
51
+ client.add("contexts", {"id": "ctx-1", "type": "fill"})
52
+ client.patch("contexts", "ctx-1", {"description": "Clay layer"})
53
+ data = client.query("contexts", "ctx-1")
54
+ ctx = data.get("contexts", {}).get("ctx-1", {})
55
+ assert ctx.get("description") == "Clay layer"
56
+ assert ctx.get("type") == "fill"
57
+ finally:
58
+ client.close()
59
+
60
+ def test_snapshot_returns_all_data(self):
61
+ sidecar = find_sidecar()
62
+ client = SyncClient(sidecar_path=sidecar)
63
+ try:
64
+ client.init(InitConfig(room_id="test-py-snap"))
65
+ client.add("contexts", {"id": "ctx-1", "type": "fill"})
66
+ client.add("observations", {"id": "obs-1", "source": "ctx-1", "target": "ctx-2"})
67
+
68
+ data = client.snapshot()
69
+ assert "contexts" in data
70
+ assert "observations" in data
71
+ assert "ctx-1" in data["contexts"]
72
+ finally:
73
+ client.close()
74
+
75
+ def test_delete_removes_document(self):
76
+ sidecar = find_sidecar()
77
+ client = SyncClient(sidecar_path=sidecar)
78
+ try:
79
+ client.init(InitConfig(room_id="test-py-del"))
80
+ client.add("contexts", {"id": "ctx-1", "type": "fill"})
81
+ query_result = client.query("contexts")
82
+ assert "ctx-1" in query_result.get("contexts", {})
83
+ client.delete("contexts", "ctx-1")
84
+ query_result = client.query("contexts")
85
+ assert "ctx-1" not in query_result.get("contexts", {})
86
+ finally:
87
+ client.close()
88
+
89
+ def test_on_sync_status_callback(self):
90
+ sidecar = find_sidecar()
91
+ client = SyncClient(sidecar_path=sidecar)
92
+ events = []
93
+ client.on("sync_status", lambda m: events.append(m))
94
+ try:
95
+ client.init(InitConfig(room_id="test-py-status"))
96
+ assert len(events) >= 1
97
+ assert events[0].get("state") == "connected"
98
+ finally:
99
+ client.close()