183 Commits

Author SHA1 Message Date
23e358da9c print message and return 0 for list queries returning empty results 2025-07-29 02:52:00 +01:00
417ad54a0c add docstrings containing help output 2025-07-28 11:32:23 +01:00
3b184c6531 upd dependencies 2025-07-24 04:21:58 +01:00
8c37ce1fc0 remove typer import 2025-07-24 04:21:48 +01:00
436e4d5345 remove alias, settings 2025-07-24 04:21:40 +01:00
2ef89be184 convert virtualcam commands 2025-07-24 04:09:49 +01:00
506aff833c convert text commands 2025-07-24 04:08:07 +01:00
eb939b735c convert studiomode commands 2025-07-24 04:05:01 +01:00
bb7a468dd5 convert stream commands 2025-07-24 03:59:37 +01:00
e77627b845 convert screenshot commands 2025-07-24 03:52:50 +01:00
93b066090b fix aliases 2025-07-24 03:49:31 +01:00
1ce832dfde convert sceneitem commands 2025-07-24 03:46:11 +01:00
e8664f0117 convert scenecollection commands 2025-07-24 02:34:15 +01:00
a3dff0f739 convert replaybuffer commands 2025-07-24 02:29:58 +01:00
6da9df5ceb convert record commands 2025-07-24 02:26:18 +01:00
75fc18273e convert projector commands 2025-07-24 02:20:17 +01:00
e658819719 convert profile commands 2025-07-24 02:14:38 +01:00
4451fbf22c conver input commands 2025-07-24 02:06:05 +01:00
132b283347 convert hotkey commands 2025-07-24 01:48:55 +01:00
ae8ff20cf4 convert group commands 2025-07-24 01:39:02 +01:00
de1c604c46 update the --help message
add descriptions for filter + scene command groups

Usage now after main CLI description
2025-07-24 01:38:42 +01:00
105aaf29b7 keep the exit codes simple (0 or 1) 2025-07-17 04:34:44 +01:00
eb34a1833f convert filter sub app 2025-07-17 04:29:21 +01:00
abbab5c746 raise OBSWSCLIError 2025-07-17 04:28:59 +01:00
f0eb518609 implement --version + debug validator
run() now handles exceptions/exit codes
2025-07-17 04:28:40 +01:00
032b957670 add custom error class + exit codes 2025-07-17 04:28:10 +01:00
8349a196e8 root meta app + scene sub_app converted
this could take a while...

note, --version flag not implemented
2025-07-17 03:18:04 +01:00
f852a733c3 upd publish action 2025-07-14 03:27:52 +01:00
44dadcee23 upd publish action 2025-07-14 03:25:52 +01:00
ed4531c305 revert publish action 2025-07-14 03:23:25 +01:00
ec42a4cdd9 patch bump 2025-07-14 03:21:29 +01:00
6123c92d00 upd publish action 2025-07-14 03:21:06 +01:00
1ceb95ab16 fix environment name 2025-07-14 03:12:35 +01:00
f06e2d3982 upd publish action 2025-07-14 03:10:04 +01:00
39dff3cc28 patch bump 2025-07-14 03:02:53 +01:00
967c4ab699 upd publish action 2025-07-14 02:58:25 +01:00
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
50 changed files with 3703 additions and 884 deletions

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

@@ -0,0 +1,39 @@
name: Publish to PyPI
on:
release:
types: [published]
push:
tags:
- 'v*.*.*'
jobs:
deploy:
runs-on: ubuntu-latest
environment: pypi
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
uses: pypa/gh-action-pypi-publish@release/v1

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

