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.
- strgraph-1.1.0/PKG-INFO +52 -0
- strgraph-1.1.0/README.md +41 -0
- strgraph-1.1.0/pyproject.toml +25 -0
- strgraph-1.1.0/setup.cfg +4 -0
- strgraph-1.1.0/stratigraph_sync/__init__.py +4 -0
- strgraph-1.1.0/stratigraph_sync/client.py +209 -0
- strgraph-1.1.0/stratigraph_sync/models.py +38 -0
- strgraph-1.1.0/strgraph.egg-info/PKG-INFO +52 -0
- strgraph-1.1.0/strgraph.egg-info/SOURCES.txt +12 -0
- strgraph-1.1.0/strgraph.egg-info/dependency_links.txt +1 -0
- strgraph-1.1.0/strgraph.egg-info/requires.txt +4 -0
- strgraph-1.1.0/strgraph.egg-info/top_level.txt +3 -0
- strgraph-1.1.0/tests/__init__.py +0 -0
- strgraph-1.1.0/tests/test_client.py +99 -0
strgraph-1.1.0/PKG-INFO
ADDED
|
@@ -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 |
|
strgraph-1.1.0/README.md
ADDED
|
@@ -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"
|
strgraph-1.1.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
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()
|