147 Commits

Author SHA1 Message Date
dc128720c7 hatch fmt 2025-07-14 02:48:21 +01:00
2e3f4267cd add workflows 2025-07-14 02:45:13 +01:00
000431ab82 add 0.20.0 to CHANGELOG 2025-07-14 02:32:59 +01:00
ec3e31cc4f add Text section to README 2025-07-14 02:32:21 +01:00
cda0bbedb9 minor bump 2025-07-14 02:32:09 +01:00
d0c96b853d add text unit tests 2025-07-14 02:31:47 +01:00
040a41daa7 add text command group 2025-07-14 02:31:35 +01:00
0c72a10fb7 bump obsws-python version 2025-07-01 09:30:04 +01:00
f882302d16 fixes missing argument 2025-07-01 09:29:56 +01:00
98e0d98cc7 typo 2025-06-27 13:45:24 +01:00
c6b22c7cf2 use console.highlight() 2025-06-27 13:29:39 +01:00
c3e55200db move style section
add link to style section in ToC.

add imgs.
2025-06-27 13:14:54 +01:00
4d37714aaf patch bump 2025-06-27 12:57:49 +01:00
157e1a167c fixes bug when setting --style=disabled (we were stil getting coloured check/cross marks) 2025-06-27 12:57:34 +01:00
d628c5d3a4 rename heading variables 2025-06-27 12:53:10 +01:00
4bf8edb692 add 0.19.0 to CHANGELOG 2025-06-23 09:11:26 +01:00
d68326f37a add record split/chapter to README 2025-06-23 09:11:15 +01:00
a001455dad add record split/chapter commands 2025-06-23 09:10:53 +01:00
4632260961 add --style validation
add Disabled class to style registry

patch bump
2025-06-22 12:35:21 +01:00
55a7da67db reword 2025-06-22 10:19:46 +01:00
7bec573ef9 by setting values in the default style to 'none' we avoid the rich markup errors in console.highlight
add comment to util.check_mark and test only NO_COLOR

patch bump
2025-06-22 10:14:46 +01:00
55e60ff977 in case NO_COLOR is set manually
patch bump
2025-06-22 02:49:32 +01:00
922efddf7a check if we're in colourless mode before passing back highlighted text.
pass context to check_mark so we can do the same there.

Fixes  rich.errors.MarkupError
2025-06-22 01:57:58 +01:00
4a0147aa8a import as version 2025-06-22 00:38:19 +01:00
cec76df1d1 add 0.18.0 to CHANGELOG 2025-06-21 23:47:19 +01:00
2e5fb3800a add Style section to README 2025-06-21 23:40:45 +01:00
3c985f5e9b apply table styling + stdout highlighting
fix issues with table heading alignments
2025-06-21 23:40:30 +01:00
fb17979cb0 add highlight helper function 2025-06-21 23:39:13 +01:00
a1ed208bdf add --style and --no-border flags.
set default values in Settings class
2025-06-21 23:37:20 +01:00
02baa13dba add style definitions 2025-06-21 23:32:02 +01:00
7abbccae99 add RootTyperAliasGroup
improve the output of projector open if the monitor index is invalid (suggests prj ls-m)

fix highlight for sceneitem commands in _validate_sources()

patch bump
2025-06-21 05:19:57 +01:00
23282a60d1 test against empty string to keep it consisten with rich
patch bump
2025-06-21 02:36:44 +01:00
b6ba66db64 update disable colouring section 2025-06-21 02:06:30 +01:00
c4480895a1 add empty_if_false to check_mark
patch bump
2025-06-21 00:41:33 +01:00
fd2e629ec2 print colourless check/cross marks if NO_COLOR is set
patch bump
2025-06-20 23:19:27 +01:00
85b653891d keep the colour cyan
patch bump
2025-06-20 21:09:35 +01:00
bff5d396a4 import console as namespace
patch bump
2025-06-20 07:51:12 +01:00
47324597d7 minor bump 2025-06-20 04:04:35 +01:00
9a0659ae35 add 0.16.11 to CHANGELOG 2025-06-20 02:30:25 +01:00
a726f9699f update scene list, sceneitem list and filter list with --uuid flags 2025-06-20 02:30:02 +01:00
fbea2cb896 import console as namespace
each console object is now a singleton

patch bump
2025-06-20 02:29:36 +01:00
e5040d5ddd add hidden --debug flag for controlling logging output
patch bump
2025-06-20 02:13:50 +01:00
39f1b01926 add --uuid flags to input list, scene list and sceneitem list 2025-06-20 01:32:36 +01:00
e9b3106aa6 if no filters are applied, ensure we include the entire kind list
patch bump
2025-06-19 23:10:52 +01:00
a26ce74151 add lazyimports environment, see https://github.com/fastapi/typer/pull/1128 2025-06-19 20:35:45 +01:00
f1c569f140 remove inline if else 2025-06-08 12:38:28 +01:00
093e9a05d4 add 0.16.8 to CHANGELOG 2025-06-07 20:11:00 +01:00
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
44527b35e2 upd --password show_default 2025-06-06 17:15:27 +01:00
0bcfc2ae14 ensure set/get both enforce OBS_ prefix
add class docstring

patch bump
2025-06-06 17:03:24 +01:00
ab71414d27 no need to create list here 2025-06-04 18:03:29 +01:00
ab0679174b patch bump 2025-06-04 17:34:59 +01:00
37781f6de7 clean up defaults in help messages 2025-06-04 17:34:45 +01:00
5e84becc57 wrap annotations with Annotated 2025-06-04 16:46:29 +01:00
b8dd94ccbc wrap annotations with Annotated 2025-06-04 16:31:43 +01:00
657fa84ea3 wrap scene switch annotations with Annotated 2025-06-04 16:26:27 +01:00
59f52417cd wrap annotations with Annotated 2025-06-04 15:52:35 +01:00
2d351e00b5 wrap annotations with Annotated 2025-06-04 15:49:44 +01:00
5f606b42d0 wrap annotations with Annotated 2025-06-04 15:46:52 +01:00
ae4ec542aa wrap annotations with Annotated 2025-06-04 15:39:53 +01:00
6ac63aa5e8 patch bump 2025-06-04 15:27:03 +01:00
df90614352 add Changed filter list to 0.16.1 2025-06-04 15:25:14 +01:00
d8e89285cc upd Filter section in readme 2025-06-04 15:24:48 +01:00
3e2a1e4663 wrap annotations with Annotated
filter list source_name now optional, defaults to current scene

filter list now prints default values if they are unchanged
2025-06-04 15:24:35 +01:00
723d79e306 dry up the imports 2025-06-04 15:23:13 +01:00
868d40ec8d minor bump 2025-06-04 12:53:20 +01:00
30f19f4d87 add 0.16.0 to CHANGELOG 2025-06-04 12:53:04 +01:00
5b9dd97167 add screenshot sub typer 2025-06-04 12:52:51 +01:00
d41ad994b7 add screenshot section 2025-06-04 12:51:47 +01:00
51a4a60aa6 typo 2025-06-03 18:26:18 +01:00
3bf20a06d6 fix version test 2025-06-03 16:59:50 +01:00
653bdb7de7 upd link to --version 2025-06-03 13:10:58 +01:00
8a04303af7 swap out pydantic-settings for dotenv (speedup import time)
add short names for root options.
2025-06-03 12:39:39 +01:00
7b94ca2d7d write version to rich console 2025-06-02 18:24:19 +01:00
3dbff1cc4d add 0.15.0 to CHANGELOG
minor bump
2025-06-02 17:32:52 +01:00
75fdbf5ad8 raise typer.Exit() to signify we return early with exit code 0 2025-06-02 17:32:34 +01:00
ec444d9cdd add version_callback
rename version command to obs-version

upd version unit test
2025-06-02 17:31:44 +01:00
370c82f393 update root typer section:
- add --version/-v option
- upd version command
2025-06-02 17:22:15 +01:00
b9d2afb108 add 0.14.2 to CHANGELOG 2025-05-29 14:49:23 +01:00
79d4611312 patch bump 2025-05-29 14:46:02 +01:00
e637c0456e print a more useful sceneitem list table
--parent flag for sceneitem commands renamed to --group

should an item in a group be passed without the --group option, print a more useful error message.

README updated to reflect change
2025-05-29 14:45:49 +01:00
2127d175c7 catch OBSSDKRequestError 2025-05-29 14:42:32 +01:00
530daced56 ensure record stop contains 'Saved to:' 2025-05-28 14:36:33 +01:00
5bfd642032 upd stream/record tests
test different exit codes and outputs according to current stream/record state
2025-05-28 14:28:38 +01:00
f8d3ed75cb ensure studio mode disabled at end of tests 2025-05-28 14:27:52 +01:00
36e260efde ensure tests are randomized 2025-05-28 14:27:41 +01:00
49f918db00 write stream not in progress status to stdout
patch bump
2025-05-28 14:27:25 +01:00
704c8c1bf4 add 0.14.0 to CHANGELOG 2025-05-27 01:24:21 +01:00
e7d9deba71 record stop now prints the the output path of the recording
added record directory command

record unit test updated

README updated

minor bump
2025-05-27 01:20:35 +01:00
ca0f01ef79 fix index column alignment 2025-05-26 22:14:30 +01:00
c4f3f1713f add open scene/group examples to projector section 2025-05-26 22:07:30 +01:00
47b6ef49ed add output to projector open
patch bump
2025-05-26 21:59:46 +01:00
2c7302cfde project open source_name arg now optional.
defaults to current scene

patch bump
2025-05-26 21:37:32 +01:00
a7385e58c6 minor bump 2025-05-26 20:55:30 +01:00
ac4dbb69ec add 0.13.0 to CHANGELOG
add projector section to README
2025-05-26 20:54:47 +01:00
2739fa28f0 add projector subtyper 2025-05-26 20:54:22 +01:00
b5364bfedc return 0 and write to stdout if empty list 2025-05-26 20:54:06 +01:00
9fa61351d0 return 0 for empty lists and write to stdout 2025-05-26 20:45:36 +01:00
c71aa82914 patch bump 2025-05-26 00:22:41 +01:00
9dd5fedd92 split stderr from stdout in tests 2025-05-26 00:22:26 +01:00
85d2cce4c6 update the pytest scene name
create and destroy source/scene filters at start/end of tests

ensure we stop replay buffer if its running at end of tests
2025-05-26 00:22:11 +01:00
fdbb3ebe22 add hotkey tests 2025-05-26 00:21:14 +01:00
06d83ce05a add input tests 2025-05-26 00:21:04 +01:00
df6f65eda0 replaybuffer start/stop now check status first
add replaybuffer tests
2025-05-26 00:20:22 +01:00
0a944f1f58 check for error code 600 and print error message
add filter tests
2025-05-26 00:18:53 +01:00
057e677d90 rename file 2025-05-26 00:18:03 +01:00
eb686ae58e virtualcam - print output_active status on toggle
patch bump
2025-05-25 22:17:10 +01:00
1c86b1f6ef add examples without passing optional arg 2025-05-25 14:53:12 +01:00
86dc542937 upd table padding + column heading
patch bump
2025-05-25 14:42:55 +01:00
69fccbfe99 upd filter examples 2025-05-25 12:38:58 +01:00
31c3f87c7e md fix 2025-05-25 12:35:17 +01:00
133ce8e711 fix table alignments
patch bump
2025-05-25 10:27:10 +01:00
a82344b79e write to rich consoles
patch bump
2025-05-24 20:33:53 +01:00
f223c51a71 patch bump 2025-05-24 06:08:33 +01:00
5988f450b4 print sceneitem list as rich table
scene_name arg now optional

upd README
2025-05-24 06:08:20 +01:00
fd3c020c3f print scene collection list as rich table 2025-05-24 06:07:52 +01:00
b21ed78bfa print scene list as rich table 2025-05-24 06:07:35 +01:00
9cfbd67b25 write to rich consoles 2025-05-24 06:06:45 +01:00
5189ee1d5b print profile list as rich table
patch bump
2025-05-23 22:37:22 +01:00
94d6c32c31 print hotkey list as rich table
patch bump
2025-05-23 22:28:43 +01:00
995500b971 print input list as rich table
patch bump
2025-05-23 22:20:10 +01:00
abeb5285d8 print group list as rich table
scene_name arg is now optional

upd README

patch bump
2025-05-23 21:55:52 +01:00
37dbbdf4e2 print list as rich table
swap out typer.echo for rich consoles

add filter status command

add util function

minor bump
2025-05-23 21:29:18 +01:00
eaa66f0bd5 add filter commands 2025-05-23 10:28:03 +01:00
e72d1d2eb8 upd hotkey trs help message 2025-05-23 10:15:37 +01:00
5192368ba8 add 0.11.0 section to changelog 2025-05-22 09:43:54 +01:00
a84754d5ec add hotkey sub typer
minor bump
2025-05-22 09:43:42 +01:00
48fab684a3 add hotkey section 2025-05-22 09:42:51 +01:00
b186685e2f keep it consistent 2025-05-19 14:41:42 +01:00
1dd6992129 add output to scene switch command
add unit test

patch bump
2025-05-19 01:45:31 +01:00
81762508a7 add env var and defaults to --help
move Settings into settings module

patch bump
2025-05-19 01:42:59 +01:00
34fbc77182 fixes AttributeError
release bump
2025-05-14 20:36:43 +01:00
e8b699cba6 add output to studiomode enable/disable comands
upd studiomode unit tests
2025-05-08 01:15:52 +01:00
4f0a3816ba upd group toggle test 2025-05-08 00:39:06 +01:00
02614cd33c set scene colletion to default at end of test 2025-05-07 23:26:09 +01:00
4a41239e50 update toggle commands
add toggle record/toggle stream tests

pre-release patch bump
2025-05-07 19:43:32 +01:00
48 changed files with 2994 additions and 585 deletions

40
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Publish to PyPI
on:
release:
types: [published]
push:
tags:
- 'v*.*.*'
jobs:
deploy:
runs-on: ubuntu-latest
environment: release
permissions:
# This permission is needed for private repositories.
contents: read
# IMPORTANT: this permission is mandatory for trusted publishing
id-token: write
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install hatch
- name: Build package
run: hatch build
- name: Publish on PyPI
if: github.ref == 'refs/heads/main'
run: hatch publish

19
.github/workflows/ruff.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Ruff
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v3
with:
args: 'format --check --diff'

