Metadata-Version: 2.4
Name: loom-client
Version: 0.0.3
Summary: Python WebSocket client for the Loom Genome Browser
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: pydantic>=2.0
Requires-Dist: websockets>=12.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"

# loom-client

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.

## Installation

```bash
pip install loom-client
```

Or install from source:

```bash
git clone https://github.com/riyavsinha/loom-client.git
cd loom-client
pip install -e .
```

Requires Python >= 3.11.

## Quick start

### Option 1: Connect to a Loom instance

Use this when your code initiates the WebSocket connection to a running Loom server.

```python
import asyncio
from loom_client import LoomClient

async def main():
    client = LoomClient("ws://localhost:8080")
    await client.connect()

    # Get current browser state
    state = await client.get_browser_state()
    print(state.locus_string)   # e.g. "chr1:1,000-2,000"
    print(state.zoom_level)     # e.g. ZoomLevel.GENE

    # Navigate to a locus
    await client.navigate(locus="chr17:7565097-7590856")  # TP53

    # Zoom out 2x
    await client.navigate(zoom="out", factor=2.0)

    await client.close()

asyncio.run(main())
```

### Option 2: Use an existing WebSocket connection

Use this when the WebSocket is already established — for example, a frontend-initiated connection handled by your backend, or when bridging through a proxy.

```python
from loom_client import LoomClient

async def handle_connection(websocket):
    """Example: called by your WebSocket server when a client connects."""
    client = LoomClient.from_websocket(websocket)
    client.start_listening()

    state = await client.get_browser_state()
    print(state.locus_string)

    await client.close()
```

## API reference

### Connection

```python
# Option 1: Initiate a new connection
client = LoomClient(url)   # Create client
await client.connect()     # Open WebSocket + start listener

# Option 2: Reuse an existing WebSocket
client = LoomClient.from_websocket(ws)  # Wrap existing connection
client.start_listening()                # Start listener

await client.close()       # Tear down
```

### Commands

All command methods are async and return typed Pydantic models.

#### `get_browser_state(record=None) -> ProjectedState`

Returns the current viewport state including locus, zoom level, loaded tracks, and ROIs.

```python
state = await client.get_browser_state()
for track in state.tracks:
    print(f"{track.name} ({track.type})")
```

#### `navigate(locus=None, zoom=None, factor=None) -> bool`

Move the viewport to a genomic locus or zoom in/out.

```python
await client.navigate(locus="chr1:1000-2000")
await client.navigate(zoom="in", factor=3.0)
```

#### `modify_tracks(actions) -> ModifyTracksResult`

Add, remove, find, or update tracks in a single batch.

```python
from loom_client import AddTrackAction, RemoveTrackAction, TrackSessionConfig, DataSourceConfig, WireTrackSelector

# Add a BigWig track
result = await client.modify_tracks([
    AddTrackAction(config=TrackSessionConfig(
        type="wig",
        name="H3K27ac",
        data_source=DataSourceConfig(
            type="bigwig",
            url="https://example.com/h3k27ac.bw",
        ),
    ))
])

# Remove by name pattern
result = await client.modify_tracks([
    RemoveTrackAction(selector=WireTrackSelector(name_regex="H3K.*"))
])
```

#### `query_features(track_id, summarize=None) -> QueryFeaturesResult`

Query features for a loaded track.

```python
result = await client.query_features("track-id-123", summarize=True)
print(result.feature_count)
```

#### `set_layout(tracks, locus=None) -> SetLayoutResult`

Replace the entire track layout at once.

```python
result = await client.set_layout(
    tracks=[
        TrackSessionConfig(type="ruler"),
        TrackSessionConfig(type="sequence"),
        TrackSessionConfig(
            type="annotation",
            name="GENCODE",
            data_source=DataSourceConfig(type="gencode", genome="hg38"),
        ),
    ],
    locus="chr1:1000-5000",
)
```

#### `export_view(format, width=None) -> str | SessionConfig`

Export the current view as SVG, PNG (returned as strings), or a session config object.

```python
svg = await client.export_view("svg", width=1200)
session = await client.export_view("session")  # returns SessionConfig
```

#### `manage_rois(action) -> ROI result`

Manage regions of interest. The action type determines the operation.

```python
from loom_client import AddROIAction, ROI, ListROIAction, FindROIAtLocusAction

# Add a region of interest
result = await client.manage_rois(AddROIAction(
    roi=ROI(id="roi-1", chr="chr17", start=7565097, end=7590856,
            name="TP53", color="rgba(255,0,0,0.3)"),
    set_name="Genes of interest",
))

# List all ROIs
result = await client.manage_rois(ListROIAction())

# Find ROIs overlapping a locus
result = await client.manage_rois(
    FindROIAtLocusAction(chr="chr17", start=7500000, end=7600000)
)
```

#### `subscribe_events(events) -> SubscribeEventsResult`

Subscribe to server-pushed events. Use `["*"]` for all events or `[]` to unsubscribe.

```python
await client.subscribe_events(["locuschange", "trackadded", "dataloaded"])
```

### Event handling

Register callbacks for browser events:

```python
def on_locus_change(data):
    locus = data["locus"]
    print(f"Moved to {locus['chr']}:{locus['start']}-{locus['end']}")

client.on("locuschange", on_locus_change)

# Wildcard handler receives (event_name, data)
client.on("*", lambda name, data: print(f"Event: {name}"))

# Unregister
client.off("locuschange", on_locus_change)
```

**Available events:** `locuschange`, `trackadded`, `trackremoved`, `dataloaded`, `dataerror`, `rendererror`, `trackclick`, `trackhover`, `trackcontextmenu`, `trackorderchanged`, `roiadded`, `roiremoved`, `roichanged`, `roiclick`, `roicontextmenu`

### Error handling

Server errors raise `CommandException`:

```python
from loom_client import CommandException

try:
    await client.navigate(locus="invalid")
except CommandException as e:
    print(e.code, e.message)
```

## Development

```bash
pip install -e ".[dev]"
pytest
```

## License

MIT