247
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,6 +556,125 @@ 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
@@ -459,3 +682,5 @@ obsws-cli virtualcam status
[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.2'

View File

@@ -2,6 +2,6 @@
#
# SPDX-License-Identifier: MIT
from .app import app
from .app import run
__all__ = ["app"]
__all__ = ['run']

View File

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

View File

@@ -1,97 +1,137 @@
"""Command line interface for the OBS WebSocket API."""
from pathlib import Path
from typing import Annotated, Optional
import importlib
import logging
from dataclasses import dataclass
from typing import Annotated, Any
import obsws_python as obsws
import typer
from pydantic_settings import BaseSettings, SettingsConfigDict
from cyclopts import App, Group, Parameter, config
from . import (
group,
input,
profile,
record,
replaybuffer,
scene,
scenecollection,
sceneitem,
stream,
studiomode,
virtualcam,
from obsws_cli.__about__ import __version__ as version
from . import console, styles
from .context import Context
from .error import OBSWSCLIError
app = App(
config=config.Env(
'OBS_'
), # Environment variable prefix for configuration parameters
version=version,
usage='[bold][yellow]Usage:[/yellow] [white]obsws-cli [OPTIONS] COMMAND [ARGS]...[/white][/bold]',
)
from .alias import AliasGroup
class Settings(BaseSettings):
"""Settings for the OBS WebSocket client."""
model_config = SettingsConfigDict(
env_file=(
'.env',
Path.home() / '.config' / 'obsws-cli' / 'obsws.env',
),
env_file_encoding='utf-8',
env_prefix='OBS_',
)
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.meta.group_parameters = Group('Options', sort_key=0)
for sub_app in (
'filter',
'group',
'hotkey',
'input',
'profile',
'projector',
'record',
'replaybuffer',
'scene',
'scenecollection',
'sceneitem',
'screenshot',
'stream',
'studiomode',
'text',
'virtualcam',
):
app.add_typer(module.app, name=module.__name__.split('.')[-1])
module = importlib.import_module(f'.{sub_app}', package=__package__)
app.command(module.app)
@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,
@Parameter(name='*')
@dataclass
class OBSConfig:
"""Dataclass to hold OBS connection parameters.
Attributes:
host (str): The hostname or IP address of the OBS WebSocket server.
port (int): The port number of the OBS WebSocket server.
password (str): The password for the OBS WebSocket server, if required.
"""
host: str = 'localhost'
port: int = 4455
password: str = ''
@dataclass
class StyleConfig:
"""Dataclass to hold style parameters.
Attributes:
name (str): The name of the style to use for console output.
no_border (bool): Whether to style the borders in the console output.
"""
name: str = 'disabled'
no_border: bool = False
def setup_logging(type_, value: Any):
"""Set up logging for the application."""
log_level = logging.DEBUG if value else logging.CRITICAL
logging.basicConfig(
level=log_level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
@app.meta.default
def launcher(
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
obs_config: OBSConfig,
style_config: StyleConfig,
debug: Annotated[
bool,
Parameter(validator=setup_logging, help='Enable debug logging'),
] = False,
):
"""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,
)
"""Command line interface for the OBS WebSocket API."""
with obsws.ReqClient(
host=obs_config.host,
port=obs_config.port,
password=obs_config.password,
) as client:
additional_kwargs = {}
command, bound, ignored = app.parse_args(tokens)
if 'ctx' in ignored:
# If 'ctx' is in ignored, it means it was not passed as an argument
# and we need to add it to the bound arguments.
additional_kwargs['ctx'] = ignored['ctx'](
client,
styles.request_style_obj(style_config.name, style_config.no_border),
)
return command(*bound.args, **bound.kwargs, **additional_kwargs)
@app.command()
def version(ctx: typer.Context):
@app.command
def obs_version(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""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.client.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)}'
)
def run():
"""Run the OBS WebSocket CLI application.
Handles exceptions and prints error messages to the console.
"""
try:
app.meta()
except OBSWSCLIError as e:
console.err.print(f'Error: {e}')
return e.code

13
obsws_cli/console.py Normal file
View File

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

15
obsws_cli/context.py Normal file
View File

@@ -0,0 +1,15 @@
"""module for managing the application context."""
from dataclasses import dataclass
import obsws_python as obsws
from . import styles
@dataclass
class Context:
"""Context for the application, holding OBS and style configurations."""
client: obsws.ReqClient
style: styles.Style

10
obsws_cli/enum.py Normal file
View File

@@ -0,0 +1,10 @@
"""module for exit codes used in the application."""
from enum import IntEnum, auto
class ExitCode(IntEnum):
"""Exit codes for the application."""
SUCCESS = 0
ERROR = auto()

11
obsws_cli/error.py Normal file
View File

@@ -0,0 +1,11 @@
"""module containing error handling for OBS WebSocket CLI."""
class OBSWSCLIError(Exception):
"""Base class for OBS WebSocket CLI errors."""
def __init__(self, message: str, code: int = 1):
"""Initialize the error with a message and an optional code."""
super().__init__(message)
self.message = message
self.code = code

221
obsws_cli/filter.py Normal file
View File

@@ -0,0 +1,221 @@
"""module containing commands for manipulating filters in scenes."""
from typing import Annotated, Optional
import obsws_python as obsws
from cyclopts import App, Parameter
from rich.table import Table
from rich.text import Text
from . import console, util
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='filter', help='Commands for managing filters in OBS sources')
@app.command(name=['list', 'ls'])
def list_(
source_name: Optional[str] = None,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""List filters for a source.
Parameters
----------
source_name : str, optional
The name of the source to list filters for. If not provided, the current program scene's source will be used.
ctx : Context
The context containing the OBS client and other settings.
"""
if not source_name:
source_name = ctx.client.get_current_program_scene().scene_name
try:
resp = ctx.client.get_source_filter_list(source_name)
except obsws.error.OBSSDKRequestError as e:
if e.code == 600:
raise OBSWSCLIError(
f'No source found by the name of [yellow]{source_name}[/yellow].',
code=ExitCode.ERROR,
)
else:
raise
if not resp.filters:
console.out.print(
f'No filters found for source {console.highlight(ctx, source_name)}'
)
return
table = Table(
title=f'Filters for Source: {source_name}',
padding=(0, 2),
border_style=ctx.style.border,
)
columns = [
(Text('Filter Name', justify='center'), 'left', ctx.style.column),
(Text('Kind', justify='center'), 'left', ctx.style.column),
(Text('Enabled', justify='center'), 'center', None),
(Text('Settings', justify='center'), 'center', ctx.style.column),
]
for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style)
for filter in resp.filters:
resp = ctx.client.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: Context, source_name: str, filter_name: str):
"""Get the status of a filter for a source."""
resp = ctx.client.get_source_filter(source_name, filter_name)
return resp.filter_enabled
@app.command(name=['enable', 'on'])
def enable(
source_name: str,
filter_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Enable a filter for a source.
Parameters
----------
source_name : str
The name of the source to enable the filter for.
filter_name : str
The name of the filter to enable.
ctx : Context
The context containing the OBS client and other settings.
"""
if _get_filter_enabled(ctx, source_name, filter_name):
raise OBSWSCLIError(
f'Filter [yellow]{filter_name}[/yellow] is already enabled for source [yellow]{source_name}[/yellow]',
code=ExitCode.ERROR,
)
ctx.client.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(name=['disable', 'off'])
def disable(
source_name: str,
filter_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Disable a filter for a source.
Parameters
----------
source_name : str
The name of the source to disable the filter for.
filter_name : str
The name of the filter to disable.
ctx : Context
The context containing the OBS client and other settings.
"""
if not _get_filter_enabled(ctx, source_name, filter_name):
raise OBSWSCLIError(
f'Filter [yellow]{filter_name}[/yellow] is already disabled for source [yellow]{source_name}[/yellow]',
code=ExitCode.ERROR,
)
ctx.client.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(name=['toggle', 'tg'])
def toggle(
source_name: str,
filter_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Toggle a filter for a source.
Parameters
----------
source_name : str
The name of the source to toggle the filter for.
filter_name : str
The name of the filter to toggle.
ctx : Context
The context containing the OBS client and other settings.
"""
is_enabled = _get_filter_enabled(ctx, source_name, filter_name)
new_state = not is_enabled
ctx.client.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(name=['status', 'ss'])
def status(
source_name: str,
filter_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the status of a filter for a source.
Parameters
----------
source_name : str
The name of the source to check the filter status for.
filter_name : str
The name of the filter to check the status for.
ctx : Context
The context containing the OBS client and other settings.
"""
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,31 +1,82 @@
"""module containing commands for manipulating groups in scenes."""
import typer
from typing import Annotated, Optional
from . import validate
from .alias import AliasGroup
from cyclopts import App, Parameter
from rich.table import Table
from rich.text import Text
from . import console, util, validate
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
from .protocols import DataclassProtocol
app = typer.Typer(cls=AliasGroup)
app = App(name='group', help='Commands for managing groups in OBS scenes')
@app.callback()
def main():
"""Control groups in OBS scenes."""
@app.command(name=['list', 'ls'])
def list_(
scene_name: Optional[str] = None,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""List groups in a scene.
Parameters
----------
scene_name : str, optional
The name of the scene to list groups for. If not provided, the current program scene
will be used.
ctx : Context
The context containing the OBS client and other settings.
"""
if not scene_name:
scene_name = ctx.client.get_current_program_scene().scene_name
@app.command('list | ls')
def list(ctx: typer.Context, scene_name: str):
"""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)
resp = ctx.obj.get_scene_item_list(scene_name)
groups = (
item.get('sourceName') for item in resp.scene_items if item.get('isGroup')
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
typer.echo('\n'.join(groups))
resp = ctx.client.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)}.'
)
return
table = Table(
title=f'Groups in Scene: {scene_name}',
padding=(0, 2),
border_style=ctx.style.border,
)
columns = [
(Text('ID', justify='center'), 'center', ctx.style.column),
(Text('Group Name', justify='center'), 'left', ctx.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:
@@ -41,91 +92,175 @@ def _get_group(group_name: str, resp: DataclassProtocol) -> dict | None:
return group
@app.command('show | sh')
def show(ctx: typer.Context, scene_name: str, group_name: str):
"""Show a group in a scene."""
@app.command(name=['show', 'sh'])
def show(
scene_name: str,
group_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Show a group in a scene.
Parameters
----------
scene_name : str
The name of the scene where the group is located.
group_name : str
The name of the group to show.
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.obj.get_scene_item_list(scene_name)
resp = ctx.client.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)
raise OBSWSCLIError(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].',
code=ExitCode.ERROR,
)
ctx.obj.set_scene_item_enabled(
ctx.client.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):
"""Hide a group in a scene."""
@app.command(name=['hide', 'h'])
def hide(
scene_name: str,
group_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Hide a group in a scene.
Parameters
----------
scene_name : str
The name of the scene where the group is located.
group_name : str
The name of the group to hide.
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.obj.get_scene_item_list(scene_name)
resp = ctx.client.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)
raise OBSWSCLIError(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].',
code=ExitCode.ERROR,
)
ctx.obj.set_scene_item_enabled(
ctx.client.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):
"""Toggle a group in a scene."""
@app.command(name=['toggle', 'tg'])
def toggle(
scene_name: str,
group_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Toggle a group in a scene.
Parameters
----------
scene_name : str
The name of the scene where the group is located.
group_name : str
The name of the group to toggle.
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.obj.get_scene_item_list(scene_name)
resp = ctx.client.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)
raise OBSWSCLIError(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].',
code=ExitCode.ERROR,
)
new_state = not group.get('sceneItemEnabled')
ctx.obj.set_scene_item_enabled(
ctx.client.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):
"""Get the status of a group in a scene."""
@app.command(name=['status', 'ss'])
def status(
scene_name: str,
group_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the status of a group in a scene.
Parameters
----------
scene_name : str
The name of the scene where the group is located.
group_name : str
The name of the group to check.
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.scene_in_scenes(ctx, scene_name):
typer.echo(f"Scene '{scene_name}' not found.", err=True)
raise typer.Exit(code=1)
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
resp = ctx.obj.get_scene_item_list(scene_name)
resp = ctx.client.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)
raise OBSWSCLIError(
f'Group [yellow]{group_name}[/yellow] not found in scene [yellow]{scene_name}[/yellow].',
code=ExitCode.ERROR,
)
enabled = ctx.obj.get_scene_item_enabled(
enabled = ctx.client.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.')

96
obsws_cli/hotkey.py Normal file
View File

@@ -0,0 +1,96 @@
"""module containing commands for hotkey management."""
from typing import Annotated
from cyclopts import App, Parameter
from rich.table import Table
from rich.text import Text
from . import console
from .context import Context
app = App(name='hotkey', help='Commands for managing hotkeys in OBS')
@app.command(name=['list', 'ls'])
def list_(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""List all hotkeys.
Parameters
----------
ctx : Context
The context containing the OBS client to interact with.
"""
resp = ctx.client.get_hotkey_list()
table = Table(
title='Hotkeys',
padding=(0, 2),
border_style=ctx.style.border,
)
table.add_column(
Text('Hotkey Name', justify='center'),
justify='left',
style=ctx.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(name=['trigger', 'tr'])
def trigger(
hotkey: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Trigger a hotkey by name.
Parameters
----------
hotkey : str
The name of the hotkey to trigger.
ctx : Context
The context containing the OBS client to interact with.
"""
ctx.client.trigger_hotkey_by_name(hotkey)
@app.command(name=['trigger-sequence', 'trs'])
def trigger_sequence(
key_id: str,
/,
shift: bool = False,
ctrl: bool = False,
alt: bool = False,
cmd: bool = False,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Trigger a hotkey by sequence.
Parameters
----------
key_id : str
The OBS key ID to trigger, see https://github.com/onyx-and-iris/obsws-cli?tab=readme-ov-file#hotkey for more info
shift : bool, optional
Press shift when triggering the hotkey (default is False)
ctrl : bool, optional
Press control when triggering the hotkey (default is False)
alt : bool, optional
Press alt when triggering the hotkey (default is False)
cmd : bool, optional
Press cmd when triggering the hotkey (default is False)
ctx : Context
The context containing the OBS client to interact with.
"""
ctx.client.trigger_hotkey_by_key_sequence(key_id, shift, ctrl, alt, cmd)

View File

@@ -2,28 +2,51 @@
from typing import Annotated
import typer
import obsws_python as obsws
from cyclopts import App, Parameter
from rich.table import Table
from rich.text import Text
from . import validate
from .alias import AliasGroup
from . import console, util, validate
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = typer.Typer(cls=AliasGroup)
app = App(name='input', help='Commands for managing inputs in OBS')
@app.callback()
def main():
"""Control inputs in OBS."""
@app.command('list | ls')
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,
@app.command(name=['list', 'ls'])
def list_(
input: bool = False,
output: bool = False,
colour: bool = False,
ffmpeg: bool = False,
vlc: bool = False,
uuid: bool = False,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""List all inputs."""
resp = ctx.obj.get_input_list()
"""List all inputs.
Parameters
----------
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.
ctx:
The context containing the client and style.
"""
resp = ctx.client.get_input_list()
kinds = []
if input:
@@ -32,62 +55,171 @@ 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.client.get_input_kind_list(False).input_kinds
inputs = filter(
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,
)
typer.echo('\n'.join(input_.get('inputName') for input_ in inputs))
),
key=lambda x: x[0], # Sort by input name
)
if not inputs:
console.out.print('No inputs found matching the specified filters.')
return
table = Table(title='Inputs', padding=(0, 2), border_style=ctx.style.border)
if uuid:
columns = [
(Text('Input Name', justify='center'), 'left', ctx.style.column),
(Text('Kind', justify='center'), 'center', ctx.style.column),
(Text('Muted', justify='center'), 'center', None),
(Text('UUID', justify='center'), 'left', ctx.style.column),
]
else:
columns = [
(Text('Input Name', justify='center'), 'left', ctx.style.column),
(Text('Kind', justify='center'), 'center', ctx.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.client.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):
"""Mute an input."""
@app.command(name=['mute', 'm'])
def mute(
input_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Mute an input.
Parameters
----------
input_name: str
Name of the input to mute.
ctx: Context
The context containing the client and style.
"""
if not validate.input_in_inputs(ctx, input_name):
typer.echo(f"Input '{input_name}' not found.", err=True)
raise typer.Exit(code=1)
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
ctx.obj.set_input_mute(
ctx.client.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):
"""Unmute an input."""
@app.command(name=['unmute', 'um'])
def unmute(
input_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Unmute an input.
Parameters
----------
input_name: str
Name of the input to unmute.
ctx: Context
The context containing the client and style.
"""
if not validate.input_in_inputs(ctx, input_name):
typer.echo(f"Input '{input_name}' not found.", err=True)
raise typer.Exit(code=1)
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
ctx.obj.set_input_mute(
ctx.client.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):
"""Toggle an input."""
@app.command(name=['toggle', 'tg'])
def toggle(
input_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Toggle an input.
Parameters
----------
input_name: str
Name of the input to toggle.
ctx: Context
The context containing the client and style.
"""
if not validate.input_in_inputs(ctx, input_name):
typer.echo(f"Input '{input_name}' not found.", err=True)
raise typer.Exit(code=1)
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
# Get the current mute state
resp = ctx.obj.get_input_mute(name=input_name)
resp = ctx.client.get_input_mute(name=input_name)
new_state = not resp.input_muted
ctx.obj.set_input_mute(
ctx.client.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,68 +1,161 @@
"""module containing commands for manipulating profiles in OBS."""
import typer
from typing import Annotated
from . import validate
from .alias import AliasGroup
from cyclopts import App, Parameter
from rich.table import Table
from rich.text import Text
app = typer.Typer(cls=AliasGroup)
from . import console, util, validate
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='profile', help='Commands for managing profiles in OBS')
@app.callback()
def main():
"""Control profiles in OBS."""
@app.command(name=['list', 'ls'])
def list_(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""List profiles.
Parameters
----------
ctx: Context
The context containing the client and style.
"""
resp = ctx.client.get_profile_list()
table = Table(title='Profiles', padding=(0, 2), border_style=ctx.style.border)
columns = [
(Text('Profile Name', justify='center'), 'left', ctx.style.column),
(Text('Current', justify='center'), 'center', None),
]
for heading, justify, style in columns:
table.add_column(heading, justify=justify, style=style)
if not resp.profiles:
console.out.print('No profiles found.')
return
@app.command('list | ls')
def list(ctx: typer.Context):
"""List profiles."""
resp = ctx.obj.get_profile_list()
for profile in resp.profiles:
typer.echo(profile)
@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)
@app.command('switch | set')
def switch(ctx: typer.Context, profile_name: str):
"""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)
resp = ctx.obj.get_profile_list()
if resp.current_profile_name == profile_name:
typer.echo(
f"Profile '{profile_name}' is already the current profile.", err=True
table.add_row(
profile,
util.check_mark(
ctx, profile == resp.current_profile_name, empty_if_false=True
),
)
raise typer.Exit(code=1)
ctx.obj.set_current_profile(profile_name)
typer.echo(f"Switched to profile '{profile_name}'.")
console.out.print(table)
@app.command('create | new')
def create(ctx: typer.Context, profile_name: str):
"""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)
@app.command(name=['current', 'get'])
def current(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the current profile.
ctx.obj.create_profile(profile_name)
typer.echo(f"Created profile '{profile_name}'.")
Parameters
----------
ctx: Context
The context containing the client and style.
"""
resp = ctx.client.get_profile_list()
console.out.print(
f'Current profile: {console.highlight(ctx, resp.current_profile_name)}'
)
@app.command('remove | rm')
def remove(ctx: typer.Context, profile_name: str):
"""Remove a profile."""
@app.command(name=['switch', 'set'])
def switch(
profile_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Switch to a profile.
Parameters
----------
profile_name: str
Name of the profile to switch to.
ctx: Context
The context containing the client and style.
"""
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 OBSWSCLIError(
f'Profile [yellow]{profile_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
ctx.obj.remove_profile(profile_name)
typer.echo(f"Removed profile '{profile_name}'.")
resp = ctx.client.get_profile_list()
if resp.current_profile_name == profile_name:
raise OBSWSCLIError(
f'Profile [yellow]{profile_name}[/yellow] is already the current profile.',
code=ExitCode.ERROR,
)
ctx.client.set_current_profile(profile_name)
console.out.print(f'Switched to profile {console.highlight(ctx, profile_name)}.')
@app.command(name=['create', 'new'])
def create(
profile_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Create a new profile.
Parameters
----------
profile_name: str
Name of the profile to create.
ctx: Context
The context containing the client and style.
"""
if validate.profile_exists(ctx, profile_name):
raise OBSWSCLIError(
f'Profile [yellow]{profile_name}[/yellow] already exists.',
code=ExitCode.ERROR,
)
ctx.client.create_profile(profile_name)
console.out.print(f'Created profile {console.highlight(ctx, profile_name)}.')
@app.command(name=['remove', 'rm'])
def remove(
profile_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Remove a profile.
Parameters
----------
profile_name: str
Name of the profile to remove.
ctx: Context
The context containing the client and style.
"""
if not validate.profile_exists(ctx, profile_name):
console.err.print(f'Profile [yellow]{profile_name}[/yellow] not found.')
raise OBSWSCLIError(
f'Profile [yellow]{profile_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
ctx.client.remove_profile(profile_name)
console.out.print(f'Removed profile {console.highlight(ctx, profile_name)}.')

104
obsws_cli/projector.py Normal file
View File

@@ -0,0 +1,104 @@
"""module containing commands for manipulating projectors in OBS."""
from typing import Annotated, Optional
from cyclopts import App, Parameter, validators
from rich.table import Table
from rich.text import Text
from . import console
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='projector', help='Commands for managing projectors in OBS')
@app.command(name=['list-monitors', 'ls-m'])
def list_monitors(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""List available monitors.
Parameters
----------
ctx : Context
The context containing the OBS client and configuration.
"""
resp = ctx.client.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],
)
if not monitors:
console.out.print('No monitors available.')
return
table = Table(
title='Available Monitors',
padding=(0, 2),
border_style=ctx.style.border,
)
table.add_column(
Text('Index', justify='center'), justify='center', style=ctx.style.column
)
table.add_column(
Text('Name', justify='center'), justify='left', style=ctx.style.column
)
for index, monitor in monitors:
table.add_row(str(index), monitor)
console.out.print(table)
@app.command(name=['open', 'o'])
def open(
source_name: Optional[str] = None,
/,
monitor_index: Annotated[int, Parameter(validator=validators.Number(gte=0))] = 0,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Open a fullscreen projector for a source on a specific monitor.
Parameters
----------
source_name : str, optional
The name of the source to project. If not provided, the current program scene will be used.
monitor_index : int, optional
The index of the monitor to open the projector on. Defaults to 0 (the primary monitor).
ctx : Context
The context containing the OBS client and configuration.
"""
if not source_name:
source_name = ctx.client.get_current_program_scene().scene_name
monitors = ctx.client.get_monitor_list().monitors
for monitor in monitors:
if monitor['monitorIndex'] == monitor_index:
ctx.client.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:
raise OBSWSCLIError(
f'Monitor with index [yellow]{monitor_index}[/yellow] not found. '
f'Use [yellow]obsws-cli projector ls-m[/yellow] to see available monitors.',
ExitCode.ERROR,
)

View File

@@ -1,101 +1,253 @@
"""module for controlling OBS recording functionality."""
import typer
from pathlib import Path
from typing import Annotated, Optional
from .alias import AliasGroup
from cyclopts import App, Parameter
app = typer.Typer(cls=AliasGroup)
from . import console
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='record', help='Commands for controlling OBS recording functionality.')
@app.callback()
def main():
"""Control OBS recording functionality."""
def _get_recording_status(ctx: typer.Context) -> tuple:
def _get_recording_status(ctx: Context) -> tuple:
"""Get recording status."""
resp = ctx.obj.get_record_status()
resp = ctx.client.get_record_status()
return resp.output_active, resp.output_paused
@app.command('start | s')
def start(ctx: typer.Context):
"""Start recording."""
@app.command(name=['start', 's'])
def start(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Start recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx)
if active:
err_msg = 'Recording is already in progress, cannot start.'
if paused:
err_msg += ' Try resuming it.'
raise OBSWSCLIError(err_msg, ExitCode.ERROR)
typer.echo(err_msg, err=True)
raise typer.Exit(1)
ctx.obj.start_record()
typer.echo('Recording started successfully.')
ctx.client.start_record()
console.out.print('Recording started successfully.')
@app.command('stop | st')
def stop(ctx: typer.Context):
"""Stop recording."""
@app.command(name=['stop', 'st'])
def stop(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Stop recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, _ = _get_recording_status(ctx)
if not active:
typer.echo('Recording is not in progress, cannot stop.', err=True)
raise typer.Exit(1)
ctx.obj.stop_record()
typer.echo('Recording stopped successfully.')
@app.command('status | ss')
def status(ctx: typer.Context):
"""Get recording status."""
active, paused = _get_recording_status(ctx)
if active:
if paused:
typer.echo('Recording is in progress and paused.')
else:
typer.echo('Recording is in progress.')
else:
typer.echo('Recording is not in progress.')
@app.command('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)
@app.command('resume | r')
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)
raise typer.Exit(1)
if not paused:
typer.echo('Recording is in progress but not paused, cannot resume.', err=True)
raise typer.Exit(1)
ctx.obj.resume_record()
typer.echo('Recording resumed successfully.')
@app.command('pause | p')
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)
raise typer.Exit(1)
if paused:
typer.echo(
'Recording is in progress but already paused, cannot pause.', err=True
raise OBSWSCLIError(
'Recording is not in progress, cannot stop.', ExitCode.ERROR
)
raise typer.Exit(1)
ctx.obj.pause_record()
typer.echo('Recording paused successfully.')
resp = ctx.client.stop_record()
console.out.print(
f'Recording stopped successfully. Saved to: {console.highlight(ctx, resp.output_path)}'
)
@app.command(name=['toggle', 'tg'])
def toggle(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Toggle recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.toggle_record()
if resp.output_active:
console.out.print('Recording started successfully.')
else:
console.out.print('Recording stopped successfully.')
@app.command(name=['status', 'ss'])
def status(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get recording status.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx)
if active:
if paused:
console.out.print('Recording is in progress and paused.')
else:
console.out.print('Recording is in progress.')
else:
console.out.print('Recording is not in progress.')
@app.command(name=['resume', 'r'])
def resume(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Resume recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx)
if not active:
raise OBSWSCLIError(
'Recording is not in progress, cannot resume.', ExitCode.ERROR
)
if not paused:
raise OBSWSCLIError(
'Recording is in progress but not paused, cannot resume.', ExitCode.ERROR
)
ctx.client.resume_record()
console.out.print('Recording resumed successfully.')
@app.command(name=['pause', 'p'])
def pause(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Pause recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx)
if not active:
raise OBSWSCLIError(
'Recording is not in progress, cannot pause.', ExitCode.ERROR
)
if paused:
raise OBSWSCLIError(
'Recording is in progress but already paused, cannot pause.', ExitCode.ERROR
)
ctx.client.pause_record()
console.out.print('Recording paused successfully.')
@app.command(name=['directory', 'd'])
def directory(
# Since the CLI and OBS may be running on different platforms,
# we won't validate the path here.
record_directory: Optional[Path] = None,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get or set the recording directory.
Parameters
----------
record_directory: Optional[Path]
The directory to set for recording. If not provided, the current recording directory is displayed.
ctx: Context
The context containing the OBS client and other settings.
"""
if record_directory is not None:
ctx.client.set_record_directory(str(record_directory))
console.out.print(
f'Recording directory updated to: {console.highlight(ctx, record_directory)}'
)
else:
resp = ctx.client.get_record_directory()
console.out.print(
f'Recording directory: {console.highlight(ctx, resp.record_directory)}'
)
@app.command(name=['split', 'sp'])
def split(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Split the current recording.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx)
if not active:
console.err.print('Recording is not in progress, cannot split.')
raise OBSWSCLIError(
'Recording is not in progress, cannot split.', ExitCode.ERROR
)
if paused:
raise OBSWSCLIError('Recording is paused, cannot split.', ExitCode.ERROR)
ctx.client.split_record_file()
console.out.print('Recording split successfully.')
@app.command(name=['chapter', 'ch'])
def chapter(
chapter_name: Optional[str] = None,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Create a chapter in the current recording.
Parameters
----------
chapter_name: Optional[str]
The name of the chapter to create. If not provided, an unnamed chapter is created.
ctx: Context
The context containing the OBS client and other settings.
"""
active, paused = _get_recording_status(ctx)
if not active:
raise OBSWSCLIError(
'Recording is not in progress, cannot create chapter.', ExitCode.ERROR
)
if paused:
raise OBSWSCLIError(
'Recording is paused, cannot create chapter.', ExitCode.ERROR
)
ctx.client.create_record_chapter(chapter_name)
console.out.print(
f'Chapter {console.highlight(ctx, chapter_name or "unnamed")} created successfully.'
)

View File

@@ -1,43 +1,113 @@
"""module containing commands for manipulating the replay buffer in OBS."""
import typer
from typing import Annotated
from .alias import AliasGroup
from cyclopts import App, Parameter
app = typer.Typer(cls=AliasGroup)
from . import console
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(
name='replaybuffer', help='Commands for controlling the replay buffer in OBS.'
)
@app.callback()
def main():
"""Control profiles in OBS."""
@app.command(name=['start', 's'])
def start(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Start the replay buffer.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
@app.command('start | s')
def start(ctx: typer.Context):
"""Start the replay buffer."""
ctx.obj.start_replay_buffer()
typer.echo('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.')
@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.client.get_replay_buffer_status()
if resp.output_active:
typer.echo('Replay buffer is active.')
raise OBSWSCLIError('Replay buffer is already active.', ExitCode.ERROR)
ctx.client.start_replay_buffer()
console.out.print('Replay buffer started.')
@app.command(name=['stop', 'st'])
def stop(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Stop the replay buffer.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.get_replay_buffer_status()
if not resp.output_active:
raise OBSWSCLIError('Replay buffer is not active.', ExitCode.ERROR)
ctx.client.stop_replay_buffer()
console.out.print('Replay buffer stopped.')
@app.command(name=['toggle', 'tg'])
def toggle(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Toggle the replay buffer.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.toggle_replay_buffer()
if resp.output_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.')
@app.command(name=['status', 'ss'])
def status(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the status of the replay buffer.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.get_replay_buffer_status()
if resp.output_active:
console.out.print('Replay buffer is active.')
else:
console.out.print('Replay buffer is not active.')
@app.command(name=['save', 'sv'])
def save(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Save the replay buffer.
Parameters
----------
ctx: Context
The context containing the OBS client and other settings.
"""
ctx.client.save_replay_buffer()
console.out.print('Replay buffer saved.')

View File

@@ -2,68 +2,150 @@
from typing import Annotated
import typer
from cyclopts import App, Parameter
from rich.table import Table
from rich.text import Text
from . import validate
from .alias import AliasGroup
from . import console, util, validate
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = typer.Typer(cls=AliasGroup)
app = App(name='scene', help='Commands for managing OBS scenes')
@app.callback()
def main():
"""Control OBS scenes."""
@app.command('list | ls')
def list(ctx: typer.Context):
"""List all scenes."""
resp = ctx.obj.get_scene_list()
scenes = (scene.get('sceneName') for scene in reversed(resp.scenes))
typer.echo('\n'.join(scenes))
@app.command('current | get')
def current(
ctx: typer.Context,
preview: Annotated[
bool, typer.Option(help='Get the preview scene instead of the program scene')
] = False,
@app.command(name=['list', 'ls'])
def list_(
uuid: bool = False,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the current program scene or preview scene."""
"""List all scenes.
Parameters
----------
uuid : bool
Show UUIDs of scenes.
ctx : Context
The context containing the OBS client and configuration.
"""
resp = ctx.client.get_scene_list()
scenes = (
(scene.get('sceneName'), scene.get('sceneUuid'))
for scene in reversed(resp.scenes)
)
if not scenes:
console.out.print('No scenes found.')
return
active_scene = ctx.client.get_current_program_scene().scene_name
table = Table(title='Scenes', padding=(0, 2), border_style=ctx.style.border)
if uuid:
columns = [
(Text('Scene Name', justify='center'), 'left', ctx.style.column),
(Text('Active', justify='center'), 'center', None),
(Text('UUID', justify='center'), 'left', ctx.style.column),
]
else:
columns = [
(Text('Scene Name', justify='center'), 'left', ctx.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(name=['current', 'get'])
def current(
preview: bool = False,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the current program scene or preview scene.
Parameters
----------
preview : bool
If True, get the preview scene instead of the program scene.
ctx : Context
The context containing the OBS client and configuration.
"""
if preview and not validate.studio_mode_enabled(ctx):
typer.echo('Studio mode is not enabled, cannot get preview scene.', err=True)
raise typer.Exit(1)
raise OBSWSCLIError(
'Studio mode is not enabled, cannot get preview scene.',
code=ExitCode.ERROR,
)
if preview:
resp = ctx.obj.get_current_preview_scene()
typer.echo(resp.current_preview_scene_name)
else:
resp = ctx.obj.get_current_program_scene()
typer.echo(resp.current_program_scene_name)
@app.command('switch | set')
def switch(
ctx: typer.Context,
scene_name: str,
preview: Annotated[
bool,
typer.Option(help='Switch to the preview scene instead of the program scene'),
] = False,
):
"""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
resp = ctx.client.get_current_preview_scene()
console.out.print(
f'Current Preview Scene: {console.highlight(ctx, resp.current_preview_scene_name)}'
)
else:
resp = ctx.client.get_current_program_scene()
console.out.print(
f'Current Program Scene: {console.highlight(ctx, resp.current_program_scene_name)}'
)
@app.command(name=['switch', 'set'])
def switch(
scene_name: str,
/,
preview: bool = False,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Switch to a scene.
Parameters
----------
scene_name : str
The name of the scene to switch to.
preview : bool
If True, switch to the preview scene instead of the program scene.
ctx : Context
The context containing the OBS client and configuration.
"""
if preview and not validate.studio_mode_enabled(ctx):
raise OBSWSCLIError(
'Studio mode is not enabled, cannot switch to preview scene.',
code=ExitCode.ERROR,
)
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)
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
code=ExitCode.ERROR,
)
if preview:
ctx.obj.set_current_preview_scene(scene_name)
ctx.client.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.client.set_current_program_scene(scene_name)
console.out.print(
f'Switched to program scene: {console.highlight(ctx, scene_name)}'
)

View File

@@ -1,60 +1,133 @@
"""module containing commands for manipulating scene collections."""
import typer
from typing import Annotated
from . import validate
from .alias import AliasGroup
from cyclopts import App, Parameter
from rich.table import Table
app = typer.Typer(cls=AliasGroup)
from . import console, validate
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(
name='scenecollection', help='Commands for controlling scene collections in OBS.'
)
@app.callback()
def main():
"""Control scene collections in OBS."""
@app.command(name=['list', 'ls'])
def list_(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""List all scene collections.
Parameters
----------
ctx : Context
The context containing the OBS client and configuration.
"""
resp = ctx.client.get_scene_collection_list()
table = Table(
title='Scene Collections',
padding=(0, 2),
border_style=ctx.style.border,
)
table.add_column('Scene Collection Name', justify='left', style=ctx.style.column)
if not resp.scene_collections:
console.out.print('No scene collections found.')
return
for scene_collection_name in resp.scene_collections:
table.add_row(scene_collection_name)
console.out.print(table)
@app.command('list | ls')
def list(ctx: typer.Context):
"""List all scene collections."""
resp = ctx.obj.get_scene_collection_list()
typer.echo('\n'.join(resp.scene_collections))
@app.command(name=['current', 'get'])
def current(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the current scene collection.
Parameters
----------
ctx : Context
The context containing the OBS client and configuration.
"""
resp = ctx.client.get_scene_collection_list()
console.out.print(
f'Current scene collection: {console.highlight(ctx, resp.current_scene_collection_name)}'
)
@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)
@app.command(name=['switch', 'set'])
def switch(
scene_collection_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Switch to a scene collection.
Parameters
----------
scene_collection_name : str
The name of the scene collection to switch to.
ctx : Context
The context containing the OBS client and configuration.
@app.command('switch | set')
def switch(ctx: typer.Context, scene_collection_name: str):
"""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)
raise OBSWSCLIError(
f'Scene collection [yellow]{scene_collection_name}[/yellow] not found.',
exit_code=ExitCode.ERROR,
)
current_scene_collection = (
ctx.obj.get_scene_collection_list().current_scene_collection_name
ctx.client.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
raise OBSWSCLIError(
f'Scene collection [yellow]{scene_collection_name}[/yellow] is already active.',
exit_code=ExitCode.ERROR,
)
raise typer.Exit(code=1)
ctx.obj.set_current_scene_collection(scene_collection_name)
typer.echo(f"Switched to scene collection '{scene_collection_name}'")
ctx.client.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):
"""Create a new scene collection."""
@app.command(name=['create', 'new'])
def create(
scene_collection_name: str,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Create a new scene collection.
Parameters
----------
scene_collection_name : str
The name of the scene collection to create.
ctx : Context
The context containing the OBS client and configuration.
"""
if validate.scene_collection_in_scene_collections(ctx, scene_collection_name):
typer.echo(
f"Scene collection '{scene_collection_name}' already exists.", err=True
raise OBSWSCLIError(
f'Scene collection [yellow]{scene_collection_name}[/yellow] already exists.',
exit_code=ExitCode.ERROR,
)
raise typer.Exit(code=1)
ctx.obj.create_scene_collection(scene_collection_name)
typer.echo(f'Created scene collection {scene_collection_name}')
ctx.client.create_scene_collection(scene_collection_name)
console.out.print(
f'Created scene collection {console.highlight(ctx, scene_collection_name)}.'
)

View File

@@ -1,294 +1,498 @@
"""module containing commands for manipulating items in scenes."""
from collections.abc import Callable
from typing import Annotated, Optional
import typer
from cyclopts import App, Parameter
from rich.table import Table
from . import validate
from .alias import AliasGroup
from . import console, util, validate
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = typer.Typer(cls=AliasGroup)
app = App(name='sceneitem', help='Commands for controlling scene items in OBS.')
@app.callback()
def main():
"""Control items in OBS scenes."""
@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,
@app.command(name=['list', 'ls'])
def list_(
scene_name: Optional[str] = None,
/,
uuid: bool = False,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Validate the scene name and item name."""
"""List all items in a scene.
def wrapper(
ctx: typer.Context,
Parameters
----------
scene_name : str, optional
The name of the scene to list items for. If not provided, the current program scene
will be used.
uuid : bool
Show UUIDs of scene items.
ctx : Context
The context containing the OBS client and configuration.
"""
if not scene_name:
scene_name = ctx.client.get_current_program_scene().scene_name
if not validate.scene_in_scenes(ctx, scene_name):
console.err.print(f'Scene [yellow]{scene_name}[/yellow] not found.')
raise OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
exit_code=ExitCode.ERROR,
)
resp = ctx.client.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)}.'
)
return
table = Table(
title=f'Items in Scene: {scene_name}',
padding=(0, 2),
border_style=ctx.style.border,
)
if uuid:
columns = [
('Item ID', 'center', ctx.style.column),
('Item Name', 'left', ctx.style.column),
('In Group', 'left', ctx.style.column),
('Enabled', 'center', None),
('UUID', 'left', ctx.style.column),
]
else:
columns = [
('Item ID', 'center', ctx.style.column),
('Item Name', 'left', ctx.style.column),
('In Group', 'left', ctx.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.client.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 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),
)
console.out.print(table)
def _validate_sources(
ctx: Context,
scene_name: str,
item_name: str,
parent: Optional[str] = None,
group: Optional[str] = None,
):
"""Validate the scene name and item name."""
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 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 OBSWSCLIError(
f'Scene [yellow]{scene_name}[/yellow] not found.',
exit_code=ExitCode.ERROR,
)
if group:
if not validate.item_in_scene_item_list(ctx, scene_name, group):
raise OBSWSCLIError(
f'Group [yellow]{group}[/yellow] not found in scene [yellow]{scene_name}[/yellow].',
exit_code=ExitCode.ERROR,
)
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 OBSWSCLIError(
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.',
exit_code=ExitCode.ERROR,
)
raise typer.Exit(code=1)
return func(ctx, scene_name, item_name, parent)
return wrapper
def _get_scene_name_and_item_id(
ctx: typer.Context, scene_name: str, item_name: str, parent: Optional[str] = None
ctx: 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.client.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)
raise OBSWSCLIError(
f'Item [yellow]{item_name}[/yellow] not found in group [yellow]{group}[/yellow].',
exit_code=ExitCode.ERROR,
)
else:
resp = ctx.obj.get_scene_item_id(scene_name, item_name)
resp = ctx.client.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')
@app.command(name=['show', 'sh'])
def show(
ctx: typer.Context,
scene_name: str,
item_name: str,
parent: Annotated[Optional[str], typer.Option(help='Parent group name')] = None,
/,
group: Optional[str] = None,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Show an item in a scene."""
"""Show an item in a scene.
Parameters
----------
scene_name : str
The name of the scene the item is in.
item_name : str
The name of the item to show in the scene.
group : str, optional
The name of the parent group the item is in, if applicable.
ctx : Context
The context containing the OBS client and configuration.
"""
_validate_sources(ctx, scene_name, item_name, group)
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.client.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.'
)
@_validate_scene_name_and_item_name
@app.command('transform | t')
def transform(
ctx: typer.Context,
@app.command(name=['hide', 'h'])
def hide(
scene_name: str,
item_name: str,
parent: 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,
bounds_alignment: Annotated[
Optional[int], typer.Option(help='Bounds alignment of the item in the scene')
] = None,
bounds_height: Annotated[
Optional[float], typer.Option(help='Height of the item in the scene')
] = None,
bounds_type: Annotated[
Optional[str], typer.Option(help='Type of bounds for the item in the scene')
] = None,
bounds_width: Annotated[
Optional[float], typer.Option(help='Width of the item in the scene')
] = None,
crop_to_bounds: Annotated[
Optional[bool], typer.Option(help='Crop the item to the bounds')
] = None,
crop_bottom: Annotated[
Optional[float], typer.Option(help='Bottom crop of the item in the scene')
] = None,
crop_left: Annotated[
Optional[float], typer.Option(help='Left crop of the item in the scene')
] = None,
crop_right: Annotated[
Optional[float], typer.Option(help='Right crop of the item in the scene')
] = None,
crop_top: Annotated[
Optional[float], typer.Option(help='Top crop of the item in the scene')
] = None,
position_x: Annotated[
Optional[float], typer.Option(help='X position of the item in the scene')
] = None,
position_y: Annotated[
Optional[float], typer.Option(help='Y position of the item in the scene')
] = None,
rotation: Annotated[
Optional[float], typer.Option(help='Rotation of the item in the scene')
] = None,
scale_x: Annotated[
Optional[float], typer.Option(help='X scale of the item in the scene')
] = None,
scale_y: Annotated[
Optional[float], typer.Option(help='Y scale of the item in the scene')
] = None,
/,
group: Optional[str] = None,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""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)
"""Hide an item in a scene.
Parameters
----------
scene_name : str
The name of the scene the item is in.
item_name : str
The name of the item to hide in the scene.
group : str, optional
The name of the parent group the item is in, if applicable.
ctx : Context
The context containing the OBS client and configuration.
"""
_validate_sources(ctx, scene_name, item_name, group)
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.client.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(name=['toggle', 'tg'])
def toggle(
scene_name: str,
item_name: str,
/,
group: Optional[str] = None,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Toggle an item in a scene.
Parameters
----------
scene_name : str
The name of the scene the item is in.
item_name : str
The name of the item to toggle in the scene.
group : str, optional
The name of the parent group the item is in, if applicable.
ctx : Context
The context containing the OBS client and configuration.
"""
_validate_sources(ctx, scene_name, item_name, group)
old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, group
)
enabled = ctx.client.get_scene_item_enabled(
scene_name=scene_name,
item_id=int(scene_item_id),
)
new_state = not enabled.scene_item_enabled
ctx.client.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(name=['visible', 'v'])
def visible(
scene_name: str,
item_name: str,
/,
group: Optional[str] = None,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Check if an item in a scene is visible.
Parameters
----------
scene_name : str
The name of the scene the item is in.
item_name : str
The name of the item to check visibility in the scene.
group : str, optional
The name of the parent group the item is in, if applicable.
ctx : Context
The context containing the OBS client and configuration.
"""
_validate_sources(ctx, scene_name, item_name, group)
old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, group
)
enabled = ctx.client.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"}.'
)
@app.command(name=['transform', 't'])
def transform(
scene_name: str,
item_name: str,
/,
group: Optional[str] = None,
alignment: Optional[int] = None,
bounds_alignment: Optional[int] = None,
bounds_height: Optional[float] = None,
bounds_type: Optional[str] = None,
bounds_width: Optional[float] = None,
crop_to_bounds: Optional[bool] = None,
crop_bottom: Optional[float] = None,
crop_left: Optional[float] = None,
crop_right: Optional[float] = None,
crop_top: Optional[float] = None,
position_x: Optional[float] = None,
position_y: Optional[float] = None,
rotation: Optional[float] = None,
scale_x: Optional[float] = None,
scale_y: Optional[float] = None,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Set the transform of an item in a scene.
Parameters
----------
scene_name : str
The name of the scene the item is in.
item_name : str
The name of the item to transform in the scene.
group : str, optional
The name of the parent group the item is in, if applicable.
alignment : int, optional
Alignment of the item in the scene.
bounds_alignment : int, optional
Bounds alignment of the item in the scene.
bounds_height : float, optional
Height of the item in the scene.
bounds_type : str, optional
Type of bounds for the item in the scene.
bounds_width : float, optional
Width of the item in the scene.
crop_to_bounds : bool, optional
Crop the item to the bounds.
crop_bottom : float, optional
Bottom crop of the item in the scene.
crop_left : float, optional
Left crop of the item in the scene.
crop_right : float, optional
Right crop of the item in the scene.
crop_top : float, optional
Top crop of the item in the scene.
position_x : float, optional
X position of the item in the scene.
position_y : float, optional
Y position of the item in the scene.
rotation : float, optional
Rotation of the item in the scene.
scale_x : float, optional
X scale of the item in the scene.
scale_y : float, optional
Y scale of the item in the scene.
ctx : Context
The context containing the OBS client and configuration.
"""
_validate_sources(ctx, scene_name, item_name, group)
old_scene_name = scene_name
scene_name, scene_item_id = _get_scene_name_and_item_id(
ctx, scene_name, item_name, group
)
transform = {}
@@ -324,22 +528,27 @@ def transform(
transform['scaleY'] = scale_y
if not transform:
typer.echo('No transform options provided.', err=True)
raise typer.Exit(code=1)
raise OBSWSCLIError(
'No transform options provided. Use at least one of the transform options.',
exit_code=ExitCode.ERROR,
)
transform = ctx.obj.set_scene_item_transform(
transform = ctx.client.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.'
)

75
obsws_cli/screenshot.py Normal file
View File

@@ -0,0 +1,75 @@
"""module for taking screenshots using OBS WebSocket API."""
from pathlib import Path
from typing import Annotated
import obsws_python as obsws
from cyclopts import App, Parameter, validators
from . import console
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='screenshot', help='Commands for taking screenshots using OBS.')
@app.command(name=['save', 'sv'])
def save(
source_name: str,
# Since the CLI and OBS may be running on different platforms,
# we won't validate the path here.
output_path: Path,
/,
width: float = 1920,
height: float = 1080,
quality: Annotated[
float, Parameter(validator=validators.Number(gte=-1, lte=100))
] = -1.0,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Take a screenshot and save it to a file.
Parameters
----------
source_name : str
Name of the source to take a screenshot of.
output_path : Path
Path to save the screenshot (must include file name and extension).
width : float
Width of the screenshot.
height : float
Height of the screenshot.
quality : float
Quality of the screenshot. A value of -1 uses the default quality.
ctx : Context
Context containing the OBS WebSocket client instance.
"""
try:
ctx.client.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:
raise OBSWSCLIError(
'The [yellow]image format[/yellow] (file extension) must be included in the file name, '
"for example: '/path/to/screenshot.png'.",
code=ExitCode.ERROR,
)
case 600:
raise OBSWSCLIError(
'No source was found by the name of [yellow]{source_name}[/yellow]',
code=ExitCode.ERROR,
)
case _:
raise
console.out.print(f'Screenshot saved to {console.highlight(ctx, output_path)}.')

View File

@@ -1,50 +1,104 @@
"""module for controlling OBS stream functionality."""
import typer
from typing import Annotated
from .alias import AliasGroup
from cyclopts import App, Parameter
app = typer.Typer(cls=AliasGroup)
from . import console
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='stream', help='Commands for controlling OBS stream functionality.')
@app.callback()
def main():
"""Control OBS stream functionality."""
def _get_streaming_status(ctx: typer.Context) -> tuple:
def _get_streaming_status(ctx: Context) -> tuple:
"""Get streaming status."""
resp = ctx.obj.get_stream_status()
resp = ctx.client.get_stream_status()
return resp.output_active, resp.output_duration
@app.command('start | s')
def start(ctx: typer.Context):
"""Start streaming."""
@app.command(name=['start', 's'])
def start(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Start streaming.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
active, _ = _get_streaming_status(ctx)
if active:
typer.echo('Streaming is already in progress, cannot start.', err=True)
raise typer.Exit(code=1)
raise OBSWSCLIError(
'Streaming is already in progress, cannot start.',
code=ExitCode.ERROR,
)
ctx.obj.start_stream()
typer.echo('Streaming started successfully.')
ctx.client.start_stream()
console.out.print('Streaming started successfully.')
@app.command('stop | st')
def stop(ctx: typer.Context):
"""Stop streaming."""
@app.command(name=['stop', 'st'])
def stop(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Stop streaming.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
active, _ = _get_streaming_status(ctx)
if not active:
typer.echo('Streaming is not in progress, cannot stop.', err=True)
raise typer.Exit(code=1)
raise OBSWSCLIError(
'Streaming is not in progress, cannot stop.',
code=ExitCode.ERROR,
)
ctx.obj.stop_stream()
typer.echo('Streaming stopped successfully.')
ctx.client.stop_stream()
console.out.print('Streaming stopped successfully.')
@app.command('status | ss')
def status(ctx: typer.Context):
"""Get streaming status."""
@app.command(name=['toggle', 'tg'])
def toggle(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Toggle streaming.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
resp = ctx.client.toggle_stream()
if resp.output_active:
console.out.print('Streaming started successfully.')
else:
console.out.print('Streaming stopped successfully.')
@app.command(name=['status', 'ss'])
def status(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get streaming status.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
active, duration = _get_streaming_status(ctx)
if active:
if duration > 0:
@@ -52,25 +106,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

@@ -1,46 +1,86 @@
"""module containing commands for manipulating studio mode in OBS."""
import typer
from typing import Annotated
from .alias import AliasGroup
from cyclopts import App, Parameter
app = typer.Typer(cls=AliasGroup)
from . import console
from .context import Context
app = App(name='studiomode', help='Commands for controlling studio mode in OBS.')
@app.callback()
def main():
"""Control studio mode in OBS."""
@app.command(name=['enable', 'on'])
def enable(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Enable studio mode.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
ctx.client.set_studio_mode_enabled(True)
console.out.print('Studio mode has been enabled.')
@app.command('enable | on')
def enable(ctx: typer.Context):
"""Enable studio mode."""
ctx.obj.set_studio_mode_enabled(True)
@app.command(name=['disable', 'off'])
def disable(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Disable studio mode.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
ctx.client.set_studio_mode_enabled(False)
console.out.print('Studio mode has been disabled.')
@app.command('disable | off')
def disable(ctx: typer.Context):
"""Disable studio mode."""
ctx.obj.set_studio_mode_enabled(False)
@app.command(name=['toggle', 'tg'])
def toggle(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Toggle studio mode.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
@app.command('toggle | tg')
def toggle(ctx: typer.Context):
"""Toggle studio mode."""
resp = ctx.obj.get_studio_mode_enabled()
"""
resp = ctx.client.get_studio_mode_enabled()
if resp.studio_mode_enabled:
ctx.obj.set_studio_mode_enabled(False)
typer.echo('Studio mode is now disabled.')
ctx.client.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.client.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()
@app.command(name=['status', 'ss'])
def status(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the status of studio mode.
Parameters
----------
ctx : Context
Context containing the OBS WebSocket client instance.
"""
resp = ctx.client.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)

94
obsws_cli/text.py Normal file
View File

@@ -0,0 +1,94 @@
"""module containing commands for manipulating text inputs."""
from typing import Annotated, Optional
from cyclopts import App, Parameter
from . import console, validate
from .context import Context
from .enum import ExitCode
from .error import OBSWSCLIError
app = App(name='text', help='Commands for controlling text inputs in OBS.')
@app.command(name=['current', 'get'])
def current(
input_name: str,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the current text for a text input.
Parameters
----------
input_name : str
The name of the text input to retrieve the current text from.
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.input_in_inputs(ctx, input_name):
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] not found.', code=ExitCode.ERROR
)
resp = ctx.client.get_input_settings(name=input_name)
if not resp.input_kind.startswith('text_'):
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] is not a text input.',
code=ExitCode.ERROR,
)
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(name=['update', 'set'])
def update(
input_name: str,
new_text: Optional[str] = None,
/,
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Update the text of a text input.
Parameters
----------
input_name : str
The name of the text input to update.
new_text : Optional[str]
The new text to set for the input. If not provided, the text will be cleared
(set to an empty string).
ctx : Context
The context containing the OBS client and other settings.
"""
if not validate.input_in_inputs(ctx, input_name):
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] not found.', code=ExitCode.ERROR
)
resp = ctx.client.get_input_settings(name=input_name)
if not resp.input_kind.startswith('text_'):
raise OBSWSCLIError(
f'Input [yellow]{input_name}[/yellow] is not a text input.',
code=ExitCode.ERROR,
)
ctx.client.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

@@ -1,48 +1,49 @@
"""module containing validation functions."""
import typer
# type alias for an option that is skipped when the command is run
skipped_option = typer.Option(parser=lambda _: _, hidden=True, expose_value=False)
from .context import Context
def input_in_inputs(ctx: typer.Context, input_name: str) -> bool:
def input_in_inputs(ctx: Context, input_name: str) -> bool:
"""Check if an input is in the input list."""
inputs = ctx.obj.get_input_list().inputs
inputs = ctx.client.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:
def scene_in_scenes(ctx: Context, scene_name: str) -> bool:
"""Check if a scene exists in the list of scenes."""
resp = ctx.obj.get_scene_list()
resp = ctx.client.get_scene_list()
return any(scene.get('sceneName') == scene_name for scene in resp.scenes)
def studio_mode_enabled(ctx: typer.Context) -> bool:
def studio_mode_enabled(ctx: Context) -> bool:
"""Check if studio mode is enabled."""
resp = ctx.obj.get_studio_mode_enabled()
resp = ctx.client.get_studio_mode_enabled()
return resp.studio_mode_enabled
def scene_collection_in_scene_collections(
ctx: typer.Context, scene_collection_name: str
ctx: Context, scene_collection_name: str
) -> bool:
"""Check if a scene collection exists."""
resp = ctx.obj.get_scene_collection_list()
resp = ctx.client.get_scene_collection_list()
return any(
collection == scene_collection_name for collection in resp.scene_collections
)
def item_in_scene_item_list(
ctx: typer.Context, scene_name: str, item_name: str
) -> bool:
def item_in_scene_item_list(ctx: 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.client.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:
def profile_exists(ctx: Context, profile_name: str) -> bool:
"""Check if a profile exists."""
resp = ctx.obj.get_profile_list()
resp = ctx.client.get_profile_list()
return any(profile == profile_name for profile in resp.profiles)
def monitor_exists(ctx: Context, monitor_index: int) -> bool:
"""Check if a monitor exists."""
resp = ctx.client.get_monitor_list()
return any(monitor['monitorIndex'] == monitor_index for monitor in resp.monitors)

View File

@@ -1,43 +1,84 @@
"""module containing commands for manipulating virtual camera in OBS."""
import typer
from typing import Annotated
from .alias import AliasGroup
from cyclopts import App, Parameter
app = typer.Typer(cls=AliasGroup)
from . import console
from .context import Context
app = App(name='virtualcam', help='Commands for controlling the virtual camera in OBS.')
@app.callback()
def main():
"""Control virtual camera in OBS."""
@app.command(name=['start', 's'])
def start(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Start the virtual camera.
Parameters
----------
ctx : Context
The context containing the OBS client and other settings.
"""
ctx.client.start_virtual_cam()
console.out.print('Virtual camera started.')
@app.command('start | s')
def start(ctx: typer.Context):
"""Start the virtual camera."""
ctx.obj.start_virtual_cam()
typer.echo('Virtual camera started.')
@app.command(name=['stop', 'p'])
def stop(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Stop the virtual camera.
Parameters
----------
ctx : Context
The context containing the OBS client and other settings.
"""
ctx.client.stop_virtual_cam()
console.out.print('Virtual camera stopped.')
@app.command('stop | p')
def stop(ctx: typer.Context):
"""Stop the virtual camera."""
ctx.obj.stop_virtual_cam()
typer.echo('Virtual camera stopped.')
@app.command(name=['toggle', 'tg'])
def toggle(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Toggle the virtual camera.
Parameters
----------
ctx : Context
The context containing the OBS client and other settings.
@app.command('toggle | tg')
def toggle(ctx: typer.Context):
"""Toggle the virtual camera."""
ctx.obj.toggle_virtual_cam()
typer.echo('Virtual camera toggled.')
@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.client.toggle_virtual_cam()
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.')
@app.command(name=['status', 'ss'])
def status(
*,
ctx: Annotated[Context, Parameter(parse=False)],
):
"""Get the status of the virtual camera.
Parameters
----------
ctx : Context
The context containing the OBS client and other settings.
"""
resp = ctx.client.get_virtual_cam_status()
if resp.output_active:
console.out.print('Virtual camera is enabled.')
else:
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 = ["cyclopts>=3.22.2", "obsws-python>=1.8.0"]
[project.urls]
@@ -34,7 +30,7 @@ Issues = "https://github.com/onyx-and-iris/obsws-cli/issues"
Source = "https://github.com/onyx-and-iris/obsws-cli"
[project.scripts]
obsws-cli = "obsws_cli:app"
obsws-cli = "obsws_cli:run"
[tool.hatch.version]
path = "obsws_cli/__about__.py"
@@ -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', 'status'])
assert result.exit_code == 0
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
assert 'Recording is in progress.' in result.stdout
active = 'Recording is in progress.' in result.stdout
result = runner.invoke(app, ['record', 'stop'])
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.' in result.stdout
assert 'Recording stopped successfully. Saved to:' in result.stdout
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" },
]