Compare commits

...

6 Commits

Author SHA1 Message Date
23d3118e6a keep names consistent 2025-06-13 14:30:50 +01:00
c369f4e3d5 add 0.10.0 to CHANGELOG 2025-06-13 14:19:22 +01:00
129c3f57f2 minor bump 2025-06-13 14:14:16 +01:00
42519ba294 slow down the scene tests in an attempt to avoid rate limiting issues 2025-06-13 14:11:16 +01:00
09a44b2dea add SlobsCliProtocolError for wrapping ProtocolError
handle ProtocolError(s) and reraise as SlobsCliProtocolError. This has the following benefits:
A user friendly error message
A non-zero exit code
2025-06-13 14:10:54 +01:00
f4421b3351 call next(iter()) on excgroup.exceptions to convery intent a little better
(we dont intend to iterate through them, we just want to raise the first one)
2025-06-13 14:04:05 +01:00
11 changed files with 110 additions and 44 deletions

View File

@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
# [0.10.0] - 2025-06-13
### Changed
- scene commands are prone to raise ProtocolErrors if called too quickly in succession. To make these errors a little more user friendly the following changes have been made:
- They print error messages to stderr
- They return exit code 2
# [0.9.0] - 2025-06-12 # [0.9.0] - 2025-06-12
### Added ### Added

View File

@ -1,3 +1,3 @@
"""module for package metadata.""" """module for package metadata."""
__version__ = '0.9.3' __version__ = '0.10.0'

View File

@ -86,8 +86,8 @@ async def mute(ctx: click.Context, source_name: str):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@audio.command() @audio.command()
@ -117,8 +117,8 @@ async def unmute(ctx: click.Context, source_name: str):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@audio.command() @audio.command()
@ -151,8 +151,8 @@ async def toggle(ctx: click.Context, source_name: str):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@audio.command() @audio.command()
@ -182,5 +182,5 @@ async def status(ctx: click.Context, source_name: str):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable

View File

@ -1,5 +1,7 @@
"""module for custom exceptions in Slobs CLI.""" """module for custom exceptions in Slobs CLI."""
import json
import asyncclick as click import asyncclick as click
@ -14,3 +16,31 @@ class SlobsCliError(click.ClickException):
def show(self): def show(self):
"""Display the error message in red.""" """Display the error message in red."""
click.secho(f'Error: {self.message}', fg='red', err=True) click.secho(f'Error: {self.message}', fg='red', err=True)
class SlobsCliProtocolError(SlobsCliError):
"""Converts pyslobs ProtocolError to a SlobsCliProtocolError."""
def __init__(self, message: str):
"""Initialize the SlobsCliProtocolError with a message."""
protocol_message_to_dict = json.loads(
str(message).replace('"', '\\"').replace("'", '"')
)
super().__init__(
protocol_message_to_dict.get('message', 'Unable to parse error message')
)
self.exit_code = 2
self.protocol_code = protocol_message_to_dict.get('code', 'Unknown error code')
def show(self):
"""Display the protocol error message in red."""
match self.protocol_code:
case -32600:
click.secho(
'Oops! Looks like we hit a rate limit for this command. Please try again later.',
fg='red',
err=True,
)
case _:
# Fall back to the base error display for unknown protocol codes
super().show()

View File

@ -38,8 +38,8 @@ async def start(ctx: click.Context):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@record.command() @record.command()
@ -67,8 +67,8 @@ async def stop(ctx: click.Context):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@record.command() @record.command()

View File

@ -37,8 +37,8 @@ async def start(ctx: click.Context):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@replaybuffer.command() @replaybuffer.command()
@ -65,8 +65,8 @@ async def stop(ctx: click.Context):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@replaybuffer.command() @replaybuffer.command()

View File

