Compare commits

...

12 Commits

Author SHA1 Message Date
1a1fbf1da1 sort input list by input name
patch bump
2025-06-07 00:24:48 +01:00
fd2baf3350 remove no filter line 2025-06-07 00:06:53 +01:00
5334879ba9 patch bump 2025-06-06 23:27:45 +01:00
77dbe52ae6 upd input list to include new options 2025-06-06 23:27:31 +01:00
1ff610410a use tuples as records to build the tables
add --fempg and --vlc options to filter list

add Muted column to list table
2025-06-06 23:27:16 +01:00
cd7614bfd6 use tuples as records to build the tables 2025-06-06 23:26:33 +01:00
74503f17e0 upd console colouring
patch bump
2025-06-06 22:27:17 +01:00
32bc4277f2 add 0.16.5 to CHANGELOG 2025-06-06 21:09:33 +01:00
21f1b5e1bb add note about disabling console colouring to README 2025-06-06 20:58:28 +01:00
434f8c0e0c add monitor validate function
upd tests to match console colour changes
2025-06-06 20:58:15 +01:00
81518a14ea error messages now have style bold red
error highlights are now yellow

normal highlights are now green

_validate_scene_name_and_item_name renamed to _validate_sources

its now a normal function and not a decorator

it also returns bool instead of raising typer.Exit()

patch bump
2025-06-06 20:55:35 +01:00
ddb92bb317 upd console colouring
error messages now have style `bold red`
error highlights are now yellow

normal highlights are now green

patch bump
2025-06-06 20:53:35 +01:00
24 changed files with 329 additions and 212 deletions

View File

