Compare commits

...

25 Commits

Author SHA1 Message Date
22cf1c817f closes #6 2026-02-26 20:29:11 +00:00
da69702a18 temporary fix for hatch build, see https://github.com/pypa/hatch/issues/2193 2026-02-26 14:44:47 +00:00
9b0d20effd upd hatch badge 2026-02-22 11:37:03 +00:00
998e72f43e upd set_logging for improved readability 2026-02-09 10:32:13 +00:00
71d4a81855 upd skip group test env var 2026-02-09 01:55:43 +00:00
e8f0764a50 upd README 2026-02-09 01:50:41 +00:00
d88a0b62ad use hatch-dotenv plugin to load env vars for tests
fail fast if .test.env is not found
2026-02-09 01:50:34 +00:00
7f3d47e7b0 use getLevelNamesMapping() to get loglevel names 2026-02-09 01:49:49 +00:00
fb19a67e64
Merge pull request #5 from onyx-and-iris/dependabot/github_actions/actions/setup-python-6
Bump actions/setup-python from 4 to 6
2026-02-09 00:56:48 +00:00
383df9d4e4
Merge pull request #4 from onyx-and-iris/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 4 to 6
2026-02-09 00:56:36 +00:00
dependabot[bot]
8116deed27
Bump actions/setup-python from 4 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 00:53:51 +00:00
dependabot[bot]
ae57f0dbc3
Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 00:53:47 +00:00
55e4769f32
Configure Dependabot for GitHub Actions and pip
Added support for GitHub Actions and pip updates with a weekly schedule.
2026-02-09 00:53:09 +00:00
35be262b2a patch bump 2026-02-07 08:32:58 +00:00
58907fe2b5 add 0.24.8 to CHANGELOG 2026-02-07 08:32:34 +00:00
1a05a89042 update README 2026-02-07 08:32:26 +00:00
13a2443d48 remove --debug flag, replace it with --loglevel 2026-02-07 08:32:17 +00:00
45479563a0 upd shell completions 2026-02-07 05:25:22 +00:00
35bd2522cf patch bump 2026-02-07 05:21:28 +00:00
bec10d5d05 add Shell Completion to README 2026-02-07 05:21:05 +00:00
9693d4f913 we lose aliases in the help output but this does fix shell completion, which is more important.
fixes #3
2026-02-07 05:20:37 +00:00
34067ca61d patch bump 2026-01-25 16:20:03 +00:00
3ca138ef6d add 0.24.6 to CHANGELOG 2026-01-25 16:19:37 +00:00
1b8bc72097 update Environment Variable section in README 2026-01-25 16:19:28 +00:00
3a8d4ef0f0 rename config module to envconfig
envconfig:
- add method for normalising the keys
- add has_key method
- update env var prefix to OBSWS_CLI_

update tests to reflect changes
2026-01-25 16:19:12 +00:00
31 changed files with 430 additions and 264 deletions

20
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,20 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"

View File

@ -19,10 +19,10 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.11'
cache: 'pip'

View File

@ -13,7 +13,7 @@ jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: astral-sh/ruff-action@v3
with:
args: 'format --check --diff'

View File