@ -2,11 +2,11 @@
import asyncclick as click import asyncclick as click
from anyio import create_task_group from anyio import create_task_group
from pyslobs import ScenesService, TransitionsService from pyslobs import ProtocolError, ScenesService, TransitionsService
from terminaltables3 import AsciiTable from terminaltables3 import AsciiTable
from .cli import cli from .cli import cli
from .errors import SlobsCliError from .errors import SlobsCliError, SlobsCliProtocolError
@cli.group() @cli.group()
@ -56,9 +56,14 @@ async def list(ctx: click.Context, id: bool = False):
conn.close() conn.close()
try:
async with create_task_group() as tg: async with create_task_group() as tg:
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* ProtocolError as excgroup:
p_error = next(iter(excgroup.exceptions))
raisable = SlobsCliProtocolError(str(p_error))
raise raisable
@scene.command() @scene.command()
@ -77,9 +82,14 @@ async def current(ctx: click.Context, id: bool = False):
) )
conn.close() conn.close()
try:
async with create_task_group() as tg: async with create_task_group() as tg:
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* ProtocolError as excgroup:
p_error = next(iter(excgroup.exceptions))
raisable = SlobsCliProtocolError(str(p_error))
raise raisable
@scene.command() @scene.command()
@ -138,12 +148,16 @@ async def switch(
break break
else: # If no scene by the given name was found else: # If no scene by the given name was found
conn.close() conn.close()
raise SlobsCliError(f"Scene '{scene_name}' not found.") raise SlobsCliError(f'Scene "{scene_name}" not found.')
try: try:
async with create_task_group() as tg: async with create_task_group() as tg:
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
except* ProtocolError as excgroup:
p_error = next(iter(excgroup.exceptions))
raisable = SlobsCliProtocolError(str(p_error))
raise raisable

View File

@ -88,8 +88,8 @@ async def load(ctx: click.Context, scenecollection_name: str):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@scenecollection.command() @scenecollection.command()
@ -136,8 +136,8 @@ async def delete(ctx: click.Context, scenecollection_name: str):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@scenecollection.command() @scenecollection.command()
@ -169,5 +169,5 @@ async def rename(ctx: click.Context, scenecollection_name: str, new_name: str):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable

View File

@ -37,8 +37,8 @@ async def start(ctx: click.Context):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@stream.command() @stream.command()
@ -65,8 +65,8 @@ async def stop(ctx: click.Context):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@stream.command() @stream.command()

View File

@ -35,8 +35,8 @@ async def enable(ctx: click.Context):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@studiomode.command() @studiomode.command()
@ -61,8 +61,8 @@ async def disable(ctx: click.Context):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable
@studiomode.command() @studiomode.command()
@ -129,5 +129,5 @@ async def force_transition(ctx: click.Context):
tg.start_soon(conn.background_processing) tg.start_soon(conn.background_processing)
tg.start_soon(_run) tg.start_soon(_run)
except* SlobsCliError as excgroup: except* SlobsCliError as excgroup:
for e in excgroup.exceptions: raisable = next(iter(excgroup.exceptions))
raise e raise raisable

View File

@ -1,5 +1,6 @@
"""Test cases for scene commands in slobs_cli.""" """Test cases for scene commands in slobs_cli."""
import anyio
import pytest import pytest
from asyncclick.testing import CliRunner from asyncclick.testing import CliRunner
@ -15,6 +16,7 @@ async def test_scene_list():
assert 'slobs-test-scene-1' in result.output assert 'slobs-test-scene-1' in result.output
assert 'slobs-test-scene-2' in result.output assert 'slobs-test-scene-2' in result.output
assert 'slobs-test-scene-3' in result.output assert 'slobs-test-scene-3' in result.output
await anyio.sleep(0.2) # Avoid rate limiting issues
@pytest.mark.anyio @pytest.mark.anyio
@ -23,7 +25,19 @@ async def test_scene_current():
runner = CliRunner() runner = CliRunner()
result = await runner.invoke(cli, ['scene', 'switch', 'slobs-test-scene-2']) result = await runner.invoke(cli, ['scene', 'switch', 'slobs-test-scene-2'])
assert result.exit_code == 0 assert result.exit_code == 0
await anyio.sleep(0.2) # Avoid rate limiting issues
result = await runner.invoke(cli, ['scene', 'current']) result = await runner.invoke(cli, ['scene', 'current'])
assert result.exit_code == 0 assert result.exit_code == 0
assert 'Current active scene: slobs-test-scene-2' in result.output assert 'Current active scene: slobs-test-scene-2' in result.output
await anyio.sleep(0.2) # Avoid rate limiting issues
@pytest.mark.anyio
async def test_scene_invalid_switch():
"""Test switching to an invalid scene."""
runner = CliRunner()
result = await runner.invoke(cli, ['scene', 'switch', 'invalid-scene'])
assert result.exit_code != 0
assert 'Scene "invalid-scene" not found' in result.output
await anyio.sleep(0.2) # Avoid rate limiting issues