@ -5,6 +5,21 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
# [0.16.5] - 2025-06-06
### Added
- [Disable Colouring](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#disable-colouring) section added to README.
### Changed
- error output:
- now printed in bold red.
- highlights are now yellow
- normal output:
- highlights are now green
- help messages:
- removed a lot of the `[default: None]`, this affects optional flags/arguments without default values.
# [0.16.1] - 2025-06-04
### Added

View File

@ -265,6 +265,8 @@ obsws-cli group status START "test_group"
- --input: Filter by input type.
- --output: Filter by output type.
- --colour: Filter by colour source type.
- --ffmpeg: Filter by ffmpeg source type.
- --vlc: Filter by VLC source type.
```console
obsws-cli input list
@ -602,10 +604,15 @@ obsws-cli projector open --monitor-index=1 "test_group"
obsws-cli screenshot save --width=2560 --height=1440 "Scene" "C:\Users\me\Videos\screenshot.png"
```
## Disable Colouring
If you prefer colourless output you can set the environment variable `NO_COLOR`. See the [rich documentation][rich-doc-envvars]
## License
`obsws-cli` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
[obs-studio]: https://obsproject.com/
[obs-keyids]: https://github.com/obsproject/obs-studio/blob/master/libobs/obs-hotkeys.h
[obs-keyids]: https://github.com/obsproject/obs-studio/blob/master/libobs/obs-hotkeys.h
[rich-doc-envvars]: https://rich.readthedocs.io/en/stable/console.html#environment-variables

View File

@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2025-present onyx-and-iris <code@onyxandiris.online>
#
# SPDX-License-Identifier: MIT
__version__ = "0.16.3"
__version__ = "0.16.8"

View File

@ -34,7 +34,7 @@ for sub_typer in (
app.add_typer(module.app, name=sub_typer)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
def version_callback(value: bool):

View File

@ -12,7 +12,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()
@ -39,23 +39,27 @@ def list_(
resp = ctx.obj.get_source_filter_list(source_name)
except obsws.error.OBSSDKRequestError as e:
if e.code == 600:
err_console.print(f"No source was found by the name of '{source_name}'.")
err_console.print(
f'No source was found by the name of [yellow]{source_name}[/yellow].'
)
raise typer.Exit(1)
else:
raise
if not resp.filters:
out_console.print(f'No filters found for source {source_name}')
out_console.print(f'No filters found for source [yellow]{source_name}[/yellow]')
raise typer.Exit()
table = Table(title=f'Filters for Source: {source_name}', padding=(0, 2))
for column in ('Filter Name', 'Kind', 'Enabled', 'Settings'):
table.add_column(
column,
justify='left' if column in ('Filter Name', 'Kind') else 'center',
style='cyan',
)
columns = [
('Filter Name', 'left', 'cyan'),
('Kind', 'left', 'cyan'),
('Enabled', 'center', None),
('Settings', 'center', 'cyan'),
]
for name, justify, style in columns:
table.add_column(name, justify=justify, style=style if style else None)
for filter in resp.filters:
resp = ctx.obj.get_source_filter_default_settings(filter['filterKind'])
@ -101,12 +105,14 @@ def enable(
"""Enable a filter for a source."""
if _get_filter_enabled(ctx, source_name, filter_name):
err_console.print(
f'Filter {filter_name} is already enabled for source {source_name}'
f'Filter [yellow]{filter_name}[/yellow] is already enabled for source [yellow]{source_name}[/yellow]'
)
raise typer.Exit(1)
ctx.obj.set_source_filter_enabled(source_name, filter_name, enabled=True)
out_console.print(f'Enabled filter {filter_name} for source {source_name}')
out_console.print(
f'Enabled filter [green]{filter_name}[/green] for source [green]{source_name}[/green]'
)
@app.command('disable | off')
@ -128,12 +134,14 @@ def disable(
"""Disable a filter for a source."""
if not _get_filter_enabled(ctx, source_name, filter_name):
err_console.print(
f'Filter {filter_name} is already disabled for source {source_name}'
f'Filter [yellow]{filter_name}[/yellow] is already disabled for source [yellow]{source_name}[/yellow]'
)
raise typer.Exit(1)
ctx.obj.set_source_filter_enabled(source_name, filter_name, enabled=False)
out_console.print(f'Disabled filter {filter_name} for source {source_name}')
out_console.print(
f'Disabled filter [green]{filter_name}[/green] for source [green]{source_name}[/green]'
)
@app.command('toggle | tg')
@ -158,9 +166,13 @@ def toggle(
ctx.obj.set_source_filter_enabled(source_name, filter_name, enabled=new_state)
if new_state:
out_console.print(f'Enabled filter {filter_name} for source {source_name}')
out_console.print(
f'Enabled filter [green]{filter_name}[/green] for source [green]{source_name}[/green]'
)
else:
out_console.print(f'Disabled filter {filter_name} for source {source_name}')
out_console.print(
f'Disabled filter [green]{filter_name}[/green] for source [green]{source_name}[/green]'
)
@app.command('status | ss')
@ -182,6 +194,10 @@ def status(
"""Get the status of a filter for a source."""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name)
if is_enabled:
out_console.print(f'Filter {filter_name} is enabled for source {source_name}')
out_console.print(
f'Filter [green]{filter_name}[/green] is enabled for source [green]{source_name}[/green]'
)
else:
out_console.print(f'Filter {filter_name} is disabled for source {source_name}')
out_console.print(
f'Filter [green]{filter_name}[/green] is disabled for source [green]{source_name}[/green]'
)

View File

@ -12,7 +12,7 @@ from .protocols import DataclassProtocol
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()
@ -52,10 +52,13 @@ def list_(
table = Table(title=f'Groups in Scene: {scene_name}', padding=(0, 2))
for column in ('ID', 'Group Name', 'Enabled'):
table.add_column(
column, justify='left' if column == 'Group Name' else 'center', style='cyan'
)
columns = [
('ID', 'center', 'cyan'),
('Group Name', 'left', 'cyan'),
('Enabled', 'center', None),
]
for column, justify, style in columns:
table.add_column(column, justify=justify, style=style)
for item_id, group_name, is_enabled in groups:
table.add_row(
@ -98,7 +101,9 @@ def show(
resp = ctx.obj.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None:
err_console.print(f"Group '{group_name}' not found in scene {scene_name}.")
err_console.print(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
)
raise typer.Exit(1)
ctx.obj.set_scene_item_enabled(
@ -107,7 +112,7 @@ def show(
enabled=True,
)
out_console.print(f"Group '{group_name}' is now visible.")
out_console.print(f'Group [green]{group_name}[/green] is now visible.')
@app.command('hide | h')
@ -122,12 +127,14 @@ def hide(
):
"""Hide a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
err_console.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None:
err_console.print(f"Group '{group_name}' not found in scene {scene_name}.")
err_console.print(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
)
raise typer.Exit(1)
ctx.obj.set_scene_item_enabled(
@ -136,7 +143,7 @@ def hide(
enabled=False,
)
out_console.print(f"Group '{group_name}' is now hidden.")
out_console.print(f'Group [green]{group_name}[/green] is now hidden.')
@app.command('toggle | tg')
@ -151,12 +158,14 @@ def toggle(
):
"""Toggle a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
err_console.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None:
err_console.print(f"Group '{group_name}' not found in scene {scene_name}.")
err_console.print(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
)
raise typer.Exit(1)
new_state = not group.get('sceneItemEnabled')
@ -167,9 +176,9 @@ def toggle(
)
if new_state:
out_console.print(f"Group '{group_name}' is now visible.")
out_console.print(f'Group [green]{group_name}[/green] is now visible.')
else:
out_console.print(f"Group '{group_name}' is now hidden.")
out_console.print(f'Group [green]{group_name}[/green] is now hidden.')
@app.command('status | ss')
@ -184,12 +193,14 @@ def status(
):
"""Get the status of a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
err_console.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None:
err_console.print(f"Group '{group_name}' not found in scene {scene_name}.")
err_console.print(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
)
raise typer.Exit(1)
enabled = ctx.obj.get_scene_item_enabled(
@ -198,6 +209,6 @@ def status(
)
if enabled.scene_item_enabled:
out_console.print(f"Group '{group_name}' is now visible.")
out_console.print(f'Group [green]{group_name}[/green] is now visible.')
else:
out_console.print(f"Group '{group_name}' is now hidden.")
out_console.print(f'Group [green]{group_name}[/green] is now hidden.')

View File

@ -10,7 +10,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()

View File

@ -11,7 +11,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()
@ -25,6 +25,8 @@ def list_(
input: Annotated[bool, typer.Option(help='Filter by input type.')] = False,
output: Annotated[bool, typer.Option(help='Filter by output type.')] = False,
colour: Annotated[bool, typer.Option(help='Filter by colour source type.')] = False,
ffmpeg: Annotated[bool, typer.Option(help='Filter by ffmpeg source type.')] = False,
vlc: Annotated[bool, typer.Option(help='Filter by VLC source type.')] = False,
):
"""List all inputs."""
resp = ctx.obj.get_input_list()
@ -36,31 +38,50 @@ def list_(
kinds.append('output')
if colour:
kinds.append('color')
if not any([input, output, colour]):
kinds = ['input', 'output', 'color']
if ffmpeg:
kinds.append('ffmpeg')
if vlc:
kinds.append('vlc')
if not any([input, output, colour, ffmpeg, vlc]):
kinds = ['input', 'output', 'color', 'ffmpeg', 'vlc']
inputs = [
(input_.get('inputName'), input_.get('inputKind'))
for input_ in filter(
lambda input_: any(kind in input_.get('inputKind') for kind in kinds),
resp.inputs,
)
]
inputs = sorted(
(
(input_.get('inputName'), input_.get('inputKind'))
for input_ in filter(
lambda input_: any(kind in input_.get('inputKind') for kind in kinds),
resp.inputs,
)
),
key=lambda x: x[0], # Sort by input name
)
if not inputs:
out_console.print('No inputs found.')
raise typer.Exit()
table = Table(title='Inputs', padding=(0, 2))
for column in ('Input Name', 'Kind'):
table.add_column(
column, justify='left' if column == 'Input Name' else 'center', style='cyan'
)
columns = [
('Input Name', 'left', 'cyan'),
('Kind', 'center', 'cyan'),
('Muted', 'center', None),
]
for column, justify, style in columns:
table.add_column(column, justify=justify, style=style)
for input_name, input_kind in inputs:
input_mark = ''
if any(
kind in input_kind
for kind in ['input_capture', 'output_capture', 'ffmpeg', 'vlc']
):
input_muted = ctx.obj.get_input_mute(name=input_name).input_muted
input_mark = ':white_heavy_check_mark:' if input_muted else ':x:'
table.add_row(
input_name,
util.snakecase_to_titlecase(input_kind),
input_mark,
)
out_console.print(table)
@ -75,7 +96,7 @@ def mute(
):
"""Mute an input."""
if not validate.input_in_inputs(ctx, input_name):
err_console.print(f"Input '{input_name}' not found.")
err_console.print(f'Input [yellow]{input_name}[/yellow] not found.')
raise typer.Exit(1)
ctx.obj.set_input_mute(
@ -83,7 +104,7 @@ def mute(
muted=True,
)
out_console.print(f"Input '{input_name}' muted.")
out_console.print(f'Input [green]{input_name}[/green] muted.')
@app.command('unmute | um')
@ -96,7 +117,7 @@ def unmute(
):
"""Unmute an input."""
if not validate.input_in_inputs(ctx, input_name):
err_console.print(f"Input '{input_name}' not found.")
err_console.print(f'Input [yellow]{input_name}[/yellow] not found.')
raise typer.Exit(1)
ctx.obj.set_input_mute(
@ -104,7 +125,7 @@ def unmute(
muted=False,
)
out_console.print(f"Input '{input_name}' unmuted.")
out_console.print(f'Input [green]{input_name}[/green] unmuted.')
@app.command('toggle | tg')
@ -117,7 +138,7 @@ def toggle(
):
"""Toggle an input."""
if not validate.input_in_inputs(ctx, input_name):
err_console.print(f"Input '{input_name}' not found.")
err_console.print(f'Input [yellow]{input_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj.get_input_mute(name=input_name)
@ -128,6 +149,11 @@ def toggle(
muted=new_state,
)
out_console.print(
f"Input '{input_name}' {'muted' if new_state else 'unmuted'}.",
)
if new_state:
out_console.print(
f'Input [green]{input_name}[/green] muted.',
)
else:
out_console.print(
f'Input [green]{input_name}[/green] unmuted.',
)

View File

@ -11,7 +11,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()
@ -25,12 +25,12 @@ def list_(ctx: typer.Context):
resp = ctx.obj.get_profile_list()
table = Table(title='Profiles', padding=(0, 2))
for column in ('Profile Name', 'Current'):
table.add_column(
column,
justify='left' if column == 'Profile Name' else 'center',
style='cyan',
)
columns = [
('Profile Name', 'left', 'cyan'),
('Current', 'center', None),
]
for column, justify, style in columns:
table.add_column(column, justify=justify, style=style)
for profile in resp.profiles:
table.add_row(
@ -60,16 +60,18 @@ def switch(
):
"""Switch to a profile."""
if not validate.profile_exists(ctx, profile_name):
err_console.print(f"Profile '{profile_name}' not found.")
err_console.print(f'Profile [yellow]{profile_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj.get_profile_list()
if resp.current_profile_name == profile_name:
err_console.print(f"Profile '{profile_name}' is already the current profile.")
err_console.print(
f'Profile [yellow]{profile_name}[/yellow] is already the current profile.'
)
raise typer.Exit(1)
ctx.obj.set_current_profile(profile_name)
out_console.print(f"Switched to profile '{profile_name}'.")
out_console.print(f'Switched to profile [green]{profile_name}[/green].')
@app.command('create | new')
@ -82,11 +84,11 @@ def create(
):
"""Create a new profile."""
if validate.profile_exists(ctx, profile_name):
err_console.print(f"Profile '{profile_name}' already exists.")
err_console.print(f'Profile [yellow]{profile_name}[/yellow] already exists.')
raise typer.Exit(1)
ctx.obj.create_profile(profile_name)
out_console.print(f"Created profile '{profile_name}'.")
out_console.print(f'Created profile [green]{profile_name}[/green].')
@app.command('remove | rm')
@ -99,8 +101,8 @@ def remove(
):
"""Remove a profile."""
if not validate.profile_exists(ctx, profile_name):
err_console.print(f"Profile '{profile_name}' not found.")
err_console.print(f'Profile [yellow]{profile_name}[/yellow] not found.')
raise typer.Exit(1)
ctx.obj.remove_profile(profile_name)
out_console.print(f"Removed profile '{profile_name}'.")
out_console.print(f'Removed profile [green]{profile_name}[/green].')

View File

@ -10,7 +10,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()
@ -61,11 +61,21 @@ def open(
if not source_name:
source_name = ctx.obj.get_current_program_scene().scene_name
ctx.obj.open_source_projector(
source_name=source_name,
monitor_index=monitor_index,
)
monitors = ctx.obj.get_monitor_list().monitors
for monitor in monitors:
if monitor['monitorIndex'] == monitor_index:
ctx.obj.open_source_projector(
source_name=source_name,
monitor_index=monitor_index,
)
out_console.print(
f'Opened projector for source [bold]{source_name}[/] on monitor [bold]{monitor_index}[/].'
)
out_console.print(
f'Opened projector for source [green]{source_name}[/] on monitor [green]{monitor["monitorName"]}[/].'
)
break
else:
err_console.print(
f'Monitor with index [yellow]{monitor_index}[/yellow] not found.'
)
raise typer.Exit(code=1)

View File

@ -10,7 +10,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()
@ -49,7 +49,9 @@ def stop(ctx: typer.Context):
raise typer.Exit(1)
resp = ctx.obj.stop_record()
out_console.print(f'Recording stopped successfully. Saved to: {resp.output_path}')
out_console.print(
f'Recording stopped successfully. Saved to: [green]{resp.output_path}[/green]'
)
@app.command('toggle | tg')
@ -125,5 +127,5 @@ def directory(
out_console.print(f'Recording directory updated to: {record_directory}')
else:
out_console.print(
f'Recording directory: {ctx.obj.get_record_directory().record_directory}'
f'Recording directory: [green]{ctx.obj.get_record_directory().record_directory}[/green]'
)

View File

@ -7,7 +7,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()

View File

@ -11,7 +11,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()
@ -29,8 +29,12 @@ def list_(ctx: typer.Context):
)
table = Table(title='Scenes', padding=(0, 2))
for column in ('Scene Name', 'UUID'):
table.add_column(column, justify='left', style='cyan')
columns = [
('Scene Name', 'left', 'cyan'),
('UUID', 'left', 'cyan'),
]
for column, justify, style in columns:
table.add_column(column, justify=justify, style=style)
for scene_name, scene_uuid in scenes:
table.add_row(
@ -78,12 +82,12 @@ def switch(
raise typer.Exit(1)
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
err_console.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
if preview:
ctx.obj.set_current_preview_scene(scene_name)
out_console.print(f'Switched to preview scene: {scene_name}')
out_console.print(f'Switched to preview scene: [green]{scene_name}[/green]')
else:
ctx.obj.set_current_program_scene(scene_name)
out_console.print(f'Switched to program scene: {scene_name}')
out_console.print(f'Switched to program scene: [green]{scene_name}[/green]')

View File

@ -11,7 +11,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()
@ -49,7 +49,9 @@ def switch(
):
"""Switch to a scene collection."""
if not validate.scene_collection_in_scene_collections(ctx, scene_collection_name):
err_console.print(f"Scene collection '{scene_collection_name}' not found.")
err_console.print(
f'Scene collection [yellow]{scene_collection_name}[/yellow] not found.'
)
raise typer.Exit(1)
current_scene_collection = (
@ -57,12 +59,14 @@ def switch(
)
if scene_collection_name == current_scene_collection:
err_console.print(
f'Scene collection "{scene_collection_name}" is already active.'
f'Scene collection [yellow]{scene_collection_name}[/yellow] is already active.'
)
raise typer.Exit(1)
ctx.obj.set_current_scene_collection(scene_collection_name)
out_console.print(f"Switched to scene collection '{scene_collection_name}'")
out_console.print(
f'Switched to scene collection [green]{scene_collection_name}[/green].'
)
@app.command('create | new')
@ -74,8 +78,12 @@ def create(
):
"""Create a new scene collection."""
if validate.scene_collection_in_scene_collections(ctx, scene_collection_name):
err_console.print(f"Scene collection '{scene_collection_name}' already exists.")
err_console.print(
f'Scene collection [yellow]{scene_collection_name}[/yellow] already exists.'
)
raise typer.Exit(1)
ctx.obj.create_scene_collection(scene_collection_name)
out_console.print(f'Created scene collection {scene_collection_name}')
out_console.print(
f'Created scene collection [green]{scene_collection_name}[/green].'
)

View File

@ -1,6 +1,5 @@
"""module containing commands for manipulating items in scenes."""
from collections.abc import Callable
from typing import Annotated, Optional
import obsws_python as obsws
@ -13,7 +12,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()
@ -37,7 +36,7 @@ def list_(
scene_name = ctx.obj.get_current_program_scene().scene_name
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
err_console.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name)
@ -55,14 +54,19 @@ def list_(
)
if not items:
out_console.print(f"No items found in scene '{scene_name}'.")
out_console.print(f'No items found in scene [green]{scene_name}[/green].')
raise typer.Exit()
table = Table(title=f'Items in Scene: {scene_name}', padding=(0, 2))
for column in ('Item ID', 'Item Name', 'In Group', 'Enabled'):
table.add_column(
column, justify='left' if column == 'Item Name' else 'center', style='cyan'
)
columns = [
('Item ID', 'center', 'cyan'),
('Item Name', 'left', 'cyan'),
('In Group', 'left', 'cyan'),
('Enabled', 'center', None),
]
# Add columns to the table
for column, justify, style in columns:
table.add_column(column, justify=justify, style=style)
for item_id, item_name, is_group, is_enabled in items:
if is_group:
@ -98,37 +102,31 @@ def list_(
out_console.print(table)
def _validate_scene_name_and_item_name(
func: Callable,
):
def _validate_sources(
ctx: typer.Context,
scene_name: str,
item_name: str,
group: Optional[str] = None,
) -> bool:
"""Validate the scene name and item name."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
return False
def wrapper(
ctx: typer.Context,
scene_name: str,
item_name: str,
group: Optional[str] = None,
):
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
raise typer.Exit(1)
if group:
if not validate.item_in_scene_item_list(ctx, scene_name, group):
err_console.print(
f'Group [yellow]{group}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
)
return False
else:
if not validate.item_in_scene_item_list(ctx, scene_name, item_name):
err_console.print(
f'Item [yellow]{item_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].'
)
return False
if group:
if not validate.item_in_scene_item_list(ctx, scene_name, group):
err_console.print(
f"Parent group '{group}' not found in scene '{scene_name}'."
)
raise typer.Exit(1)
else:
if not validate.item_in_scene_item_list(ctx, scene_name, item_name):
err_console.print(
f"Item '{item_name}' not found in scene '{scene_name}'."
)
raise typer.Exit(1)
return func(ctx, scene_name, item_name, group)
return wrapper
return True
def _get_scene_name_and_item_id(
@ -143,7 +141,9 @@ def _get_scene_name_and_item_id(
scene_item_id = item.get('sceneItemId')
break
else:
err_console.print(f"Item '{item_name}' not found in group '{group}'.")
err_console.print(
f'Item [yellow]{item_name}[/yellow] not found in group [yellow]{group}[/yellow].'
)
raise typer.Exit(1)
else:
try:
@ -151,9 +151,9 @@ def _get_scene_name_and_item_id(
except obsws.error.OBSSDKRequestError as e:
if e.code == 600:
err_console.print(
f"Item '{item_name}' not found in scene '{scene_name}'. Is the item in a group? "
f'Item [yellow]{item_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow]. Is the item in a group? '
'If so use the --group option to specify the parent group. '
'See `obsws-cli sceneitem list` for a list of items in the scene.'
'Use `obsws-cli sceneitem list` for a list of items in the scene.'
)
raise typer.Exit(1)
else:
@ -163,17 +163,23 @@ def _get_scene_name_and_item_id(
return scene_name, scene_item_id
@_validate_scene_name_and_item_name
@app.command('show | sh')
def show(
ctx: typer.Context,
scene_name: Annotated[str, typer.Argument(..., help='Scene name the item is in')],
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the item is in')
],
item_name: Annotated[
str, typer.Argument(..., help='Item name to show in the scene')
str,
typer.Argument(..., show_default=False, help='Item name to show in the scene'),
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Show an item in a scene."""
if not _validate_sources(ctx, scene_name, item_name, group):
raise typer.Exit(1)
old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, group
)
@ -186,27 +192,35 @@ def show(
if group:
out_console.print(
f"Item '{item_name}' in group '{group}' in scene '{scene_name}' has been shown."
f'Item [green]{item_name}[/green] in group [green]{group}[/green] in scene [green]{old_scene_name}[/green] has been shown.'
)
else:
# If not in a parent group, just show the scene name
# This is to avoid confusion with the parent group name
# which is not the same as the scene name
# and is not needed in this case
out_console.print(f"Item '{item_name}' in scene '{scene_name}' has been shown.")
out_console.print(
f'Item [green]{item_name}[/green] in scene [green]{scene_name}[/green] has been shown.'
)
@_validate_scene_name_and_item_name
@app.command('hide | h')
def hide(
ctx: typer.Context,
scene_name: Annotated[str, typer.Argument(..., help='Scene name the item is in')],
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the item is in')
],
item_name: Annotated[
str, typer.Argument(..., help='Item name to hide in the scene')
str,
typer.Argument(..., show_default=False, help='Item name to hide in the scene'),
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Hide an item in a scene."""
if not _validate_sources(ctx, scene_name, item_name, group):
raise typer.Exit(1)
old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, group
)
@ -219,7 +233,7 @@ def hide(
if group:
out_console.print(
f"Item '{item_name}' in group '{group}' in scene '{scene_name}' has been hidden."
f'Item [green]{item_name}[/green] in group [green]{group}[/green] in scene [green]{old_scene_name}[/green] has been hidden.'
)
else:
# If not in a parent group, just show the scene name
@ -227,36 +241,29 @@ def hide(
# which is not the same as the scene name
# and is not needed in this case
out_console.print(
f"Item '{item_name}' in scene '{scene_name}' has been hidden."
f'Item [green]{item_name}[/green] in scene [green]{scene_name}[/green] has been hidden.'
)
@_validate_scene_name_and_item_name
@app.command('toggle | tg')
def toggle(
ctx: typer.Context,
scene_name: Annotated[str, typer.Argument(..., help='Scene name the item is in')],
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the item is in')
],
item_name: Annotated[
str, typer.Argument(..., help='Item name to toggle in the scene')
str,
typer.Argument(
..., show_default=False, help='Item name to toggle in the scene'
),
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Toggle an item in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
err_console.print(f"Scene '{scene_name}' not found.")
if not _validate_sources(ctx, scene_name, item_name, group):
raise typer.Exit(1)
if group:
if not validate.item_in_scene_item_list(ctx, scene_name, group):
err_console.print(
f"Parent group '{group}' not found in scene '{scene_name}'."
)
raise typer.Exit(1)
else:
if not validate.item_in_scene_item_list(ctx, scene_name, item_name):
err_console.print(f"Item '{item_name}' not found in scene '{scene_name}'.")
raise typer.Exit(1)
old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, group
)
@ -276,11 +283,11 @@ def toggle(
if group:
if new_state:
out_console.print(
f"Item '{item_name}' in group '{group}' in scene '{scene_name}' has been shown."
f'Item [green]{item_name}[/green] in group [green]{group}[/green] in scene [green]{old_scene_name}[/green] has been shown.'
)
else:
out_console.print(
f"Item '{item_name}' in group '{group}' in scene '{scene_name}' has been hidden."
f'Item [green]{item_name}[/green] in group [green]{group}[/green] in scene [green]{old_scene_name}[/green] has been hidden.'
)
else:
# If not in a parent group, just show the scene name
@ -289,35 +296,31 @@ def toggle(
# and is not needed in this case
if new_state:
out_console.print(
f"Item '{item_name}' in scene '{scene_name}' has been shown."
f'Item [green]{item_name}[/green] in scene [green]{scene_name}[/green] has been shown.'
)
else:
out_console.print(
f"Item '{item_name}' in scene '{scene_name}' has been hidden."
f'Item [green]{item_name}[/green] in scene [green]{scene_name}[/green] has been hidden.'
)
@_validate_scene_name_and_item_name
@app.command('visible | v')
def visible(
ctx: typer.Context,
scene_name: Annotated[str, typer.Argument(..., help='Scene name the item is in')],
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the item is in')
],
item_name: Annotated[
str, typer.Argument(..., help='Item name to check visibility in the scene')
str,
typer.Argument(
..., show_default=False, help='Item name to check visibility in the scene'
),
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Check if an item in a scene is visible."""
if group:
if not validate.item_in_scene_item_list(ctx, scene_name, group):
err_console.print(
f"Parent group '{group}' not found in scene '{scene_name}'."
)
raise typer.Exit(1)
else:
if not validate.item_in_scene_item_list(ctx, scene_name, item_name):
err_console.print(f"Item '{item_name}' not found in scene '{scene_name}'.")
raise typer.Exit(1)
if not _validate_sources(ctx, scene_name, item_name, group):
raise typer.Exit(1)
old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id(
@ -331,7 +334,7 @@ def visible(
if group:
out_console.print(
f"Item '{item_name}' in group '{group}' in scene '{old_scene_name}' is currently {'visible' if enabled.scene_item_enabled else 'hidden'}."
f'Item [green]{item_name}[/green] in group [green]{group}[/green] in scene [green]{old_scene_name}[/green] is currently {"visible" if enabled.scene_item_enabled else "hidden"}.'
)
else:
# If not in a parent group, just show the scene name
@ -339,16 +342,22 @@ def visible(
# which is not the same as the scene name
# and is not needed in this case
out_console.print(
f"Item '{item_name}' in scene '{scene_name}' is currently {'visible' if enabled.scene_item_enabled else 'hidden'}."
f'Item [green]{item_name}[/green] in scene [green]{scene_name}[/green] is currently {"visible" if enabled.scene_item_enabled else "hidden"}.'
)
@_validate_scene_name_and_item_name
@app.command('transform | t')
def transform(
ctx: typer.Context,
scene_name: str,
item_name: str,
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the item is in')
],
item_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='Item name to transform in the scene'
),
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
alignment: Annotated[
Optional[int], typer.Option(help='Alignment of the item in the scene')
@ -397,16 +406,8 @@ def transform(
] = None,
):
"""Set the transform of an item in a scene."""
if group:
if not validate.item_in_scene_item_list(ctx, scene_name, group):
err_console.print(
f"Parent group '{group}' not found in scene '{scene_name}'."
)
raise typer.Exit(1)
else:
if not validate.item_in_scene_item_list(ctx, scene_name, item_name):
err_console.print(f"Item '{item_name}' not found in scene '{scene_name}'.")
raise typer.Exit(1)
if not _validate_sources(ctx, scene_name, item_name, group):
raise typer.Exit(1)
old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id(
@ -457,7 +458,7 @@ def transform(
if group:
out_console.print(
f"Item '{item_name}' in group '{group}' in scene '{old_scene_name}' has been transformed."
f'Item [green]{item_name}[/green] in group [green]{group}[/green] in scene [green]{old_scene_name}[/green] has been transformed.'
)
else:
# If not in a parent group, just show the scene name
@ -465,5 +466,5 @@ def transform(
# which is not the same as the scene name
# and is not needed in this case
out_console.print(
f"Item '{item_name}' in scene '{scene_name}' has been transformed."
f'Item [green]{item_name}[/green] in scene [green]{scene_name}[/green] has been transformed.'
)

View File

@ -79,14 +79,16 @@ def save(
match e.code:
case 403:
err_console.print(
'The image format (file extension) must be included in the file name '
'The [yellow]image format[/yellow] (file extension) must be included in the file name, '
"for example: '/path/to/screenshot.png'.",
)
raise typer.Exit(1)
case 600:
err_console.print(f"No source was found by the name of '{source_name}'")
err_console.print(
f'No source was found by the name of [yellow]{source_name}[/yellow]'
)
raise typer.Exit(1)
case _:
raise
out_console.print(f"Screenshot saved to [bold]'{output_path}'[/bold].")
out_console.print(f'Screenshot saved to [green]{output_path}[/green].')

View File

@ -7,7 +7,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()

View File

@ -7,7 +7,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()

View File

@ -46,3 +46,9 @@ def profile_exists(ctx: typer.Context, profile_name: str) -> bool:
"""Check if a profile exists."""
resp = ctx.obj.get_profile_list()
return any(profile == profile_name for profile in resp.profiles)
def monitor_exists(ctx: typer.Context, monitor_index: int) -> bool:
"""Check if a monitor exists."""
resp = ctx.obj.get_monitor_list()
return any(monitor['monitorIndex'] == monitor_index for monitor in resp.monitors)

View File

@ -7,7 +7,7 @@ from .alias import AliasGroup
app = typer.Typer(cls=AliasGroup)
out_console = Console()
err_console = Console(stderr=True)
err_console = Console(stderr=True, style='bold red')
@app.callback()

View File

@ -27,4 +27,4 @@ def test_filter_list_invalid_source():
"""Test the filter list command with an invalid source."""
result = runner.invoke(app, ['filter', 'list', 'invalid_source'])
assert result.exit_code != 0
assert "No source was found by the name of 'invalid_source'" in result.stderr
assert 'No source was found by the name of invalid_source' in result.stderr

View File

@ -18,29 +18,29 @@ def test_group_show():
"""Test the group show command."""
result = runner.invoke(app, ['group', 'show', 'Scene', 'test_group'])
assert result.exit_code == 0
assert "Group 'test_group' is now visible." in result.stdout
assert 'Group test_group is now visible.' in result.stdout
def test_group_toggle():
"""Test the group toggle command."""
result = runner.invoke(app, ['group', 'status', 'Scene', 'test_group'])
assert result.exit_code == 0
enabled = "Group 'test_group' is now visible." in result.stdout
enabled = 'Group test_group is now visible.' in result.stdout
result = runner.invoke(app, ['group', 'toggle', 'Scene', 'test_group'])
assert result.exit_code == 0
if enabled:
assert "Group 'test_group' is now hidden." in result.stdout
assert 'Group test_group is now hidden.' in result.stdout
else:
assert "Group 'test_group' is now visible." in result.stdout
assert 'Group test_group is now visible.' in result.stdout
def test_group_status():
"""Test the group status command."""
result = runner.invoke(app, ['group', 'show', 'Scene', 'test_group'])
assert result.exit_code == 0
assert "Group 'test_group' is now visible." in result.stdout
assert 'Group test_group is now visible.' in result.stdout
result = runner.invoke(app, ['group', 'status', 'Scene', 'test_group'])
assert result.exit_code == 0
assert "Group 'test_group' is now visible." in result.stdout
assert 'Group test_group is now visible.' in result.stdout

View File

@ -36,3 +36,10 @@ def test_scene_switch():
result = runner.invoke(app, ['scene', 'switch', 'pytest_scene'])
assert result.exit_code == 0
assert 'Switched to program scene: pytest_scene' in result.stdout
def test_scene_switch_invalid():
"""Test the scene switch command with an invalid scene."""
result = runner.invoke(app, ['scene', 'switch', 'non_existent_scene'])
assert result.exit_code != 0
assert 'Scene non_existent_scene not found' in result.stderr

View File

@ -29,6 +29,6 @@ def test_sceneitem_transform():
)
assert result.exit_code == 0
assert (
"Item 'pytest_input_2' in scene 'pytest_scene' has been transformed"
'Item pytest_input_2 in scene pytest_scene has been transformed'
in result.stdout
)