View File

@@ -5,6 +5,132 @@ 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.20.0] - 2025-07-14
### Added
- text command group, see [Text](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#text)
# [0.19.0] - 2025-06-23
### Added
- record split and record chapter commands, see [Record](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#record)
- As of OBS 30.2.0, the only file format supporting *record chapter* is Hybrid MP4.
# [0.18.0] - 2025-06-21
### Added
- Various colouring styles, see [Style](https://github.com/onyx-and-iris/obsws-cli/tree/main?tab=readme-ov-file#style)
- colouring is applied to list tables as well as highlighted information in stdout/stderr output.
- table border styling may be optionally disabled with the --no-border flag.
# [0.17.3] - 2025-06-20
### Added
- input list, scene list and sceneitem list now accept --uuid flag.
- Active column added to scene list table.
### Changed
- scene list no longer prints the UUIDs by default, enable it with the --uuid flag.
- if NO_COLOR is set, print colourless check and cross marks in tables.
### Fixed
- Issue with input list not printing all inputs if no filters were applied.
# [0.16.8] - 2025-06-07
### Added
- filter list:
- --ffmpeg, --vlc flags
- Muted column to list table
# [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
- screenshot save command, see [Screenshot](https://github.com/onyx-and-iris/obsws-cli/tree/main?tab=readme-ov-file#screenshot)
### Changed
- filter list:
- source_name arg is now optional, it defaults to the current scene.
- default values are printed if unmodified.
# [0.15.0] - 2025-06-02
### Added
- root typer now accepts --version/-v option, it returns the CLI version. See [Flags](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#flags)
### Changed
- version command renamed to obs-version
# [0.14.2] - 2025-05-29
### Changed
- The --parent flag for sceneitem commands has been renamed to --group. See [Scene Item](https://github.com/onyx-and-iris/obsws-cli/tree/main?tab=readme-ov-file#scene-item)
# [0.14.0] - 2025-05-27
### Added
- record directory command, see [directory under Record](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#record)
### Changed
- project open <source_name> arg now optional, if not passed the current scene will be projected
- record stop now prints the output path of the recording.
### Fixed
- Index column alignment in projector list-monitors now centred.
# [0.13.0] - 2025-05-26
### Added
- projector commands, see [projector](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#projector)
### Changed
- list commands that result in empty lists now return exit code 0 and write to stdout.
# [0.12.0] - 2025-05-23
### Added
- filter commands, see [Filter](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#filter)
# [0.11.0] - 2025-05-22
### Added
- hotkey commands, see [Hotkey](https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#hotkey)
# [0.10.0] - 2025-04-27
### Added

249
README.md
View File

@@ -14,6 +14,7 @@ For an outline of past/future changes refer to: [CHANGELOG](CHANGELOG.md)
- [Installation](#installation)
- [Configuration](#configuration)
- [Style](#style)
- [Commands](#root-typer)
- [License](#license)
@@ -42,7 +43,13 @@ The CLI should now be discoverable as `obsws-cli`
#### Flags
Pass `--host`, `--port` and `--password` as flags to the root command, for example:
- --host/-H: Websocket host
- --port/-P Websocket port
- --password/-p: Websocket password
- --timeout/-T: Websocket timeout
- --version/-v: Print the obsws-cli version
Pass `--host`, `--port` and `--password` as flags on the root command, for example:
```console
obsws-cli --host=localhost --port=4455 --password=<websocket password> --help
@@ -62,12 +69,43 @@ OBS_PASSWORD=<websocket password>
Flags can be used to override environment variables.
## Root Typer
## Style
- version: Get the OBS Client and WebSocket versions.
Styling is opt-in, by default you will get a colourless output:
![colourless](./img/colourless.png)
You may enable styling with the --style/-s flag:
```console
obsws-cli version
obsws-cli --style="cyan" sceneitem list
```
Available styles: _red, magenta, purple, blue, cyan, green, yellow, orange, white, grey, navy, black_
![coloured](./img/coloured-border.png)
Optionally you may disable border colouring with the --no-border flag:
![coloured-no-border](./img/coloured-no-border.png)
```console
obsws-cli --style="cyan" --no-border sceneitem list
```
Or with environment variables:
```env
OBS_STYLE=cyan
OBS_STYLE_NO_BORDER=true
```
## Root Typer
- obs-version: Get the OBS Client and WebSocket versions.
```console
obsws-cli obs-version
```
## Sub Typers
@@ -75,6 +113,10 @@ obsws-cli version
#### Scene
- list: List all scenes.
- flags:
*optional*
- --uuid: Show UUIDs of scenes
```console
obsws-cli scene list
@@ -96,9 +138,18 @@ obsws-cli scene switch LIVE
#### Scene Item
- list: List all items in a scene.
- flags:
*optional*
- --uuid: Show UUIDs of scene items
*optional*
- args: <scene_name>
- defaults to current scene
```console
obsws-cli sceneitem list
obsws-cli sceneitem list LIVE
```
@@ -106,7 +157,7 @@ obsws-cli sceneitem list LIVE
- flags:
*optional*
- --parent: Parent group name
- --group: Parent group name
- args: <scene_name> <item_name>
```console
@@ -117,7 +168,7 @@ obsws-cli sceneitem show START "Colour Source"
- flags:
*optional*
- --parent: Parent group name
- --group: Parent group name
- args: <scene_name> <item_name>
```console
@@ -128,28 +179,29 @@ obsws-cli sceneitem hide START "Colour Source"
- flags:
*optional*
- --parent: Parent group name
- --group: Parent group name
- args: <scene_name> <item_name>
```console
obsws-cli sceneitem toggle --parent=test_group START "Colour Source 3"
obsws-cli sceneitem toggle --group=test_group START "Colour Source 3"
```
- visible: Check if an item in a scene is visible.
- flags:
*optional*
- --parent: Parent group name
- --group: Parent group name
- args: <scene_name> <item_name>
```console
obsws-cli sceneitem visible --parent=test_group START "Colour Source 4"
obsws-cli sceneitem visible --group=test_group START "Colour Source 4"
```
- transform: Set the transform of an item in a scene.
- flags:
*optional*
- --parent: Parent group name.
- --group: Parent group name.
- --alignment: Alignment of the item in the scene
- --bounds-alignment: Bounds alignment of the item in the scene
@@ -205,9 +257,14 @@ obsws-cli scenecollection create test-collection
#### Group
- list: List groups in a scene.
*optional*
- args: <scene_name>
- defaults to current scene
```console
obsws-cli group list
obsws-cli group list START
```
@@ -248,6 +305,9 @@ 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.
- --uuid: Show UUIDs of inputs.
```console
obsws-cli input list
@@ -275,6 +335,22 @@ obsws-cli input unmute "Mic/Aux"
obsws-cli input toggle "Mic/Aux"
```
#### Text
- current: Get the current text for a text input.
- args: <input_name>
```console
obsws-cli text current "My Text Input"
```
- update: Update the text of a text input.
- args: <input_name> <new_text>
```console
obsws-cli text update "My Text Input" "hi OBS!"
```
#### Record
- start: Start recording.
@@ -313,6 +389,34 @@ obsws-cli record resume
obsws-cli record pause
```
- directory: Get or set the recording directory.
*optional*
- args: <record_directory>
- if not passed the current record directory will be printed.
```console
obsws-cli record directory
obsws-cli record directory "/home/me/obs-vids/"
obsws-cli record directory "C:/Users/me/Videos"
```
- split: Split the current recording.
```console
obsws-cli record split
```
- chapter: Create a chapter in the current recording.
*optional*
- args: <chapter_name>
```console
obsws-cli record chapter "Chapter Name"
```
#### Stream
- start: Start streaming.
@@ -452,10 +556,131 @@ obsws-cli virtualcam toggle
obsws-cli virtualcam status
```
#### Hotkey
- list: List all hotkeys.
```console
obsws-cli hotkey list
```
- trigger: Trigger a hotkey by name.
```console
obsws-cli hotkey trigger OBSBasic.StartStreaming
obsws-cli hotkey trigger OBSBasic.StopStreaming
```
- trigger-sequence: Trigger a hotkey by sequence.
- flags:
*optional*
- --shift: Press shift.
- --ctrl: Press control.
- --alt: Press alt.
- --cmd: Press command (mac).
- args: <key_id>
- Check [obs-hotkeys.h][obs-keyids] for a full list of OBS key ids.
```console
obsws-cli hotkey trigger-sequence OBS_KEY_F1 --ctrl
obsws-cli hotkey trigger-sequence OBS_KEY_F1 --shift --ctrl
```
#### Filter
- list: List filters for a source.
*optional*
- args: <source_name>
- defaults to current scene
```console
obsws-cli filter list "Mic/Aux"
```
- enable: Enable a filter for a source.
- args: <source_name> <filter_name>
```console
obsws-cli filter enable "Mic/Aux" "Gain"
```
- disable: Disable a filter for a source.
- args: <source_name> <filter_name>
```console
obsws-cli filter disable "Mic/Aux" "Gain"
```
- toggle: Toggle a filter for a source.
- args: <source_name> <filter_name>
```console
obsws-cli filter toggle "Mic/Aux" "Gain"
```
- status: Get the status of a filter for a source.
- args: <source_name> <filter_name>
```console
obsws-cli filter status "Mic/Aux" "Gain"
```
#### Projector
- list-monitors: List available monitors.
```console
obsws-cli projector list-monitors
```
- open: Open a fullscreen projector for a source on a specific monitor.
- flags:
*optional*
- --monitor-index: Index of the monitor to open the projector on.
- defaults to 0
*optional*
- args: <source_name>
- defaults to current scene
```console
obsws-cli projector open
obsws-cli projector open --monitor-index=1 "test_scene"
obsws-cli projector open --monitor-index=1 "test_group"
```
#### Screenshot
- save: Take a screenshot and save it to a file.
- flags:
*optional*
- --width:
- defaults to 1920
- --height:
- defaults to 1080
- --quality:
- defaults to -1
- args: <source_name> <output_path>
```console
obsws-cli screenshot save --width=2560 --height=1440 "Scene" "C:\Users\me\Videos\screenshot.png"
```
## License
`obsws-cli` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
[obs-studio]: https://obsproject.com/
[obs-studio]: https://obsproject.com/
[obs-keyids]: https://github.com/obsproject/obs-studio/blob/master/libobs/obs-hotkeys.h
[no-colour]: https://no-color.org/

BIN
img/coloured-border.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
img/coloured-no-border.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
img/colourless.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

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

View File

@@ -4,4 +4,4 @@
from .app import app
__all__ = ["app"]
__all__ = ['app']

View File

@@ -5,8 +5,54 @@ import re
import typer
class AliasGroup(typer.core.TyperGroup):
"""A custom group class to handle command name aliases."""
class RootTyperAliasGroup(typer.core.TyperGroup):
"""A custom group class to handle command name aliases for the root typer."""
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."""
match cmd_name:
case 'f':
cmd_name = 'filter'
case 'g':
cmd_name = 'group'
case 'hk':
cmd_name = 'hotkey'
case 'i':
cmd_name = 'input'
case 'prf':
cmd_name = 'profile'
case 'prj':
cmd_name = 'projector'
case 'rc':
cmd_name = 'record'
case 'rb':
cmd_name = 'replaybuffer'
case 'sc':
cmd_name = 'scene'
case 'scc':
cmd_name = 'scenecollection'
case 'si':
cmd_name = 'sceneitem'
case 'ss':
cmd_name = 'screenshot'
case 'st':
cmd_name = 'stream'
case 'sm':
cmd_name = 'studiomode'
case 't':
cmd_name = 'text'
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' ?[,|] ?')
@@ -22,7 +68,6 @@ class AliasGroup(typer.core.TyperGroup):
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
if cmd.name and default_name in self._CMD_SPLIT_P.split(cmd.name):
return cmd.name
return default_name

View File

@@ -1,97 +1,165 @@
"""Command line interface for the OBS WebSocket API."""
from pathlib import Path
from typing import Annotated, Optional
import importlib
import logging
from typing import Annotated
import obsws_python as obsws
import typer
from pydantic_settings import BaseSettings, SettingsConfigDict
from . import (
group,
input,
profile,
record,
replaybuffer,
scene,
scenecollection,
sceneitem,
stream,
studiomode,
virtualcam,
)
from .alias import AliasGroup
from obsws_cli.__about__ import __version__ as version
from . import console, settings, styles
from .alias import RootTyperAliasGroup
app = typer.Typer(cls=RootTyperAliasGroup)
for sub_typer in (
'filter',
'group',
'hotkey',
'input',
'profile',
'projector',
'record',
'replaybuffer',
'scene',
'scenecollection',
'sceneitem',
'screenshot',
'stream',
'studiomode',
'text',
'virtualcam',
):
module = importlib.import_module(f'.{sub_typer}', package=__package__)
app.add_typer(module.app, name=sub_typer)
class Settings(BaseSettings):
"""Settings for the OBS WebSocket client."""
def version_callback(value: bool):
"""Show the version of the CLI."""
if value:
console.out.print(f'obsws-cli version: {version}')
raise typer.Exit()
model_config = SettingsConfigDict(
env_file=(
'.env',
Path.home() / '.config' / 'obsws-cli' / 'obsws.env',
),
env_file_encoding='utf-8',
env_prefix='OBS_',
def setup_logging(debug: bool):
"""Set up logging for the application."""
log_level = logging.DEBUG if debug else logging.CRITICAL
logging.basicConfig(
level=log_level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
HOST: str = 'localhost'
PORT: int = 4455
PASSWORD: str = '' # No password by default
TIMEOUT: int = 5 # Timeout for requests in seconds
app = typer.Typer(cls=AliasGroup)
for module in (
group,
input,
profile,
record,
replaybuffer,
scene,
scenecollection,
sceneitem,
stream,
studiomode,
virtualcam,
):
app.add_typer(module.app, name=module.__name__.split('.')[-1])
def validate_style(value: str):
"""Validate and return the style."""
if value not in styles.registry:
raise typer.BadParameter(
f'Invalid style: {value}. Available styles: {", ".join(styles.registry.keys())}'
)
return value
@app.callback()
def main(
ctx: typer.Context,
host: Annotated[Optional[str], typer.Option(help='WebSocket host')] = None,
port: Annotated[Optional[int], typer.Option(help='WebSocket port')] = None,
password: Annotated[Optional[str], typer.Option(help='WebSocket password')] = None,
timeout: Annotated[Optional[int], typer.Option(help='WebSocket timeout')] = None,
host: Annotated[
str,
typer.Option(
'--host',
'-H',
envvar='OBS_HOST',
help='WebSocket host',
show_default='localhost',
),
] = settings.get('host'),
port: Annotated[
int,
typer.Option(
'--port',
'-P',
envvar='OBS_PORT',
help='WebSocket port',
show_default=4455,
),
] = settings.get('port'),
password: Annotated[
str,
typer.Option(
'--password',
'-p',
envvar='OBS_PASSWORD',
help='WebSocket password',
show_default=False,
),
] = settings.get('password'),
timeout: Annotated[
int,
typer.Option(
'--timeout',
'-T',
envvar='OBS_TIMEOUT',
help='WebSocket timeout',
show_default=5,
),
] = settings.get('timeout'),
version: Annotated[
bool,
typer.Option(
'--version',
'-v',
is_eager=True,
help='Show the CLI version and exit',
show_default=False,
callback=version_callback,
),
] = False,
style: Annotated[
str,
typer.Option(
'--style',
'-s',
envvar='OBS_STYLE',
help='Set the style for the CLI output',
show_default='disabled',
callback=validate_style,
),
] = settings.get('style'),
no_border: Annotated[
bool,
typer.Option(
'--no-border',
'-b',
envvar='OBS_STYLE_NO_BORDER',
help='Disable table border styling in the CLI output',
show_default=False,
),
] = settings.get('style_no_border'),
debug: Annotated[
bool,
typer.Option(
'--debug',
'-d',
envvar='OBS_DEBUG',
is_eager=True,
help='Enable debug logging',
show_default=False,
callback=setup_logging,
hidden=True,
),
] = settings.get('debug'),
):
"""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.with_resource(
obsws.ReqClient(
host=settings.HOST,
port=settings.PORT,
password=settings.PASSWORD,
timeout=settings.TIMEOUT,
)
)
ctx.ensure_object(dict)
ctx.obj['obsws'] = ctx.with_resource(obsws.ReqClient(**ctx.params))
ctx.obj['style'] = styles.request_style_obj(style, no_border)
@app.command()
def version(ctx: typer.Context):
def obs_version(ctx: typer.Context):
"""Get the OBS Client and WebSocket versions."""
resp = ctx.obj.get_version()
typer.echo(
f'OBS Client version: {resp.obs_version} with WebSocket version: {resp.obs_web_socket_version}'
resp = ctx.obj['obsws'].get_version()
console.out.print(
f'OBS Client version: {console.highlight(ctx, resp.obs_version)}'
f' with WebSocket version: {console.highlight(ctx, resp.obs_web_socket_version)}'
)

12
obsws_cli/console.py Normal file
View File

@@ -0,0 +1,12 @@
"""module for console output handling in obsws_cli."""
import typer
from rich.console import Console
out = Console()
err = Console(stderr=True, style='bold red')
def highlight(ctx: typer.Context, text: str) -> str:
"""Highlight text using the current context's style."""
return f'[{ctx.obj["style"].highlight}]{text}[/{ctx.obj["style"].highlight}]'

209
obsws_cli/filter.py Normal file
View File

@@ -0,0 +1,209 @@
"""module containing commands for manipulating filters in scenes."""
from typing import Annotated, Optional
import obsws_python as obsws
import typer
from rich.table import Table
from rich.text import Text
from . import console, util
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
def main():
"""Control filters in OBS scenes."""
@app.command('list | ls')
def list_(
ctx: typer.Context,
source_name: Annotated[
Optional[str],
typer.Argument(
show_default='The current scene',
help='The source to list filters for',
),
] = None,
):
"""List filters for a source."""
if not source_name:
source_name = ctx.obj['obsws'].get_current_program_scene().scene_name
try:
resp = ctx.obj['obsws'].get_source_filter_list(source_name)
except obsws.error.OBSSDKRequestError as e:
if e.code == 600:
console.err.print(
f'No source was found by the name of [yellow]{source_name}[/yellow].'
)
raise typer.Exit(1)
else:
raise
if not resp.filters:
console.out.print(
f'No filters found for source {console.highlight(ctx, source_name)}'
)
raise typer.Exit()
table = Table(
title=f'Filters for Source: {source_name}',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
columns = [
(Text('Filter Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Kind', justify='center'), 'left', ctx.obj['style'].column),
(Text('Enabled', justify='center'), 'center', None),
(Text('Settings', justify='center'), 'center', ctx.obj['style'].column),
]
for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style)
for filter in resp.filters:
resp = ctx.obj['obsws'].get_source_filter_default_settings(filter['filterKind'])
settings = resp.default_filter_settings | filter['filterSettings']
table.add_row(
filter['filterName'],
util.snakecase_to_titlecase(filter['filterKind']),
util.check_mark(filter['filterEnabled']),
'\n'.join(
[
f'{util.snakecase_to_titlecase(k):<20} {v:>10}'
for k, v in settings.items()
]
),
)
console.out.print(table)
def _get_filter_enabled(ctx: typer.Context, source_name: str, filter_name: str):
"""Get the status of a filter for a source."""
resp = ctx.obj['obsws'].get_source_filter(source_name, filter_name)
return resp.filter_enabled
@app.command('enable | on')
def enable(
ctx: typer.Context,
source_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The source to enable the filter for'
),
],
filter_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The name of the filter to enable'
),
],
):
"""Enable a filter for a source."""
if _get_filter_enabled(ctx, source_name, filter_name):
console.err.print(
f'Filter [yellow]{filter_name}[/yellow] is already enabled for source [yellow]{source_name}[/yellow]'
)
raise typer.Exit(1)
ctx.obj['obsws'].set_source_filter_enabled(source_name, filter_name, enabled=True)
console.out.print(
f'Enabled filter {console.highlight(ctx, filter_name)} for source {console.highlight(ctx, source_name)}'
)
@app.command('disable | off')
def disable(
ctx: typer.Context,
source_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The source to disable the filter for'
),
],
filter_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The name of the filter to disable'
),
],
):
"""Disable a filter for a source."""
if not _get_filter_enabled(ctx, source_name, filter_name):
console.err.print(
f'Filter [yellow]{filter_name}[/yellow] is already disabled for source [yellow]{source_name}[/yellow]'
)
raise typer.Exit(1)
ctx.obj['obsws'].set_source_filter_enabled(source_name, filter_name, enabled=False)
console.out.print(
f'Disabled filter {console.highlight(ctx, filter_name)} for source {console.highlight(ctx, source_name)}'
)
@app.command('toggle | tg')
def toggle(
ctx: typer.Context,
source_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The source to toggle the filter for'
),
],
filter_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The name of the filter to toggle'
),
],
):
"""Toggle a filter for a source."""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name)
new_state = not is_enabled
ctx.obj['obsws'].set_source_filter_enabled(
source_name, filter_name, enabled=new_state
)
if new_state:
console.out.print(
f'Enabled filter {console.highlight(ctx, filter_name)} for source {console.highlight(ctx, source_name)}'
)
else:
console.out.print(
f'Disabled filter {console.highlight(ctx, filter_name)} for source {console.highlight(ctx, source_name)}'
)
@app.command('status | ss')
def status(
ctx: typer.Context,
source_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The source to get the filter status for'
),
],
filter_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='The name of the filter to get the status for'
),
],
):
"""Get the status of a filter for a source."""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name)
if is_enabled:
console.out.print(
f'Filter {console.highlight(ctx, filter_name)} is enabled for source {console.highlight(ctx, source_name)}'
)
else:
console.out.print(
f'Filter {console.highlight(ctx, filter_name)} is disabled for source {console.highlight(ctx, source_name)}'
)