@ -5,6 +5,22 @@ 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.24.8] - 2026-02-07
### Changed
- --debug flag removed and replaced with --loglevel. See [Flags](https://github.com/onyx-and-iris/obsws-cli/tree/main?tab=readme-ov-file#flags). This gives the user more control over the level of logging. The default level has been set to WARNING.
### Fixed
- shell completion now works, see [Shell Completion](https://github.com/onyx-and-iris/obsws-cli/tree/main?tab=readme-ov-file#shell-completion). Unfortunately, command aliases in the help output are no longer present as it was breaking shell completion. However, the aliases do still work. See [issue #3](https://github.com/onyx-and-iris/obsws-cli/issues/3)
# [0.24.6] - 2026-01-26
### Changed
- environment variables should now be prefixed with 'OBSWS_CLI_', see [Environment Variables](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#environment-variables)
# [0.24.0] - 2026-01-09
### Added

View File

@ -1,6 +1,6 @@
# obsws-cli
[![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch)
[![Hatch project](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pypa/hatch/master/docs/assets/badge/v0.json)](https://github.com/pypa/hatch)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
@ -16,6 +16,7 @@ For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
- [Configuration](#configuration)
- [Style](#style)
- [Commands](#root-typer)
- [Shell Completion](#shell-completion)
- [License](#license)
## Requirements
@ -48,6 +49,8 @@ The CLI should now be discoverable as `obsws-cli`
- --password/-p: Websocket password
- --timeout/-T: Websocket timeout
- --version/-v: Print the obsws-cli version
- --loglevel/-l: Set the application's logging level
- One of *NOTSET, DEBUG, INFO, WARN, WARNING, ERROR, CRITICAL, FATAL*
Pass `--host`, `--port` and `--password` as flags on the root command, for example:
@ -62,9 +65,10 @@ Store and load environment variables from:
- `user home directory / .config / obsws-cli / obsws.env`
```env
OBS_HOST=localhost
OBS_PORT=4455
OBS_PASSWORD=<websocket password>
OBSWS_CLI_HOST=localhost
OBSWS_CLI_PORT=4455
OBSWS_CLI_PASSWORD=<websocket password>
OBSWS_CLI_LOGLEVEL=DEBUG
```
Flags can be used to override environment variables.
@ -96,8 +100,8 @@ obsws-cli --style="cyan" --no-border sceneitem list
Or with environment variables:
```env
OBS_STYLE=cyan
OBS_STYLE_NO_BORDER=true
OBSWS_CLI_STYLE=cyan
OBSWS_CLI_STYLE_NO_BORDER=true
```
## Root Typer
@ -814,6 +818,14 @@ obsws-cli media stop "Media"
obsws-cli media restart "Media"
```
## Shell Completion
```console
obsws-cli --install-completion
```
Currently supported shells: *bash* *zsh* *fish* *powershell*
## License

View File

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

View File

@ -1,7 +1,5 @@
"""module defining a custom group class for handling command name aliases."""
import re
import typer
@ -53,25 +51,3 @@ class RootTyperAliasGroup(typer.core.TyperGroup):
case 'vc':
cmd_name = 'virtualcam'
return super().get_command(ctx, cmd_name)
class SubTyperAliasGroup(typer.core.TyperGroup):
"""A custom group class to handle command name aliases for sub typers."""
_CMD_SPLIT_P = re.compile(r' ?[,|] ?')
def __init__(self, *args, **kwargs):
"""Initialize the AliasGroup."""
super().__init__(*args, **kwargs)
self.no_args_is_help = True
def get_command(self, ctx, cmd_name):
"""Get a command by name."""
cmd_name = self._group_cmd_name(cmd_name)
return super().get_command(ctx, cmd_name)
def _group_cmd_name(self, default_name):
for cmd in self.commands.values():
if cmd.name and default_name in self._CMD_SPLIT_P.split(cmd.name):
return cmd.name
return default_name

View File

@ -10,7 +10,7 @@ import typer
from obsws_cli.__about__ import __version__ as version
from . import commands, config, console, styles
from . import commands, console, envconfig, styles
from .alias import RootTyperAliasGroup
app = typer.Typer(cls=RootTyperAliasGroup)
@ -28,11 +28,21 @@ def version_callback(value: bool):
raise typer.Exit()
def setup_logging(debug: bool):
def setup_logging(loglevel: str):
"""Set up logging for the application."""
log_level = logging.DEBUG if debug else logging.CRITICAL
level_map = logging.getLevelNamesMapping()
try:
level_int = level_map[loglevel.upper()]
except KeyError:
possible_levels = ', '.join(
sorted(level_map.keys(), key=lambda k: level_map[k])
)
raise typer.BadParameter(
f'Invalid log level: {loglevel}. Valid options are: {possible_levels}'
) from None
logging.basicConfig(
level=log_level,
level=level_int,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
@ -54,62 +64,62 @@ def main(
typer.Option(
'--host',
'-H',
envvar='OBS_HOST',
envvar='OBSWS_CLI_HOST',
help='WebSocket host',
show_default='localhost',
),
] = config.get('host'),
] = envconfig.get('host'),
port: Annotated[
int,
typer.Option(
'--port',
'-P',
envvar='OBS_PORT',
envvar='OBSWS_CLI_PORT',
help='WebSocket port',
show_default=4455,
),
] = config.get('port'),
] = envconfig.get('port'),
password: Annotated[
str,
typer.Option(
'--password',
'-p',
envvar='OBS_PASSWORD',
envvar='OBSWS_CLI_PASSWORD',
help='WebSocket password',
show_default=False,
),
] = config.get('password'),
] = envconfig.get('password'),
timeout: Annotated[
int,
typer.Option(
'--timeout',
'-T',
envvar='OBS_TIMEOUT',
envvar='OBSWS_CLI_TIMEOUT',
help='WebSocket timeout',
show_default=5,
),
] = config.get('timeout'),
] = envconfig.get('timeout'),
style: Annotated[
str,
typer.Option(
'--style',
'-s',
envvar='OBS_STYLE',
envvar='OBSWS_CLI_STYLE',
help='Set the style for the CLI output',
show_default='disabled',
callback=validate_style,
),
] = config.get('style'),
] = envconfig.get('style'),
no_border: Annotated[
bool,
typer.Option(
'--no-border',
'-b',
envvar='OBS_STYLE_NO_BORDER',
envvar='OBSWS_CLI_STYLE_NO_BORDER',
help='Disable table border styling in the CLI output',
show_default=False,
),
] = config.get('style_no_border'),
] = envconfig.get('style_no_border'),
version: Annotated[
bool,
typer.Option(
@ -121,19 +131,18 @@ def main(
callback=version_callback,
),
] = False,
debug: Annotated[
bool,
loglevel: Annotated[
str,
typer.Option(
'--debug',
'-d',
envvar='OBS_DEBUG',
'--loglevel',
'-l',
envvar='OBSWS_CLI_LOGLEVEL',
is_eager=True,
help='Enable debug logging',
help='Set the logging level',
show_default=False,
callback=setup_logging,
hidden=True,
),
] = config.get('debug'),
] = envconfig.get('loglevel'),
):
"""obsws_cli is a command line interface for the OBS WebSocket API."""
ctx.ensure_object(dict)

View File

@ -8,9 +8,8 @@ from rich.table import Table
from rich.text import Text
from obsws_cli import console, util
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -18,7 +17,8 @@ def main():
"""Control filters in OBS scenes."""
@app.command('list | ls')
@app.command('list')
@app.command('ls', hidden=True)
def list_(
ctx: typer.Context,
source_name: Annotated[
@ -90,7 +90,8 @@ def _get_filter_enabled(ctx: typer.Context, source_name: str, filter_name: str):
return resp.filter_enabled
@app.command('enable | on')
@app.command('enable')
@app.command('on', hidden=True)
def enable(
ctx: typer.Context,
source_name: Annotated[
@ -119,7 +120,8 @@ def enable(
)
@app.command('disable | off')
@app.command('disable')
@app.command('off', hidden=True)
def disable(
ctx: typer.Context,
source_name: Annotated[
@ -148,7 +150,8 @@ def disable(
)
@app.command('toggle | tg')
@app.command('toggle')
@app.command('tg', hidden=True)
def toggle(
ctx: typer.Context,
source_name: Annotated[
@ -181,7 +184,8 @@ def toggle(
)
@app.command('status | ss')
@app.command('status')
@app.command('ss', hidden=True)
def status(
ctx: typer.Context,
source_name: Annotated[

View File

@ -7,10 +7,9 @@ from rich.table import Table
from rich.text import Text
from obsws_cli import console, util, validate
from obsws_cli.alias import SubTyperAliasGroup
from obsws_cli.protocols import DataclassProtocol
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -18,7 +17,8 @@ def main():
"""Control groups in OBS scenes."""
@app.command('list | ls')
@app.command('list')
@app.command('ls', hidden=True)
def list_(
ctx: typer.Context,
scene_name: Annotated[
@ -84,7 +84,8 @@ def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None:
return group
@app.command('show | sh')
@app.command('show')
@app.command('sh', hidden=True)
def show(
ctx: typer.Context,
scene_name: Annotated[
@ -117,7 +118,8 @@ def show(
console.out.print(f'Group {console.highlight(ctx, group_name)} is now visible.')
@app.command('hide | h')
@app.command('hide')
@app.command('h', hidden=True)
def hide(
ctx: typer.Context,
scene_name: Annotated[
@ -150,7 +152,8 @@ def hide(
console.out.print(f'Group {console.highlight(ctx, group_name)} is now hidden.')
@app.command('toggle | tg')
@app.command('toggle')
@app.command('tg', hidden=True)
def toggle(
ctx: typer.Context,
scene_name: Annotated[
@ -187,7 +190,8 @@ def toggle(
console.out.print(f'Group {console.highlight(ctx, group_name)} is now hidden.')
@app.command('status | ss')
@app.command('status')
@app.command('ss', hidden=True)
def status(
ctx: typer.Context,
scene_name: Annotated[

View File

@ -7,9 +7,8 @@ from rich.table import Table
from rich.text import Text
from obsws_cli import console
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -17,7 +16,8 @@ def main():
"""Control hotkeys in OBS."""
@app.command('list | ls')
@app.command('list')
@app.command('ls', hidden=True)
def list_(
ctx: typer.Context,
):
@ -45,7 +45,8 @@ def list_(
console.out.print(table)
@app.command('trigger | tr')
@app.command('trigger')
@app.command('tr', hidden=True)
def trigger(
ctx: typer.Context,
hotkey: Annotated[
@ -56,7 +57,8 @@ def trigger(
ctx.obj['obsws'].trigger_hotkey_by_name(hotkey)
@app.command('trigger-sequence | trs')
@app.command('trigger-sequence')
@app.command('trs', hidden=True)
def trigger_sequence(
ctx: typer.Context,
key_id: Annotated[

View File

@ -8,9 +8,8 @@ from rich.table import Table
from rich.text import Text
from obsws_cli import console, util, validate
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -18,7 +17,8 @@ def main():
"""Control inputs in OBS."""
@app.command('create | add')
@app.command('create')
@app.command('cr', hidden=True)
def create(
ctx: typer.Context,
input_name: Annotated[
@ -62,7 +62,8 @@ def create(
)
@app.command('remove | rm')
@app.command('remove')
@app.command('rm', hidden=True)
def remove(
ctx: typer.Context,
input_name: Annotated[
@ -81,7 +82,8 @@ def remove(
console.out.print(f'Input {console.highlight(ctx, input_name)} removed.')
@app.command('list | ls')
@app.command('list')
@app.command('ls', hidden=True)
def list_(
ctx: typer.Context,
input: Annotated[bool, typer.Option(help='Filter by input type.')] = False,
@ -168,7 +170,8 @@ def list_(
console.out.print(table)
@app.command('list-kinds | ls-k')
@app.command('list-kinds')
@app.command('ls-k', hidden=True)
def list_kinds(
ctx: typer.Context,
):
@ -195,7 +198,8 @@ def list_kinds(
console.out.print(table)
@app.command('mute | m')
@app.command('mute')
@app.command('m', hidden=True)
def mute(
ctx: typer.Context,
input_name: Annotated[
@ -217,7 +221,8 @@ def mute(
console.out.print(f'Input {console.highlight(ctx, input_name)} muted.')
@app.command('unmute | um')
@app.command('unmute')
@app.command('um', hidden=True)
def unmute(
ctx: typer.Context,
input_name: Annotated[
@ -239,7 +244,8 @@ def unmute(
console.out.print(f'Input {console.highlight(ctx, input_name)} unmuted.')
@app.command('toggle | tg')
@app.command('toggle')
@app.command('tg', hidden=True)
def toggle(
ctx: typer.Context,
input_name: Annotated[
@ -271,7 +277,8 @@ def toggle(
)
@app.command('volume | vol')
@app.command('volume')
@app.command('vol', hidden=True)
def volume(
ctx: typer.Context,
input_name: Annotated[
@ -305,7 +312,8 @@ def volume(
)
@app.command('show | s')
@app.command('show')
@app.command('s', hidden=True)
def show(
ctx: typer.Context,
input_name: Annotated[
@ -398,7 +406,8 @@ def show(
console.out.print(table)
@app.command('update | upd')
@app.command('update')
@app.command('upd', hidden=True)
def update(
ctx: typer.Context,
input_name: Annotated[

View File

@ -5,9 +5,8 @@ from typing import Annotated, Optional
import typer
from obsws_cli import console, util, validate
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -15,7 +14,8 @@ def main():
"""Commands for media inputs."""
@app.command('cursor | c')
@app.command('cursor')
@app.command('cur', hidden=True)
def cursor(
ctx: typer.Context,
input_name: Annotated[
@ -45,7 +45,8 @@ def cursor(
)
@app.command('play | p')
@app.command('play')
@app.command('p', hidden=True)
def play(
ctx: typer.Context,
input_name: Annotated[
@ -59,7 +60,8 @@ def play(
console.out.print(f'Playing media input {console.highlight(ctx, input_name)}.')
@app.command('pause | pa')
@app.command('pause')
@app.command('pa', hidden=True)
def pause(
ctx: typer.Context,
input_name: Annotated[
@ -73,7 +75,8 @@ def pause(
console.out.print(f'Paused media input {console.highlight(ctx, input_name)}.')
@app.command('stop | s')
@app.command('stop')
@app.command('s', hidden=True)
def stop(
ctx: typer.Context,
input_name: Annotated[
@ -87,7 +90,8 @@ def stop(
console.out.print(f'Stopped media input {console.highlight(ctx, input_name)}.')
@app.command('restart | r')
@app.command('restart')
@app.command('r', hidden=True)
def restart(
ctx: typer.Context,
input_name: Annotated[

View File

@ -7,9 +7,8 @@ from rich.table import Table
from rich.text import Text
from obsws_cli import console, util, validate
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -17,7 +16,8 @@ def main():
"""Control profiles in OBS."""
@app.command('list | ls')
@app.command('list')
@app.command('ls', hidden=True)
def list_(ctx: typer.Context):
"""List profiles."""
resp = ctx.obj['obsws'].get_profile_list()
@ -47,7 +47,8 @@ def list_(ctx: typer.Context):
console.out.print(table)
@app.command('current | get')
@app.command('current')
@app.command('get', hidden=True)
def current(ctx: typer.Context):
"""Get the current profile."""
resp = ctx.obj['obsws'].get_profile_list()
@ -56,7 +57,8 @@ def current(ctx: typer.Context):
)
@app.command('switch | set')
@app.command('switch')
@app.command('set', hidden=True)
def switch(
ctx: typer.Context,
profile_name: Annotated[
@ -81,7 +83,8 @@ def switch(
console.out.print(f'Switched to profile {console.highlight(ctx, profile_name)}.')
@app.command('create | new')
@app.command('create')
@app.command('new', hidden=True)
def create(
ctx: typer.Context,
profile_name: Annotated[
@ -99,7 +102,8 @@ def create(
console.out.print(f'Created profile {console.highlight(ctx, profile_name)}.')
@app.command('remove | rm')
@app.command('remove')
@app.command('rm', hidden=True)
def remove(
ctx: typer.Context,
profile_name: Annotated[

View File

@ -7,9 +7,8 @@ from rich.table import Table
from rich.text import Text
from obsws_cli import console
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -17,7 +16,8 @@ def main():
"""Control projectors in OBS."""
@app.command('list-monitors | ls-m')
@app.command('list-monitors')
@app.command('ls-m', hidden=True)
def list_monitors(ctx: typer.Context):
"""List available monitors."""
resp = ctx.obj['obsws'].get_monitor_list()
@ -48,7 +48,8 @@ def list_monitors(ctx: typer.Context):
console.out.print(table)
@app.command('open | o')
@app.command('open')
@app.command('o', hidden=True)
def open(
ctx: typer.Context,
monitor_index: Annotated[

View File

@ -6,9 +6,8 @@ from typing import Annotated, Optional
import typer
from obsws_cli import console
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -22,7 +21,8 @@ def _get_recording_status(ctx: typer.Context) -> tuple:
return resp.output_active, resp.output_paused
@app.command('start | s')
@app.command('start')
@app.command('s', hidden=True)
def start(ctx: typer.Context):
"""Start recording."""
active, paused = _get_recording_status(ctx)
@ -38,7 +38,8 @@ def start(ctx: typer.Context):
console.out.print('Recording started successfully.')
@app.command('stop | st')
@app.command('stop')
@app.command('st', hidden=True)
def stop(ctx: typer.Context):
"""Stop recording."""
active, _ = _get_recording_status(ctx)
@ -52,7 +53,8 @@ def stop(ctx: typer.Context):
)
@app.command('toggle | tg')
@app.command('toggle')
@app.command('tg', hidden=True)
def toggle(ctx: typer.Context):
"""Toggle recording."""
resp = ctx.obj['obsws'].toggle_record()
@ -62,7 +64,8 @@ def toggle(ctx: typer.Context):
console.out.print('Recording stopped successfully.')
@app.command('status | ss')
@app.command('status')
@app.command('ss', hidden=True)
def status(ctx: typer.Context):
"""Get recording status."""
active, paused = _get_recording_status(ctx)
@ -75,7 +78,8 @@ def status(ctx: typer.Context):
console.out.print('Recording is not in progress.')
@app.command('resume | r')
@app.command('resume')
@app.command('r', hidden=True)
def resume(ctx: typer.Context):
"""Resume recording."""
active, paused = _get_recording_status(ctx)
@ -90,7 +94,8 @@ def resume(ctx: typer.Context):
console.out.print('Recording resumed successfully.')
@app.command('pause | p')
@app.command('pause')
@app.command('p', hidden=True)
def pause(ctx: typer.Context):
"""Pause recording."""
active, paused = _get_recording_status(ctx)
@ -105,7 +110,8 @@ def pause(ctx: typer.Context):
console.out.print('Recording paused successfully.')
@app.command('directory | d')
@app.command('directory')
@app.command('d', hidden=True)
def directory(
ctx: typer.Context,
record_directory: Annotated[
@ -132,7 +138,8 @@ def directory(
)
@app.command('split | sp')
@app.command('split')
@app.command('sp', hidden=True)
def split(ctx: typer.Context):
"""Split the current recording."""
active, paused = _get_recording_status(ctx)
@ -147,7 +154,8 @@ def split(ctx: typer.Context):
console.out.print('Recording split successfully.')
@app.command('chapter | ch')
@app.command('chapter')
@app.command('ch', hidden=True)
def chapter(
ctx: typer.Context,
chapter_name: Annotated[

View File

@ -3,9 +3,8 @@
import typer
from obsws_cli import console
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -13,7 +12,8 @@ def main():
"""Control profiles in OBS."""
@app.command('start | s')
@app.command('start')
@app.command('s', hidden=True)
def start(ctx: typer.Context):
"""Start the replay buffer."""
resp = ctx.obj['obsws'].get_replay_buffer_status()
@ -25,7 +25,8 @@ def start(ctx: typer.Context):
console.out.print('Replay buffer started.')
@app.command('stop | st')
@app.command('stop')
@app.command('st', hidden=True)
def stop(ctx: typer.Context):
"""Stop the replay buffer."""
resp = ctx.obj['obsws'].get_replay_buffer_status()
@ -37,7 +38,8 @@ def stop(ctx: typer.Context):
console.out.print('Replay buffer stopped.')
@app.command('toggle | tg')
@app.command('toggle')
@app.command('tg', hidden=True)
def toggle(ctx: typer.Context):
"""Toggle the replay buffer."""
resp = ctx.obj['obsws'].toggle_replay_buffer()
@ -47,7 +49,8 @@ def toggle(ctx: typer.Context):
console.out.print('Replay buffer is not active.')
@app.command('status | ss')
@app.command('status')
@app.command('ss', hidden=True)
def status(ctx: typer.Context):
"""Get the status of the replay buffer."""
resp = ctx.obj['obsws'].get_replay_buffer_status()
@ -57,7 +60,8 @@ def status(ctx: typer.Context):
console.out.print('Replay buffer is not active.')
@app.command('save | sv')
@app.command('save')
@app.command('sv', hidden=True)
def save(ctx: typer.Context):
"""Save the replay buffer."""
ctx.obj['obsws'].save_replay_buffer()

View File

@ -7,9 +7,8 @@ from rich.table import Table
from rich.text import Text
from obsws_cli import console, util, validate
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -17,7 +16,8 @@ def main():
"""Control OBS scenes."""
@app.command('list | ls')
@app.command('list')
@app.command('ls', hidden=True)
def list_(
ctx: typer.Context,
uuid: Annotated[bool, typer.Option(help='Show UUIDs of scenes')] = False,
@ -66,7 +66,8 @@ def list_(
console.out.print(table)
@app.command('current | get')
@app.command('current')
@app.command('get', hidden=True)
def current(
ctx: typer.Context,
preview: Annotated[
@ -90,7 +91,8 @@ def current(
)
@app.command('switch | set')
@app.command('switch')
@app.command('set', hidden=True)
def switch(
ctx: typer.Context,
scene_name: Annotated[

View File

@ -6,9 +6,8 @@ import typer
from rich.table import Table
from obsws_cli import console, validate
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -16,7 +15,8 @@ def main():
"""Control scene collections in OBS."""
@app.command('list | ls')
@app.command('list')
@app.command('ls', hidden=True)
def list_(ctx: typer.Context):
"""List all scene collections."""
resp = ctx.obj['obsws'].get_scene_collection_list()
@ -40,7 +40,8 @@ def list_(ctx: typer.Context):
console.out.print(table)
@app.command('current | get')
@app.command('current')
@app.command('get', hidden=True)
def current(ctx: typer.Context):
"""Get the current scene collection."""
resp = ctx.obj['obsws'].get_scene_collection_list()
@ -49,7 +50,8 @@ def current(ctx: typer.Context):
)
@app.command('switch | set')
@app.command('switch')
@app.command('set', hidden=True)
def switch(
ctx: typer.Context,
scene_collection_name: Annotated[
@ -77,7 +79,8 @@ def switch(
)
@app.command('create | new')
@app.command('create')
@app.command('new', hidden=True)
def create(
ctx: typer.Context,
scene_collection_name: Annotated[

View File

@ -6,9 +6,8 @@ import typer
from rich.table import Table
from obsws_cli import console, util, validate
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -16,7 +15,8 @@ def main():
"""Control items in OBS scenes."""
@app.command('list | ls')
@app.command('list')
@app.command('ls', hidden=True)
def list_(
ctx: typer.Context,
scene_name: Annotated[
@ -186,7 +186,8 @@ def _get_scene_name_and_item_id(
return scene_name, scene_item_id
@app.command('show | sh')
@app.command('show')
@app.command('sh', hidden=True)
def show(
ctx: typer.Context,
scene_name: Annotated[
@ -228,7 +229,8 @@ def show(
)
@app.command('hide | h')
@app.command('hide')
@app.command('h', hidden=True)
def hide(
ctx: typer.Context,
scene_name: Annotated[
@ -269,7 +271,8 @@ def hide(
)
@app.command('toggle | tg')
@app.command('toggle')
@app.command('tg', hidden=True)
def toggle(
ctx: typer.Context,
scene_name: Annotated[
@ -330,7 +333,8 @@ def toggle(
)
@app.command('visible | v')
@app.command('visible')
@app.command('v', hidden=True)
def visible(
ctx: typer.Context,
scene_name: Annotated[
@ -374,7 +378,8 @@ def visible(
)
@app.command('transform | t')
@app.command('transform')
@app.command('t', hidden=True)
def transform(
ctx: typer.Context,
scene_name: Annotated[

View File

@ -7,9 +7,8 @@ import obsws_python as obsws
import typer
from obsws_cli import console
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -17,7 +16,8 @@ def main():
"""Take screenshots using OBS."""
@app.command('save | sv')
@app.command('save')
@app.command('s', hidden=True)
def save(
ctx: typer.Context,
source_name: Annotated[

View File

@ -7,9 +7,8 @@ from rich.table import Table
from rich.text import Text
from obsws_cli import console, util
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -17,7 +16,8 @@ def main():
"""Manage OBS settings."""
@app.command('show | sh')
@app.command('show')
@app.command('sh', hidden=True)
def show(
ctx: typer.Context,
video: Annotated[
@ -136,7 +136,8 @@ def show(
console.out.print(profile_table)
@app.command('profile | pr')
@app.command('profile')
@app.command('pr', hidden=True)
def profile(
ctx: typer.Context,
category: Annotated[
@ -182,7 +183,8 @@ def profile(
)
@app.command('stream-service | ss')
@app.command('stream-service')
@app.command('ss', hidden=True)
def stream_service(
ctx: typer.Context,
type_: Annotated[
@ -249,7 +251,8 @@ def stream_service(
console.out.print('Stream service settings updated.')
@app.command('video | vi')
@app.command('video')
@app.command('vi', hidden=True)
def video(
ctx: typer.Context,
base_width: Annotated[

View File

@ -3,9 +3,8 @@
import typer
from obsws_cli import console
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -19,7 +18,8 @@ def _get_streaming_status(ctx: typer.Context) -> tuple:
return resp.output_active, resp.output_duration
@app.command('start | s')
@app.command('start')
@app.command('s', hidden=True)
def start(ctx: typer.Context):
"""Start streaming."""
active, _ = _get_streaming_status(ctx)
@ -31,7 +31,8 @@ def start(ctx: typer.Context):
console.out.print('Streaming started successfully.')
@app.command('stop | st')
@app.command('stop')
@app.command('st', hidden=True)
def stop(ctx: typer.Context):
"""Stop streaming."""
active, _ = _get_streaming_status(ctx)
@ -43,7 +44,8 @@ def stop(ctx: typer.Context):
console.out.print('Streaming stopped successfully.')
@app.command('toggle | tg')
@app.command('toggle')
@app.command('tg', hidden=True)
def toggle(ctx: typer.Context):
"""Toggle streaming."""
resp = ctx.obj['obsws'].toggle_stream()
@ -53,7 +55,8 @@ def toggle(ctx: typer.Context):
console.out.print('Streaming stopped successfully.')
@app.command('status | ss')
@app.command('status')
@app.command('ss', hidden=True)
def status(ctx: typer.Context):
"""Get streaming status."""
active, duration = _get_streaming_status(ctx)

View File

@ -3,9 +3,8 @@
import typer
from obsws_cli import console
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -13,21 +12,24 @@ def main():
"""Control studio mode in OBS."""
@app.command('enable | on')
@app.command('enable')
@app.command('on', hidden=True)
def enable(ctx: typer.Context):
"""Enable studio mode."""
ctx.obj['obsws'].set_studio_mode_enabled(True)
console.out.print('Studio mode has been enabled.')
@app.command('disable | off')
@app.command('disable')
@app.command('off', hidden=True)
def disable(ctx: typer.Context):
"""Disable studio mode."""
ctx.obj['obsws'].set_studio_mode_enabled(False)
console.out.print('Studio mode has been disabled.')
@app.command('toggle | tg')
@app.command('toggle')
@app.command('tg', hidden=True)
def toggle(ctx: typer.Context):
"""Toggle studio mode."""
resp = ctx.obj['obsws'].get_studio_mode_enabled()
@ -39,7 +41,8 @@ def toggle(ctx: typer.Context):
console.out.print('Studio mode is now enabled.')
@app.command('status | ss')
@app.command('status')
@app.command('ss', hidden=True)
def status(ctx: typer.Context):
"""Get the status of studio mode."""
resp = ctx.obj['obsws'].get_studio_mode_enabled()

View File

@ -5,9 +5,8 @@ from typing import Annotated, Optional
import typer
from obsws_cli import console, validate
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -15,7 +14,8 @@ def main():
"""Control text inputs in OBS."""
@app.command('current | get')
@app.command('current')
@app.command('get', hidden=True)
def current(
ctx: typer.Context,
input_name: Annotated[
@ -41,7 +41,8 @@ def current(
)
@app.command('update | set')
@app.command('update')
@app.command('set', hidden=True)
def update(
ctx: typer.Context,
input_name: Annotated[

View File

@ -3,9 +3,8 @@
import typer
from obsws_cli import console
from obsws_cli.alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
app = typer.Typer()
@app.callback()
@ -13,21 +12,24 @@ def main():
"""Control virtual camera in OBS."""
@app.command('start | s')
@app.command('start')
@app.command('s', hidden=True)
def start(ctx: typer.Context):
"""Start the virtual camera."""
ctx.obj['obsws'].start_virtual_cam()
console.out.print('Virtual camera started.')
@app.command('stop | p')
@app.command('stop')
@app.command('p', hidden=True)
def stop(ctx: typer.Context):
"""Stop the virtual camera."""
ctx.obj['obsws'].stop_virtual_cam()
console.out.print('Virtual camera stopped.')
@app.command('toggle | tg')
@app.command('toggle')
@app.command('tg', hidden=True)
def toggle(ctx: typer.Context):
"""Toggle the virtual camera."""
resp = ctx.obj['obsws'].toggle_virtual_cam()
@ -37,7 +39,8 @@ def toggle(ctx: typer.Context):
console.out.print('Virtual camera is disabled.')
@app.command('status | ss')
@app.command('status')
@app.command('ss', hidden=True)
def status(ctx: typer.Context):
"""Get the status of the virtual camera."""
resp = ctx.obj['obsws'].get_virtual_cam_status()

View File

@ -1,80 +0,0 @@
"""module for settings management for obsws-cli."""
from collections import UserDict
from pathlib import Path
from dotenv import dotenv_values
ConfigValue = str | int
class Config(UserDict):
"""A class to manage config for obsws-cli.
This class extends UserDict to provide a dictionary-like interface for config.
It loads config from environment variables and .env files.
The config values are expected to be in uppercase and should start with 'OBS_'.
Example:
-------
config = Config()
host = config['OBS_HOST']
config['OBS_PORT'] = 4455
"""
PREFIX = 'OBS_'
def __init__(self, *args, **kwargs):
"""Initialize the Settings object."""
kwargs.update(
{
**dotenv_values('.env'),
**dotenv_values(Path.home() / '.config' / 'obsws-cli' / 'obsws.env'),
}
)
super().__init__(*args, **kwargs)
def __getitem__(self, key: str) -> ConfigValue:
"""Get a setting value by key."""
key = key.upper()
if not key.startswith(Config.PREFIX):
key = f'{Config.PREFIX}{key}'
return self.data[key]
def __setitem__(self, key: str, value: ConfigValue):
"""Set a setting value by key."""
key = key.upper()
if not key.startswith(Config.PREFIX):
key = f'{Config.PREFIX}{key}'
self.data[key] = value
_config = Config(
OBS_HOST='localhost',
OBS_PORT=4455,
OBS_PASSWORD='',
OBS_TIMEOUT=5,
OBS_DEBUG=False,
OBS_STYLE='disabled',
OBS_STYLE_NO_BORDER=False,
)
def get(key: str) -> ConfigValue:
"""Get a setting value by key.
Args:
----
key (str): The key of the config to retrieve.
Returns:
-------
The value of the config.
Raises:
------
KeyError: If the key does not exist in the config.
"""
return _config[key]

146
obsws_cli/envconfig.py Normal file
View File

@ -0,0 +1,146 @@
"""module for settings management for obsws-cli."""
from collections import UserDict
from pathlib import Path
from typing import Any, Union
from dotenv import dotenv_values
ConfigValue = Union[str, int, bool]
class EnvConfig(UserDict):
"""A class to manage .env config for obsws-cli.
This class extends UserDict to provide a dictionary-like interface for config.
It loads config from .env files in the following priority:
1. Local .env file
2. User config file (~/.config/obsws-cli/obsws.env)
3. Default values
Note, typer handles reading from environment variables automatically. They take precedence
over values set in this class.
The config values are expected to be in uppercase and should start with 'OBSWS_CLI_'.
Example:
-------
config = EnvConfig()
host = config['OBSWS_CLI_HOST']
config['OBSWS_CLI_PORT'] = 4455
# Or with defaults
timeout = config.get('OBSWS_CLI_TIMEOUT', 30)
# Keys will be normalised to uppercase with prefix
debug = config.get('debug', False) # Equivalent to 'OBSWS_CLI_DEBUG'
"""
PREFIX = 'OBSWS_CLI_'
def __init__(self, *args, **kwargs):
"""Initialize the Config object with hierarchical loading."""
kwargs.update(
{
**dotenv_values(Path.home() / '.config' / 'obsws-cli' / 'obsws.env'),
**dotenv_values('.env'),
}
)
super().__init__(*args, **self._convert_types(kwargs))
def _convert_types(self, config_data: dict[str, Any]) -> dict[str, ConfigValue]:
"""Convert string values to appropriate types."""
converted = {}
for key, value in config_data.items():
if isinstance(value, str):
if value.lower() in ('true', 'false'):
converted[key] = value.lower() == 'true'
elif value.isdigit():
converted[key] = int(value)
else:
converted[key] = value
else:
converted[key] = value
return converted
def __getitem__(self, key: str) -> ConfigValue:
"""Get a setting value by key."""
normalised_key = self._normalise_key(key)
try:
return self.data[normalised_key]
except KeyError as e:
raise KeyError(
f"Config key '{key}' not found. Available keys: {list(self.data.keys())}"
) from e
def __setitem__(self, key: str, value: ConfigValue):
"""Set a setting value by key."""
normalised_key = self._normalise_key(key)
self.data[normalised_key] = value
def _normalise_key(self, key: str) -> str:
"""Normalise a key to uppercase with OBS_ prefix."""
key = key.upper()
if not key.startswith(self.PREFIX):
key = f'{self.PREFIX}{key}'
return key
def get(self, key: str, default=None) -> ConfigValue:
"""Get a config value with optional default.
Args:
----
key (str): The key to retrieve
default: Default value if key is not found
Returns:
-------
The config value or default
"""
normalised_key = self._normalise_key(key)
if not self.has_key(normalised_key):
return default
return self[normalised_key]
def has_key(self, key: str) -> bool:
"""Check if a config key exists.
Args:
----
key (str): The key to check
Returns:
-------
bool: True if key exists, False otherwise
"""
normalised_key = self._normalise_key(key)
return normalised_key in self.data
_envconfig = EnvConfig(
OBSWS_CLI_HOST='localhost',
OBSWS_CLI_PORT=4455,
OBSWS_CLI_PASSWORD='',
OBSWS_CLI_TIMEOUT=5,
OBSWS_CLI_LOGLEVEL='WARNING',
OBSWS_CLI_STYLE='disabled',
OBSWS_CLI_STYLE_NO_BORDER=False,
)
def get(key: str) -> ConfigValue:
"""Get a setting value by key from the global config instance.
Args:
----
key (str): The key of the config to retrieve.
default: Default value if key is not found.
Returns:
-------
The value of the config or default value.
"""
return _envconfig.get(key)

View File

@ -35,6 +35,9 @@ obsws-cli = "obsws_cli:app"
[tool.hatch.version]
path = "obsws_cli/__about__.py"
[tool.hatch.env]
requires = ["hatch-dotenv"]
[tool.hatch.envs.default]
dependencies = ["click-man>=0.5.1"]
@ -45,6 +48,10 @@ man = "python man/generate.py --output=./man"
[tool.hatch.envs.hatch-test]
randomize = true
[tool.hatch.env.collectors.dotenv.hatch-test]
env-files = [".env", ".test.env"]
fail-on-missing = true
[tool.hatch.envs.types]
extra-dependencies = ["mypy>=1.0.0"]
[tool.hatch.envs.types.scripts]

View File

@ -4,7 +4,6 @@ import os
import time
import obsws_python as obsws
from dotenv import find_dotenv, load_dotenv
def pytest_configure(config):
@ -21,9 +20,9 @@ def pytest_sessionstart(session):
"""
# Initialize the OBS WebSocket client
session.obsws = obsws.ReqClient(
host=os.environ['OBS_HOST'],
port=os.environ['OBS_PORT'],
password=os.environ['OBS_PASSWORD'],
host=os.environ['OBSWS_CLI_HOST'],
port=os.environ['OBSWS_CLI_PORT'],
password=os.environ['OBSWS_CLI_PASSWORD'],
timeout=5,
)
resp = session.obsws.get_version()
@ -34,14 +33,12 @@ def pytest_sessionstart(session):
)
print(' '.join(out))
load_dotenv(find_dotenv('.test.env'))
session.obsws.set_stream_service_settings(
'rtmp_common',
{
'service': 'Twitch',
'server': 'auto',
'key': os.environ['OBS_STREAM_KEY'],
'key': os.environ['OBSWS_CLI_TESTS_STREAM_KEY'],
},
)
@ -63,7 +60,7 @@ def pytest_sessionstart(session):
'linux': 'pulse_output_capture',
'darwin': 'coreaudio_output_capture',
}
platform = os.environ.get('OBS_TESTS_PLATFORM', os.uname().sysname.lower())
platform = os.environ['OBSWS_CLI_TESTS_PLATFORM']
try:
session.obsws.create_input(
sceneName='pytest_scene',

View File

@ -9,7 +9,7 @@ from obsws_cli.app import app
runner = CliRunner()
if os.environ.get('OBS_TESTS_SKIP_GROUP_TESTS'):
if os.environ.get('OBSWS_CLI_TESTS_SKIP_GROUP_TESTS'):
pytest.skip(
'Skipping group tests as per environment variable', allow_module_level=True
)