Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/test.freighter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ jobs:
packages: write
with:
module: freighter/integration
tag: ghcr.io/synnaxlabs/freighter-go-integration:latest
tag: ghcr.io/synnaxlabs/freighter-go-integration:${{ github.sha }}

py:
name: Test - Python
Expand All @@ -123,7 +123,7 @@ jobs:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
services:
integration:
image: ghcr.io/synnaxlabs/freighter-go-integration:latest
image: ghcr.io/synnaxlabs/freighter-go-integration:${{ github.sha }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
Expand Down Expand Up @@ -152,7 +152,7 @@ jobs:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
services:
integration:
image: ghcr.io/synnaxlabs/freighter-go-integration:latest
image: ghcr.io/synnaxlabs/freighter-go-integration:${{ github.sha }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion aspen/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ func mergeDefaultOptions(o *options) {
// provide their own transport. Otherwise the eagerly-created pool would
// leak: nothing would reference it and nothing would close it.
if o.transport.Transport == nil {
pool := fgrpc.NewPool("", grpc.WithTransportCredentials(insecure.NewCredentials()))
pool := fgrpc.OpenPool("", grpc.WithTransportCredentials(insecure.NewCredentials()))
o.transport.ownedPool = pool
o.transport.Transport = grpct.New(pool)
}
Expand Down
1 change: 1 addition & 0 deletions client/py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ markers = [
"framer: mark test as a framer test",
"http: mark test as an http test",
"group: mark test as a group test",
"imex: mark test as an imex test",
"frame_codec: mark test as a frame codec test",
"iterator: mark test as a reader test",
"internal: mark test as an internal test",
Expand Down
9 changes: 4 additions & 5 deletions client/py/synnax/framer/codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
import struct

import synnax.channel.payload as channel
from freighter import JSONCodec
from freighter.codec import Codec as FreighterCodec
from synnax.exceptions import ValidationError
from synnax.framer.frame import Frame, FramePayload
from synnax.telem import Alignment, DataType, Series, TimeRange
from x.codec import Codec as XCodec
from x.codec import JSONCodec

ZERO_ALIGNMENTS_FLAG_POS = 5
EQUAL_ALIGNMENTS_FLAG_POS = 4
Expand Down Expand Up @@ -334,13 +334,12 @@ def decode_series(key: channel.Key) -> bool:

LOW_PERF_SPECIAL_CHAR = 254
HIGH_PERF_SPECIAL_CHAR = 255
CONTENT_TYPE = "application/vnd.synnax.frame"


class WSFramerCodec(FreighterCodec):
class WSFramerCodec(XCodec):
def __init__(self, codec: Codec) -> None:
self.codec = codec
self.lower_perf_codec = JSONCodec()

def content_type(self) -> str:
return CONTENT_TYPE
return "application/vnd.synnax.frame"
14 changes: 14 additions & 0 deletions client/py/synnax/imex/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2026 Synnax Labs, Inc.
#
# Use of this software is governed by the Business Source License included in the file
# licenses/BSL.txt.
#
# As of the Change Date specified in that file, in accordance with the Business Source
# License, use of this software will be governed by the Apache License, Version 2.0,
# included in the file licenses/APL.txt.

from synnax.imex.client import Client

__all__ = [
"Client",
]
46 changes: 46 additions & 0 deletions client/py/synnax/imex/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Copyright 2026 Synnax Labs, Inc.
#
# Use of this software is governed by the Business Source License included in the file
# licenses/BSL.txt.
#
# As of the Change Date specified in that file, in accordance with the Business Source
# License, use of this software will be governed by the Apache License, Version 2.0,
# included in the file licenses/APL.txt.


from freighter import FileTransport
from synnax import ontology
from x.fs import FilePath


class Client:
"""Imports and exports resources from and to the Core.

Each call moves exactly one envelope, streamed to or from disk: ``import_`` streams
a file from disk, and ``export`` streams the response straight into a destination
file.
"""

_file_transport: FileTransport

def __init__(self, file_transport: FileTransport) -> None:
self._file_transport = file_transport

def import_(self, source: FilePath) -> ontology.ID:
"""Imports the resource at source and returns its new ontology ID.

:param source: a file path streamed from disk.
:returns: the new resource's ontology ID.
"""
return self._file_transport.upload("/imex/import", source, ontology.ID)

def export(self, id: ontology.ID, dest: FilePath) -> None:
"""Exports the resource identified by id, streaming it into dest.

The response body is streamed straight into dest — the on-disk format is driven
by the destination's extension.

:param id: the ontology ID of the resource to export.
:param dest: a file path to stream the response body into.
"""
self._file_transport.download("/imex/export", id, dest)
3 changes: 3 additions & 0 deletions client/py/synnax/synnax.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
device,
framer,
group,
imex,
ontology,
project,
rack,
Expand Down Expand Up @@ -75,6 +76,7 @@ class Synnax(framer.Client):
arcs: arc.Client
groups: group.Client
views: view.Client
imex: imex.Client
projects: project.Client

_transport: Transport
Expand Down Expand Up @@ -167,6 +169,7 @@ def __init__(
self.racks = rack.Client(client=self._transport.unary)
self.devices = device.Client(client=self._transport.unary)
self.views = view.Client(client=self._transport.unary)
self.imex = imex.Client(file_transport=self._transport.file_transport)
self.projects = project.Client(client=self._transport.unary)
self.tasks = task.Client(
client=self._transport.unary,
Expand Down
12 changes: 8 additions & 4 deletions client/py/synnax/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,24 @@
URL,
AsyncMiddleware,
AsyncWebsocketClient,
FileTransport,
HTTPClient,
JSONCodec,
Middleware,
UnaryClient,
WebsocketClient,
async_instrumentation_middleware,
instrumentation_middleware,
)
from synnax.telem import Size, TimeSpan
from x.codec import JSONCodec


class Transport:
url: URL
stream: WebsocketClient
stream_async: AsyncWebsocketClient
unary: UnaryClient
file_transport: FileTransport
secure: bool

def __init__(
Expand All @@ -53,18 +55,20 @@ def __init__(
"close_timeout": read_timeout.seconds,
}
self.stream = WebsocketClient(**ws_args)
# We need to update these here because the websocket client doesn't support
# the same arguments as the async websocket client.
# We need to update these here because the WebSocket client doesn't support the
# same arguments as the async WebSocket client.
ws_args["ping_interval"] = keep_alive.seconds
ws_args["ping_timeout"] = 180
self.stream_async = AsyncWebsocketClient(**ws_args)
self.unary = HTTPClient(
http = HTTPClient(
url=self.url,
codec=codec,
secure=secure,
timeout=Timeout(connect=open_timeout.seconds, read=read_timeout.seconds),
retries=Retry(total=max_retries),
)
self.unary = http
self.file_transport = http
self.use(instrumentation_middleware(instrumentation))
self.use_async(async_instrumentation_middleware(instrumentation))

Expand Down
85 changes: 85 additions & 0 deletions client/py/tests/test_imex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Copyright 2026 Synnax Labs, Inc.
#
# Use of this software is governed by the Business Source License included in the file
# licenses/BSL.txt.
#
# As of the Change Date specified in that file, in accordance with the Business Source
# License, use of this software will be governed by the Apache License, Version 2.0,
# included in the file licenses/APL.txt.


import json
import uuid
from pathlib import Path

import pytest

import synnax as sy


def _log_envelope_json(name: str) -> str:
"""A minimal valid v1 log envelope, matching core's testdata/import_v1.json."""
return json.dumps(
{
"version": 1,
"type": "log",
"name": name,
"channels": [
{
"channel": 1,
"color": "red",
"notation": "scientific",
"precision": 2,
"alias": "temp",
}
],
"remote_created": False,
"timestamp_precision": 1,
"show_channel_names": True,
"show_receipt_timestamp": False,
}
)


@pytest.mark.imex
class TestImex:
"""Round-trip imex against a live Synnax Core.

Uses the ``log`` resource type.
"""

def test_import_valid(self, client: sy.Synnax, tmp_path: Path) -> None:
"""Path source → streamed upload."""
name = f"imex-path-{uuid.uuid4()}"
path = tmp_path / "in.json"
path.write_text(_log_envelope_json(name))
id = client.imex.import_(path)
assert id.type == "log"
assert uuid.UUID(id.key)

def test_import_invalid(self, client: sy.Synnax, tmp_path: Path) -> None:
"""An envelope with an unrecognized type is rejected."""
path = tmp_path / "in.json"
path.write_text(
json.dumps({"version": 1, "type": "not_a_real_type", "name": "bad"})
)
with pytest.raises(sy.ValidationError):
client.imex.import_(path)

def test_export(self, client: sy.Synnax, tmp_path: Path) -> None:
"""Path dest → streamed download; on-disk content parses back."""
name = f"imex-export-path-{uuid.uuid4()}"
src = tmp_path / "in.json"
src.write_text(_log_envelope_json(name))
id = client.imex.import_(src)
out = tmp_path / "log.json"
client.imex.export(id, out)
parsed = json.loads(out.read_bytes())
assert parsed["name"] == name and parsed["type"] == "log"

def test_export_nonexistent(self, client: sy.Synnax, tmp_path: Path) -> None:
"""Exporting a resource that does not exist raises NotFoundError."""
id = sy.ontology.ID(type="log", key=str(uuid.uuid4()))
out = tmp_path / "log.json"
with pytest.raises(sy.NotFoundError):
client.imex.export(id, out)
3 changes: 3 additions & 0 deletions client/ts/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { device } from "@/device";
import { errorsMiddleware } from "@/errors";
import { framer } from "@/framer";
import { group } from "@/group";
import { imex } from "@/imex";
import { label } from "@/label";
import { lineplot } from "@/lineplot";
import { log } from "@/log";
Expand Down Expand Up @@ -88,6 +89,7 @@ export default class Synnax extends framer.Client {
readonly logs: log.Client;
readonly tables: table.Client;
readonly groups: group.Client;
readonly imex: imex.Client;
static readonly connectivity = connection.Checker;
private readonly transport: Transport;

Expand Down Expand Up @@ -181,6 +183,7 @@ export default class Synnax extends framer.Client {
this.logs = new log.Client(this.transport.unary);
this.tables = new table.Client(this.transport.unary);
this.groups = new group.Client(this.transport.unary);
this.imex = new imex.Client(this.transport.file);
}

get key(): string {
Expand Down
Loading
Loading