loom-client 0.0.1__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.
@@ -0,0 +1,149 @@
1
+ """loom_client — Python WebSocket client for the Loom Genome Browser."""
2
+
3
+ from .client import LoomClient
4
+ from .exceptions import CommandException
5
+ from .models import (
6
+ AddROIAction,
7
+ AddROIResult,
8
+ AddTrackAction,
9
+ AnnotationFeatureSummary,
10
+ ClearROIAction,
11
+ ClearROIResult,
12
+ CommandArgs,
13
+ DataSourceConfig,
14
+ ErrorDetail,
15
+ ErrorMessage,
16
+ EventMessage,
17
+ ExportViewArgs,
18
+ FindROIAtLocusAction,
19
+ FindROIAtLocusResult,
20
+ FindTrackAction,
21
+ GetBrowserStateArgs,
22
+ GetVisibleROIAction,
23
+ GetVisibleROIResult,
24
+ InteractionFeatureSummary,
25
+ ListROIAction,
26
+ ListROIResult,
27
+ ListROISetsAction,
28
+ ListROISetsResult,
29
+ Locus,
30
+ LocusChangeData,
31
+ ManageROIsArgs,
32
+ ManageROIsResult,
33
+ ModifyTrackAction,
34
+ ModifyTrackActionResult,
35
+ ModifyTracksArgs,
36
+ ModifyTracksResult,
37
+ NavigateArgs,
38
+ OutboundMessage,
39
+ ProjectedState,
40
+ QueryFeaturesArgs,
41
+ QueryFeaturesResult,
42
+ ROI,
43
+ ROIAction,
44
+ ROIEventData,
45
+ ROIInteractionEventData,
46
+ ROISetConfig,
47
+ RemoveROIAction,
48
+ RemoveROIResult,
49
+ RemoveTrackAction,
50
+ RequestMessage,
51
+ ResponseMessage,
52
+ SessionConfig,
53
+ SetLayoutArgs,
54
+ SetLayoutResult,
55
+ SignalRange,
56
+ SingleTrackEventData,
57
+ SubscribeEventsArgs,
58
+ SubscribeEventsResult,
59
+ TrackErrorData,
60
+ TrackFeatureSummary,
61
+ TrackInteractionData,
62
+ TrackOrderChangedData,
63
+ TrackSessionConfig,
64
+ TrackSummary,
65
+ UpdateMetadataAction,
66
+ UpdateROIAction,
67
+ UpdateROIResult,
68
+ WigFeatureSummary,
69
+ WireTrackSelector,
70
+ ZoomLevel,
71
+ )
72
+
73
+ __all__ = [
74
+ "LoomClient",
75
+ "CommandException",
76
+ # Wire messages
77
+ "RequestMessage",
78
+ "ResponseMessage",
79
+ "ErrorDetail",
80
+ "ErrorMessage",
81
+ "EventMessage",
82
+ "OutboundMessage",
83
+ # Core genomic types
84
+ "Locus",
85
+ "ROI",
86
+ "ROISetConfig",
87
+ # Track session configs
88
+ "DataSourceConfig",
89
+ "TrackSessionConfig",
90
+ "SessionConfig",
91
+ # State projection
92
+ "ZoomLevel",
93
+ "SignalRange",
94
+ "WigFeatureSummary",
95
+ "AnnotationFeatureSummary",
96
+ "InteractionFeatureSummary",
97
+ "TrackFeatureSummary",
98
+ "TrackSummary",
99
+ "ProjectedState",
100
+ # Track selector
101
+ "WireTrackSelector",
102
+ # Command args
103
+ "CommandArgs",
104
+ "GetBrowserStateArgs",
105
+ "NavigateArgs",
106
+ "AddTrackAction",
107
+ "RemoveTrackAction",
108
+ "FindTrackAction",
109
+ "UpdateMetadataAction",
110
+ "ModifyTrackAction",
111
+ "ModifyTracksArgs",
112
+ "QueryFeaturesArgs",
113
+ "SetLayoutArgs",
114
+ "ExportViewArgs",
115
+ "AddROIAction",
116
+ "RemoveROIAction",
117
+ "UpdateROIAction",
118
+ "ClearROIAction",
119
+ "ListROIAction",
120
+ "ListROISetsAction",
121
+ "FindROIAtLocusAction",
122
+ "GetVisibleROIAction",
123
+ "ROIAction",
124
+ "ManageROIsArgs",
125
+ "SubscribeEventsArgs",
126
+ # Command results
127
+ "ModifyTrackActionResult",
128
+ "ModifyTracksResult",
129
+ "QueryFeaturesResult",
130
+ "SetLayoutResult",
131
+ "AddROIResult",
132
+ "RemoveROIResult",
133
+ "UpdateROIResult",
134
+ "ClearROIResult",
135
+ "ListROIResult",
136
+ "ListROISetsResult",
137
+ "FindROIAtLocusResult",
138
+ "GetVisibleROIResult",
139
+ "ManageROIsResult",
140
+ "SubscribeEventsResult",
141
+ # Event data
142
+ "LocusChangeData",
143
+ "SingleTrackEventData",
144
+ "TrackOrderChangedData",
145
+ "TrackErrorData",
146
+ "TrackInteractionData",
147
+ "ROIEventData",
148
+ "ROIInteractionEventData",
149
+ ]
loom_client/client.py ADDED
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import Callable
6
+ from typing import Any, Literal
7
+
8
+ import websockets
9
+
10
+ from .exceptions import CommandException
11
+ from .models import (
12
+ AddTrackAction,
13
+ CommandArgs,
14
+ ErrorDetail,
15
+ ExportViewArgs,
16
+ FindTrackAction,
17
+ GetBrowserStateArgs,
18
+ ManageROIsArgs,
19
+ ModifyTrackAction,
20
+ ModifyTracksArgs,
21
+ ModifyTracksResult,
22
+ NavigateArgs,
23
+ ProjectedState,
24
+ QueryFeaturesArgs,
25
+ QueryFeaturesResult,
26
+ ROIAction,
27
+ RequestMessage,
28
+ SessionConfig,
29
+ SetLayoutArgs,
30
+ SetLayoutResult,
31
+ SubscribeEventsArgs,
32
+ SubscribeEventsResult,
33
+ TrackSessionConfig,
34
+ )
35
+
36
+
37
+ class LoomClient:
38
+ """Async WebSocket client for the Loom Genome Browser."""
39
+
40
+ def __init__(self, url: str) -> None:
41
+ self._url = url
42
+ self._ws = None
43
+ self._pending: dict[str, asyncio.Future] = {}
44
+ self._event_handlers: dict[str, list[Callable]] = {}
45
+ self._listener_task: asyncio.Task | None = None
46
+
47
+ # ── Connection management ─────────────────────────────────────────────────
48
+
49
+ async def connect(self) -> None:
50
+ """Open the WebSocket connection and start the background listener."""
51
+ self._ws = await websockets.connect(self._url)
52
+ self._listener_task = asyncio.create_task(self._listen())
53
+
54
+ async def close(self) -> None:
55
+ """Cancel the listener and close the WebSocket connection."""
56
+ if self._listener_task:
57
+ self._listener_task.cancel()
58
+ if self._ws:
59
+ await self._ws.close()
60
+
61
+ # ── Message routing ───────────────────────────────────────────────────────
62
+
63
+ async def _listen(self) -> None:
64
+ async for raw in self._ws:
65
+ msg = json.loads(raw)
66
+
67
+ if "event" in msg:
68
+ # Unsolicited server-pushed event
69
+ event_name = msg["event"]
70
+ for handler in self._event_handlers.get(event_name, []):
71
+ handler(msg["data"])
72
+ for handler in self._event_handlers.get("*", []):
73
+ handler(event_name, msg["data"])
74
+
75
+ elif "id" in msg:
76
+ future = self._pending.pop(msg["id"], None)
77
+ if future is None:
78
+ continue
79
+ if "error" in msg:
80
+ err = ErrorDetail.model_validate(msg["error"])
81
+ future.set_exception(CommandException(err.code, err.message))
82
+ else:
83
+ future.set_result(msg["result"])
84
+
85
+ # ── Sending commands ──────────────────────────────────────────────────────
86
+
87
+ async def send(self, args: CommandArgs) -> Any:
88
+ """Send a command and await the correlated response."""
89
+ req = RequestMessage(command=args.command)
90
+ payload = args.model_dump(by_alias=True, exclude_none=True)
91
+ payload.pop("command")
92
+ if payload:
93
+ req.args = payload
94
+
95
+ loop = asyncio.get_event_loop()
96
+ future: asyncio.Future = loop.create_future()
97
+ self._pending[req.id] = future
98
+ await self._ws.send(req.model_dump_json())
99
+ return await future
100
+
101
+ # ── Typed command methods ─────────────────────────────────────────────────
102
+
103
+ async def get_browser_state(self, *, record: str | None = None) -> ProjectedState:
104
+ """Return the current projected browser state."""
105
+ result = await self.send(GetBrowserStateArgs(record=record))
106
+ return ProjectedState.model_validate(result)
107
+
108
+ async def navigate(
109
+ self,
110
+ *,
111
+ locus: str | None = None,
112
+ zoom: Literal["in", "out"] | None = None,
113
+ factor: float | None = None,
114
+ ) -> bool:
115
+ """Navigate the viewport to a locus or zoom in/out."""
116
+ return await self.send(NavigateArgs(locus=locus, zoom=zoom, factor=factor))
117
+
118
+ async def modify_tracks(
119
+ self, actions: list[ModifyTrackAction]
120
+ ) -> ModifyTracksResult:
121
+ """Apply one or more track modification actions."""
122
+ result = await self.send(ModifyTracksArgs(actions=actions))
123
+ return ModifyTracksResult.model_validate(result)
124
+
125
+ async def query_features(
126
+ self, track_id: str, *, summarize: bool | None = None
127
+ ) -> QueryFeaturesResult:
128
+ """Query features for a specific track."""
129
+ result = await self.send(
130
+ QueryFeaturesArgs(trackId=track_id, summarize=summarize)
131
+ )
132
+ return QueryFeaturesResult.model_validate(result)
133
+
134
+ async def set_layout(
135
+ self,
136
+ tracks: list[TrackSessionConfig],
137
+ *,
138
+ locus: str | None = None,
139
+ ) -> SetLayoutResult:
140
+ """Replace the current track layout."""
141
+ result = await self.send(SetLayoutArgs(tracks=tracks, locus=locus))
142
+ return SetLayoutResult.model_validate(result)
143
+
144
+ async def export_view(
145
+ self,
146
+ format: Literal["svg", "png", "session"],
147
+ *,
148
+ width: int | None = None,
149
+ ) -> str | SessionConfig:
150
+ """Export the current view as SVG, PNG, or a session config."""
151
+ result = await self.send(ExportViewArgs(format=format, width=width))
152
+ if format == "session":
153
+ return SessionConfig.model_validate(result)
154
+ return result # SVG/PNG as string
155
+
156
+ async def manage_rois(self, action: ROIAction) -> Any:
157
+ """Perform a ROI management action."""
158
+ result = await self.send(ManageROIsArgs(action=action))
159
+ return result
160
+
161
+ async def subscribe_events(self, events: list[str]) -> SubscribeEventsResult:
162
+ """Subscribe to browser events. Use ``["*"]`` for all, ``[]`` to unsubscribe."""
163
+ result = await self.send(SubscribeEventsArgs(events=events))
164
+ return SubscribeEventsResult.model_validate(result)
165
+
166
+ # ── Event subscription ────────────────────────────────────────────────────
167
+
168
+ def on(self, event: str, handler: Callable) -> None:
169
+ """Register an event handler. Use ``'*'`` for all events."""
170
+ self._event_handlers.setdefault(event, []).append(handler)
171
+
172
+ def off(self, event: str, handler: Callable) -> None:
173
+ """Unregister a previously registered event handler."""
174
+ handlers = self._event_handlers.get(event, [])
175
+ if handler in handlers:
176
+ handlers.remove(handler)
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class CommandException(Exception):
5
+ """Raised when the server returns an error response."""
6
+
7
+ def __init__(self, code: str, message: str) -> None:
8
+ self.code = code
9
+ self.message = message
10
+ super().__init__(f"[{code}] {message}")
loom_client/models.py ADDED
@@ -0,0 +1,526 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from enum import Enum
5
+ from typing import Annotated, Any, Literal, Union
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ # ── Wire messages ─────────────────────────────────────────────────────────────
11
+
12
+ class RequestMessage(BaseModel):
13
+ """Client → Server request."""
14
+
15
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
16
+ command: str
17
+ args: dict[str, Any] | None = None
18
+
19
+
20
+ class ResponseMessage(BaseModel):
21
+ """Server → Client success response."""
22
+
23
+ id: str
24
+ result: Any
25
+
26
+
27
+ class ErrorDetail(BaseModel):
28
+ code: str
29
+ message: str
30
+
31
+
32
+ class ErrorMessage(BaseModel):
33
+ """Server → Client error response."""
34
+
35
+ id: str
36
+ error: ErrorDetail
37
+
38
+
39
+ class EventMessage(BaseModel):
40
+ """Server → Client unsolicited event."""
41
+
42
+ event: str
43
+ data: Any
44
+
45
+
46
+ # Discriminated parsing of inbound server messages
47
+ OutboundMessage = Annotated[
48
+ Union[ResponseMessage, ErrorMessage, EventMessage],
49
+ Field(discriminator=None), # manually discriminate on field presence
50
+ ]
51
+
52
+
53
+ # ── Core genomic types ────────────────────────────────────────────────────────
54
+
55
+ class Locus(BaseModel):
56
+ chr: str
57
+ start: int
58
+ end: int
59
+
60
+
61
+ class ROI(BaseModel):
62
+ """Region of Interest."""
63
+
64
+ id: str
65
+ chr: str
66
+ start: int # 0-based
67
+ end: int
68
+ name: str | None = None
69
+ description: str | None = None
70
+ color: str | None = None # CSS color with alpha
71
+
72
+
73
+ class ROISetConfig(BaseModel):
74
+ name: str
75
+ color: str | None = None
76
+ is_user_defined: bool | None = Field(None, alias="isUserDefined")
77
+ features: list[ROI]
78
+
79
+ model_config = {"populate_by_name": True}
80
+
81
+
82
+ # ── Track session configs ─────────────────────────────────────────────────────
83
+
84
+ class DataSourceConfig(BaseModel):
85
+ """Discriminated union — use the `type` field."""
86
+
87
+ type: str
88
+ url: str | None = None
89
+ genome: str | None = None
90
+ track: str | None = None
91
+ experiment_id: str | None = Field(None, alias="experimentId")
92
+ window_function: str | None = Field(None, alias="windowFunction")
93
+ format: str | None = None
94
+ index_url: str | None = Field(None, alias="indexURL")
95
+ indexed: bool | None = None
96
+
97
+ model_config = {"populate_by_name": True}
98
+
99
+
100
+ class TrackSessionConfig(BaseModel):
101
+ """
102
+ Discriminated union on ``type``. Valid types:
103
+ ``'wig'``, ``'annotation'``, ``'ruler'``, ``'sequence'``, ``'interact'``
104
+ """
105
+
106
+ type: str
107
+ id: str | None = None
108
+ name: str | None = None
109
+ order: int | None = None
110
+ metadata: dict[str, str] | None = None
111
+ data_source: DataSourceConfig | None = Field(None, alias="dataSource")
112
+ config: dict[str, Any] | None = None
113
+
114
+ model_config = {"populate_by_name": True}
115
+
116
+
117
+ class SessionConfig(BaseModel):
118
+ version: str
119
+ locus: Locus
120
+ viewport_width: int | None = Field(None, alias="viewportWidth")
121
+ theme: dict[str, Any] | None = None
122
+ genome: dict[str, Any] | None = None
123
+ tracks: list[TrackSessionConfig]
124
+ rois: list[ROISetConfig] | None = None
125
+
126
+ model_config = {"populate_by_name": True}
127
+
128
+
129
+ # ── State projection (get_browser_state response) ─────────────────────────────
130
+
131
+ class ZoomLevel(str, Enum):
132
+ BASE = "base"
133
+ ELEMENT = "element"
134
+ GENE = "gene"
135
+ REGION = "region"
136
+ CHROMOSOME = "chromosome"
137
+ GENOME = "genome"
138
+
139
+
140
+ class SignalRange(BaseModel):
141
+ min: float
142
+ max: float
143
+
144
+
145
+ class WigFeatureSummary(BaseModel):
146
+ feature_count: int = Field(alias="featureCount")
147
+ signal_range: SignalRange = Field(alias="signalRange")
148
+
149
+ model_config = {"populate_by_name": True}
150
+
151
+
152
+ class AnnotationFeatureSummary(BaseModel):
153
+ feature_count: int = Field(alias="featureCount")
154
+ feature_names: list[str] = Field(alias="featureNames")
155
+ packing_depth: int = Field(alias="packingDepth")
156
+
157
+ model_config = {"populate_by_name": True}
158
+
159
+
160
+ class InteractionFeatureSummary(BaseModel):
161
+ feature_count: int = Field(alias="featureCount")
162
+ value_range: SignalRange = Field(alias="valueRange")
163
+
164
+ model_config = {"populate_by_name": True}
165
+
166
+
167
+ TrackFeatureSummary = WigFeatureSummary | AnnotationFeatureSummary | InteractionFeatureSummary
168
+
169
+
170
+ class TrackSummary(BaseModel):
171
+ id: str
172
+ type: str
173
+ name: str | None = None
174
+ metadata: dict[str, str] | None = None
175
+ loading: bool | None = None
176
+ feature_summary: dict[str, Any] | None = Field(None, alias="featureSummary")
177
+
178
+ model_config = {"populate_by_name": True}
179
+
180
+
181
+ class ProjectedState(BaseModel):
182
+ locus: Locus
183
+ locus_string: str = Field(alias="locusString")
184
+ span: int
185
+ zoom_level: ZoomLevel = Field(alias="zoomLevel")
186
+ genome: str | None = None
187
+ tracks: list[TrackSummary]
188
+ rois: list[ROISetConfig]
189
+
190
+ model_config = {"populate_by_name": True}
191
+
192
+
193
+ # ── Track selector ────────────────────────────────────────────────────────────
194
+
195
+ class WireTrackSelector(BaseModel):
196
+ """
197
+ JSON-safe track selector. Fields are AND-combined.
198
+
199
+ - id: exact match on track ID
200
+ - name: case-insensitive substring match
201
+ - name_regex: regex pattern (takes precedence over name), matched case-insensitive
202
+ - type: exact match on track type (``'wig'``, ``'annotation'``, etc.)
203
+ - where: AND semantics on metadata key-value pairs
204
+ """
205
+
206
+ id: str | None = None
207
+ name: str | None = None
208
+ name_regex: str | None = Field(None, alias="nameRegex")
209
+ type: str | None = None
210
+ where: dict[str, str] | None = None
211
+
212
+ model_config = {"populate_by_name": True}
213
+
214
+
215
+ # ── Command argument models ───────────────────────────────────────────────────
216
+
217
+ class GetBrowserStateArgs(BaseModel):
218
+ command: Literal["get_browser_state"] = "get_browser_state"
219
+ record: str | None = None
220
+
221
+
222
+ class NavigateArgs(BaseModel):
223
+ command: Literal["navigate"] = "navigate"
224
+ locus: str | None = None
225
+ zoom: Literal["in", "out"] | None = None
226
+ factor: float | None = None
227
+
228
+
229
+ # ── modify_tracks ─────────────────────────────────────────────────────────────
230
+
231
+ class AddTrackAction(BaseModel):
232
+ action: Literal["add"] = "add"
233
+ config: TrackSessionConfig
234
+
235
+
236
+ class RemoveTrackAction(BaseModel):
237
+ action: Literal["remove"] = "remove"
238
+ selector: WireTrackSelector
239
+
240
+
241
+ class FindTrackAction(BaseModel):
242
+ action: Literal["find"] = "find"
243
+ selector: WireTrackSelector
244
+
245
+
246
+ class UpdateMetadataAction(BaseModel):
247
+ action: Literal["update_metadata"] = "update_metadata"
248
+ selector: WireTrackSelector
249
+ metadata: dict[str, str]
250
+
251
+
252
+ ModifyTrackAction = AddTrackAction | RemoveTrackAction | FindTrackAction | UpdateMetadataAction
253
+
254
+
255
+ class ModifyTracksArgs(BaseModel):
256
+ command: Literal["modify_tracks"] = "modify_tracks"
257
+ actions: list[ModifyTrackAction]
258
+
259
+
260
+ # ── query_features ────────────────────────────────────────────────────────────
261
+
262
+ class QueryFeaturesArgs(BaseModel):
263
+ command: Literal["query_features"] = "query_features"
264
+ track_id: str = Field(alias="trackId")
265
+ summarize: bool | None = None
266
+
267
+ model_config = {"populate_by_name": True}
268
+
269
+
270
+ # ── set_layout ────────────────────────────────────────────────────────────────
271
+
272
+ class SetLayoutArgs(BaseModel):
273
+ command: Literal["set_layout"] = "set_layout"
274
+ tracks: list[TrackSessionConfig]
275
+ locus: str | None = None
276
+
277
+
278
+ # ── export_view ───────────────────────────────────────────────────────────────
279
+
280
+ class ExportViewArgs(BaseModel):
281
+ command: Literal["export_view"] = "export_view"
282
+ format: Literal["svg", "png", "session"]
283
+ width: int | None = None
284
+
285
+
286
+ # ── manage_rois ───────────────────────────────────────────────────────────────
287
+
288
+ class AddROIAction(BaseModel):
289
+ action: Literal["add"] = "add"
290
+ roi: ROI
291
+ set_name: str | None = Field(None, alias="setName")
292
+
293
+ model_config = {"populate_by_name": True}
294
+
295
+
296
+ class RemoveROIAction(BaseModel):
297
+ action: Literal["remove"] = "remove"
298
+ roi_id: str = Field(alias="roiId")
299
+
300
+ model_config = {"populate_by_name": True}
301
+
302
+
303
+ class UpdateROIAction(BaseModel):
304
+ action: Literal["update"] = "update"
305
+ roi_id: str = Field(alias="roiId")
306
+ changes: dict[str, Any]
307
+
308
+ model_config = {"populate_by_name": True}
309
+
310
+
311
+ class ClearROIAction(BaseModel):
312
+ action: Literal["clear"] = "clear"
313
+
314
+
315
+ class ListROIAction(BaseModel):
316
+ action: Literal["list"] = "list"
317
+
318
+
319
+ class ListROISetsAction(BaseModel):
320
+ action: Literal["list_sets"] = "list_sets"
321
+
322
+
323
+ class FindROIAtLocusAction(BaseModel):
324
+ action: Literal["find_at_locus"] = "find_at_locus"
325
+ chr: str
326
+ start: int
327
+ end: int
328
+
329
+
330
+ class GetVisibleROIAction(BaseModel):
331
+ action: Literal["get_visible"] = "get_visible"
332
+
333
+
334
+ ROIAction = (
335
+ AddROIAction
336
+ | RemoveROIAction
337
+ | UpdateROIAction
338
+ | ClearROIAction
339
+ | ListROIAction
340
+ | ListROISetsAction
341
+ | FindROIAtLocusAction
342
+ | GetVisibleROIAction
343
+ )
344
+
345
+
346
+ class ManageROIsArgs(BaseModel):
347
+ command: Literal["manage_rois"] = "manage_rois"
348
+ action: ROIAction
349
+
350
+
351
+ # ── subscribe_events ──────────────────────────────────────────────────────────
352
+
353
+ class SubscribeEventsArgs(BaseModel):
354
+ """Subscribe to browser events. Use ``["*"]`` for all events, ``[]`` to unsubscribe."""
355
+
356
+ command: Literal["subscribe_events"] = "subscribe_events"
357
+ events: list[str]
358
+
359
+
360
+ # ── Union of all commands ─────────────────────────────────────────────────────
361
+
362
+ CommandArgs = (
363
+ GetBrowserStateArgs
364
+ | NavigateArgs
365
+ | ModifyTracksArgs
366
+ | QueryFeaturesArgs
367
+ | SetLayoutArgs
368
+ | ExportViewArgs
369
+ | ManageROIsArgs
370
+ | SubscribeEventsArgs
371
+ )
372
+
373
+
374
+ # ── Command result models ─────────────────────────────────────────────────────
375
+
376
+ class ModifyTrackActionResult(BaseModel):
377
+ action: str
378
+ success: bool
379
+ track_id: str | None = Field(None, alias="trackId")
380
+ tracks: list[TrackSummary] | None = None
381
+ count: int | None = None
382
+ error: str | None = None
383
+
384
+ model_config = {"populate_by_name": True}
385
+
386
+
387
+ class ModifyTracksResult(BaseModel):
388
+ results: list[ModifyTrackActionResult]
389
+
390
+
391
+ class QueryFeaturesResult(BaseModel):
392
+ track_id: str = Field(alias="trackId")
393
+ feature_count: int = Field(alias="featureCount")
394
+ features: list[Any] | None = None
395
+ summary: Any | None = None
396
+
397
+ model_config = {"populate_by_name": True}
398
+
399
+
400
+ class SetLayoutResult(BaseModel):
401
+ added: list[str]
402
+ removed: list[str]
403
+ unchanged: list[str]
404
+
405
+
406
+ # ManageROIs results — discriminated on ``action``
407
+
408
+ class AddROIResult(BaseModel):
409
+ action: Literal["add"] = "add"
410
+ roi: ROI
411
+
412
+
413
+ class RemoveROIResult(BaseModel):
414
+ action: Literal["remove"] = "remove"
415
+ success: bool
416
+
417
+
418
+ class UpdateROIResult(BaseModel):
419
+ action: Literal["update"] = "update"
420
+ roi: ROI | None = None
421
+
422
+
423
+ class ClearROIResult(BaseModel):
424
+ action: Literal["clear"] = "clear"
425
+
426
+
427
+ class ListROIResult(BaseModel):
428
+ action: Literal["list"] = "list"
429
+ rois: list[ROI]
430
+
431
+
432
+ class ListROISetsResult(BaseModel):
433
+ action: Literal["list_sets"] = "list_sets"
434
+ sets: list[ROISetConfig]
435
+
436
+
437
+ class FindROIAtLocusResult(BaseModel):
438
+ action: Literal["find_at_locus"] = "find_at_locus"
439
+ rois: list[ROI]
440
+
441
+
442
+ class GetVisibleROIResult(BaseModel):
443
+ action: Literal["get_visible"] = "get_visible"
444
+ rois: list[ROI]
445
+
446
+
447
+ ManageROIsResult = (
448
+ AddROIResult
449
+ | RemoveROIResult
450
+ | UpdateROIResult
451
+ | ClearROIResult
452
+ | ListROIResult
453
+ | ListROISetsResult
454
+ | FindROIAtLocusResult
455
+ | GetVisibleROIResult
456
+ )
457
+
458
+
459
+ class SubscribeEventsResult(BaseModel):
460
+ subscribed: list[str]
461
+
462
+
463
+ # ── Event data models ─────────────────────────────────────────────────────────
464
+
465
+ class LocusChangeData(BaseModel):
466
+ locus: Locus
467
+
468
+
469
+ class SingleTrackEventData(BaseModel):
470
+ """Used for: trackadded, trackremoved, dataloaded"""
471
+
472
+ track_id: str = Field(alias="trackId")
473
+ track_type: str = Field(alias="trackType")
474
+ track_name: str | None = Field(None, alias="trackName")
475
+
476
+ model_config = {"populate_by_name": True}
477
+
478
+
479
+ class TrackOrderChangedData(BaseModel):
480
+ track_ids: list[str] = Field(alias="trackIds")
481
+
482
+ model_config = {"populate_by_name": True}
483
+
484
+
485
+ class TrackErrorData(BaseModel):
486
+ """Used for: dataerror, rendererror"""
487
+
488
+ track_id: str = Field(alias="trackId")
489
+ track_type: str = Field(alias="trackType")
490
+ track_name: str | None = Field(None, alias="trackName")
491
+ error: str
492
+
493
+ model_config = {"populate_by_name": True}
494
+
495
+
496
+ class TrackInteractionData(BaseModel):
497
+ """Used for: trackclick, trackhover, trackcontextmenu"""
498
+
499
+ track_id: str = Field(alias="trackId")
500
+ track_type: str = Field(alias="trackType")
501
+ track_name: str | None = Field(None, alias="trackName")
502
+ genomic_location: int = Field(alias="genomicLocation")
503
+ feature_count: int = Field(alias="featureCount")
504
+
505
+ model_config = {"populate_by_name": True}
506
+
507
+
508
+ class ROIEventData(BaseModel):
509
+ """Used for: roiadded, roiremoved, roichanged"""
510
+
511
+ roi: ROI
512
+ set_name: str | None = Field(None, alias="setName")
513
+ changes: dict[str, Any] | None = None
514
+
515
+ model_config = {"populate_by_name": True}
516
+
517
+
518
+ class ROIInteractionEventData(BaseModel):
519
+ """Used for: roiclick, roicontextmenu"""
520
+
521
+ roi: ROI
522
+ genomic_location: int = Field(alias="genomicLocation")
523
+ x: float
524
+ y: float
525
+
526
+ model_config = {"populate_by_name": True}
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.4
2
+ Name: loom-client
3
+ Version: 0.0.1
4
+ Summary: Python WebSocket client for the Loom Genome Browser
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pydantic>=2.0
8
+ Requires-Dist: websockets>=12.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=7.0; extra == "dev"
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
12
+
13
+ # loom-client
14
+
15
+ Python WebSocket client for the [Loom Genome Browser](https://github.com/nicholasrq/loom). Provides a fully async, typed interface for programmatically controlling a running Loom instance -- navigating loci, managing tracks and regions of interest, exporting views, and subscribing to browser events.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install loom-client
21
+ ```
22
+
23
+ Or install from source:
24
+
25
+ ```bash
26
+ git clone https://github.com/riyavsinha/loom-client.git
27
+ cd loom-client
28
+ pip install -e .
29
+ ```
30
+
31
+ Requires Python >= 3.11.
32
+
33
+ ## Quick start
34
+
35
+ ```python
36
+ import asyncio
37
+ from loom_client import LoomClient
38
+
39
+ async def main():
40
+ client = LoomClient("ws://localhost:8080")
41
+ await client.connect()
42
+
43
+ # Get current browser state
44
+ state = await client.get_browser_state()
45
+ print(state.locus_string) # e.g. "chr1:1,000-2,000"
46
+ print(state.zoom_level) # e.g. ZoomLevel.GENE
47
+
48
+ # Navigate to a locus
49
+ await client.navigate(locus="chr17:7565097-7590856") # TP53
50
+
51
+ # Zoom out 2x
52
+ await client.navigate(zoom="out", factor=2.0)
53
+
54
+ await client.close()
55
+
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ ## API reference
60
+
61
+ ### Connection
62
+
63
+ ```python
64
+ client = LoomClient(url) # Create client
65
+ await client.connect() # Open WebSocket + start listener
66
+ await client.close() # Tear down
67
+ ```
68
+
69
+ ### Commands
70
+
71
+ All command methods are async and return typed Pydantic models.
72
+
73
+ #### `get_browser_state(record=None) -> ProjectedState`
74
+
75
+ Returns the current viewport state including locus, zoom level, loaded tracks, and ROIs.
76
+
77
+ ```python
78
+ state = await client.get_browser_state()
79
+ for track in state.tracks:
80
+ print(f"{track.name} ({track.type})")
81
+ ```
82
+
83
+ #### `navigate(locus=None, zoom=None, factor=None) -> bool`
84
+
85
+ Move the viewport to a genomic locus or zoom in/out.
86
+
87
+ ```python
88
+ await client.navigate(locus="chr1:1000-2000")
89
+ await client.navigate(zoom="in", factor=3.0)
90
+ ```
91
+
92
+ #### `modify_tracks(actions) -> ModifyTracksResult`
93
+
94
+ Add, remove, find, or update tracks in a single batch.
95
+
96
+ ```python
97
+ from loom_client import AddTrackAction, RemoveTrackAction, TrackSessionConfig, DataSourceConfig, WireTrackSelector
98
+
99
+ # Add a BigWig track
100
+ result = await client.modify_tracks([
101
+ AddTrackAction(config=TrackSessionConfig(
102
+ type="wig",
103
+ name="H3K27ac",
104
+ data_source=DataSourceConfig(
105
+ type="bigwig",
106
+ url="https://example.com/h3k27ac.bw",
107
+ ),
108
+ ))
109
+ ])
110
+
111
+ # Remove by name pattern
112
+ result = await client.modify_tracks([
113
+ RemoveTrackAction(selector=WireTrackSelector(name_regex="H3K.*"))
114
+ ])
115
+ ```
116
+
117
+ #### `query_features(track_id, summarize=None) -> QueryFeaturesResult`
118
+
119
+ Query features for a loaded track.
120
+
121
+ ```python
122
+ result = await client.query_features("track-id-123", summarize=True)
123
+ print(result.feature_count)
124
+ ```
125
+
126
+ #### `set_layout(tracks, locus=None) -> SetLayoutResult`
127
+
128
+ Replace the entire track layout at once.
129
+
130
+ ```python
131
+ result = await client.set_layout(
132
+ tracks=[
133
+ TrackSessionConfig(type="ruler"),
134
+ TrackSessionConfig(type="sequence"),
135
+ TrackSessionConfig(
136
+ type="annotation",
137
+ name="GENCODE",
138
+ data_source=DataSourceConfig(type="gencode", genome="hg38"),
139
+ ),
140
+ ],
141
+ locus="chr1:1000-5000",
142
+ )
143
+ ```
144
+
145
+ #### `export_view(format, width=None) -> str | SessionConfig`
146
+
147
+ Export the current view as SVG, PNG (returned as strings), or a session config object.
148
+
149
+ ```python
150
+ svg = await client.export_view("svg", width=1200)
151
+ session = await client.export_view("session") # returns SessionConfig
152
+ ```
153
+
154
+ #### `manage_rois(action) -> ROI result`
155
+
156
+ Manage regions of interest. The action type determines the operation.
157
+
158
+ ```python
159
+ from loom_client import AddROIAction, ROI, ListROIAction, FindROIAtLocusAction
160
+
161
+ # Add a region of interest
162
+ result = await client.manage_rois(AddROIAction(
163
+ roi=ROI(id="roi-1", chr="chr17", start=7565097, end=7590856,
164
+ name="TP53", color="rgba(255,0,0,0.3)"),
165
+ set_name="Genes of interest",
166
+ ))
167
+
168
+ # List all ROIs
169
+ result = await client.manage_rois(ListROIAction())
170
+
171
+ # Find ROIs overlapping a locus
172
+ result = await client.manage_rois(
173
+ FindROIAtLocusAction(chr="chr17", start=7500000, end=7600000)
174
+ )
175
+ ```
176
+
177
+ #### `subscribe_events(events) -> SubscribeEventsResult`
178
+
179
+ Subscribe to server-pushed events. Use `["*"]` for all events or `[]` to unsubscribe.
180
+
181
+ ```python
182
+ await client.subscribe_events(["locuschange", "trackadded", "dataloaded"])
183
+ ```
184
+
185
+ ### Event handling
186
+
187
+ Register callbacks for browser events:
188
+
189
+ ```python
190
+ def on_locus_change(data):
191
+ locus = data["locus"]
192
+ print(f"Moved to {locus['chr']}:{locus['start']}-{locus['end']}")
193
+
194
+ client.on("locuschange", on_locus_change)
195
+
196
+ # Wildcard handler receives (event_name, data)
197
+ client.on("*", lambda name, data: print(f"Event: {name}"))
198
+
199
+ # Unregister
200
+ client.off("locuschange", on_locus_change)
201
+ ```
202
+
203
+ **Available events:** `locuschange`, `trackadded`, `trackremoved`, `dataloaded`, `dataerror`, `rendererror`, `trackclick`, `trackhover`, `trackcontextmenu`, `trackorderchanged`, `roiadded`, `roiremoved`, `roichanged`, `roiclick`, `roicontextmenu`
204
+
205
+ ### Error handling
206
+
207
+ Server errors raise `CommandException`:
208
+
209
+ ```python
210
+ from loom_client import CommandException
211
+
212
+ try:
213
+ await client.navigate(locus="invalid")
214
+ except CommandException as e:
215
+ print(e.code, e.message)
216
+ ```
217
+
218
+ ## Development
219
+
220
+ ```bash
221
+ pip install -e ".[dev]"
222
+ pytest
223
+ ```
224
+
225
+ ## License
226
+
227
+ MIT
@@ -0,0 +1,8 @@
1
+ loom_client/__init__.py,sha256=uJTC_QMv7UiznSFdS4k5KT0WloBYX-CxYetBiEQs8hY,3330
2
+ loom_client/client.py,sha256=INP8FMcj7F9cTwXFRtRR-PF-nF95dR2n_jlURLMp8cg,6807
3
+ loom_client/exceptions.py,sha256=ArcoUrn1dTG-iYFYa6gfhJk2ZZHdnufjuUZ99f0WSGk,294
4
+ loom_client/models.py,sha256=5Kqt-97ClFOVgitdPG8l901Wn1lZaNUHn3wlEmVGumM,14774
5
+ loom_client-0.0.1.dist-info/METADATA,sha256=h5hyR6J3NQOsqLfn_bYTDhWdnMEFv_Qtti8aRYN7SSE,5673
6
+ loom_client-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ loom_client-0.0.1.dist-info/top_level.txt,sha256=CDXwXEAiCy3zLc_Rsk69qCErhtdh5_BmXRUOEVX2D-g,12
8
+ loom_client-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ loom_client