View File

@@ -1,12 +1,16 @@
"""module containing commands for manipulating groups in scenes."""
import typer
from typing import Annotated, Optional
from . import validate
from .alias import AliasGroup
import typer
from rich.table import Table
from rich.text import Text
from . import console, util, validate
from .alias import SubTyperAliasGroup
from .protocols import DataclassProtocol
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -15,17 +19,59 @@ def main():
@app.command('list | ls')
def list(ctx: typer.Context, scene_name: str):
def list_(
ctx: typer.Context,
scene_name: Annotated[
Optional[str],
typer.Argument(
show_default='The current scene',
help='Scene name to list groups for',
),
] = None,
):
"""List groups in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
if not scene_name:
scene_name = ctx.obj['obsws'].get_current_program_scene().scene_name
resp = ctx.obj.get_scene_item_list(scene_name)
groups = (
item.get('sourceName') for item in resp.scene_items if item.get('isGroup')
if not validate.scene_in_scenes(ctx, scene_name):
console.err.print(f"Scene '{scene_name}' not found.")
raise typer.Exit(1)
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
groups = [
(item.get('sceneItemId'), item.get('sourceName'), item.get('sceneItemEnabled'))
for item in resp.scene_items
if item.get('isGroup')
]
if not groups:
console.out.print(
f'No groups found in scene {console.highlight(ctx, scene_name)}.'
)
raise typer.Exit()
table = Table(
title=f'Groups in Scene: {scene_name}',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
typer.echo('\n'.join(groups))
columns = [
(Text('ID', justify='center'), 'center', ctx.obj['style'].column),
(Text('Group Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Enabled', justify='center'), 'center', None),
]
for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style)
for item_id, group_name, is_enabled in groups:
table.add_row(
str(item_id),
group_name,
util.check_mark(is_enabled),
)
console.out.print(table)
def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None:
@@ -42,90 +88,131 @@ def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None:
@app.command('show | sh')
def show(ctx: typer.Context, scene_name: str, group_name: str):
def show(
ctx: typer.Context,
scene_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Scene name the group is in'),
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to show')
],
):
"""Show a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(f"Scene '{scene_name}' not found.")
raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name)
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None:
typer.echo(f"Group '{group_name}' not found in scene {scene_name}.", err=True)
raise typer.Exit(code=1)
console.err.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(
ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name,
item_id=int(group.get('sceneItemId')),
enabled=True,
)
typer.echo(f"Group '{group_name}' is now visible.")
console.out.print(f'Group {console.highlight(ctx, group_name)} is now visible.')
@app.command('hide | h')
def hide(ctx: typer.Context, scene_name: str, group_name: str):
def hide(
ctx: typer.Context,
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the group is in')
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to hide')
],
):
"""Hide a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name)
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None:
typer.echo(f"Group '{group_name}' not found in scene {scene_name}.", err=True)
raise typer.Exit(code=1)
console.err.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(
ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name,
item_id=int(group.get('sceneItemId')),
enabled=False,
)
typer.echo(f"Group '{group_name}' is now hidden.")
console.out.print(f'Group {console.highlight(ctx, group_name)} is now hidden.')
@app.command('toggle | tg')
def toggle(ctx: typer.Context, scene_name: str, group_name: str):
def toggle(
ctx: typer.Context,
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the group is in')
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to toggle')
],
):
"""Toggle a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name)
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None:
typer.echo(f"Group '{group_name}' not found in scene {scene_name}.", err=True)
raise typer.Exit(code=1)
console.err.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')
ctx.obj.set_scene_item_enabled(
ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name,
item_id=int(group.get('sceneItemId')),
enabled=new_state,
)
if new_state:
typer.echo(f"Group '{group_name}' is now visible.")
console.out.print(f'Group {console.highlight(ctx, group_name)} is now visible.')
else:
typer.echo(f"Group '{group_name}' is now hidden.")
console.out.print(f'Group {console.highlight(ctx, group_name)} is now hidden.')
@app.command('status | ss')
def status(ctx: typer.Context, scene_name: str, group_name: str):
def status(
ctx: typer.Context,
scene_name: Annotated[
str, typer.Argument(..., show_default=False, help='Scene name the group is in')
],
group_name: Annotated[
str, typer.Argument(..., show_default=False, help='Group name to check status')
],
):
"""Get the status of a group in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj.get_scene_item_list(scene_name)
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
if (group := _get_group(group_name, resp)) is None:
typer.echo(f"Group '{group_name}' not found in scene {scene_name}.", err=True)
raise typer.Exit(code=1)
console.err.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(
enabled = ctx.obj['obsws'].get_scene_item_enabled(
scene_name=scene_name,
item_id=int(group.get('sceneItemId')),
)
if enabled.scene_item_enabled:
typer.echo(f"Group '{group_name}' is now visible.")
console.out.print(f'Group {console.highlight(ctx, group_name)} is now visible.')
else:
typer.echo(f"Group '{group_name}' is now hidden.")
console.out.print(f'Group {console.highlight(ctx, group_name)} is now hidden.')

80
obsws_cli/hotkey.py Normal file
View File

@@ -0,0 +1,80 @@
"""module containing commands for hotkey management."""
from typing import Annotated
import typer
from rich.table import Table
from rich.text import Text
from . import console
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
def main():
"""Control hotkeys in OBS."""
@app.command('list | ls')
def list_(
ctx: typer.Context,
):
"""List all hotkeys."""
resp = ctx.obj['obsws'].get_hotkey_list()
table = Table(
title='Hotkeys',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
table.add_column(
Text('Hotkey Name', justify='center'),
justify='left',
style=ctx.obj['style'].column,
)
for i, hotkey in enumerate(resp.hotkeys):
table.add_row(hotkey, style='' if i % 2 == 0 else 'dim')
console.out.print(table)
@app.command('trigger | tr')
def trigger(
ctx: typer.Context,
hotkey: Annotated[
str, typer.Argument(..., show_default=False, help='The hotkey to trigger')
],
):
"""Trigger a hotkey by name."""
ctx.obj['obsws'].trigger_hotkey_by_name(hotkey)
@app.command('trigger-sequence | trs')
def trigger_sequence(
ctx: typer.Context,
key_id: Annotated[
str,
typer.Argument(
...,
show_default=False,
help='The OBS key ID to trigger, see https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#hotkey for more info',
),
],
shift: Annotated[
bool, typer.Option(..., help='Press shift when triggering the hotkey')
] = False,
ctrl: Annotated[
bool, typer.Option(..., help='Press control when triggering the hotkey')
] = False,
alt: Annotated[
bool, typer.Option(..., help='Press alt when triggering the hotkey')
] = False,
cmd: Annotated[
bool, typer.Option(..., help='Press cmd when triggering the hotkey')
] = False,
):
"""Trigger a hotkey by sequence."""
ctx.obj['obsws'].trigger_hotkey_by_key_sequence(key_id, shift, ctrl, alt, cmd)

View File

@@ -2,12 +2,15 @@
from typing import Annotated
import obsws_python as obsws
import typer
from rich.table import Table
from rich.text import Text
from . import validate
from .alias import AliasGroup
from . import console, util, validate
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -16,14 +19,17 @@ def main():
@app.command('list | ls')
def list(
def list_(
ctx: typer.Context,
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,
uuid: Annotated[bool, typer.Option(help='Show UUIDs of inputs.')] = False,
):
"""List all inputs."""
resp = ctx.obj.get_input_list()
resp = ctx.obj['obsws'].get_input_list()
kinds = []
if input:
@@ -32,62 +38,140 @@ 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 = ctx.obj['obsws'].get_input_kind_list(False).input_kinds
inputs = filter(
lambda input_: any(kind in input_.get('inputKind') for kind in kinds),
resp.inputs,
inputs = sorted(
(
(input_.get('inputName'), input_.get('inputKind'), input_.get('inputUuid'))
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
)
typer.echo('\n'.join(input_.get('inputName') for input_ in inputs))
if not inputs:
console.out.print('No inputs found.')
raise typer.Exit()
table = Table(title='Inputs', padding=(0, 2), border_style=ctx.obj['style'].border)
if uuid:
columns = [
(Text('Input Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Kind', justify='center'), 'center', ctx.obj['style'].column),
(Text('Muted', justify='center'), 'center', None),
(Text('UUID', justify='center'), 'left', ctx.obj['style'].column),
]
else:
columns = [
(Text('Input Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Kind', justify='center'), 'center', ctx.obj['style'].column),
(Text('Muted', justify='center'), 'center', None),
]
for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style)
for input_name, input_kind, input_uuid in inputs:
input_mark = ''
try:
input_muted = ctx.obj['obsws'].get_input_mute(name=input_name).input_muted
input_mark = util.check_mark(input_muted)
except obsws.error.OBSSDKRequestError as e:
if e.code == 604: # Input does not support audio
input_mark = 'N/A'
else:
raise
if uuid:
table.add_row(
input_name,
util.snakecase_to_titlecase(input_kind),
input_mark,
input_uuid,
)
else:
table.add_row(
input_name,
util.snakecase_to_titlecase(input_kind),
input_mark,
)
console.out.print(table)
@app.command('mute | m')
def mute(ctx: typer.Context, input_name: str):
def mute(
ctx: typer.Context,
input_name: Annotated[
str, typer.Argument(..., show_default=False, help='Name of the input to mute.')
],
):
"""Mute an input."""
if not validate.input_in_inputs(ctx, input_name):
typer.echo(f"Input '{input_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(f'Input [yellow]{input_name}[/yellow] not found.')
raise typer.Exit(1)
ctx.obj.set_input_mute(
ctx.obj['obsws'].set_input_mute(
name=input_name,
muted=True,
)
typer.echo(f"Input '{input_name}' muted.")
console.out.print(f'Input {console.highlight(ctx, input_name)} muted.')
@app.command('unmute | um')
def unmute(ctx: typer.Context, input_name: str):
def unmute(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Name of the input to unmute.'),
],
):
"""Unmute an input."""
if not validate.input_in_inputs(ctx, input_name):
typer.echo(f"Input '{input_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(f'Input [yellow]{input_name}[/yellow] not found.')
raise typer.Exit(1)
ctx.obj.set_input_mute(
ctx.obj['obsws'].set_input_mute(
name=input_name,
muted=False,
)
typer.echo(f"Input '{input_name}' unmuted.")
console.out.print(f'Input {console.highlight(ctx, input_name)} unmuted.')
@app.command('toggle | tg')
def toggle(ctx: typer.Context, input_name: str):
def toggle(
ctx: typer.Context,
input_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Name of the input to toggle.'),
],
):
"""Toggle an input."""
if not validate.input_in_inputs(ctx, input_name):
typer.echo(f"Input '{input_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(f'Input [yellow]{input_name}[/yellow] not found.')
raise typer.Exit(1)
# Get the current mute state
resp = ctx.obj.get_input_mute(name=input_name)
resp = ctx.obj['obsws'].get_input_mute(name=input_name)
new_state = not resp.input_muted
ctx.obj.set_input_mute(
ctx.obj['obsws'].set_input_mute(
name=input_name,
muted=new_state,
)
typer.echo(
f"Input '{input_name}' {'muted' if new_state else 'unmuted'}.",
)
if new_state:
console.out.print(
f'Input {console.highlight(ctx, input_name)} muted.',
)
else:
console.out.print(
f'Input {console.highlight(ctx, input_name)} unmuted.',
)

View File

@@ -1,11 +1,15 @@
"""module containing commands for manipulating profiles in OBS."""
from typing import Annotated
import typer
from rich.table import Table
from rich.text import Text
from . import validate
from .alias import AliasGroup
from . import console, util, validate
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -14,55 +18,95 @@ def main():
@app.command('list | ls')
def list(ctx: typer.Context):
def list_(ctx: typer.Context):
"""List profiles."""
resp = ctx.obj.get_profile_list()
resp = ctx.obj['obsws'].get_profile_list()
table = Table(
title='Profiles', padding=(0, 2), border_style=ctx.obj['style'].border
)
columns = [
(Text('Profile Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Current', justify='center'), 'center', None),
]
for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style)
for profile in resp.profiles:
typer.echo(profile)
table.add_row(
profile,
util.check_mark(
ctx, profile == resp.current_profile_name, empty_if_false=True
),
)
console.out.print(table)
@app.command('current | get')
def current(ctx: typer.Context):
"""Get the current profile."""
resp = ctx.obj.get_profile_list()
typer.echo(resp.current_profile_name)
resp = ctx.obj['obsws'].get_profile_list()
console.out.print(
f'Current profile: {console.highlight(ctx, resp.current_profile_name)}'
)
@app.command('switch | set')
def switch(ctx: typer.Context, profile_name: str):
def switch(
ctx: typer.Context,
profile_name: Annotated[
str,
typer.Argument(
..., show_default=False, help='Name of the profile to switch to'
),
],
):
"""Switch to a profile."""
if not validate.profile_exists(ctx, profile_name):
typer.echo(f"Profile '{profile_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(f'Profile [yellow]{profile_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj.get_profile_list()
resp = ctx.obj['obsws'].get_profile_list()
if resp.current_profile_name == profile_name:
typer.echo(
f"Profile '{profile_name}' is already the current profile.", err=True
console.err.print(
f'Profile [yellow]{profile_name}[/yellow] is already the current profile.'
)
raise typer.Exit(code=1)
raise typer.Exit(1)
ctx.obj.set_current_profile(profile_name)
typer.echo(f"Switched to profile '{profile_name}'.")
ctx.obj['obsws'].set_current_profile(profile_name)
console.out.print(f'Switched to profile {console.highlight(ctx, profile_name)}.')
@app.command('create | new')
def create(ctx: typer.Context, profile_name: str):
def create(
ctx: typer.Context,
profile_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Name of the profile to create.'),
],
):
"""Create a new profile."""
if validate.profile_exists(ctx, profile_name):
typer.echo(f"Profile '{profile_name}' already exists.", err=True)
raise typer.Exit(code=1)
console.err.print(f'Profile [yellow]{profile_name}[/yellow] already exists.')
raise typer.Exit(1)
ctx.obj.create_profile(profile_name)
typer.echo(f"Created profile '{profile_name}'.")
ctx.obj['obsws'].create_profile(profile_name)
console.out.print(f'Created profile {console.highlight(ctx, profile_name)}.')
@app.command('remove | rm')
def remove(ctx: typer.Context, profile_name: str):
def remove(
ctx: typer.Context,
profile_name: Annotated[
str,
typer.Argument(..., show_default=False, help='Name of the profile to remove.'),
],
):
"""Remove a profile."""
if not validate.profile_exists(ctx, profile_name):
typer.echo(f"Profile '{profile_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(f'Profile [yellow]{profile_name}[/yellow] not found.')
raise typer.Exit(1)
ctx.obj.remove_profile(profile_name)
typer.echo(f"Removed profile '{profile_name}'.")
ctx.obj['obsws'].remove_profile(profile_name)
console.out.print(f'Removed profile {console.highlight(ctx, profile_name)}.')

89
obsws_cli/projector.py Normal file
View File

@@ -0,0 +1,89 @@
"""module containing commands for manipulating projectors in OBS."""
from typing import Annotated
import typer
from rich.table import Table
from rich.text import Text
from . import console
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
def main():
"""Control projectors in OBS."""
@app.command('list-monitors | ls-m')
def list_monitors(ctx: typer.Context):
"""List available monitors."""
resp = ctx.obj['obsws'].get_monitor_list()
if not resp.monitors:
console.out.print('No monitors found.')
return
monitors = sorted(
((m['monitorIndex'], m['monitorName']) for m in resp.monitors),
key=lambda m: m[0],
)
table = Table(
title='Available Monitors',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
table.add_column(
Text('Index', justify='center'), justify='center', style=ctx.obj['style'].column
)
table.add_column(
Text('Name', justify='center'), justify='left', style=ctx.obj['style'].column
)
for index, monitor in monitors:
table.add_row(str(index), monitor)
console.out.print(table)
@app.command('open | o')
def open(
ctx: typer.Context,
monitor_index: Annotated[
int,
typer.Option(help='Index of the monitor to open the projector on.'),
] = 0,
source_name: Annotated[
str,
typer.Argument(
show_default='The current scene',
help='Name of the source to project.',
),
] = '',
):
"""Open a fullscreen projector for a source on a specific monitor."""
if not source_name:
source_name = ctx.obj['obsws'].get_current_program_scene().scene_name
monitors = ctx.obj['obsws'].get_monitor_list().monitors
for monitor in monitors:
if monitor['monitorIndex'] == monitor_index:
ctx.obj['obsws'].open_source_projector(
source_name=source_name,
monitor_index=monitor_index,
)
console.out.print(
f'Opened projector for source {console.highlight(ctx, source_name)} on monitor {console.highlight(ctx, monitor["monitorName"])}.'
)
break
else:
console.err.print(
f'Monitor with index [yellow]{monitor_index}[/yellow] not found. '
f'Use [yellow]obsws-cli projector ls-m[/yellow] to see available monitors.'
)
raise typer.Exit(code=1)

View File

@@ -1,10 +1,14 @@
"""module for controlling OBS recording functionality."""
from pathlib import Path
from typing import Annotated, Optional
import typer
from .alias import AliasGroup
from . import console
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -14,7 +18,7 @@ def main():
def _get_recording_status(ctx: typer.Context) -> tuple:
"""Get recording status."""
resp = ctx.obj.get_record_status()
resp = ctx.obj['obsws'].get_record_status()
return resp.output_active, resp.output_paused
@@ -27,11 +31,11 @@ def start(ctx: typer.Context):
if paused:
err_msg += ' Try resuming it.'
typer.echo(err_msg, err=True)
console.err.print(err_msg)
raise typer.Exit(1)
ctx.obj.start_record()
typer.echo('Recording started successfully.')
ctx.obj['obsws'].start_record()
console.out.print('Recording started successfully.')
@app.command('stop | st')
@@ -39,11 +43,23 @@ def stop(ctx: typer.Context):
"""Stop recording."""
active, _ = _get_recording_status(ctx)
if not active:
typer.echo('Recording is not in progress, cannot stop.', err=True)
console.err.print('Recording is not in progress, cannot stop.')
raise typer.Exit(1)
ctx.obj.stop_record()
typer.echo('Recording stopped successfully.')
resp = ctx.obj['obsws'].stop_record()
console.out.print(
f'Recording stopped successfully. Saved to: {console.highlight(ctx, resp.output_path)}'
)
@app.command('toggle | tg')
def toggle(ctx: typer.Context):
"""Toggle recording."""
resp = ctx.obj['obsws'].toggle_record()
if resp.output_active:
console.out.print('Recording started successfully.')
else:
console.out.print('Recording stopped successfully.')
@app.command('status | ss')
@@ -52,21 +68,11 @@ def status(ctx: typer.Context):
active, paused = _get_recording_status(ctx)
if active:
if paused:
typer.echo('Recording is in progress and paused.')
console.out.print('Recording is in progress and paused.')
else:
typer.echo('Recording is in progress.')
console.out.print('Recording is in progress.')
else:
typer.echo('Recording is not in progress.')
@app.command('toggle | tg')
def toggle(ctx: typer.Context):
"""Toggle recording."""
active, _ = _get_recording_status(ctx)
if active:
ctx.invoke(stop, ctx=ctx)
else:
ctx.invoke(start, ctx=ctx)
console.out.print('Recording is not in progress.')
@app.command('resume | r')
@@ -74,14 +80,14 @@ def resume(ctx: typer.Context):
"""Resume recording."""
active, paused = _get_recording_status(ctx)
if not active:
typer.echo('Recording is not in progress, cannot resume.', err=True)
console.err.print('Recording is not in progress, cannot resume.')
raise typer.Exit(1)
if not paused:
typer.echo('Recording is in progress but not paused, cannot resume.', err=True)
console.err.print('Recording is in progress but not paused, cannot resume.')
raise typer.Exit(1)
ctx.obj.resume_record()
typer.echo('Recording resumed successfully.')
ctx.obj['obsws'].resume_record()
console.out.print('Recording resumed successfully.')
@app.command('pause | p')
@@ -89,13 +95,78 @@ def pause(ctx: typer.Context):
"""Pause recording."""
active, paused = _get_recording_status(ctx)
if not active:
typer.echo('Recording is not in progress, cannot pause.', err=True)
console.err.print('Recording is not in progress, cannot pause.')
raise typer.Exit(1)
if paused:
typer.echo(
'Recording is in progress but already paused, cannot pause.', err=True
)
console.err.print('Recording is in progress but already paused, cannot pause.')
raise typer.Exit(1)
ctx.obj.pause_record()
typer.echo('Recording paused successfully.')
ctx.obj['obsws'].pause_record()
console.out.print('Recording paused successfully.')
@app.command('directory | d')
def directory(
ctx: typer.Context,
record_directory: Annotated[
Optional[Path],
# Since the CLI and OBS may be running on different platforms,
# we won't validate the path here.
typer.Argument(
file_okay=False,
dir_okay=True,
help='Directory to set for recording.',
),
] = None,
):
"""Get or set the recording directory."""
if record_directory is not None:
ctx.obj['obsws'].set_record_directory(str(record_directory))
console.out.print(
f'Recording directory updated to: {console.highlight(ctx, record_directory)}'
)
else:
resp = ctx.obj['obsws'].get_record_directory()
console.out.print(
f'Recording directory: {console.highlight(ctx, resp.record_directory)}'
)
@app.command('split | sp')
def split(ctx: typer.Context):
"""Split the current recording."""
active, paused = _get_recording_status(ctx)
if not active:
console.err.print('Recording is not in progress, cannot split.')
raise typer.Exit(1)
if paused:
console.err.print('Recording is paused, cannot split.')
raise typer.Exit(1)
ctx.obj['obsws'].split_record_file()
console.out.print('Recording split successfully.')
@app.command('chapter | ch')
def chapter(
ctx: typer.Context,
chapter_name: Annotated[
Optional[str],
typer.Argument(
help='Name of the chapter to create.',
),
] = None,
):
"""Create a chapter in the current recording."""
active, paused = _get_recording_status(ctx)
if not active:
console.err.print('Recording is not in progress, cannot create chapter.')
raise typer.Exit(1)
if paused:
console.err.print('Recording is paused, cannot create chapter.')
raise typer.Exit(1)
ctx.obj['obsws'].create_record_chapter(chapter_name)
console.out.print(
f'Chapter {console.highlight(ctx, chapter_name or "unnamed")} created successfully.'
)

View File

@@ -2,9 +2,10 @@
import typer
from .alias import AliasGroup
from . import console
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -15,29 +16,49 @@ def main():
@app.command('start | s')
def start(ctx: typer.Context):
"""Start the replay buffer."""
ctx.obj.start_replay_buffer()
typer.echo('Replay buffer started.')
resp = ctx.obj['obsws'].get_replay_buffer_status()
if resp.output_active:
console.err.print('Replay buffer is already active.')
raise typer.Exit(1)
ctx.obj['obsws'].start_replay_buffer()
console.out.print('Replay buffer started.')
@app.command('stop | st')
def stop(ctx: typer.Context):
"""Stop the replay buffer."""
ctx.obj.stop_replay_buffer()
typer.echo('Replay buffer stopped.')
resp = ctx.obj['obsws'].get_replay_buffer_status()
if not resp.output_active:
console.err.print('Replay buffer is not active.')
raise typer.Exit(1)
ctx.obj['obsws'].stop_replay_buffer()
console.out.print('Replay buffer stopped.')
@app.command('toggle | tg')
def toggle(ctx: typer.Context):
"""Toggle the replay buffer."""
resp = ctx.obj['obsws'].toggle_replay_buffer()
if resp.output_active:
console.out.print('Replay buffer is active.')
else:
console.out.print('Replay buffer is not active.')
@app.command('status | ss')
def status(ctx: typer.Context):
"""Get the status of the replay buffer."""
resp = ctx.obj.get_replay_buffer_status()
resp = ctx.obj['obsws'].get_replay_buffer_status()
if resp.output_active:
typer.echo('Replay buffer is active.')
console.out.print('Replay buffer is active.')
else:
typer.echo('Replay buffer is not active.')
console.out.print('Replay buffer is not active.')
@app.command('save | sv')
def save(ctx: typer.Context):
"""Save the replay buffer."""
ctx.obj.save_replay_buffer()
typer.echo('Replay buffer saved.')
ctx.obj['obsws'].save_replay_buffer()
console.out.print('Replay buffer saved.')

View File

@@ -3,11 +3,13 @@
from typing import Annotated
import typer
from rich.table import Table
from rich.text import Text
from . import validate
from .alias import AliasGroup
from . import console, util, validate
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -16,11 +18,48 @@ def main():
@app.command('list | ls')
def list(ctx: typer.Context):
def list_(
ctx: typer.Context,
uuid: Annotated[bool, typer.Option(help='Show UUIDs of scenes')] = False,
):
"""List all scenes."""
resp = ctx.obj.get_scene_list()
scenes = (scene.get('sceneName') for scene in reversed(resp.scenes))
typer.echo('\n'.join(scenes))
resp = ctx.obj['obsws'].get_scene_list()
scenes = (
(scene.get('sceneName'), scene.get('sceneUuid'))
for scene in reversed(resp.scenes)
)
active_scene = ctx.obj['obsws'].get_current_program_scene().scene_name
table = Table(title='Scenes', padding=(0, 2), border_style=ctx.obj['style'].border)
if uuid:
columns = [
(Text('Scene Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Active', justify='center'), 'center', None),
(Text('UUID', justify='center'), 'left', ctx.obj['style'].column),
]
else:
columns = [
(Text('Scene Name', justify='center'), 'left', ctx.obj['style'].column),
(Text('Active', justify='center'), 'center', None),
]
for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style)
for scene_name, scene_uuid in scenes:
if uuid:
table.add_row(
scene_name,
util.check_mark(scene_name == active_scene, empty_if_false=True),
scene_uuid,
)
else:
table.add_row(
scene_name,
util.check_mark(scene_name == active_scene, empty_if_false=True),
)
console.out.print(table)
@app.command('current | get')
@@ -32,21 +71,27 @@ def current(
):
"""Get the current program scene or preview scene."""
if preview and not validate.studio_mode_enabled(ctx):
typer.echo('Studio mode is not enabled, cannot get preview scene.', err=True)
console.err.print('Studio mode is not enabled, cannot get preview scene.')
raise typer.Exit(1)
if preview:
resp = ctx.obj.get_current_preview_scene()
typer.echo(resp.current_preview_scene_name)
resp = ctx.obj['obsws'].get_current_preview_scene()
console.out.print(
f'Current Preview Scene: {console.highlight(ctx, resp.current_preview_scene_name)}'
)
else:
resp = ctx.obj.get_current_program_scene()
typer.echo(resp.current_program_scene_name)
resp = ctx.obj['obsws'].get_current_program_scene()
console.out.print(
f'Current Program Scene: {console.highlight(ctx, resp.current_program_scene_name)}'
)
@app.command('switch | set')
def switch(
ctx: typer.Context,
scene_name: str,
scene_name: Annotated[
str, typer.Argument(..., help='Name of the scene to switch to')
],
preview: Annotated[
bool,
typer.Option(help='Switch to the preview scene instead of the program scene'),
@@ -54,16 +99,20 @@ def switch(
):
"""Switch to a scene."""
if preview and not validate.studio_mode_enabled(ctx):
typer.echo(
'Studio mode is not enabled, cannot set the preview scene.', err=True
)
console.err.print('Studio mode is not enabled, cannot set the preview scene.')
raise typer.Exit(1)
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
if preview:
ctx.obj.set_current_preview_scene(scene_name)
ctx.obj['obsws'].set_current_preview_scene(scene_name)
console.out.print(
f'Switched to preview scene: {console.highlight(ctx, scene_name)}'
)
else:
ctx.obj.set_current_program_scene(scene_name)
ctx.obj['obsws'].set_current_program_scene(scene_name)
console.out.print(
f'Switched to program scene: {console.highlight(ctx, scene_name)}'
)

View File

@@ -1,11 +1,14 @@
"""module containing commands for manipulating scene collections."""
from typing import Annotated
import typer
from rich.table import Table
from . import validate
from .alias import AliasGroup
from . import console, validate
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -14,47 +17,78 @@ def main():
@app.command('list | ls')
def list(ctx: typer.Context):
def list_(ctx: typer.Context):
"""List all scene collections."""
resp = ctx.obj.get_scene_collection_list()
typer.echo('\n'.join(resp.scene_collections))
resp = ctx.obj['obsws'].get_scene_collection_list()
table = Table(
title='Scene Collections',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
table.add_column(
'Scene Collection Name', justify='left', style=ctx.obj['style'].column
)
for scene_collection_name in resp.scene_collections:
table.add_row(scene_collection_name)
console.out.print(table)
@app.command('current | get')
def current(ctx: typer.Context):
"""Get the current scene collection."""
resp = ctx.obj.get_scene_collection_list()
typer.echo(resp.current_scene_collection_name)
resp = ctx.obj['obsws'].get_scene_collection_list()
console.out.print(
f'Current scene collection: {console.highlight(ctx, resp.current_scene_collection_name)}'
)
@app.command('switch | set')
def switch(ctx: typer.Context, scene_collection_name: str):
def switch(
ctx: typer.Context,
scene_collection_name: Annotated[
str, typer.Argument(..., help='Name of the scene collection to switch to')
],
):
"""Switch to a scene collection."""
if not validate.scene_collection_in_scene_collections(ctx, scene_collection_name):
typer.echo(f"Scene collection '{scene_collection_name}' not found.", err=True)
raise typer.Exit(code=1)
console.err.print(
f'Scene collection [yellow]{scene_collection_name}[/yellow] not found.'
)
raise typer.Exit(1)
current_scene_collection = (
ctx.obj.get_scene_collection_list().current_scene_collection_name
ctx.obj['obsws'].get_scene_collection_list().current_scene_collection_name
)
if scene_collection_name == current_scene_collection:
typer.echo(
f'Scene collection "{scene_collection_name}" is already active.', err=True
console.err.print(
f'Scene collection [yellow]{scene_collection_name}[/yellow] is already active.'
)
raise typer.Exit(code=1)
raise typer.Exit(1)
ctx.obj.set_current_scene_collection(scene_collection_name)
typer.echo(f"Switched to scene collection '{scene_collection_name}'")
ctx.obj['obsws'].set_current_scene_collection(scene_collection_name)
console.out.print(
f'Switched to scene collection {console.highlight(ctx, scene_collection_name)}.'
)
@app.command('create | new')
def create(ctx: typer.Context, scene_collection_name: str):
def create(
ctx: typer.Context,
scene_collection_name: Annotated[
str, typer.Argument(..., help='Name of the scene collection to create')
],
):
"""Create a new scene collection."""
if validate.scene_collection_in_scene_collections(ctx, scene_collection_name):
typer.echo(
f"Scene collection '{scene_collection_name}' already exists.", err=True
console.err.print(
f'Scene collection [yellow]{scene_collection_name}[/yellow] already exists.'
)
raise typer.Exit(code=1)
raise typer.Exit(1)
ctx.obj.create_scene_collection(scene_collection_name)
typer.echo(f'Created scene collection {scene_collection_name}')
ctx.obj['obsws'].create_scene_collection(scene_collection_name)
console.out.print(
f'Created scene collection {console.highlight(ctx, scene_collection_name)}.'
)

View File

@@ -1,14 +1,14 @@
"""module containing commands for manipulating items in scenes."""
from collections.abc import Callable
from typing import Annotated, Optional
import typer
from rich.table import Table
from . import validate
from .alias import AliasGroup
from . import console, util, validate
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -17,215 +17,379 @@ def main():
@app.command('list | ls')
def list(ctx: typer.Context, scene_name: str):
"""List all items in a scene."""
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
typer.Exit(code=1)
resp = ctx.obj.get_scene_item_list(scene_name)
items = (item.get('sourceName') for item in resp.scene_items)
typer.echo('\n'.join(items))
def _validate_scene_name_and_item_name(
func: Callable,
def list_(
ctx: typer.Context,
scene_name: Annotated[
Optional[str],
typer.Argument(
show_default='The current scene',
help='Scene name to list items for',
),
] = None,
uuid: Annotated[bool, typer.Option(help='Show UUIDs of scene items')] = False,
):
"""Validate the scene name and item name."""
"""List all items in a scene."""
if not scene_name:
scene_name = ctx.obj['obsws'].get_current_program_scene().scene_name
def wrapper(
ctx: typer.Context,
scene_name: str,
item_name: str,
parent: Optional[str] = None,
):
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
if not validate.scene_in_scenes(ctx, scene_name):
console.err.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise typer.Exit(1)
if parent:
if not validate.item_in_scene_item_list(ctx, scene_name, parent):
typer.echo(
f"Parent group '{parent}' not found in scene '{scene_name}'.",
err=True,
)
raise typer.Exit(code=1)
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
items = sorted(
(
(
item.get('sceneItemId'),
item.get('sourceName'),
item.get('isGroup'),
item.get('sceneItemEnabled'),
item.get('sourceUuid', 'N/A'), # Include source UUID
)
for item in resp.scene_items
),
key=lambda x: x[0], # Sort by sceneItemId
)
if not items:
console.out.print(
f'No items found in scene {console.highlight(ctx, scene_name)}.'
)
raise typer.Exit()
table = Table(
title=f'Items in Scene: {scene_name}',
padding=(0, 2),
border_style=ctx.obj['style'].border,
)
if uuid:
columns = [
('Item ID', 'center', ctx.obj['style'].column),
('Item Name', 'left', ctx.obj['style'].column),
('In Group', 'left', ctx.obj['style'].column),
('Enabled', 'center', None),
('UUID', 'left', ctx.obj['style'].column),
]
else:
columns = [
('Item ID', 'center', ctx.obj['style'].column),
('Item Name', 'left', ctx.obj['style'].column),
('In Group', 'left', ctx.obj['style'].column),
('Enabled', 'center', None),
]
# Add columns to the table
for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style)
for item_id, item_name, is_group, is_enabled, source_uuid in items:
if is_group:
resp = ctx.obj['obsws'].get_group_scene_item_list(item_name)
group_items = sorted(
(
(
gi.get('sceneItemId'),
gi.get('sourceName'),
gi.get('sceneItemEnabled'),
gi.get('sourceUuid', 'N/A'), # Include source UUID
)
for gi in resp.scene_items
),
key=lambda x: x[0], # Sort by sceneItemId
)
for (
group_item_id,
group_item_name,
group_item_enabled,
group_item_source_uuid,
) in group_items:
if uuid:
table.add_row(
str(group_item_id),
group_item_name,
item_name,
util.check_mark(is_enabled and group_item_enabled),
group_item_source_uuid,
)
else:
table.add_row(
str(group_item_id),
group_item_name,
item_name,
util.check_mark(is_enabled and group_item_enabled),
)
else:
if not validate.item_in_scene_item_list(ctx, scene_name, item_name):
typer.echo(
f"Item '{item_name}' not found in scene '{scene_name}'.", err=True
if uuid:
table.add_row(
str(item_id),
item_name,
'',
util.check_mark(is_enabled),
source_uuid,
)
else:
table.add_row(
str(item_id),
item_name,
'',
util.check_mark(is_enabled),
)
raise typer.Exit(code=1)
return func(ctx, scene_name, item_name, parent)
console.out.print(table)
return wrapper
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):
console.err.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
return False
if group:
if not validate.item_in_scene_item_list(ctx, scene_name, group):
console.err.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):
console.err.print(
f'Item [yellow]{item_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow]. Is the item in a group? '
f'If so use the [yellow]--group[/yellow] option to specify the parent group.\n'
'Use [yellow]obsws-cli sceneitem ls[/yellow] for a list of items in the scene.'
)
return False
return True
def _get_scene_name_and_item_id(
ctx: typer.Context, scene_name: str, item_name: str, parent: Optional[str] = None
ctx: typer.Context, scene_name: str, item_name: str, group: Optional[str] = None
):
if parent:
resp = ctx.obj.get_group_scene_item_list(parent)
"""Get the scene name and item ID for the given scene and item name."""
if group:
resp = ctx.obj['obsws'].get_group_scene_item_list(group)
for item in resp.scene_items:
if item.get('sourceName') == item_name:
scene_name = parent
scene_name = group
scene_item_id = item.get('sceneItemId')
break
else:
typer.echo(f"Item '{item_name}' not found in group '{parent}'.", err=True)
raise typer.Exit(code=1)
console.err.print(
f'Item [yellow]{item_name}[/yellow] not found in group [yellow]{group}[/yellow].'
)
raise typer.Exit(1)
else:
resp = ctx.obj.get_scene_item_id(scene_name, item_name)
resp = ctx.obj['obsws'].get_scene_item_id(scene_name, item_name)
scene_item_id = resp.scene_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: str,
item_name: str,
parent: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
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 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, parent
ctx, scene_name, item_name, group
)
ctx.obj.set_scene_item_enabled(
ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name,
item_id=int(scene_item_id),
enabled=True,
)
typer.echo(f"Item '{item_name}' in scene '{scene_name}' has been shown.")
@_validate_scene_name_and_item_name
@app.command('hide | h')
def hide(
ctx: typer.Context,
scene_name: str,
item_name: str,
parent: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Hide an item in a scene."""
scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, parent
)
ctx.obj.set_scene_item_enabled(
scene_name=scene_name,
item_id=int(scene_item_id),
enabled=False,
)
typer.echo(f"Item '{item_name}' in scene '{scene_name}' has been hidden.")
@_validate_scene_name_and_item_name
@app.command('toggle | tg')
def toggle(
ctx: typer.Context,
scene_name: str,
item_name: str,
parent: 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):
typer.echo(f"Scene '{scene_name}' not found.")
raise typer.Exit(code=1)
if parent:
if not validate.item_in_scene_item_list(ctx, scene_name, parent):
typer.echo(
f"Parent group '{parent}' not found in scene '{scene_name}'.", err=True
)
raise typer.Exit(code=1)
else:
if not validate.item_in_scene_item_list(ctx, scene_name, item_name):
typer.echo(
f"Item '{item_name}' not found in scene '{scene_name}'.", err=True
)
raise typer.Exit(code=1)
scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, parent
)
enabled = ctx.obj.get_scene_item_enabled(
scene_name=scene_name,
item_id=int(scene_item_id),
)
new_state = not enabled.scene_item_enabled
ctx.obj.set_scene_item_enabled(
scene_name=scene_name,
item_id=int(scene_item_id),
enabled=new_state,
)
typer.echo(
f"Item '{item_name}' in scene '{scene_name}' has been {'shown' if new_state else 'hidden'}."
)
@_validate_scene_name_and_item_name
@app.command('visible | v')
def visible(
ctx: typer.Context,
scene_name: str,
item_name: str,
parent: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Check if an item in a scene is visible."""
if parent:
if not validate.item_in_scene_item_list(ctx, scene_name, parent):
typer.echo(
f"Parent group '{parent}' not found in scene '{scene_name}'.", err=True
)
raise typer.Exit(code=1)
else:
if not validate.item_in_scene_item_list(ctx, scene_name, item_name):
typer.echo(
f"Item '{item_name}' not found in scene '{scene_name}'.", err=True
)
raise typer.Exit(code=1)
old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, parent
)
enabled = ctx.obj.get_scene_item_enabled(
scene_name=scene_name,
item_id=int(scene_item_id),
)
if parent:
typer.echo(
f"Item '{item_name}' in group '{parent}' in scene '{old_scene_name}' is currently {'visible' if enabled.scene_item_enabled else 'hidden'}."
if group:
console.out.print(
f'Item {console.highlight(ctx, item_name)} in group {console.highlight(ctx, group)} '
f'in scene {console.highlight(ctx, old_scene_name)} 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
typer.echo(
f"Item '{item_name}' in scene '{scene_name}' is currently {'visible' if enabled.scene_item_enabled else 'hidden'}."
console.out.print(
f'Item {console.highlight(ctx, item_name)} in scene {console.highlight(ctx, scene_name)} has been shown.'
)
@app.command('hide | h')
def hide(
ctx: typer.Context,
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 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
)
ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name,
item_id=int(scene_item_id),
enabled=False,
)
if group:
console.out.print(
f'Item {console.highlight(ctx, item_name)} in group {console.highlight(ctx, group)} in scene {console.highlight(ctx, old_scene_name)} has been hidden.'
)
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
console.out.print(
f'Item {console.highlight(ctx, item_name)} in scene {console.highlight(ctx, scene_name)} has been hidden.'
)
@app.command('toggle | tg')
def toggle(
ctx: typer.Context,
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 toggle in the scene'
),
],
group: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
):
"""Toggle 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
)
enabled = ctx.obj['obsws'].get_scene_item_enabled(
scene_name=scene_name,
item_id=int(scene_item_id),
)
new_state = not enabled.scene_item_enabled
ctx.obj['obsws'].set_scene_item_enabled(
scene_name=scene_name,
item_id=int(scene_item_id),
enabled=new_state,
)
if group:
if new_state:
console.out.print(
f'Item {console.highlight(ctx, item_name)} in group {console.highlight(ctx, group)} '
f'in scene {console.highlight(ctx, old_scene_name)} has been shown.'
)
else:
console.out.print(
f'Item {console.highlight(ctx, item_name)} in group {console.highlight(ctx, group)} '
f'in scene {console.highlight(ctx, old_scene_name)} has been hidden.'
)
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
if new_state:
console.out.print(
f'Item {console.highlight(ctx, item_name)} in scene {console.highlight(ctx, scene_name)} has been shown.'
)
else:
console.out.print(
f'Item {console.highlight(ctx, item_name)} in scene {console.highlight(ctx, scene_name)} has been hidden.'
)
@app.command('visible | v')
def visible(
ctx: typer.Context,
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 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 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
)
enabled = ctx.obj['obsws'].get_scene_item_enabled(
scene_name=scene_name,
item_id=int(scene_item_id),
)
if group:
console.out.print(
f'Item {console.highlight(ctx, item_name)} in group {console.highlight(ctx, group)} '
f'in scene {console.highlight(ctx, old_scene_name)} is currently {"visible" if enabled.scene_item_enabled else "hidden"}.'
)
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
console.out.print(
f'Item {console.highlight(ctx, item_name)} in scene {console.highlight(ctx, scene_name)} '
f'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,
parent: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
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')
] = None,
@@ -273,22 +437,12 @@ def transform(
] = None,
):
"""Set the transform of an item in a scene."""
if parent:
if not validate.item_in_scene_item_list(ctx, scene_name, parent):
typer.echo(
f"Parent group '{parent}' not found in scene '{scene_name}'.", err=True
)
raise typer.Exit(code=1)
else:
if not validate.item_in_scene_item_list(ctx, scene_name, item_name):
typer.echo(
f"Item '{item_name}' not found in scene '{scene_name}'.", err=True
)
raise typer.Exit(code=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(
ctx, scene_name, item_name, parent
ctx, scene_name, item_name, group
)
transform = {}
@@ -324,22 +478,25 @@ def transform(
transform['scaleY'] = scale_y
if not transform:
typer.echo('No transform options provided.', err=True)
raise typer.Exit(code=1)
console.err.print('No transform options provided.')
raise typer.Exit(1)
transform = ctx.obj.set_scene_item_transform(
transform = ctx.obj['obsws'].set_scene_item_transform(
scene_name=scene_name,
item_id=int(scene_item_id),
transform=transform,
)
if parent:
typer.echo(
f"Item '{item_name}' in group '{parent}' in scene '{old_scene_name}' has been transformed."
if group:
console.out.print(
f'Item {console.highlight(ctx, item_name)} in group {console.highlight(ctx, group)} '
f'in scene {console.highlight(ctx, old_scene_name)} has been transformed.'
)
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
typer.echo(f"Item '{item_name}' in scene '{scene_name}' has been transformed.")
console.out.print(
f'Item {console.highlight(ctx, item_name)} in scene {console.highlight(ctx, scene_name)} has been transformed.'
)

90
obsws_cli/screenshot.py Normal file
View File

@@ -0,0 +1,90 @@
"""module for taking screenshots using OBS WebSocket API."""
from pathlib import Path
from typing import Annotated
import obsws_python as obsws
import typer
from . import console
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
def main():
"""Take screenshots using OBS."""
@app.command('save | sv')
def save(
ctx: typer.Context,
source_name: Annotated[
str,
typer.Argument(
...,
show_default=False,
help='Name of the source to take a screenshot of.',
),
],
output_path: Annotated[
Path,
# Since the CLI and OBS may be running on different platforms,
# we won't validate the path here.
typer.Argument(
...,
show_default=False,
file_okay=True,
dir_okay=False,
help='Path to save the screenshot (must include file name and extension).',
),
],
width: Annotated[
float,
typer.Option(
help='Width of the screenshot.',
),
] = 1920,
height: Annotated[
float,
typer.Option(
help='Height of the screenshot.',
),
] = 1080,
quality: Annotated[
float,
typer.Option(
min=-1,
max=100,
help='Quality of the screenshot.',
),
] = -1,
):
"""Take a screenshot and save it to a file."""
try:
ctx.obj['obsws'].save_source_screenshot(
name=source_name,
img_format=output_path.suffix.lstrip('.').lower(),
file_path=str(output_path),
width=width,
height=height,
quality=quality,
)
except obsws.error.OBSSDKRequestError as e:
match e.code:
case 403:
console.err.print(
'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:
console.err.print(
f'No source was found by the name of [yellow]{source_name}[/yellow]'
)
raise typer.Exit(1)
case _:
raise
console.out.print(f'Screenshot saved to {console.highlight(ctx, output_path)}.')

80
obsws_cli/settings.py Normal file
View File

@@ -0,0 +1,80 @@
"""module for settings management for obsws-cli."""
from collections import UserDict
from pathlib import Path
from dotenv import dotenv_values
SettingsValue = str | int
class Settings(UserDict):
"""A class to manage settings for obsws-cli.
This class extends UserDict to provide a dictionary-like interface for settings.
It loads settings from environment variables and .env files.
The settings are expected to be in uppercase and should start with 'OBS_'.
Example:
-------
settings = Settings()
host = settings['OBS_HOST']
settings['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) -> SettingsValue:
"""Get a setting value by key."""
key = key.upper()
if not key.startswith(Settings.PREFIX):
key = f'{Settings.PREFIX}{key}'
return self.data[key]
def __setitem__(self, key: str, value: SettingsValue):
"""Set a setting value by key."""
key = key.upper()
if not key.startswith(Settings.PREFIX):
key = f'{Settings.PREFIX}{key}'
self.data[key] = value
_settings = Settings(
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) -> SettingsValue:
"""Get a setting value by key.
Args:
----
key (str): The key of the setting to retrieve.
Returns:
-------
The value of the setting.
Raises:
------
KeyError: If the key does not exist in the settings.
"""
return _settings[key]

View File

@@ -2,9 +2,10 @@
import typer
from .alias import AliasGroup
from . import console
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -14,7 +15,7 @@ def main():
def _get_streaming_status(ctx: typer.Context) -> tuple:
"""Get streaming status."""
resp = ctx.obj.get_stream_status()
resp = ctx.obj['obsws'].get_stream_status()
return resp.output_active, resp.output_duration
@@ -23,11 +24,11 @@ def start(ctx: typer.Context):
"""Start streaming."""
active, _ = _get_streaming_status(ctx)
if active:
typer.echo('Streaming is already in progress, cannot start.', err=True)
raise typer.Exit(code=1)
console.err.print('Streaming is already in progress, cannot start.')
raise typer.Exit(1)
ctx.obj.start_stream()
typer.echo('Streaming started successfully.')
ctx.obj['obsws'].start_stream()
console.out.print('Streaming started successfully.')
@app.command('stop | st')
@@ -35,11 +36,21 @@ def stop(ctx: typer.Context):
"""Stop streaming."""
active, _ = _get_streaming_status(ctx)
if not active:
typer.echo('Streaming is not in progress, cannot stop.', err=True)
raise typer.Exit(code=1)
console.err.print('Streaming is not in progress, cannot stop.')
raise typer.Exit(1)
ctx.obj.stop_stream()
typer.echo('Streaming stopped successfully.')
ctx.obj['obsws'].stop_stream()
console.out.print('Streaming stopped successfully.')
@app.command('toggle | tg')
def toggle(ctx: typer.Context):
"""Toggle streaming."""
resp = ctx.obj['obsws'].toggle_stream()
if resp.output_active:
console.out.print('Streaming started successfully.')
else:
console.out.print('Streaming stopped successfully.')
@app.command('status | ss')
@@ -52,25 +63,19 @@ def status(ctx: typer.Context):
minutes = int(seconds // 60)
seconds = int(seconds % 60)
if minutes > 0:
typer.echo(
console.out.print(
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.')
console.out.print(
f'Streaming is in progress for {seconds} seconds.'
)
else:
typer.echo('Streaming is in progress for less than a second.')
console.out.print(
'Streaming is in progress for less than a second.'
)
else:
typer.echo('Streaming is in progress.')
console.out.print('Streaming is in progress.')
else:
typer.echo('Streaming is not in progress.')
@app.command('toggle | tg')
def toggle(ctx: typer.Context):
"""Toggle streaming."""
active, _ = _get_streaming_status(ctx)
if active:
ctx.invoke(stop, ctx=ctx)
else:
ctx.invoke(start, ctx=ctx)
console.out.print('Streaming is not in progress.')

View File

@@ -2,9 +2,10 @@
import typer
from .alias import AliasGroup
from . import console
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -15,32 +16,34 @@ def main():
@app.command('enable | on')
def enable(ctx: typer.Context):
"""Enable studio mode."""
ctx.obj.set_studio_mode_enabled(True)
ctx.obj['obsws'].set_studio_mode_enabled(True)
console.out.print('Studio mode has been enabled.')
@app.command('disable | off')
def disable(ctx: typer.Context):
"""Disable studio mode."""
ctx.obj.set_studio_mode_enabled(False)
ctx.obj['obsws'].set_studio_mode_enabled(False)
console.out.print('Studio mode has been disabled.')
@app.command('toggle | tg')
def toggle(ctx: typer.Context):
"""Toggle studio mode."""
resp = ctx.obj.get_studio_mode_enabled()
resp = ctx.obj['obsws'].get_studio_mode_enabled()
if resp.studio_mode_enabled:
ctx.obj.set_studio_mode_enabled(False)
typer.echo('Studio mode is now disabled.')
ctx.obj['obsws'].set_studio_mode_enabled(False)
console.out.print('Studio mode is now disabled.')
else:
ctx.obj.set_studio_mode_enabled(True)
typer.echo('Studio mode is now enabled.')
ctx.obj['obsws'].set_studio_mode_enabled(True)
console.out.print('Studio mode is now enabled.')
@app.command('status | ss')
def status(ctx: typer.Context):
"""Get the status of studio mode."""
resp = ctx.obj.get_studio_mode_enabled()
resp = ctx.obj['obsws'].get_studio_mode_enabled()
if resp.studio_mode_enabled:
typer.echo('Studio mode is enabled.')
console.out.print('Studio mode is enabled.')
else:
typer.echo('Studio mode is disabled.')
console.out.print('Studio mode is disabled.')

183
obsws_cli/styles.py Normal file
View File

@@ -0,0 +1,183 @@
"""module containing styles for the OBS WebSocket CLI."""
import os
from dataclasses import dataclass
registry = {}
def register_style(cls):
"""Register a style class."""
key = cls.__name__.lower()
if key in registry:
raise ValueError(f'Style {key} is already registered.')
registry[key] = cls
return cls
@dataclass
class Style:
"""Base class for styles."""
name: str
border: str
column: str
highlight: str
no_border: bool = False
def __post_init__(self):
"""Post-initialization to set default values and normalize the name."""
self.name = self.name.lower()
if self.no_border:
self.border = None
@register_style
@dataclass
class Disabled(Style):
"""Disabled style."""
name: str = 'disabled'
border: str = 'none'
column: str = 'none'
highlight: str = 'none'
@register_style
@dataclass
class Red(Style):
"""Red style."""
name: str = 'red'
border: str = 'red3'
column: str = 'red1'
highlight: str = 'red1'
@register_style
@dataclass
class Magenta(Style):
"""Magenta style."""
name: str = 'magenta'
border: str = 'magenta3'
column: str = 'orchid1'
highlight: str = 'orchid1'
@register_style
@dataclass
class Purple(Style):
"""Purple style."""
name: str = 'purple'
border: str = 'medium_purple4'
column: str = 'medium_purple'
highlight: str = 'medium_purple'
@register_style
@dataclass
class Blue(Style):
"""Blue style."""
name: str = 'blue'
border: str = 'cornflower_blue'
column: str = 'sky_blue2'
highlight: str = 'sky_blue2'
@register_style
@dataclass
class Cyan(Style):
"""Cyan style."""
name: str = 'cyan'
border: str = 'dark_cyan'
column: str = 'cyan'
highlight: str = 'cyan'
@register_style
@dataclass
class Green(Style):
"""Green style."""
name: str = 'green'
border: str = 'green4'
column: str = 'spring_green3'
highlight: str = 'spring_green3'
@register_style
@dataclass
class Yellow(Style):
"""Yellow style."""
name: str = 'yellow'
border: str = 'yellow3'
column: str = 'wheat1'
highlight: str = 'wheat1'
@register_style
@dataclass
class Orange(Style):
"""Orange style."""
name: str = 'orange'
border: str = 'dark_orange'
column: str = 'orange1'
highlight: str = 'orange1'
@register_style
@dataclass
class White(Style):
"""White style."""
name: str = 'white'
border: str = 'grey82'
column: str = 'grey100'
highlight: str = 'grey100'
@register_style
@dataclass
class Grey(Style):
"""Grey style."""
name: str = 'grey'
border: str = 'grey50'
column: str = 'grey70'
highlight: str = 'grey70'
@register_style
@dataclass
class Navy(Style):
"""Navy Blue style."""
name: str = 'navyblue'
border: str = 'deep_sky_blue4'
column: str = 'light_sky_blue3'
highlight: str = 'light_sky_blue3'
@register_style
@dataclass
class Black(Style):
"""Black style."""
name: str = 'black'
border: str = 'grey19'
column: str = 'grey11'
highlight: str = 'grey11'
def request_style_obj(style_name: str, no_border: bool) -> Style:
"""Entry point for style objects. Returns a Style object based on the style name."""
if style_name == 'disabled':
os.environ['NO_COLOR'] = '1'
return registry[style_name.lower()](no_border=no_border)

78
obsws_cli/text.py Normal file
View File

@@ -0,0 +1,78 @@
"""module containing commands for manipulating text inputs."""
from typing import Annotated, Optional
import typer
from . import console, validate
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
def main():
"""Control text inputs in OBS."""
@app.command('current | get')
def current(
ctx: typer.Context,
input_name: Annotated[str, typer.Argument(help='Name of the text input to get.')],
):
"""Get the current text for a text input."""
if not validate.input_in_inputs(ctx, input_name):
console.err.print(f'Input [yellow]{input_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj['obsws'].get_input_settings(name=input_name)
if not resp.input_kind.startswith('text_'):
console.err.print(
f'Input [yellow]{input_name}[/yellow] is not a text input.',
)
raise typer.Exit(1)
current_text = resp.input_settings.get('text', '')
if not current_text:
current_text = '(empty)'
console.out.print(
f'Current text for input {console.highlight(ctx, input_name)}: {current_text}',
)
@app.command('update | set')
def update(
ctx: typer.Context,
input_name: Annotated[
str, typer.Argument(help='Name of the text input to update.')
],
new_text: Annotated[
Optional[str],
typer.Argument(
help='The new text to set for the input.',
),
] = None,
):
"""Update the text of a text input."""
if not validate.input_in_inputs(ctx, input_name):
console.err.print(f'Input [yellow]{input_name}[/yellow] not found.')
raise typer.Exit(1)
resp = ctx.obj['obsws'].get_input_settings(name=input_name)
if not resp.input_kind.startswith('text_'):
console.err.print(
f'Input [yellow]{input_name}[/yellow] is not a text input.',
)
raise typer.Exit(1)
ctx.obj['obsws'].set_input_settings(
name=input_name,
settings={'text': new_text},
overlay=True,
)
if not new_text:
new_text = '(empty)'
console.out.print(
f'Text for input {console.highlight(ctx, input_name)} updated to: {new_text}',
)

22
obsws_cli/util.py Normal file
View File

@@ -0,0 +1,22 @@
"""module contains utility functions for the obsws_cli package."""
import os
def snakecase_to_titlecase(snake_str: str) -> str:
"""Convert a snake_case string to a title case string."""
return snake_str.replace('_', ' ').title()
def check_mark(value: bool, empty_if_false: bool = False) -> str:
"""Return a check mark or cross mark based on the boolean value."""
if empty_if_false and not value:
return ''
# rich gracefully handles the absence of colour throughout the rest of the application,
# but here we must handle it manually.
# If NO_COLOR is set, we return plain text symbols.
# Otherwise, we return coloured symbols.
if os.getenv('NO_COLOR', '') != '':
return '' if value else ''
return '' if value else ''

View File

@@ -8,19 +8,19 @@ skipped_option = typer.Option(parser=lambda _: _, hidden=True, expose_value=Fals
def input_in_inputs(ctx: typer.Context, input_name: str) -> bool:
"""Check if an input is in the input list."""
inputs = ctx.obj.get_input_list().inputs
inputs = ctx.obj['obsws'].get_input_list().inputs
return any(input_.get('inputName') == input_name for input_ in inputs)
def scene_in_scenes(ctx: typer.Context, scene_name: str) -> bool:
"""Check if a scene exists in the list of scenes."""
resp = ctx.obj.get_scene_list()
resp = ctx.obj['obsws'].get_scene_list()
return any(scene.get('sceneName') == scene_name for scene in resp.scenes)
def studio_mode_enabled(ctx: typer.Context) -> bool:
"""Check if studio mode is enabled."""
resp = ctx.obj.get_studio_mode_enabled()
resp = ctx.obj['obsws'].get_studio_mode_enabled()
return resp.studio_mode_enabled
@@ -28,7 +28,7 @@ def scene_collection_in_scene_collections(
ctx: typer.Context, scene_collection_name: str
) -> bool:
"""Check if a scene collection exists."""
resp = ctx.obj.get_scene_collection_list()
resp = ctx.obj['obsws'].get_scene_collection_list()
return any(
collection == scene_collection_name for collection in resp.scene_collections
)
@@ -38,11 +38,17 @@ def item_in_scene_item_list(
ctx: typer.Context, scene_name: str, item_name: str
) -> bool:
"""Check if an item exists in a scene."""
resp = ctx.obj.get_scene_item_list(scene_name)
resp = ctx.obj['obsws'].get_scene_item_list(scene_name)
return any(item.get('sourceName') == item_name for item in resp.scene_items)
def profile_exists(ctx: typer.Context, profile_name: str) -> bool:
"""Check if a profile exists."""
resp = ctx.obj.get_profile_list()
resp = ctx.obj['obsws'].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['obsws'].get_monitor_list()
return any(monitor['monitorIndex'] == monitor_index for monitor in resp.monitors)

View File

@@ -2,9 +2,10 @@
import typer
from .alias import AliasGroup
from . import console
from .alias import SubTyperAliasGroup
app = typer.Typer(cls=AliasGroup)
app = typer.Typer(cls=SubTyperAliasGroup)
@app.callback()
@@ -15,29 +16,32 @@ def main():
@app.command('start | s')
def start(ctx: typer.Context):
"""Start the virtual camera."""
ctx.obj.start_virtual_cam()
typer.echo('Virtual camera started.')
ctx.obj['obsws'].start_virtual_cam()
console.out.print('Virtual camera started.')
@app.command('stop | p')
def stop(ctx: typer.Context):
"""Stop the virtual camera."""
ctx.obj.stop_virtual_cam()
typer.echo('Virtual camera stopped.')
ctx.obj['obsws'].stop_virtual_cam()
console.out.print('Virtual camera stopped.')
@app.command('toggle | tg')
def toggle(ctx: typer.Context):
"""Toggle the virtual camera."""
ctx.obj.toggle_virtual_cam()
typer.echo('Virtual camera toggled.')
resp = ctx.obj['obsws'].toggle_virtual_cam()
if resp.output_active:
console.out.print('Virtual camera is enabled.')
else:
console.out.print('Virtual camera is disabled.')
@app.command('status | ss')
def status(ctx: typer.Context):
"""Get the status of the virtual camera."""
resp = ctx.obj.get_virtual_cam_status()
resp = ctx.obj['obsws'].get_virtual_cam_status()
if resp.output_active:
typer.echo('Virtual camera is enabled.')
console.out.print('Virtual camera is enabled.')
else:
typer.echo('Virtual camera is disabled.')
console.out.print('Virtual camera is disabled.')

View File

@@ -21,11 +21,7 @@ classifiers = [
"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",
]
dependencies = ["typer>=0.16.0", "obsws-python>=1.8.0", "python-dotenv>=1.1.0"]
[project.urls]
@@ -46,8 +42,11 @@ dependencies = ["click-man>=0.5.1"]
cli = "obsws-cli {args:}"
man = "python man/generate.py --output=./man"
[tool.hatch.envs.lazyimports.scripts]
cli = "obsws-cli {args:}"
[tool.hatch.envs.hatch-test]
dependencies = ["pytest>=8.3.5"]
randomize = true
[tool.hatch.envs.types]
extra-dependencies = ["mypy>=1.0.0"]

View File

@@ -46,9 +46,9 @@ def pytest_sessionstart(session):
session.obsws.set_current_scene_collection('test-collection')
session.obsws.create_scene('pytest')
session.obsws.create_scene('pytest_scene')
session.obsws.create_input(
sceneName='pytest',
sceneName='pytest_scene',
inputName='pytest_input',
inputKind='color_source_v3',
inputSettings={
@@ -60,7 +60,7 @@ def pytest_sessionstart(session):
sceneItemEnabled=True,
)
session.obsws.create_input(
sceneName='pytest',
sceneName='pytest_scene',
inputName='pytest_input_2',
inputKind='color_source_v3',
inputSettings={
@@ -71,11 +71,18 @@ def pytest_sessionstart(session):
},
sceneItemEnabled=True,
)
resp = session.obsws.get_scene_item_list('pytest')
session.obsws.create_input(
sceneName='pytest_scene',
inputName='pytest_text_input',
inputKind='text_gdiplus_v3',
inputSettings={'text': 'Hello, OBS!'},
sceneItemEnabled=True,
)
resp = session.obsws.get_scene_item_list('pytest_scene')
for item in resp.scene_items:
if item['sourceName'] == 'pytest_input_2':
session.obsws.set_scene_item_transform(
'pytest',
'pytest_scene',
item['sceneItemId'],
{
'rotation': 0,
@@ -83,18 +90,65 @@ def pytest_sessionstart(session):
)
break
# Create a source filter for the Mic/Aux source
session.obsws.create_source_filter(
source_name='Mic/Aux',
filter_name='pytest filter',
filter_kind='compressor_filter',
filter_settings={
'threshold': -20,
'ratio': 4,
'attack_time': 10,
'release_time': 100,
'output_gain': -3.6,
'sidechain_source': None,
},
)
# Create a source filter for the pytest scene
session.obsws.create_source_filter(
source_name='pytest_scene',
filter_name='pytest filter',
filter_kind='luma_key_filter_v2',
filter_settings={'luma_max': 0.6509},
)
def pytest_sessionfinish(session, exitstatus):
"""Call after the whole test run finishes.
Return the exit status to the system.
"""
session.obsws.remove_scene('pytest')
session.obsws.remove_source_filter(
source_name='Mic/Aux',
filter_name='pytest filter',
)
session.obsws.remove_source_filter(
source_name='pytest_scene',
filter_name='pytest filter',
)
session.obsws.remove_scene('pytest_scene')
session.obsws.set_current_scene_collection('default')
resp = session.obsws.get_stream_status()
if resp.output_active:
session.obsws.stop_stream()
resp = session.obsws.get_record_status()
if resp.output_active:
session.obsws.stop_record()
resp = session.obsws.get_replay_buffer_status()
if resp.output_active:
session.obsws.stop_replay_buffer()
resp = session.obsws.get_studio_mode_enabled()
if resp.studio_mode_enabled:
session.obsws.set_studio_mode_enabled(False)
# Close the OBS WebSocket client connection
session.obsws.disconnect()

View File

@@ -1,15 +0,0 @@
"""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

30
tests/test_filter.py Normal file
View File

@@ -0,0 +1,30 @@
"""Unit tests for the filter command in the OBS WebSocket CLI."""
from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner(mix_stderr=False)
def test_filter_list():
"""Test the filter list command on an audio source."""
result = runner.invoke(app, ['filter', 'list', 'Mic/Aux'])
assert result.exit_code == 0
assert 'Filters for Source: Mic/Aux' in result.stdout
assert 'pytest filter' in result.stdout
def test_filter_list_scene():
"""Test the filter list command on a scene."""
result = runner.invoke(app, ['filter', 'list', 'pytest_scene'])
assert result.exit_code == 0
assert 'Filters for Source: pytest_scene' in result.stdout
assert 'pytest filter' in result.stdout
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

View File

@@ -4,7 +4,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner()
runner = CliRunner(mix_stderr=False)
def test_group_list():
@@ -18,26 +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', 'hide', 'Scene', 'test_group'])
result = runner.invoke(app, ['group', 'status', 'Scene', 'test_group'])
assert result.exit_code == 0
assert "Group 'test_group' is now hidden." 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
assert "Group 'test_group' is now visible." in result.stdout
if enabled:
assert 'Group test_group is now hidden.' in result.stdout
else:
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

14
tests/test_hotkey.py Normal file
View File

@@ -0,0 +1,14 @@
"""Unit tests for the hotkey command in the OBS WebSocket CLI."""
from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner(mix_stderr=False)
def test_hotkey_list():
"""Test the hotkey list command."""
result = runner.invoke(app, ['hotkey', 'list'])
assert result.exit_code == 0
assert 'Hotkeys' in result.stdout

47
tests/test_input.py Normal file
View File

@@ -0,0 +1,47 @@
"""Unit tests for the input command in the OBS WebSocket CLI."""
from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner(mix_stderr=False)
def test_input_list():
"""Test the input list command."""
result = runner.invoke(app, ['input', 'list'])
assert result.exit_code == 0
assert 'Desktop Audio' in result.stdout
assert 'Mic/Aux' in result.stdout
assert all(
item in result.stdout
for item in ('Colour Source', 'Colour Source 2', 'Colour Source 3')
)
def test_input_list_filter_input():
"""Test the input list command with input filter."""
result = runner.invoke(app, ['input', 'list', '--input'])
assert result.exit_code == 0
assert 'Desktop Audio' not in result.stdout
assert 'Mic/Aux' in result.stdout
def test_input_list_filter_output():
"""Test the input list command with output filter."""
result = runner.invoke(app, ['input', 'list', '--output'])
assert result.exit_code == 0
assert 'Desktop Audio' in result.stdout
assert 'Mic/Aux' not in result.stdout
def test_input_list_filter_colour():
"""Test the input list command with colour filter."""
result = runner.invoke(app, ['input', 'list', '--colour'])
assert result.exit_code == 0
assert all(
item in result.stdout
for item in ('Colour Source', 'Colour Source 2', 'Colour Source 3')
)
assert 'Desktop Audio' not in result.stdout
assert 'Mic/Aux' not in result.stdout

View File

@@ -6,27 +6,51 @@ from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner()
runner = CliRunner(mix_stderr=False)
def test_record_start_status_stop():
def test_record_start():
"""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
active = 'Recording is in progress.' in result.stdout
result = runner.invoke(app, ['record', 'start'])
if active:
assert result.exit_code != 0
assert 'Recording is already in progress, cannot start.' in result.stderr
else:
assert result.exit_code == 0
assert 'Recording started successfully.' in result.stdout
time.sleep(0.5) # Wait for the recording to start
def test_record_stop():
"""Test the record stop command."""
result = runner.invoke(app, ['record', 'status'])
assert result.exit_code == 0
active = '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
if not active:
assert result.exit_code != 0
assert 'Recording is not in progress, cannot stop.' in result.stderr
else:
assert result.exit_code == 0
assert 'Recording stopped successfully. Saved to:' in result.stdout
time.sleep(0.5) # Wait for the recording to stop
time.sleep(0.5) # Wait for the recording to stop
def test_record_toggle():
"""Test the record toggle command."""
result = runner.invoke(app, ['record', 'status'])
assert result.exit_code == 0
assert 'Recording is not in progress.' in result.stdout
active = 'Recording is in progress.' in result.stdout
result = runner.invoke(app, ['record', 'toggle'])
assert result.exit_code == 0
time.sleep(0.5) # Wait for the recording to toggle
if active:
assert 'Recording stopped successfully.' in result.stdout
else:
assert 'Recording started successfully.' in result.stdout

View File

@@ -0,0 +1,52 @@
"""Unit tests for the replaybuffer command in the OBS WebSocket CLI."""
from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner(mix_stderr=False)
def test_replaybuffer_start():
"""Test the replay buffer start command."""
resp = runner.invoke(app, ['replaybuffer', 'status'])
assert resp.exit_code == 0
active = 'Replay buffer is active.' in resp.stdout
resp = runner.invoke(app, ['replaybuffer', 'start'])
if active:
assert resp.exit_code != 0
assert 'Replay buffer is already active.' in resp.stderr
else:
assert resp.exit_code == 0
assert 'Replay buffer started.' in resp.stdout
def test_replaybuffer_stop():
"""Test the replay buffer stop command."""
resp = runner.invoke(app, ['replaybuffer', 'status'])
assert resp.exit_code == 0
active = 'Replay buffer is active.' in resp.stdout
resp = runner.invoke(app, ['replaybuffer', 'stop'])
if not active:
assert resp.exit_code != 0
assert 'Replay buffer is not active.' in resp.stderr
else:
assert resp.exit_code == 0
assert 'Replay buffer stopped.' in resp.stdout
def test_replaybuffer_toggle():
"""Test the replay buffer toggle command."""
resp = runner.invoke(app, ['replaybuffer', 'status'])
assert resp.exit_code == 0
active = 'Replay buffer is active.' in resp.stdout
resp = runner.invoke(app, ['replaybuffer', 'toggle'])
if active:
assert resp.exit_code == 0
assert 'Replay buffer is not active.' in resp.stdout
else:
assert resp.exit_code == 0
assert 'Replay buffer is active.' in resp.stdout

View File

@@ -4,19 +4,42 @@ from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner()
runner = CliRunner(mix_stderr=False)
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
assert 'pytest_scene' in result.stdout
def test_scene_current():
"""Test the scene current command."""
runner.invoke(app, ['scene', 'switch', 'pytest'])
runner.invoke(app, ['scene', 'switch', 'pytest_scene'])
result = runner.invoke(app, ['scene', 'current'])
assert result.exit_code == 0
assert 'pytest' in result.stdout
assert 'pytest_scene' in result.stdout
def test_scene_switch():
"""Test the scene switch command."""
result = runner.invoke(app, ['studiomode', 'status'])
assert result.exit_code == 0
enabled = 'Studio mode is enabled.' in result.stdout
if enabled:
result = runner.invoke(app, ['scene', 'switch', 'pytest_scene', '--preview'])
assert result.exit_code == 0
assert 'Switched to preview scene: pytest_scene' in result.stdout
else:
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

@@ -4,12 +4,12 @@ from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner()
runner = CliRunner(mix_stderr=False)
def test_sceneitem_list():
"""Test the sceneitem list command."""
result = runner.invoke(app, ['sceneitem', 'list', 'pytest'])
result = runner.invoke(app, ['sceneitem', 'list', 'pytest_scene'])
assert result.exit_code == 0
assert 'pytest_input' in result.stdout
assert 'pytest_input_2' in result.stdout
@@ -23,11 +23,12 @@ def test_sceneitem_transform():
'sceneitem',
'transform',
'--rotation=60',
'pytest',
'pytest_scene',
'pytest_input_2',
],
)
assert result.exit_code == 0
assert (
"Item 'pytest_input_2' in scene 'pytest' has been transformed" in result.stdout
'Item pytest_input_2 in scene pytest_scene has been transformed'
in result.stdout
)

View File

@@ -6,7 +6,7 @@ from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner()
runner = CliRunner(mix_stderr=False)
def test_stream_start():
@@ -16,14 +16,14 @@ def test_stream_start():
active = 'Streaming is in progress' in result.stdout
result = runner.invoke(app, ['stream', 'start'])
assert result.exit_code == 0
time.sleep(2) # Wait for the stream to start
if active:
assert 'Streaming is already in progress, cannot start.' in result.stdout
assert result.exit_code != 0
assert 'Streaming is already in progress, cannot start.' in result.stderr
else:
assert result.exit_code == 0
assert 'Streaming started successfully.' in result.stdout
time.sleep(1) # Wait for the streaming to start
def test_stream_stop():
@@ -33,11 +33,28 @@ def test_stream_stop():
active = 'Streaming is in progress' in result.stdout
result = runner.invoke(app, ['stream', 'stop'])
if active:
assert result.exit_code == 0
assert 'Streaming stopped successfully.' in result.stdout
time.sleep(1) # Wait for the streaming to stop
else:
assert result.exit_code != 0
assert 'Streaming is not in progress, cannot stop.' in result.stderr
def test_stream_toggle():
"""Test the stream toggle command."""
result = runner.invoke(app, ['stream', 'status'])
assert result.exit_code == 0
active = 'Streaming is in progress' in result.stdout
result = runner.invoke(app, ['stream', 'toggle'])
assert result.exit_code == 0
time.sleep(2) # Wait for the stream to stop
time.sleep(1) # Wait for the stream to toggle
if active:
assert 'Streaming stopped successfully.' in result.stdout
else:
assert 'Streaming is not in progress, cannot stop.' in result.stdout
assert 'Streaming started successfully.' in result.stdout

View File

@@ -4,13 +4,14 @@ from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner()
runner = CliRunner(mix_stderr=False)
def test_studio_enable():
"""Test the studio enable command."""
result = runner.invoke(app, ['studiomode', 'enable'])
assert result.exit_code == 0
assert 'Studio mode has been enabled.' in result.stdout
result = runner.invoke(app, ['studiomode', 'status'])
assert result.exit_code == 0
@@ -21,6 +22,7 @@ def test_studio_disable():
"""Test the studio disable command."""
result = runner.invoke(app, ['studiomode', 'disable'])
assert result.exit_code == 0
assert 'Studio mode has been disabled.' in result.stdout
result = runner.invoke(app, ['studiomode', 'status'])
assert result.exit_code == 0

18
tests/test_text.py Normal file
View File

@@ -0,0 +1,18 @@
"""Unit tests for the text command in the OBS WebSocket CLI."""
from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner(mix_stderr=False)
def test_text_update():
"""Test the text update command."""
result = runner.invoke(app, ['text', 'current', 'pytest_text_input'])
assert result.exit_code == 0
assert 'Current text for input pytest_text_input: Hello, OBS!' in result.stdout
result = runner.invoke(app, ['text', 'update', 'pytest_text_input', 'New Text'])
assert result.exit_code == 0
assert 'Text for input pytest_text_input updated to: New Text' in result.stdout

22
tests/test_version.py Normal file
View File

@@ -0,0 +1,22 @@
"""Unit tests for the root command in the OBS WebSocket CLI."""
from typer.testing import CliRunner
from obsws_cli.app import app
runner = CliRunner(mix_stderr=False)
def test_version():
"""Test the version option."""
result = runner.invoke(app, ['--version'])
assert result.exit_code == 0
assert 'obsws-cli version:' in result.stdout
def test_obs_version():
"""Test the obs-version command."""
result = runner.invoke(app, ['obs-version'])
assert result.exit_code == 0
assert 'OBS Client version' in result.stdout
assert 'WebSocket version' in result.stdout

187
uv.lock generated Normal file
View File

@@ -0,0 +1,187 @@
version = 1
revision = 2
requires-python = ">=3.10"
[[package]]
name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "obsws-cli"
source = { editable = "." }
dependencies = [
{ name = "obsws-python" },
{ name = "python-dotenv" },
{ name = "typer" },
]
[package.metadata]
requires-dist = [
{ name = "obsws-python", specifier = ">=1.7.2" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "typer", specifier = ">=0.16.0" },
]
[[package]]
name = "obsws-python"
version = "1.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/aa/1a4e9db03c0eda9a2594c9aeccea5e93b5d2308f5273dd7217b346b523d4/obsws_python-1.7.2.tar.gz", hash = "sha256:b5cdaad30fbe1f6d4787b6530048b9882f070c3ee7830abb6dad4a47f84d7fa0", size = 29133, upload-time = "2025-05-14T19:20:26.005Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/68/a5d63428b26221e0f6cb3968d93ffb951825ba0f82b23d356efedfa19fd5/obsws_python-1.7.2-py3-none-any.whl", hash = "sha256:acda31852ad9d7165de915b0603c13f6df527d3f61619970bf5fb562e300bc85", size = 30863, upload-time = "2025-05-14T19:20:24.603Z" },
]
[[package]]
name = "pygments"
version = "2.19.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" },
]
[[package]]
name = "rich"
version = "14.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "tomli"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
{ url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
{ url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
{ url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
{ url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
{ url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
{ url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
{ url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
{ url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
{ url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
{ url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
{ url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
{ url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
{ url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
{ url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
{ url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
{ url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
{ url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
]
[[package]]
name = "typer"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
]
[[package]]
name = "typing-extensions"
version = "4.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
]
[[package]]
name = "websocket-client"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648, upload-time = "2024-04-23T22:16:16.976Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload-time = "2024-04-23T22:16:14.422Z" },
]