From de94fea985fa3f3b0f3b779f658336c4bea524f3 Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Tue, 22 Sep 2026 13:42:32 +0200 Subject: [PATCH 1/3] add template recordings to python sdk --- .../api/recordings/create_recording.py | 36 +++- .../models/__init__.py | 6 + .../models/composition_source.py | 21 +-- .../models/create_recording_files_body.py | 93 +++++++++++ .../models/recording.py | 32 +++- .../models/recording_config.py | 33 +++- .../models/template_source.py | 75 +++++++++ .../models/template_source_resolution.py | 49 ++++++ fishjam/api/_fishjam_client.py | 88 ++++++++-- fishjam/recording/__init__.py | 8 + protos | 2 +- templates/openapi/endpoint_module.py.jinja | 156 ++++++++++++++++++ tests/support/env.py | 4 + tests/test_notifier.py | 6 + tests/test_recording_api.py | 93 ++++++++++- 15 files changed, 653 insertions(+), 49 deletions(-) create mode 100644 fishjam/_fishjam_openapi_client/models/create_recording_files_body.py create mode 100644 fishjam/_fishjam_openapi_client/models/template_source.py create mode 100644 fishjam/_fishjam_openapi_client/models/template_source_resolution.py create mode 100644 templates/openapi/endpoint_module.py.jinja diff --git a/fishjam/_fishjam_openapi_client/api/recordings/create_recording.py b/fishjam/_fishjam_openapi_client/api/recordings/create_recording.py index ae3507f..b26ead4 100644 --- a/fishjam/_fishjam_openapi_client/api/recordings/create_recording.py +++ b/fishjam/_fishjam_openapi_client/api/recordings/create_recording.py @@ -5,15 +5,16 @@ from ... import errors from ...client import AuthenticatedClient, Client +from ...models.create_recording_files_body import CreateRecordingFilesBody from ...models.error import Error from ...models.recording_config import RecordingConfig from ...models.recording_details_response import RecordingDetailsResponse -from ...types import Response +from ...types import UNSET, Response, Unset def _get_kwargs( *, - body: RecordingConfig, + body: RecordingConfig | CreateRecordingFilesBody | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -22,9 +23,12 @@ def _get_kwargs( "url": "/recordings", } - _kwargs["json"] = body.to_dict() + if isinstance(body, RecordingConfig): + _kwargs["json"] = body.to_dict() - headers["Content-Type"] = "application/json" + headers["Content-Type"] = "application/json" + if isinstance(body, CreateRecordingFilesBody): + _kwargs["files"] = body.to_multipart() _kwargs["headers"] = headers return _kwargs @@ -78,14 +82,18 @@ def _build_response( def sync_detailed( *, client: AuthenticatedClient, - body: RecordingConfig, + body: RecordingConfig | CreateRecordingFilesBody | Unset = UNSET, ) -> Response[Error | RecordingDetailsResponse]: """Create a recording Create a recording resource. Capturing starts synchronously, so it is returned with status `active`. + A source naming an `outputId` records an output the composition already has and is sent as JSON. A + template source renders its own scene and has to be sent as `multipart/form-data`: the configuration + in a `config` part, the React bundle in a `template` part. Args: body (RecordingConfig): Recording configuration + body (CreateRecordingFilesBody): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -109,14 +117,18 @@ def sync_detailed( def sync( *, client: AuthenticatedClient, - body: RecordingConfig, + body: RecordingConfig | CreateRecordingFilesBody | Unset = UNSET, ) -> Error | RecordingDetailsResponse | None: """Create a recording Create a recording resource. Capturing starts synchronously, so it is returned with status `active`. + A source naming an `outputId` records an output the composition already has and is sent as JSON. A + template source renders its own scene and has to be sent as `multipart/form-data`: the configuration + in a `config` part, the React bundle in a `template` part. Args: body (RecordingConfig): Recording configuration + body (CreateRecordingFilesBody): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -135,14 +147,18 @@ def sync( async def asyncio_detailed( *, client: AuthenticatedClient, - body: RecordingConfig, + body: RecordingConfig | CreateRecordingFilesBody | Unset = UNSET, ) -> Response[Error | RecordingDetailsResponse]: """Create a recording Create a recording resource. Capturing starts synchronously, so it is returned with status `active`. + A source naming an `outputId` records an output the composition already has and is sent as JSON. A + template source renders its own scene and has to be sent as `multipart/form-data`: the configuration + in a `config` part, the React bundle in a `template` part. Args: body (RecordingConfig): Recording configuration + body (CreateRecordingFilesBody): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -164,14 +180,18 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient, - body: RecordingConfig, + body: RecordingConfig | CreateRecordingFilesBody | Unset = UNSET, ) -> Error | RecordingDetailsResponse | None: """Create a recording Create a recording resource. Capturing starts synchronously, so it is returned with status `active`. + A source naming an `outputId` records an output the composition already has and is sent as JSON. A + template source renders its own scene and has to be sent as `multipart/form-data`: the configuration + in a `config` part, the React bundle in a `template` part. Args: body (RecordingConfig): Recording configuration + body (CreateRecordingFilesBody): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/fishjam/_fishjam_openapi_client/models/__init__.py b/fishjam/_fishjam_openapi_client/models/__init__.py index ba6ee03..1861304 100644 --- a/fishjam/_fishjam_openapi_client/models/__init__.py +++ b/fishjam/_fishjam_openapi_client/models/__init__.py @@ -5,6 +5,7 @@ from .audio_sample_rate import AudioSampleRate from .composition_info import CompositionInfo from .composition_source import CompositionSource +from .create_recording_files_body import CreateRecordingFilesBody from .error import Error from .list_recordings_metadata import ListRecordingsMetadata from .moq_access import MoqAccess @@ -52,6 +53,8 @@ from .subscribe_mode import SubscribeMode from .subscribe_tracks_body import SubscribeTracksBody from .subscriptions import Subscriptions +from .template_source import TemplateSource +from .template_source_resolution import TemplateSourceResolution from .track import Track from .track_forwarding import TrackForwarding from .track_forwarding_info import TrackForwardingInfo @@ -69,6 +72,7 @@ "AudioSampleRate", "CompositionInfo", "CompositionSource", + "CreateRecordingFilesBody", "Error", "ListRecordingsMetadata", "MoqAccess", @@ -116,6 +120,8 @@ "SubscribeMode", "SubscribeTracksBody", "Subscriptions", + "TemplateSource", + "TemplateSourceResolution", "Track", "TrackForwarding", "TrackForwardingInfo", diff --git a/fishjam/_fishjam_openapi_client/models/composition_source.py b/fishjam/_fishjam_openapi_client/models/composition_source.py index 2060980..185ff77 100644 --- a/fishjam/_fishjam_openapi_client/models/composition_source.py +++ b/fishjam/_fishjam_openapi_client/models/composition_source.py @@ -4,7 +4,6 @@ from typing import Any, TypeVar from attrs import define as _attrs_define -from attrs import field as _attrs_field from ..types import UNSET, Unset @@ -25,7 +24,6 @@ class CompositionSource: composition_url: str output_id: str scale_ratio: float | Unset = 1.0 - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: composition_url = self.composition_url @@ -35,7 +33,7 @@ def to_dict(self) -> dict[str, Any]: scale_ratio = self.scale_ratio field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) + field_dict.update({ "compositionURL": composition_url, "outputId": output_id, @@ -60,21 +58,4 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: scale_ratio=scale_ratio, ) - composition_source.additional_properties = d return composition_source - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/fishjam/_fishjam_openapi_client/models/create_recording_files_body.py b/fishjam/_fishjam_openapi_client/models/create_recording_files_body.py new file mode 100644 index 0000000..46eaab3 --- /dev/null +++ b/fishjam/_fishjam_openapi_client/models/create_recording_files_body.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import json +from collections.abc import Mapping +from io import BytesIO +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from .. import types +from ..types import File + +if TYPE_CHECKING: + from ..models.recording_config import RecordingConfig + + +T = TypeVar("T", bound="CreateRecordingFilesBody") + + +@_attrs_define +class CreateRecordingFilesBody: + """ + Attributes: + config (RecordingConfig): Recording configuration + template (File): The React template bundle to render, at most 1 MiB + """ + + config: RecordingConfig + template: File + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + config = self.config.to_dict() + + template = self.template.to_tuple() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "config": config, + "template": template, + }) + + return field_dict + + def to_multipart(self) -> types.RequestFiles: + files: types.RequestFiles = [] + + files.append(( + "config", + (None, json.dumps(self.config.to_dict()).encode(), "application/json"), + )) + + files.append(("template", self.template.to_tuple())) + + for prop_name, prop in self.additional_properties.items(): + files.append((prop_name, (None, str(prop).encode(), "text/plain"))) + + return files + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.recording_config import RecordingConfig + + d = dict(src_dict) + config = RecordingConfig.from_dict(d.pop("config")) + + template = File(payload=BytesIO(d.pop("template"))) + + create_recording_files_body = cls( + config=config, + template=template, + ) + + create_recording_files_body.additional_properties = d + return create_recording_files_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fishjam/_fishjam_openapi_client/models/recording.py b/fishjam/_fishjam_openapi_client/models/recording.py index acc983b..da34422 100644 --- a/fishjam/_fishjam_openapi_client/models/recording.py +++ b/fishjam/_fishjam_openapi_client/models/recording.py @@ -13,6 +13,7 @@ from ..models.composition_source import CompositionSource from ..models.recording_file import RecordingFile from ..models.recording_metadata_type_0 import RecordingMetadataType0 + from ..models.template_source import TemplateSource T = TypeVar("T", bound="Recording") @@ -26,7 +27,7 @@ class Recording: files (list[RecordingFile]): Media files of the recording, in playback order. Empty until the recording is `available`. id (str): Assigned recording id - source (CompositionSource): Recording source coming from composition + source (CompositionSource | TemplateSource): The source for the recording status (RecordingStatus): Lifecycle status of a recording metadata (None | RecordingMetadataType0 | Unset): Free-form, user-supplied metadata used to organize and filter recordings @@ -34,12 +35,13 @@ class Recording: files: list[RecordingFile] id: str - source: CompositionSource + source: CompositionSource | TemplateSource status: RecordingStatus metadata: None | RecordingMetadataType0 | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: + from ..models.composition_source import CompositionSource from ..models.recording_metadata_type_0 import RecordingMetadataType0 files = [] @@ -49,7 +51,11 @@ def to_dict(self) -> dict[str, Any]: id = self.id - source = self.source.to_dict() + source: dict[str, Any] + if isinstance(self.source, CompositionSource): + source = self.source.to_dict() + else: + source = self.source.to_dict() status = self.status.value @@ -79,6 +85,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.composition_source import CompositionSource from ..models.recording_file import RecordingFile from ..models.recording_metadata_type_0 import RecordingMetadataType0 + from ..models.template_source import TemplateSource d = dict(src_dict) files = [] @@ -90,7 +97,24 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: id = d.pop("id") - source = CompositionSource.from_dict(d.pop("source")) + def _parse_source(data: object) -> CompositionSource | TemplateSource: + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_recording_source_type_0 = CompositionSource.from_dict( + data + ) + + return componentsschemas_recording_source_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + componentsschemas_recording_source_type_1 = TemplateSource.from_dict(data) + + return componentsschemas_recording_source_type_1 + + source = _parse_source(d.pop("source")) status = RecordingStatus(d.pop("status")) diff --git a/fishjam/_fishjam_openapi_client/models/recording_config.py b/fishjam/_fishjam_openapi_client/models/recording_config.py index 689fcd8..56afdd5 100644 --- a/fishjam/_fishjam_openapi_client/models/recording_config.py +++ b/fishjam/_fishjam_openapi_client/models/recording_config.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from ..models.composition_source import CompositionSource from ..models.recording_config_metadata_type_0 import RecordingConfigMetadataType0 + from ..models.template_source import TemplateSource T = TypeVar("T", bound="RecordingConfig") @@ -20,20 +21,25 @@ class RecordingConfig: """Recording configuration Attributes: - source (CompositionSource): Recording source coming from composition + source (CompositionSource | TemplateSource): The source for the recording metadata (None | RecordingConfigMetadataType0 | Unset): Free-form, user-supplied metadata used to organize and filter recordings """ - source: CompositionSource + source: CompositionSource | TemplateSource metadata: None | RecordingConfigMetadataType0 | Unset = UNSET def to_dict(self) -> dict[str, Any]: + from ..models.composition_source import CompositionSource from ..models.recording_config_metadata_type_0 import ( RecordingConfigMetadataType0, ) - source = self.source.to_dict() + source: dict[str, Any] + if isinstance(self.source, CompositionSource): + source = self.source.to_dict() + else: + source = self.source.to_dict() metadata: dict[str, Any] | None | Unset if isinstance(self.metadata, Unset): @@ -59,9 +65,28 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.recording_config_metadata_type_0 import ( RecordingConfigMetadataType0, ) + from ..models.template_source import TemplateSource d = dict(src_dict) - source = CompositionSource.from_dict(d.pop("source")) + + def _parse_source(data: object) -> CompositionSource | TemplateSource: + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_recording_source_type_0 = CompositionSource.from_dict( + data + ) + + return componentsschemas_recording_source_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + componentsschemas_recording_source_type_1 = TemplateSource.from_dict(data) + + return componentsschemas_recording_source_type_1 + + source = _parse_source(d.pop("source")) def _parse_metadata( data: object, diff --git a/fishjam/_fishjam_openapi_client/models/template_source.py b/fishjam/_fishjam_openapi_client/models/template_source.py new file mode 100644 index 0000000..05c5c18 --- /dev/null +++ b/fishjam/_fishjam_openapi_client/models/template_source.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.template_source_resolution import TemplateSourceResolution + + +T = TypeVar("T", bound="TemplateSource") + + +@_attrs_define +class TemplateSource: + """Recording source that renders its own scene from a template, rather than cloning an existing output of the + composition + + Attributes: + composition_url (str): URL of the composition to record + audio (bool | Unset): Whether the recording captures audio. Defaults to true. Default: True. + resolution (TemplateSourceResolution | Unset): Resolution the scene is rendered at. Defaults to 1280x720. + """ + + composition_url: str + audio: bool | Unset = True + resolution: TemplateSourceResolution | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + composition_url = self.composition_url + + audio = self.audio + + resolution: dict[str, Any] | Unset = UNSET + if not isinstance(self.resolution, Unset): + resolution = self.resolution.to_dict() + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "compositionURL": composition_url, + }) + if audio is not UNSET: + field_dict["audio"] = audio + if resolution is not UNSET: + field_dict["resolution"] = resolution + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.template_source_resolution import TemplateSourceResolution + + d = dict(src_dict) + composition_url = d.pop("compositionURL") + + audio = d.pop("audio", UNSET) + + _resolution = d.pop("resolution", UNSET) + resolution: TemplateSourceResolution | Unset + if isinstance(_resolution, Unset): + resolution = UNSET + else: + resolution = TemplateSourceResolution.from_dict(_resolution) + + template_source = cls( + composition_url=composition_url, + audio=audio, + resolution=resolution, + ) + + return template_source diff --git a/fishjam/_fishjam_openapi_client/models/template_source_resolution.py b/fishjam/_fishjam_openapi_client/models/template_source_resolution.py new file mode 100644 index 0000000..b74c274 --- /dev/null +++ b/fishjam/_fishjam_openapi_client/models/template_source_resolution.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define + +T = TypeVar("T", bound="TemplateSourceResolution") + + +@_attrs_define +class TemplateSourceResolution: + """Resolution the scene is rendered at. Defaults to 1280x720. + + Attributes: + height (int): + width (int): + """ + + height: int + width: int + + def to_dict(self) -> dict[str, Any]: + height = self.height + + width = self.width + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "height": height, + "width": width, + }) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + height = d.pop("height") + + width = d.pop("width") + + template_source_resolution = cls( + height=height, + width=width, + ) + + return template_source_resolution diff --git a/fishjam/api/_fishjam_client.py b/fishjam/api/_fishjam_client.py index fc4e91e..02ebda0 100644 --- a/fishjam/api/_fishjam_client.py +++ b/fishjam/api/_fishjam_client.py @@ -2,6 +2,8 @@ from dataclasses import dataclass, field from http import HTTPStatus +from io import BytesIO +from pathlib import Path from typing import Any, Literal, cast from fishjam._fishjam_openapi_client.api.credentials import ( @@ -57,6 +59,7 @@ AudioSampleRate, CompositionInfo, CompositionSource, + CreateRecordingFilesBody, ListRecordingsMetadata, MoqAccess, MoqAccessConfig, @@ -85,14 +88,16 @@ StreamerToken, SubscribeMode, SubscribeTracksBody, + TemplateSource, TrackForwarding, VideoCodec, ViewerToken, WebRTCMetadata, ) -from fishjam._fishjam_openapi_client.types import UNSET, Unset +from fishjam._fishjam_openapi_client.types import UNSET, File, Unset from fishjam.agent import Agent from fishjam.api._client import Client +from fishjam.composition import FileSource from fishjam.errors import ( InvalidFishjamCredentialsError, ) @@ -555,16 +560,11 @@ def create_recording( metadata: Free-form metadata used to organize and filter recordings. Returns: - Recording: The created recording. + Recording: The started recording details. """ - if metadata is None: - config_metadata = UNSET - else: - config_metadata = RecordingConfigMetadataType0() - for key, value in metadata.items(): - config_metadata.additional_properties[key] = value - - config = RecordingConfig(source=source, metadata=config_metadata) + config = RecordingConfig( + source=source, metadata=self.__parse_recording_metadata(metadata) + ) resp = cast( RecordingDetailsResponse, @@ -573,6 +573,41 @@ def create_recording( return resp.data + def create_template_recording( + self, + source: TemplateSource, + template: FileSource, + metadata: dict[str, Any] | None = None, + ) -> Recording: + """Starts a new recording that renders its own scene from a template. + + The template bundle can weigh at most 1 MiB; only valid template + bundles are accepted. + + Args: + source: The source of the recording. + template: The bundle to render, as bytes or a path to read them from. + metadata: Free-form metadata used to organize and filter recordings. + + Returns: + Recording: The created recording. + """ + config = RecordingConfig( + source=source, metadata=self.__parse_recording_metadata(metadata) + ) + + resp = cast( + RecordingDetailsResponse, + self._request( + recording_create_recording, + body=CreateRecordingFilesBody( + config=config, template=_to_file(template, "template.js") + ), + ), + ) + + return resp.data + def get_recording(self, recording_id: str) -> Recording: """Returns the recording with the given id. @@ -696,6 +731,18 @@ def __flatten_metadata_filter( else: params[param_key] = value + def __parse_recording_metadata( + self, metadata: dict[str, Any] | None + ) -> RecordingConfigMetadataType0 | Unset: + if metadata is None: + return UNSET + + config_metadata = RecordingConfigMetadataType0() + for key, value in metadata.items(): + config_metadata.additional_properties[key] = value + + return config_metadata + def __parse_peer_metadata(self, metadata: dict | None) -> WebRTCMetadata: peer_metadata = WebRTCMetadata() @@ -708,6 +755,27 @@ def __parse_peer_metadata(self, metadata: dict | None) -> WebRTCMetadata: return peer_metadata +def _to_file(source: FileSource, name: str) -> File: + """Read an upload from bytes or from a path. + + The upload is named, so it is sent as a file rather than a plain form field. + Never pass a path taken from untrusted input, since its contents are uploaded. + + Args: + source: The bytes to upload, or a path to read them from. + name: Name to send the upload under, when the source has none of its own. + + Returns: + The upload, as the generated client takes it. + """ + if isinstance(source, bytes): + return File(payload=BytesIO(source), file_name=name) + + path = Path(source) + + return File(payload=BytesIO(path.read_bytes()), file_name=path.name) + + def _to_room(room) -> Room: composition_info = room.composition_info diff --git a/fishjam/recording/__init__.py b/fishjam/recording/__init__.py index c6be474..0e9de0e 100644 --- a/fishjam/recording/__init__.py +++ b/fishjam/recording/__init__.py @@ -2,10 +2,18 @@ CompositionSource, Recording, RecordingStatus, + TemplateSource, + TemplateSourceResolution, ) +RecordingSource = CompositionSource | TemplateSource +"""What a recording captures, one member per source type.""" + __all__ = [ "CompositionSource", "Recording", + "RecordingSource", "RecordingStatus", + "TemplateSource", + "TemplateSourceResolution", ] diff --git a/protos b/protos index da96ef1..5f84ff1 160000 --- a/protos +++ b/protos @@ -1 +1 @@ -Subproject commit da96ef19f67cefd9ebb818d5f9b8c4c5bece74fa +Subproject commit 5f84ff1ebcada392e43fb766dde0622e469076fd diff --git a/templates/openapi/endpoint_module.py.jinja b/templates/openapi/endpoint_module.py.jinja new file mode 100644 index 0000000..a2bb871 --- /dev/null +++ b/templates/openapi/endpoint_module.py.jinja @@ -0,0 +1,156 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET, Unset +from ... import errors + +{% for relative in endpoint.relative_imports | sort %} +{{ relative }} +{% endfor %} + +{% from "endpoint_macros.py.jinja" import header_params, cookie_params, query_params, + arguments, client, kwargs, parse_response, docstring, body_to_kwarg %} + +{% set return_string = endpoint.response_type() %} +{% set parsed_responses = (endpoint.responses | length > 0) and return_string != "Any" %} + +def _get_kwargs( + {{ arguments(endpoint, include_client=False) | indent(4) }} +) -> dict[str, Any]: + {{ header_params(endpoint) | indent(4) }} + + {{ cookie_params(endpoint) | indent(4) }} + + {{ query_params(endpoint) | indent(4) }} + + _kwargs: dict[str, Any] = { + "method": "{{ endpoint.method }}", + {% if endpoint.path_parameters %} + "url": "{{ endpoint.path }}".format( + {%- for parameter in endpoint.path_parameters -%} + {{parameter.python_name}}=quote(str({{parameter.python_name}}), safe=""), + {%- endfor -%} + ), + {% else %} + "url": "{{ endpoint.path }}", + {% endif %} + {% if endpoint.query_parameters %} + "params": params, + {% endif %} + {% if endpoint.cookie_parameters %} + "cookies": cookies, + {% endif %} + } + +{% if endpoint.bodies | length > 1 %} +{% for body in endpoint.bodies %} + if isinstance(body, {{body.prop.get_type_string(no_optional=True) }}): + {{ body_to_kwarg(body) | indent(8) }} + {% if body.content_type != "multipart/form-data" %}{# Need httpx to set the boundary automatically #} + headers["Content-Type"] = "{{ body.content_type }}" + {% endif %} +{% endfor %} +{% elif endpoint.bodies | length == 1 %} +{% set body = endpoint.bodies[0] %} + {{ body_to_kwarg(body) | indent(4) }} + {% if body.content_type != "multipart/form-data" %}{# Need httpx to set the boundary automatically #} + headers["Content-Type"] = "{{ body.content_type }}" + {% endif %} +{% endif %} + +{% if endpoint.header_parameters or endpoint.bodies | length > 0 %} + _kwargs["headers"] = headers +{% endif %} + return _kwargs + +{% if endpoint.responses.default %} + {% set return_type = return_string %} +{% else %} + {% set return_type = return_string + " | None" %} +{% endif %} + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> {{return_type}}: + {% for response in endpoint.responses.patterns %} + {% set code_range = response.status_code.range %} + {% if code_range[0] == code_range[1] %} + if response.status_code == {{ code_range[0] }}: + {% else %} + if {{ code_range[0] }} <= response.status_code <= {{ code_range[1] }}: + {% endif %} + {{ parse_response(parsed_responses, response) | indent(8) }} + {% endfor %} + {% if endpoint.responses.default %} + {{ parse_response(parsed_responses, endpoint.responses.default) | indent(4) }} + {% else %} + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + {% endif %} + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[{{ return_string }}]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + {{ arguments(endpoint) | indent(4) }} +) -> Response[{{ return_string }}]: + {{ docstring(endpoint, return_string, is_detailed=true) | indent(4) }} + + kwargs = _get_kwargs( + {{ kwargs(endpoint, include_client=False) }} + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + +{% if parsed_responses %} +def sync( + {{ arguments(endpoint) | indent(4) }} +) -> {{ return_string }} | None: + {{ docstring(endpoint, return_string, is_detailed=false) | indent(4) }} + + return sync_detailed( + {{ kwargs(endpoint) }} + ).parsed +{% endif %} + +async def asyncio_detailed( + {{ arguments(endpoint) | indent(4) }} +) -> Response[{{ return_string }}]: + {{ docstring(endpoint, return_string, is_detailed=true) | indent(4) }} + + kwargs = _get_kwargs( + {{ kwargs(endpoint, include_client=False) }} + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return _build_response(client=client, response=response) + +{% if parsed_responses %} +async def asyncio( + {{ arguments(endpoint) | indent(4) }} +) -> {{ return_string }} | None: + {{ docstring(endpoint, return_string, is_detailed=false) | indent(4) }} + + return (await asyncio_detailed( + {{ kwargs(endpoint) }} + )).parsed +{% endif %} diff --git a/tests/support/env.py b/tests/support/env.py index b96d138..ebd300e 100644 --- a/tests/support/env.py +++ b/tests/support/env.py @@ -4,3 +4,7 @@ FISHJAM_MANAGEMENT_TOKEN = os.environ["FISHJAM_MANAGEMENT_TOKEN"] WEBHOOK_SERVER_URL = os.getenv("WEBHOOK_SERVER_URL", "http://localhost:5000") WEBHOOK_URL = f"{WEBHOOK_SERVER_URL}/webhook" +# Fishjam must reach the webhook server over the internet (CI exposes it through a +# tunnel and sets WEBHOOK_SERVER_URL); the localhost fallback is only reachable +# from the test process itself. +WEBHOOK_SERVER_PUBLIC = "WEBHOOK_SERVER_URL" in os.environ diff --git a/tests/test_notifier.py b/tests/test_notifier.py index 40a4db3..62c16ee 100644 --- a/tests/test_notifier.py +++ b/tests/test_notifier.py @@ -25,6 +25,7 @@ from tests.support.env import ( FISHJAM_ID, FISHJAM_MANAGEMENT_TOKEN, + WEBHOOK_SERVER_PUBLIC, WEBHOOK_SERVER_URL, WEBHOOK_URL, ) @@ -250,6 +251,11 @@ async def test_batched_webhook_notifications( self.assert_webhook_events(event_checks, event_queue, room.id) def assert_webhook_events(self, event_checks, event_queue, room_id, timeout=60): + if not WEBHOOK_SERVER_PUBLIC: + pytest.skip( + "Fishjam cannot reach the local webhook server; " + "set WEBHOOK_SERVER_URL to a public tunnel URL" + ) deadline = time.monotonic() + timeout received = [] diff --git a/tests/test_recording_api.py b/tests/test_recording_api.py index b48b0c3..83b0398 100644 --- a/tests/test_recording_api.py +++ b/tests/test_recording_api.py @@ -6,13 +6,20 @@ from fishjam import FishjamClient, Recording from fishjam.errors import ( + BadRequestError, InternalServerError, NotFoundError, QuotaExceededError, ServiceUnavailableError, UnauthorizedError, ) -from fishjam.recording import CompositionSource, RecordingStatus +from fishjam.recording import ( + CompositionSource, + RecordingSource, + RecordingStatus, + TemplateSource, + TemplateSourceResolution, +) from tests.support.env import FISHJAM_ID, FISHJAM_MANAGEMENT_TOKEN NONEXISTENT_RECORDING_ID = "515c8b52-168b-4b39-a227-4d6b4f102a56" @@ -31,7 +38,11 @@ def make_composition_source(): ) -def make_recording_json(source: CompositionSource, status: str): +def make_template_source(): + return TemplateSource(composition_url="https://example.com/composition") + + +def make_recording_json(source: RecordingSource, status: str): return { "id": RECORDING_ID, "files": [], @@ -44,6 +55,7 @@ def mock_request(status_code: int, json_body): captured_requests = [] def mock_send(request, **kwargs): + request.read() captured_requests.append(request) return httpx.Response(status_code, json=json_body, request=request) @@ -127,6 +139,83 @@ def test_quota_exceeded(self, recording_api: FishjamClient): recording_api.create_recording(make_composition_source()) +class TestCreateTemplateRecording: + def test_uploads_the_bundle_alongside_the_config( + self, recording_api: FishjamClient + ): + source = make_template_source() + recording_json = make_recording_json(source, "active") + recording_json["metadata"] = {"env": "test"} + captured_requests, request_patch = mock_request(201, {"data": recording_json}) + + with request_patch: + recording = recording_api.create_template_recording( + source, b"bundle", metadata={"env": "test"} + ) + + assert isinstance(recording, Recording) + assert recording.id == RECORDING_ID + assert recording.status == RecordingStatus.ACTIVE + + assert len(captured_requests) == 1 + request = captured_requests[0] + assert request.method == "POST" + assert request.url.path.endswith("/recordings") + assert request.headers["content-type"].startswith( + "multipart/form-data; boundary=" + ) + + content = request.content + assert b'name="config"' in content + assert ( + json.dumps({ + "source": source.to_dict(), + "metadata": {"env": "test"}, + }).encode() + in content + ) + assert b'name="template"; filename=' in content + assert b"bundle" in content + + def test_reads_the_bundle_from_a_path(self, recording_api: FishjamClient, tmp_path): + source = make_template_source() + bundle = tmp_path / "index.js" + bundle.write_bytes(b"bundle-from-a-file") + captured_requests, request_patch = mock_request( + 201, {"data": make_recording_json(source, "active")} + ) + + with request_patch: + recording_api.create_template_recording(source, bundle) + + content = captured_requests[0].content + assert b"bundle-from-a-file" in content + assert b'filename="index.js"' in content + + def test_returns_a_recording_rendering_its_own_scene( + self, recording_api: FishjamClient + ): + source = TemplateSource( + composition_url="https://example.com/composition", + resolution=TemplateSourceResolution(width=1920, height=1080), + audio=False, + ) + recording_json = make_recording_json(source, "available") + _, request_patch = mock_request(200, {"data": recording_json}) + + with request_patch: + recording = recording_api.get_recording(RECORDING_ID) + + assert isinstance(recording, Recording) + assert recording.source == source + + def test_rejected_bundle_raises(self, recording_api: FishjamClient): + _, request_patch = mock_request(400, {"errors": "template bundle is invalid"}) + + with request_patch, pytest.raises(BadRequestError): + recording_api.create_template_recording(make_template_source(), b"bundle") + + class TestGetRecording: def test_returns_recording(self, recording_api: FishjamClient): source = make_composition_source() From fa4592d6c9238ae150a0fa83212389dd4b4d7c11 Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Tue, 22 Sep 2026 14:28:22 +0200 Subject: [PATCH 2/3] remove the unnecessary template --- scripts.py | 36 +++++ templates/openapi/endpoint_module.py.jinja | 156 --------------------- 2 files changed, 36 insertions(+), 156 deletions(-) delete mode 100644 templates/openapi/endpoint_module.py.jinja diff --git a/scripts.py b/scripts.py index 6dabb55..388abf6 100644 --- a/scripts.py +++ b/scripts.py @@ -153,6 +153,42 @@ def _generate_client(file_arg: str, config: str, output_path: str): --output-path={output_path} \ --custom-template-path=templates/openapi" ) + _fix_multi_content_type_endpoints(output_path) + + +def _fix_multi_content_type_endpoints(output_path: str): + """Work around openapi-python-client 0.28 on endpoints taking several content types. + + An endpoint that accepts more than one request content type is generated with a body + annotated `Unset` that the module never imports, and with a `multipart/form-data` + content type set without the boundary httpx computes for the parts it uploads. + + Args: + output_path: Directory the client was generated into. + """ + unset_import = re.compile( + r"^from \.\.\.types import UNSET, Response$", re.MULTILINE + ) + multipart_header = ' headers["Content-Type"] = "multipart/form-data"\n' + fixed = [] + + for path in Path(output_path).rglob("*.py"): + source = path.read_text() + patched = source.replace(f"{multipart_header}\n", "").replace( + multipart_header, "" + ) + + if "| Unset = UNSET" in patched: + patched = unset_import.sub( + "from ...types import UNSET, Response, Unset", patched + ) + + if patched != source: + path.write_text(patched) + fixed.append(str(path)) + + for path in fixed: + print(f"Patched {path}") def update_client(): diff --git a/templates/openapi/endpoint_module.py.jinja b/templates/openapi/endpoint_module.py.jinja deleted file mode 100644 index a2bb871..0000000 --- a/templates/openapi/endpoint_module.py.jinja +++ /dev/null @@ -1,156 +0,0 @@ -from http import HTTPStatus -from typing import Any, cast -from urllib.parse import quote - -import httpx - -from ...client import AuthenticatedClient, Client -from ...types import Response, UNSET, Unset -from ... import errors - -{% for relative in endpoint.relative_imports | sort %} -{{ relative }} -{% endfor %} - -{% from "endpoint_macros.py.jinja" import header_params, cookie_params, query_params, - arguments, client, kwargs, parse_response, docstring, body_to_kwarg %} - -{% set return_string = endpoint.response_type() %} -{% set parsed_responses = (endpoint.responses | length > 0) and return_string != "Any" %} - -def _get_kwargs( - {{ arguments(endpoint, include_client=False) | indent(4) }} -) -> dict[str, Any]: - {{ header_params(endpoint) | indent(4) }} - - {{ cookie_params(endpoint) | indent(4) }} - - {{ query_params(endpoint) | indent(4) }} - - _kwargs: dict[str, Any] = { - "method": "{{ endpoint.method }}", - {% if endpoint.path_parameters %} - "url": "{{ endpoint.path }}".format( - {%- for parameter in endpoint.path_parameters -%} - {{parameter.python_name}}=quote(str({{parameter.python_name}}), safe=""), - {%- endfor -%} - ), - {% else %} - "url": "{{ endpoint.path }}", - {% endif %} - {% if endpoint.query_parameters %} - "params": params, - {% endif %} - {% if endpoint.cookie_parameters %} - "cookies": cookies, - {% endif %} - } - -{% if endpoint.bodies | length > 1 %} -{% for body in endpoint.bodies %} - if isinstance(body, {{body.prop.get_type_string(no_optional=True) }}): - {{ body_to_kwarg(body) | indent(8) }} - {% if body.content_type != "multipart/form-data" %}{# Need httpx to set the boundary automatically #} - headers["Content-Type"] = "{{ body.content_type }}" - {% endif %} -{% endfor %} -{% elif endpoint.bodies | length == 1 %} -{% set body = endpoint.bodies[0] %} - {{ body_to_kwarg(body) | indent(4) }} - {% if body.content_type != "multipart/form-data" %}{# Need httpx to set the boundary automatically #} - headers["Content-Type"] = "{{ body.content_type }}" - {% endif %} -{% endif %} - -{% if endpoint.header_parameters or endpoint.bodies | length > 0 %} - _kwargs["headers"] = headers -{% endif %} - return _kwargs - -{% if endpoint.responses.default %} - {% set return_type = return_string %} -{% else %} - {% set return_type = return_string + " | None" %} -{% endif %} - - -def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> {{return_type}}: - {% for response in endpoint.responses.patterns %} - {% set code_range = response.status_code.range %} - {% if code_range[0] == code_range[1] %} - if response.status_code == {{ code_range[0] }}: - {% else %} - if {{ code_range[0] }} <= response.status_code <= {{ code_range[1] }}: - {% endif %} - {{ parse_response(parsed_responses, response) | indent(8) }} - {% endfor %} - {% if endpoint.responses.default %} - {{ parse_response(parsed_responses, endpoint.responses.default) | indent(4) }} - {% else %} - if client.raise_on_unexpected_status: - raise errors.UnexpectedStatus(response.status_code, response.content) - else: - return None - {% endif %} - - -def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[{{ return_string }}]: - return Response( - status_code=HTTPStatus(response.status_code), - content=response.content, - headers=response.headers, - parsed=_parse_response(client=client, response=response), - ) - - -def sync_detailed( - {{ arguments(endpoint) | indent(4) }} -) -> Response[{{ return_string }}]: - {{ docstring(endpoint, return_string, is_detailed=true) | indent(4) }} - - kwargs = _get_kwargs( - {{ kwargs(endpoint, include_client=False) }} - ) - - response = client.get_httpx_client().request( - **kwargs, - ) - - return _build_response(client=client, response=response) - -{% if parsed_responses %} -def sync( - {{ arguments(endpoint) | indent(4) }} -) -> {{ return_string }} | None: - {{ docstring(endpoint, return_string, is_detailed=false) | indent(4) }} - - return sync_detailed( - {{ kwargs(endpoint) }} - ).parsed -{% endif %} - -async def asyncio_detailed( - {{ arguments(endpoint) | indent(4) }} -) -> Response[{{ return_string }}]: - {{ docstring(endpoint, return_string, is_detailed=true) | indent(4) }} - - kwargs = _get_kwargs( - {{ kwargs(endpoint, include_client=False) }} - ) - - response = await client.get_async_httpx_client().request( - **kwargs - ) - - return _build_response(client=client, response=response) - -{% if parsed_responses %} -async def asyncio( - {{ arguments(endpoint) | indent(4) }} -) -> {{ return_string }} | None: - {{ docstring(endpoint, return_string, is_detailed=false) | indent(4) }} - - return (await asyncio_detailed( - {{ kwargs(endpoint) }} - )).parsed -{% endif %} From 66730a6db2fbcc28f7b555cc8bdaa4564824f5a4 Mon Sep 17 00:00:00 2001 From: Adrian Czerwiec Date: Thu, 24 Sep 2026 10:20:54 +0200 Subject: [PATCH 3/3] deprecate create_recording --- fishjam/api/_fishjam_client.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/fishjam/api/_fishjam_client.py b/fishjam/api/_fishjam_client.py index 02ebda0..99bf00a 100644 --- a/fishjam/api/_fishjam_client.py +++ b/fishjam/api/_fishjam_client.py @@ -5,6 +5,7 @@ from io import BytesIO from pathlib import Path from typing import Any, Literal, cast +from warnings import deprecated from fishjam._fishjam_openapi_client.api.credentials import ( validate_credentials as credentials_validate_credentials, @@ -546,6 +547,7 @@ def create_moq_access( return response + @deprecated("Use `create_composition_recording` instead.") def create_recording( self, source: CompositionSource, @@ -573,6 +575,33 @@ def create_recording( return resp.data + def create_composition_recording( + self, + source: CompositionSource, + metadata: dict[str, Any] | None = None, + ) -> Recording: + """Creates a new recording. + + Capturing starts synchronously, so the returned recording is `active`. + + Args: + source: The source of the recording. + metadata: Free-form metadata used to organize and filter recordings. + + Returns: + Recording: The started recording details. + """ + config = RecordingConfig( + source=source, metadata=self.__parse_recording_metadata(metadata) + ) + + resp = cast( + RecordingDetailsResponse, + self._request(recording_create_recording, body=config), + ) + + return resp.data + def create_template_recording( self, source: TemplateSource,