mirror of
https://github.com/onyx-and-iris/obsws-cli.git
synced 2025-06-06 19:50:32 +01:00
first commit
This commit is contained in:
commit
8ce9a80eed
178
.gitignore
vendored
Normal file
178
.gitignore
vendored
Normal file
@ -0,0 +1,178 @@
|
||||
# Auto-generated .gitignore by gignore: github.com/onyx-and-iris/gignore
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.hatch
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
# End of gignore: github.com/onyx-and-iris/gignore
|
||||
|
||||
test-*.py
|
9
LICENSE.txt
Normal file
9
LICENSE.txt
Normal file
@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present onyx-and-iris <code@onyxandiris.online>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
233
README.md
Normal file
233
README.md
Normal file
@ -0,0 +1,233 @@
|
||||
# obsws-cli
|
||||
|
||||
[](https://github.com/pypa/hatch)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
|
||||
-----
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [License](#license)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10 or greater
|
||||
- [OBS Studio 28+][obs-studio]
|
||||
|
||||
## Installation
|
||||
|
||||
##### *with uv*
|
||||
|
||||
```console
|
||||
uv tool install .
|
||||
```
|
||||
|
||||
##### *with pipx*
|
||||
|
||||
```console
|
||||
pipx install .
|
||||
```
|
||||
|
||||
The CLI should now be discoverable as `obsws-cli`
|
||||
|
||||
## Configuration
|
||||
|
||||
#### Flags
|
||||
|
||||
Pass `--host`, `--port` and `--password` as flags to the root command, for example:
|
||||
|
||||
```console
|
||||
obsws-cli --host=localhost --port=4455 --password=<websocket password> --help
|
||||
```
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
Store and load environment variables from:
|
||||
- A `.env` file in the cwd
|
||||
- `user home directory / .config / obsws-cli / obsws.env`
|
||||
|
||||
```env
|
||||
OBSWS_HOST=localhost
|
||||
OBSWS_PORT=4455
|
||||
OBSWS_PASSWORD=<websocket password>
|
||||
```
|
||||
|
||||
Flags can be used to override environment variables.
|
||||
|
||||
## Root Typer
|
||||
|
||||
- version: Get the OBS Client and WebSocket versions.
|
||||
|
||||
```console
|
||||
obsws-cli version
|
||||
```
|
||||
|
||||
## Sub Typers
|
||||
|
||||
#### Scene
|
||||
|
||||
- list: List all scenes.
|
||||
|
||||
```console
|
||||
obsws-cli scene list
|
||||
```
|
||||
|
||||
- current: Get the current program scene.
|
||||
|
||||
```console
|
||||
obsws-cli scene current
|
||||
```
|
||||
|
||||
- switch: Switch to a scene.
|
||||
- args: <scene_name>
|
||||
|
||||
```console
|
||||
obsws-cli scene switch LIVE
|
||||
```
|
||||
|
||||
#### Item
|
||||
|
||||
- show: Show an item in a scene.
|
||||
- args: <scene_name> <item_name>
|
||||
|
||||
```console
|
||||
obsws-cli item show START "Colour Source"
|
||||
```
|
||||
|
||||
- hide: Hide an item in a scene.
|
||||
- args: <scene_name> <item_name>
|
||||
|
||||
```console
|
||||
obsws-cli item hide START "Colour Source"
|
||||
```
|
||||
|
||||
- list: List all items in a scene.
|
||||
- args: <scene_name>
|
||||
|
||||
```console
|
||||
obsws-cli item list LIVE
|
||||
```
|
||||
|
||||
#### Group
|
||||
|
||||
- show: Show a group in a scene.
|
||||
- args: <scene_name> <group_name>
|
||||
|
||||
```console
|
||||
obsws-cli group show START "test_group"
|
||||
```
|
||||
|
||||
- hide: Hide a group in a scene.
|
||||
- args: <scene_name> <group_name>
|
||||
|
||||
```console
|
||||
obsws-cli group hide START "test_group"
|
||||
```
|
||||
|
||||
- list: List groups in a scene.
|
||||
- args: <scene_name>
|
||||
|
||||
```console
|
||||
obsws-cli group list START
|
||||
```
|
||||
|
||||
#### Input
|
||||
|
||||
- list: List all inputs.
|
||||
|
||||
```console
|
||||
obsws-cli input list
|
||||
```
|
||||
|
||||
- mute: Mute an input.
|
||||
- args: <input_name>
|
||||
|
||||
```console
|
||||
obsws-cli input mute "Mic/Aux"
|
||||
```
|
||||
|
||||
- unmute: Unmute an input.
|
||||
- args: <input_name>
|
||||
|
||||
```console
|
||||
obsws-cli input unmute "Mic/Aux"
|
||||
```
|
||||
|
||||
- toggle: Toggle an input.
|
||||
|
||||
```console
|
||||
obsws-cli input toggle "Mic/Aux"
|
||||
```
|
||||
|
||||
#### Record
|
||||
|
||||
- start: Start recording.
|
||||
|
||||
```console
|
||||
obsws-cli record start
|
||||
```
|
||||
|
||||
- stop: Stop recording.
|
||||
|
||||
```console
|
||||
obsws-cli record stop
|
||||
```
|
||||
|
||||
- status: Get recording status.
|
||||
|
||||
```console
|
||||
obsws-cli record status
|
||||
```
|
||||
|
||||
- toggle: Toggle recording.
|
||||
|
||||
```console
|
||||
obsws-cli record toggle
|
||||
```
|
||||
|
||||
- resume: Resume recording.
|
||||
|
||||
```console
|
||||
obsws-cli record resume
|
||||
```
|
||||
|
||||
- pause: Pause recording.
|
||||
|
||||
```console
|
||||
obsws-cli record pause
|
||||
```
|
||||
|
||||
#### Stream
|
||||
|
||||
- start: Start streaming.
|
||||
|
||||
```console
|
||||
obsws-cli stream start
|
||||
```
|
||||
|
||||
- stop: Stop streaming.
|
||||
|
||||
```console
|
||||
obsws-cli stream stop
|
||||
```
|
||||
|
||||
- status: Get streaming status.
|
||||
|
||||
```console
|
||||
obsws-cli stream status
|
||||
```
|
||||
|
||||
- toggle: Toggle streaming.
|
||||
|
||||
```console
|
||||
obsws-cli stream toggle
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
`obsws-cli` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
||||
|
||||
|
||||
[obs-studio]: https://obsproject.com/
|
4
obsws_cli/__about__.py
Normal file
4
obsws_cli/__about__.py
Normal file
@ -0,0 +1,4 @@
|
||||
# SPDX-FileCopyrightText: 2025-present onyx-and-iris <code@onyxandiris.online>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
__version__ = "0.1.0"
|
7
obsws_cli/__init__.py
Normal file
7
obsws_cli/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2025-present onyx-and-iris <code@onyxandiris.online>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from .app import app
|
||||
|
||||
__all__ = ["app"]
|
23
obsws_cli/alias.py
Normal file
23
obsws_cli/alias.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""module defining a custom group class for handling command name aliases."""
|
||||
|
||||
import re
|
||||
|
||||
import typer
|
||||
|
||||
|
||||
class AliasGroup(typer.core.TyperGroup):
|
||||
"""A custom group class to handle command name aliases."""
|
||||
|
||||
_CMD_SPLIT_P = re.compile(r' ?[,|] ?')
|
||||
|
||||
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():
|
||||
name = cmd.name
|
||||
if name and default_name in self._CMD_SPLIT_P.split(name):
|
||||
return name
|
||||
return default_name
|
78
obsws_cli/app.py
Normal file
78
obsws_cli/app.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""Command line interface for the OBS WebSocket API."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import obsws_python as obsws
|
||||
import typer
|
||||
from pydantic import ConfigDict
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from . import group, input, item, record, scene, stream
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Settings for the OBS WebSocket client."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
env_file=(
|
||||
'.env',
|
||||
Path.home() / '.config' / 'obsws-cli' / 'obsws.env',
|
||||
),
|
||||
env_file_encoding='utf-8',
|
||||
env_prefix='OBSWS_',
|
||||
)
|
||||
|
||||
HOST: str = 'localhost'
|
||||
PORT: int = 4455
|
||||
PASSWORD: str = '' # No password by default
|
||||
TIMEOUT: int = 5 # Timeout for requests in seconds
|
||||
|
||||
|
||||
app = typer.Typer()
|
||||
app.add_typer(scene.app, name='scene')
|
||||
app.add_typer(item.app, name='item')
|
||||
app.add_typer(group.app, name='group')
|
||||
app.add_typer(input.app, name='input')
|
||||
app.add_typer(record.app, name='record')
|
||||
app.add_typer(stream.app, name='stream')
|
||||
|
||||
|
||||
@app.command()
|
||||
def version(ctx: typer.Context):
|
||||
"""Get the OBS Client and WebSocket versions."""
|
||||
resp = ctx.obj['obsws'].get_version()
|
||||
typer.echo(
|
||||
f'OBS Client version: {resp.obs_version} with WebSocket version: {resp.obs_web_socket_version}'
|
||||
)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main(
|
||||
ctx: typer.Context,
|
||||
host: Annotated[str, typer.Option(help='WebSocket host')] = None,
|
||||
port: Annotated[int, typer.Option(help='WebSocket port')] = None,
|
||||
password: Annotated[str, typer.Option(help='WebSocket password')] = None,
|
||||
timeout: Annotated[int, typer.Option(help='WebSocket timeout')] = None,
|
||||
):
|
||||
"""obsws_cli is a command line interface for the OBS WebSocket API."""
|
||||
settings = Settings()
|
||||
# Allow overriding settings with command line options
|
||||
if host:
|
||||
settings.HOST = host
|
||||
if port:
|
||||
settings.PORT = port
|
||||
if password:
|
||||
settings.PASSWORD = password
|
||||
if timeout:
|
||||
settings.TIMEOUT = timeout
|
||||
|
||||
ctx.obj = ctx.ensure_object(dict)
|
||||
ctx.obj['obsws'] = ctx.with_resource(
|
||||
obsws.ReqClient(
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
password=settings.PASSWORD,
|
||||
timeout=settings.TIMEOUT,
|
||||
)
|
||||
)
|
18
obsws_cli/errors.py
Normal file
18
obsws_cli/errors.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Exceptions for obsws_cli."""
|
||||
|
||||
|
||||
class ObswsCliError(Exception):
|
||||
"""Base class for all exceptions raised by obsws_cli."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
"""Initialize the exception with a message."""
|
||||
message = (
|
||||
message.split('With message: ')[1]
|
||||
if 'With message: ' in message
|
||||
else message
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ObswsCliBadParameter(ObswsCliError):
|
||||
"""Exception raised when a bad parameter is passed to a command."""
|
81
obsws_cli/group.py
Normal file
81
obsws_cli/group.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""module containing commands for manipulating groups in scenes."""
|
||||
|
||||
import obsws_python as obsws
|
||||
import typer
|
||||
|
||||
from .alias import AliasGroup
|
||||
from .errors import ObswsCliBadParameter
|
||||
from .protocols import DataclassProtocol
|
||||
|
||||
app = typer.Typer(cls=AliasGroup)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main():
|
||||
"""Control groups in OBS scenes."""
|
||||
|
||||
|
||||
def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None:
|
||||
"""Get a group from the scene item list response."""
|
||||
group = next(
|
||||
(
|
||||
item
|
||||
for item in resp.scene_items
|
||||
if item.get('sourceName') == group_name and item.get('isGroup')
|
||||
),
|
||||
None,
|
||||
)
|
||||
return group
|
||||
|
||||
|
||||
@app.command()
|
||||
def show(ctx: typer.Context, scene_name: str, group_name: str):
|
||||
"""Show a group in a scene."""
|
||||
try:
|
||||
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
|
||||
if (group := _get_group(group_name, resp)) is None:
|
||||
raise ObswsCliBadParameter(f"Group '{group_name}' not found in scene.")
|
||||
|
||||
ctx.obj['obsws'].set_scene_item_enabled(
|
||||
scene_name=scene_name,
|
||||
item_id=int(group.get('sceneItemId')),
|
||||
enabled=True,
|
||||
)
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 600:
|
||||
raise ObswsCliBadParameter(str(e)) from e
|
||||
raise
|
||||
|
||||
|
||||
@app.command()
|
||||
def hide(ctx: typer.Context, scene_name: str, group_name: str):
|
||||
"""Hide a group in a scene."""
|
||||
try:
|
||||
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
|
||||
if (group := _get_group(group_name, resp)) is None:
|
||||
raise ObswsCliBadParameter(f"Group '{group_name}' not found in scene.")
|
||||
|
||||
ctx.obj['obsws'].set_scene_item_enabled(
|
||||
scene_name=scene_name,
|
||||
item_id=int(group.get('sceneItemId')),
|
||||
enabled=False,
|
||||
)
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 600:
|
||||
raise ObswsCliBadParameter(str(e)) from e
|
||||
raise
|
||||
|
||||
|
||||
@app.command('list | ls')
|
||||
def list(ctx: typer.Context, scene_name: str):
|
||||
"""List groups in a scene."""
|
||||
try:
|
||||
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
|
||||
groups = (
|
||||
item.get('sourceName') for item in resp.scene_items if item.get('isGroup')
|
||||
)
|
||||
typer.echo('\n'.join(groups))
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 600:
|
||||
raise ObswsCliBadParameter(str(e)) from e
|
||||
raise
|
89
obsws_cli/input.py
Normal file
89
obsws_cli/input.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""module containing commands for manipulating inputs."""
|
||||
|
||||
import obsws_python as obsws
|
||||
import typer
|
||||
|
||||
from .alias import AliasGroup
|
||||
from .errors import ObswsCliBadParameter
|
||||
from .protocols import DataclassProtocol
|
||||
|
||||
app = typer.Typer(cls=AliasGroup)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main():
|
||||
"""Control inputs in OBS."""
|
||||
|
||||
|
||||
@app.command('ls')
|
||||
def list(ctx: typer.Context):
|
||||
"""List all inputs."""
|
||||
resp = ctx.obj['obsws'].get_input_list()
|
||||
inputs = (input.get('inputName') for input in resp.inputs)
|
||||
typer.echo('\n'.join(inputs))
|
||||
|
||||
|
||||
def _get_input(input_name: str, resp: DataclassProtocol) -> dict | None:
|
||||
"""Get an input from the input list response."""
|
||||
input_ = next(
|
||||
(input_ for input_ in resp.inputs if input_.get('inputName') == input_name),
|
||||
None,
|
||||
)
|
||||
|
||||
return input_
|
||||
|
||||
|
||||
@app.command()
|
||||
def mute(ctx: typer.Context, input_name: str):
|
||||
"""Mute an input."""
|
||||
try:
|
||||
resp = ctx.obj['obsws'].get_input_list()
|
||||
if (input_ := _get_input(input_name, resp)) is None:
|
||||
raise ObswsCliBadParameter(f"Input '{input_name}' not found.")
|
||||
|
||||
ctx.obj['obsws'].set_input_mute(
|
||||
name=input_.get('inputName'),
|
||||
muted=True,
|
||||
)
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 600:
|
||||
raise ObswsCliBadParameter(str(e)) from e
|
||||
raise
|
||||
|
||||
|
||||
@app.command()
|
||||
def unmute(ctx: typer.Context, input_name: str):
|
||||
"""Unmute an input."""
|
||||
try:
|
||||
resp = ctx.obj['obsws'].get_input_list()
|
||||
if (input_ := _get_input(input_name, resp)) is None:
|
||||
raise ObswsCliBadParameter(f"Input '{input_name}' not found.")
|
||||
|
||||
ctx.obj['obsws'].set_input_mute(
|
||||
name=input_.get('inputName'),
|
||||
muted=False,
|
||||
)
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 600:
|
||||
raise ObswsCliBadParameter(str(e)) from e
|
||||
raise
|
||||
|
||||
|
||||
@app.command()
|
||||
def toggle(ctx: typer.Context, input_name: str):
|
||||
"""Toggle an input."""
|
||||
try:
|
||||
resp = ctx.obj['obsws'].get_input_list()
|
||||
if (input_ := _get_input(input_name, resp)) is None:
|
||||
raise ObswsCliBadParameter(f"Input '{input_name}' not found.")
|
||||
|
||||
resp = ctx.obj['obsws'].get_input_mute(name=input_.get('inputName'))
|
||||
|
||||
ctx.obj['obsws'].set_input_mute(
|
||||
name=input_.get('inputName'),
|
||||
muted=not resp.input_muted,
|
||||
)
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 600:
|
||||
raise ObswsCliBadParameter(str(e)) from e
|
||||
raise
|
61
obsws_cli/item.py
Normal file
61
obsws_cli/item.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""module containing commands for manipulating items in scenes."""
|
||||
|
||||
import obsws_python as obsws
|
||||
import typer
|
||||
|
||||
from .alias import AliasGroup
|
||||
from .errors import ObswsCliBadParameter
|
||||
|
||||
app = typer.Typer(cls=AliasGroup)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main():
|
||||
"""Control items in OBS scenes."""
|
||||
|
||||
|
||||
@app.command()
|
||||
def show(ctx: typer.Context, scene_name: str, item_name: str):
|
||||
"""Show an item in a scene."""
|
||||
try:
|
||||
resp = ctx.obj['obsws'].get_scene_item_id(scene_name, item_name)
|
||||
|
||||
ctx.obj['obsws'].set_scene_item_enabled(
|
||||
scene_name=scene_name,
|
||||
item_id=int(resp.scene_item_id),
|
||||
enabled=True,
|
||||
)
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 600:
|
||||
raise ObswsCliBadParameter(str(e)) from e
|
||||
raise
|
||||
|
||||
|
||||
@app.command()
|
||||
def hide(ctx: typer.Context, scene_name: str, item_name: str):
|
||||
"""Hide an item in a scene."""
|
||||
try:
|
||||
resp = ctx.obj['obsws'].get_scene_item_id(scene_name, item_name)
|
||||
|
||||
ctx.obj['obsws'].set_scene_item_enabled(
|
||||
scene_name=scene_name,
|
||||
item_id=int(resp.scene_item_id),
|
||||
enabled=False,
|
||||
)
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 600:
|
||||
raise ObswsCliBadParameter(str(e)) from e
|
||||
raise
|
||||
|
||||
|
||||
@app.command('list | ls')
|
||||
def list(ctx: typer.Context, scene_name: str):
|
||||
"""List all items in a scene."""
|
||||
try:
|
||||
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
|
||||
items = (item.get('sourceName') for item in resp.scene_items)
|
||||
typer.echo('\n'.join(items))
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 600:
|
||||
raise ObswsCliBadParameter(str(e)) from e
|
||||
raise
|
10
obsws_cli/protocols.py
Normal file
10
obsws_cli/protocols.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""module defining protocols for type hinting."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@dataclass
|
||||
class DataclassProtocol(Protocol):
|
||||
"""A protocol for dataclass-like structures."""
|
110
obsws_cli/record.py
Normal file
110
obsws_cli/record.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""module for controlling OBS recording functionality."""
|
||||
|
||||
import obsws_python as obsws
|
||||
import typer
|
||||
|
||||
from .errors import ObswsCliError
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main():
|
||||
"""Control OBS recording functionality."""
|
||||
|
||||
|
||||
@app.command()
|
||||
def start(ctx: typer.Context):
|
||||
"""Start recording."""
|
||||
try:
|
||||
ctx.obj['obsws'].start_record()
|
||||
typer.echo('Recording started successfully.')
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 500:
|
||||
raise ObswsCliError(
|
||||
'Recording is already in progress, cannot start.'
|
||||
) from e
|
||||
raise
|
||||
|
||||
|
||||
@app.command()
|
||||
def stop(ctx: typer.Context):
|
||||
"""Stop recording."""
|
||||
try:
|
||||
ctx.obj['obsws'].stop_record()
|
||||
typer.echo('Recording stopped successfully.')
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 501:
|
||||
raise ObswsCliError('Recording is not in progress, cannot stop.') from e
|
||||
raise
|
||||
|
||||
|
||||
def _get_recording_status(ctx: typer.Context) -> tuple:
|
||||
"""Get recording status."""
|
||||
resp = ctx.obj['obsws'].get_record_status()
|
||||
return resp.output_active, resp.output_paused
|
||||
|
||||
|
||||
@app.command()
|
||||
def status(ctx: typer.Context):
|
||||
"""Get recording status."""
|
||||
active, paused = _get_recording_status(ctx)
|
||||
if active:
|
||||
if paused:
|
||||
typer.echo('Recording is in progress and paused.')
|
||||
else:
|
||||
typer.echo('Recording is in progress.')
|
||||
else:
|
||||
typer.echo('Recording is not in progress.')
|
||||
|
||||
|
||||
@app.command()
|
||||
def toggle(ctx: typer.Context):
|
||||
"""Toggle recording."""
|
||||
active, _ = _get_recording_status(ctx)
|
||||
if active:
|
||||
try:
|
||||
ctx.obj['obsws'].stop_record()
|
||||
typer.echo('Recording stopped successfully.')
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
raise ObswsCliError(str(e)) from e
|
||||
else:
|
||||
try:
|
||||
ctx.obj['obsws'].start_record()
|
||||
typer.echo('Recording started successfully.')
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
raise ObswsCliError(str(e)) from e
|
||||
|
||||
|
||||
@app.command()
|
||||
def resume(ctx: typer.Context):
|
||||
"""Resume recording."""
|
||||
active, paused = _get_recording_status(ctx)
|
||||
if not active:
|
||||
raise ObswsCliError('Recording is not in progress, cannot resume.')
|
||||
if not paused:
|
||||
raise ObswsCliError('Recording is in progress but not paused, cannot resume.')
|
||||
|
||||
try:
|
||||
ctx.obj['obsws'].resume_record()
|
||||
typer.echo('Recording resumed successfully.')
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
raise ObswsCliError(str(e)) from e
|
||||
|
||||
|
||||
@app.command()
|
||||
def pause(ctx: typer.Context):
|
||||
"""Pause recording."""
|
||||
active, paused = _get_recording_status(ctx)
|
||||
if not active:
|
||||
raise ObswsCliError('Recording is not in progress, cannot pause.')
|
||||
if paused:
|
||||
raise ObswsCliError(
|
||||
'Recording is in progress but already paused, cannot pause.'
|
||||
)
|
||||
|
||||
try:
|
||||
ctx.obj['obsws'].pause_record()
|
||||
typer.echo('Recording paused successfully.')
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
raise ObswsCliError(str(e)) from e
|
40
obsws_cli/scene.py
Normal file
40
obsws_cli/scene.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""module containing commands for controlling OBS scenes."""
|
||||
|
||||
import obsws_python as obsws
|
||||
import typer
|
||||
|
||||
from .alias import AliasGroup
|
||||
from .errors import ObswsCliBadParameter
|
||||
|
||||
app = typer.Typer(cls=AliasGroup)
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main():
|
||||
"""Control OBS scenes."""
|
||||
|
||||
|
||||
@app.command('list | ls')
|
||||
def list(ctx: typer.Context):
|
||||
"""List all scenes."""
|
||||
resp = ctx.obj['obsws'].get_scene_list()
|
||||
scenes = (scene.get('sceneName') for scene in reversed(resp.scenes))
|
||||
typer.echo('\n'.join(scenes))
|
||||
|
||||
|
||||
@app.command('current | get')
|
||||
def current(ctx: typer.Context):
|
||||
"""Get the current program scene."""
|
||||
resp = ctx.obj['obsws'].get_current_program_scene()
|
||||
typer.echo(resp.current_program_scene_name)
|
||||
|
||||
|
||||
@app.command('switch | set')
|
||||
def switch(ctx: typer.Context, scene_name: str):
|
||||
"""Switch to a scene."""
|
||||
try:
|
||||
ctx.obj['obsws'].set_current_program_scene(scene_name)
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 600:
|
||||
raise ObswsCliBadParameter(f"Scene '{scene_name}' not found.")
|
||||
raise
|
87
obsws_cli/stream.py
Normal file
87
obsws_cli/stream.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""module for controlling OBS stream functionality."""
|
||||
|
||||
import obsws_python as obsws
|
||||
import typer
|
||||
|
||||
from .errors import ObswsCliError
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main():
|
||||
"""Control OBS stream functionality."""
|
||||
|
||||
|
||||
@app.command()
|
||||
def start(ctx: typer.Context):
|
||||
"""Start streaming."""
|
||||
try:
|
||||
ctx.obj['obsws'].start_stream()
|
||||
typer.echo('Streaming started successfully.')
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 500:
|
||||
raise ObswsCliError(
|
||||
'Streaming is already in progress, cannot start.'
|
||||
) from e
|
||||
raise
|
||||
|
||||
|
||||
@app.command()
|
||||
def stop(ctx: typer.Context):
|
||||
"""Stop streaming."""
|
||||
try:
|
||||
ctx.obj['obsws'].stop_stream()
|
||||
typer.echo('Streaming stopped successfully.')
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
if e.code == 501:
|
||||
raise ObswsCliError('Streaming is not in progress, cannot stop.') from e
|
||||
raise
|
||||
|
||||
|
||||
def _get_streaming_status(ctx: typer.Context) -> tuple:
|
||||
"""Get streaming status."""
|
||||
resp = ctx.obj['obsws'].get_stream_status()
|
||||
return resp.output_active, resp.output_duration
|
||||
|
||||
|
||||
@app.command()
|
||||
def status(ctx: typer.Context):
|
||||
"""Get streaming status."""
|
||||
active, duration = _get_streaming_status(ctx)
|
||||
if active:
|
||||
if duration > 0:
|
||||
seconds = duration / 1000
|
||||
minutes = int(seconds // 60)
|
||||
seconds = int(seconds % 60)
|
||||
if minutes > 0:
|
||||
typer.echo(
|
||||
f'Streaming is in progress for {minutes} minutes and {seconds} seconds.'
|
||||
)
|
||||
else:
|
||||
if seconds > 0:
|
||||
typer.echo(f'Streaming is in progress for {seconds} seconds.')
|
||||
else:
|
||||
typer.echo('Streaming is in progress for less than a second.')
|
||||
else:
|
||||
typer.echo('Streaming is in progress.')
|
||||
else:
|
||||
typer.echo('Streaming is not in progress.')
|
||||
|
||||
|
||||
@app.command()
|
||||
def toggle(ctx: typer.Context):
|
||||
"""Toggle streaming."""
|
||||
active, _ = _get_streaming_status(ctx)
|
||||
if active:
|
||||
try:
|
||||
ctx.obj['obsws'].stop_stream()
|
||||
typer.echo('Streaming stopped successfully.')
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
raise ObswsCliError(str(e)) from e
|
||||
else:
|
||||
try:
|
||||
ctx.obj['obsws'].start_stream()
|
||||
typer.echo('Streaming started successfully.')
|
||||
except obsws.error.OBSSDKRequestError as e:
|
||||
raise ObswsCliError(str(e)) from e
|
50
pyproject.toml
Normal file
50
pyproject.toml
Normal file
@ -0,0 +1,50 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "obsws-cli"
|
||||
dynamic = ["version"]
|
||||
description = 'A command line interface for the OBS WebSocket API.'
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = "MIT"
|
||||
keywords = ["obs", "obs-websocket", "obs-websocket-api", "cli", "command-line"]
|
||||
authors = [{ name = "onyx-and-iris", email = "code@onyxandiris.online" }]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
]
|
||||
dependencies = [
|
||||
"typer>=0.15.2",
|
||||
"obsws-python>=1.7.1",
|
||||
"pydantic-settings>=2.9.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Documentation = "https://github.com/onyx-and-iris/obsws-cli#readme"
|
||||
Issues = "https://github.com/onyx-and-iris/obsws-cli/issues"
|
||||
Source = "https://github.com/onyx-and-iris/obsws-cli"
|
||||
|
||||
[project.scripts]
|
||||
obsws-cli = "obsws_cli:app"
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "obsws_cli/__about__.py"
|
||||
|
||||
[tool.hatch.envs.default.scripts]
|
||||
test = "pytest {args:obsws_cli tests}"
|
||||
|
||||
[tool.hatch.envs.hatch-test]
|
||||
dependencies = ["pytest>=8.3.5", "pytest-dotenv"]
|
||||
|
||||
[tool.hatch.envs.types]
|
||||
extra-dependencies = ["mypy>=1.0.0"]
|
||||
[tool.hatch.envs.types.scripts]
|
||||
check = "mypy --install-types --non-interactive {args:obsws_cli tests}"
|
78
ruff.toml
Normal file
78
ruff.toml
Normal file
@ -0,0 +1,78 @@
|
||||
# Exclude a variety of commonly ignored directories.
|
||||
exclude = [
|
||||
".bzr",
|
||||
".direnv",
|
||||
".eggs",
|
||||
".git",
|
||||
".git-rewrite",
|
||||
".hg",
|
||||
".ipynb_checkpoints",
|
||||
".mypy_cache",
|
||||
".nox",
|
||||
".pants.d",
|
||||
".pyenv",
|
||||
".pytest_cache",
|
||||
".pytype",
|
||||
".ruff_cache",
|
||||
".svn",
|
||||
".tox",
|
||||
".venv",
|
||||
".vscode",
|
||||
"__pypackages__",
|
||||
"_build",
|
||||
"buck-out",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"site-packages",
|
||||
"venv",
|
||||
]
|
||||
|
||||
# Same as Black.
|
||||
line-length = 88
|
||||
indent-width = 4
|
||||
|
||||
# Assume Python 3.9
|
||||
target-version = "py39"
|
||||
|
||||
[lint]
|
||||
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
||||
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
|
||||
# McCabe complexity (`C901`) by default.
|
||||
# Enable pydocstyle (`D`) codes by default.
|
||||
select = ["E4", "E7", "E9", "F", "D"]
|
||||
ignore = []
|
||||
|
||||
# Allow fix for all enabled rules (when `--fix`) is provided.
|
||||
fixable = ["ALL"]
|
||||
unfixable = []
|
||||
|
||||
# Allow unused variables when underscore-prefixed.
|
||||
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||
|
||||
[format]
|
||||
# Unlike Black, use single quotes for strings.
|
||||
quote-style = "single"
|
||||
|
||||
# Like Black, indent with spaces, rather than tabs.
|
||||
indent-style = "space"
|
||||
|
||||
# Like Black, respect magic trailing commas.
|
||||
skip-magic-trailing-comma = false
|
||||
|
||||
# Like Black, automatically detect the appropriate line ending.
|
||||
line-ending = "auto"
|
||||
|
||||
# Enable auto-formatting of code examples in docstrings. Markdown,
|
||||
# reStructuredText code/literal blocks and doctests are all supported.
|
||||
#
|
||||
# This is currently disabled by default, but it is planned for this
|
||||
# to be opt-out in the future.
|
||||
docstring-code-format = false
|
||||
|
||||
# Set the line length limit used when formatting code snippets in
|
||||
# docstrings.
|
||||
#
|
||||
# This only has an effect when the `docstring-code-format` setting is
|
||||
# enabled.
|
||||
docstring-code-line-length = "dynamic"
|
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2025-present onyx-and-iris <code@onyxandiris.online>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
74
tests/conftest.py
Normal file
74
tests/conftest.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""pytest configuration file."""
|
||||
|
||||
import os
|
||||
|
||||
import obsws_python as obsws
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Call after command line options are parsed.
|
||||
|
||||
All plugins and initial conftest files are loaded.
|
||||
"""
|
||||
|
||||
|
||||
def pytest_sessionstart(session):
|
||||
"""Call after the Session object is created.
|
||||
|
||||
Before performing collection and entering the run test loop.
|
||||
"""
|
||||
# Initialize the OBS WebSocket client
|
||||
session.obsws = obsws.ReqClient(
|
||||
host=os.environ['OBSWS_HOST'],
|
||||
port=os.environ['OBSWS_PORT'],
|
||||
password=os.environ['OBSWS_PASSWORD'],
|
||||
timeout=5,
|
||||
)
|
||||
resp = session.obsws.get_version()
|
||||
|
||||
out = (
|
||||
'Running tests with:',
|
||||
f'OBS Client version: {resp.obs_version} with WebSocket version: {resp.obs_web_socket_version}',
|
||||
)
|
||||
print(' '.join(out))
|
||||
|
||||
session.obsws.create_scene('pytest')
|
||||
session.obsws.create_input(
|
||||
sceneName='pytest',
|
||||
inputName='pytest_input',
|
||||
inputKind='color_source_v3',
|
||||
inputSettings={
|
||||
'color': 3279460728,
|
||||
'width': 1920,
|
||||
'height': 1080,
|
||||
'visible': True,
|
||||
},
|
||||
sceneItemEnabled=True,
|
||||
)
|
||||
session.obsws.create_input(
|
||||
sceneName='pytest',
|
||||
inputName='pytest_input_2',
|
||||
inputKind='color_source_v3',
|
||||
inputSettings={
|
||||
'color': 1789347616,
|
||||
'width': 720,
|
||||
'height': 480,
|
||||
'visible': True,
|
||||
},
|
||||
sceneItemEnabled=True,
|
||||
)
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
"""Call after the whole test run finishes.
|
||||
|
||||
Return the exit status to the system.
|
||||
"""
|
||||
session.obsws.remove_scene('pytest')
|
||||
|
||||
# Close the OBS WebSocket client connection
|
||||
session.obsws.disconnect()
|
||||
|
||||
|
||||
def pytest_unconfigure(config):
|
||||
"""Call before test process is exited."""
|
15
tests/test_cli.py
Normal file
15
tests/test_cli.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""Unit tests for the root command in the OBS WebSocket CLI."""
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from obsws_cli.app import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_version():
|
||||
"""Test the version command."""
|
||||
result = runner.invoke(app, ['version'])
|
||||
assert result.exit_code == 0
|
||||
assert 'OBS Client version' in result.stdout
|
||||
assert 'WebSocket version' in result.stdout
|
15
tests/test_item.py
Normal file
15
tests/test_item.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""Unit tests for the item command in the OBS WebSocket CLI."""
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from obsws_cli.app import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_item_list():
|
||||
"""Test the item list command."""
|
||||
result = runner.invoke(app, ['item', 'list', 'pytest'])
|
||||
assert result.exit_code == 0
|
||||
assert 'pytest_input' in result.stdout
|
||||
assert 'pytest_input_2' in result.stdout
|
32
tests/test_record.py
Normal file
32
tests/test_record.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Unit tests for the record command in the OBS WebSocket CLI."""
|
||||
|
||||
import time
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from obsws_cli.app import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_record_start_status_stop():
|
||||
"""Test the record start command."""
|
||||
result = runner.invoke(app, ['record', 'start'])
|
||||
assert result.exit_code == 0
|
||||
assert 'Recording started successfully.' in result.stdout
|
||||
|
||||
time.sleep(0.5) # Wait for the recording to start
|
||||
|
||||
result = runner.invoke(app, ['record', 'status'])
|
||||
assert result.exit_code == 0
|
||||
assert 'Recording is in progress.' in result.stdout
|
||||
|
||||
result = runner.invoke(app, ['record', 'stop'])
|
||||
assert result.exit_code == 0
|
||||
assert 'Recording stopped successfully.' in result.stdout
|
||||
|
||||
time.sleep(0.5) # Wait for the recording to stop
|
||||
|
||||
result = runner.invoke(app, ['record', 'status'])
|
||||
assert result.exit_code == 0
|
||||
assert 'Recording is not in progress.' in result.stdout
|
22
tests/test_scene.py
Normal file
22
tests/test_scene.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Unit tests for the scene commands in the OBS WebSocket CLI."""
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from obsws_cli.app import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_scene_list():
|
||||
"""Test the scene list command."""
|
||||
result = runner.invoke(app, ['scene', 'list'])
|
||||
assert result.exit_code == 0
|
||||
assert 'pytest' in result.stdout
|
||||
|
||||
|
||||
def test_scene_current():
|
||||
"""Test the scene current command."""
|
||||
runner.invoke(app, ['scene', 'switch', 'pytest'])
|
||||
result = runner.invoke(app, ['scene', 'current'])
|
||||
assert result.exit_code == 0
|
||||
assert 'pytest' in result.stdout
|
Loading…
x
Reference in New Issue
Block a user