enable rich tracebacks

console, error_console are now app attributes.
This commit is contained in:
onyx-and-iris 2026-03-02 12:55:33 +00:00
parent 4fff581c95
commit d9810ce270
12 changed files with 80 additions and 61 deletions

View File

@ -3,16 +3,21 @@ from typing import Annotated
import vban_cmd import vban_cmd
from cyclopts import App, Argument, Parameter, config from cyclopts import App, Argument, Parameter, config
from rich.traceback import install as install_rich_traceback
from . import __version__ as version from . import __version__ as version
from . import bus, command, console, recorder, strip from . import bus, command, console, recorder, strip
from .context import Context from .context import Context
from .error import VbanCLIConnectionError
app = App( app = App(
config=config.Env( config=config.Env(
'VBAN_CLI_', 'VBAN_CLI_',
), # Environment variable prefix for configuration parameters ), # Environment variable prefix for configuration parameters
version=version, version=version,
console=console.out,
error_console=console.err,
exit_on_error=True,
) )
app.command(strip.app.meta, name='strip') app.command(strip.app.meta, name='strip')
app.command(bus.app.meta, name='bus') app.command(bus.app.meta, name='bus')
@ -20,6 +25,8 @@ app.command(command.app, name='command')
app.command(recorder.app, name='recorder') app.command(recorder.app, name='recorder')
app.register_install_completion_command() app.register_install_completion_command()
install_rich_traceback(console=console.err)
@Parameter(name='*') @Parameter(name='*')
@dataclass @dataclass
@ -43,14 +50,17 @@ def launcher(
if command.__name__ == 'sendtext': if command.__name__ == 'sendtext':
disable_rt_listeners = True disable_rt_listeners = True
with vban_cmd.api( try:
vban_config.kind, with vban_cmd.api(
ip=vban_config.host, vban_config.kind,
port=vban_config.port, ip=vban_config.host,
streamname=vban_config.streamname, port=vban_config.port,
disable_rt_listeners=disable_rt_listeners, streamname=vban_config.streamname,
) as client: disable_rt_listeners=disable_rt_listeners,
return command(*bound.args, **bound.kwargs, ctx=Context(client=client)) ) as client:
return command(*bound.args, **bound.kwargs, ctx=Context(client=client))
except vban_cmd.error.VBANCMDConnectionError as e:
raise VbanCLIConnectionError(str(e)) from e
@app.command(name='sendtext') @app.command(name='sendtext')
@ -62,12 +72,8 @@ def sendtext(
): ):
"""Send a text command to the current Voicemeeter/Matrix instance.""" """Send a text command to the current Voicemeeter/Matrix instance."""
if resp := ctx.client.sendtext(text): if resp := ctx.client.sendtext(text):
console.out.print(resp) app.console.print(resp)
def run(): def run():
try: app.meta()
app.meta()
except Exception as e:
console.err.print(f'Error: {e}')
return e.code

View File

@ -2,7 +2,6 @@ from typing import Annotated, Literal, Optional
from cyclopts import App, Argument, Parameter from cyclopts import App, Argument, Parameter
from . import console
from .context import Context from .context import Context
from .help import BusHelpFormatter from .help import BusHelpFormatter
@ -46,7 +45,7 @@ def mono(
If provided, sets the mono state to this value. If not provided, the current mono state is printed. If provided, sets the mono state to this value. If not provided, the current mono state is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(['off', 'mono', 'stereoreverse'][ctx.client.bus[index].mono]) app.console.print(['off', 'mono', 'stereoreverse'][ctx.client.bus[index].mono])
return return
ctx.client.bus[index].mono = ['off', 'mono', 'stereoreverse'].index(new_value) ctx.client.bus[index].mono = ['off', 'mono', 'stereoreverse'].index(new_value)
@ -66,7 +65,7 @@ def mute(
If provided, sets the mute state to this value. If not provided, the current mute state is printed. If provided, sets the mute state to this value. If not provided, the current mute state is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(ctx.client.bus[index].mute) app.console.print(ctx.client.bus[index].mute)
return return
ctx.client.bus[index].mute = new_value ctx.client.bus[index].mute = new_value
@ -104,6 +103,6 @@ def mode(
If provided, sets the bus mode to this value. If not provided, the current bus mode is printed. If provided, sets the bus mode to this value. If not provided, the current bus mode is printed.
""" """
if type_ is None: if type_ is None:
console.out.print(ctx.client.bus[index].mode.get()) app.console.print(ctx.client.bus[index].mode.get())
return return
setattr(ctx.client.bus[index].mode, type_, True) setattr(ctx.client.bus[index].mode, type_, True)

View File

@ -2,7 +2,6 @@ from typing import Annotated
from cyclopts import App, Parameter from cyclopts import App, Parameter
from . import console
from .context import Context from .context import Context
from .help import BaseHelpFormatter from .help import BaseHelpFormatter
@ -16,7 +15,7 @@ def show(
): ):
"""Bring the Voicemeeter GUI to the foreground.""" """Bring the Voicemeeter GUI to the foreground."""
ctx.client.command.show() ctx.client.command.show()
console.out.print('Voicemeeter GUI should now be in the foreground.') app.console.print('Voicemeeter GUI should now be in the foreground.')
@app.command(name='hide') @app.command(name='hide')
@ -26,7 +25,7 @@ def hide(
): ):
"""Send the Voicemeeter GUI to the background.""" """Send the Voicemeeter GUI to the background."""
ctx.client.command.hide() ctx.client.command.hide()
console.out.print('Voicemeeter GUI should now be in the background.') app.console.print('Voicemeeter GUI should now be in the background.')
@app.command(name='shutdown') @app.command(name='shutdown')
@ -36,7 +35,7 @@ def shutdown(
): ):
"""Shut down Voicemeeter.""" """Shut down Voicemeeter."""
ctx.client.command.shutdown() ctx.client.command.shutdown()
console.out.print('Voicemeeter should now be shut down.') app.console.print('Voicemeeter should now be shut down.')
@app.command(name='restart') @app.command(name='restart')
@ -46,4 +45,4 @@ def restart(
): ):
"""Restart the Voicemeeter engine.""" """Restart the Voicemeeter engine."""
ctx.client.command.restart() ctx.client.command.restart()
console.out.print('Voicemeeter engine should now be restarting.') app.console.print('Voicemeeter engine should now be restarting.')

View File

@ -39,7 +39,7 @@ def knob(
""" """
if new_knob is None: if new_knob is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(ctx.client.strip[index].comp.knob) # app.console.print(ctx.client.strip[index].comp.knob)
return return
ctx.client.strip[index].comp.knob = new_knob ctx.client.strip[index].comp.knob = new_knob
@ -60,6 +60,6 @@ def input_gain(
""" """
if new_gain is None: if new_gain is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(ctx.client.strip[index].comp.gainin) # app.console.print(ctx.client.strip[index].comp.gainin)
return return
ctx.client.strip[index].comp.gainin = new_gain ctx.client.strip[index].comp.gainin = new_gain

View File

@ -39,6 +39,6 @@ def knob(
""" """
if new_knob is None: if new_knob is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(ctx.client.strip[index].denoiser.knob) # app.console.print(ctx.client.strip[index].denoiser.knob)
return return
ctx.client.strip[index].denoiser.knob = new_knob ctx.client.strip[index].denoiser.knob = new_knob

View File

@ -48,7 +48,7 @@ def on(
""" """
if new_state is None: if new_state is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(target.on) # app.console.print(target.on)
return return
target.on = new_state target.on = new_state
@ -86,7 +86,7 @@ def cell_on(
""" """
if new_state is None: if new_state is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(target.on) # app.console.print(target.on)
return return
target.on = new_state target.on = new_state
@ -106,7 +106,7 @@ def cell_freq(
""" """
if new_freq is None: if new_freq is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(target.f) # app.console.print(target.f)
return return
target.f = new_freq target.f = new_freq
@ -126,7 +126,7 @@ def cell_gain(
""" """
if new_gain is None: if new_gain is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(target.gain) # app.console.print(target.gain)
return return
target.gain = new_gain target.gain = new_gain
@ -146,7 +146,7 @@ def cell_q(
""" """
if new_q is None: if new_q is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(target.q) # app.console.print(target.q)
return return
target.q = new_q target.q = new_q
@ -166,6 +166,6 @@ def cell_type(
""" """
if new_type is None: if new_type is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(target.type) # app.console.print(target.type)
return return
target.type = new_type target.type = new_type

14
src/vban_cli/error.py Normal file
View File

@ -0,0 +1,14 @@
class VbanCLIError(Exception):
"""Base class for exceptions in this module."""
def __init__(self, message: str, code: int = 1):
super().__init__(message)
self.code = code
class VbanCLIConnectionError(VbanCLIError):
"""Exception raised for connection errors."""
class VbanCLIValidationError(VbanCLIError):
"""Exception raised for validation errors."""

View File

@ -2,7 +2,6 @@ from typing import Annotated
from cyclopts import App, Argument, Parameter from cyclopts import App, Argument, Parameter
from . import console
from .context import Context from .context import Context
from .help import GainlayerHelpFormatter from .help import GainlayerHelpFormatter
@ -43,6 +42,6 @@ def level(
If provided, sets the level to this value. If not provided, the current level is printed. If provided, sets the level to this value. If not provided, the current level is printed.
""" """
if new_level is None: if new_level is None:
console.out.print(ctx.client.strip[strip_index].gainlayer[gainlayer_index].gain) app.console.print(ctx.client.strip[strip_index].gainlayer[gainlayer_index].gain)
return return
ctx.client.strip[strip_index].gainlayer[gainlayer_index].gain = new_level ctx.client.strip[strip_index].gainlayer[gainlayer_index].gain = new_level

View File

@ -39,7 +39,7 @@ def knob(
""" """
if new_knob is None: if new_knob is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(ctx.client.strip[index].gate.knob) # app.console.print(ctx.client.strip[index].gate.knob)
return return
ctx.client.strip[index].gate.knob = new_knob ctx.client.strip[index].gate.knob = new_knob
@ -60,6 +60,6 @@ def threshold(
""" """
if new_threshold is None: if new_threshold is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(ctx.client.strip[index].gate.threshold) # app.console.print(ctx.client.strip[index].gate.threshold)
return return
ctx.client.strip[index].gate.threshold = new_threshold ctx.client.strip[index].gate.threshold = new_threshold

View File

@ -3,7 +3,7 @@ from typing import Annotated
from cyclopts import App, Parameter, validators from cyclopts import App, Parameter, validators
from . import console, validation from . import validation
from .context import Context from .context import Context
from .help import BaseHelpFormatter from .help import BaseHelpFormatter
@ -17,7 +17,7 @@ def play(
): ):
"""Start the recorder playback.""" """Start the recorder playback."""
ctx.client.recorder.play() ctx.client.recorder.play()
console.out.print('Recorder playback started.') app.console.print('Recorder playback started.')
@app.command(name='pause') @app.command(name='pause')
@ -27,7 +27,7 @@ def pause(
): ):
"""Pause the recorder playback.""" """Pause the recorder playback."""
ctx.client.recorder.stop() ctx.client.recorder.stop()
console.out.print('Recorder playback paused.') app.console.print('Recorder playback paused.')
@app.command(name='stop') @app.command(name='stop')
@ -40,7 +40,7 @@ def stop(
ctx.client.recorder.goto('00:00:00') ctx.client.recorder.goto('00:00:00')
# We have no way of knowing if the recorder was playing or recording, so we print a generic message. # We have no way of knowing if the recorder was playing or recording, so we print a generic message.
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 4. # See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 4.
console.out.print('Recorder stopped.') app.console.print('Recorder stopped.')
@app.command(name='replay') @app.command(name='replay')
@ -50,7 +50,7 @@ def replay(
): ):
"""Replay the recorder playback.""" """Replay the recorder playback."""
ctx.client.recorder.replay() ctx.client.recorder.replay()
console.out.print('Recorder playback replay started.') app.console.print('Recorder playback replay started.')
@app.command(name='record') @app.command(name='record')
@ -60,7 +60,7 @@ def record(
): ):
"""Start recording.""" """Start recording."""
ctx.client.recorder.record() ctx.client.recorder.record()
console.out.print('Recorder recording started.') app.console.print('Recorder recording started.')
@app.command(name='pause-recording') @app.command(name='pause-recording')
@ -70,7 +70,7 @@ def pause_recording(
): ):
"""Pause the recorder recording.""" """Pause the recorder recording."""
ctx.client.recorder.pause() ctx.client.recorder.pause()
console.out.print('Recorder recording paused.') app.console.print('Recorder recording paused.')
@app.command(name='ff') @app.command(name='ff')
@ -80,7 +80,7 @@ def ff(
): ):
"""Fast forward the recorder playback.""" """Fast forward the recorder playback."""
ctx.client.recorder.ff() ctx.client.recorder.ff()
console.out.print('Recorder playback fast forwarded.') app.console.print('Recorder playback fast forwarded.')
@app.command(name='rew') @app.command(name='rew')
@ -90,7 +90,7 @@ def rew(
): ):
"""Rewind the recorder playback.""" """Rewind the recorder playback."""
ctx.client.recorder.rew() ctx.client.recorder.rew()
console.out.print('Recorder playback rewound.') app.console.print('Recorder playback rewound.')
@app.command(name='load') @app.command(name='load')
@ -110,7 +110,7 @@ def load(
note: This command may only work if vban-cli is running on localhost and may not work if vban-cli is running on a remote server.""" note: This command may only work if vban-cli is running on localhost and may not work if vban-cli is running on a remote server."""
ctx.client.recorder.load(file_path) ctx.client.recorder.load(file_path)
console.out.print(f'Loaded file: {file_path}') app.console.print(f'Loaded file: {file_path}')
@app.command(name='goto') @app.command(name='goto')
@ -128,4 +128,4 @@ def goto(
): ):
"""Go to a specific timestamp in the recorder playback.""" """Go to a specific timestamp in the recorder playback."""
ctx.client.recorder.goto(time_string) ctx.client.recorder.goto(time_string)
console.out.print(f'Went to timestamp {time_string} in recorder playback.') app.console.print(f'Went to timestamp {time_string} in recorder playback.')

View File

@ -2,7 +2,7 @@ from typing import Annotated, Optional
from cyclopts import App, Argument, Parameter from cyclopts import App, Argument, Parameter
from . import comp, console, denoiser, eq, gainlayer, gate from . import comp, denoiser, eq, gainlayer, gate
from .context import Context from .context import Context
from .help import StripHelpFormatter from .help import StripHelpFormatter
@ -47,7 +47,7 @@ def mono(
If provided, sets the mono state to this value. If not provided, the current mono state is printed. If provided, sets the mono state to this value. If not provided, the current mono state is printed.
""" """
if new_state is None: if new_state is None:
console.out.print(ctx.client.strip[index].mono) app.console.print(ctx.client.strip[index].mono)
return return
ctx.client.strip[index].mono = new_state ctx.client.strip[index].mono = new_state
@ -67,7 +67,7 @@ def solo(
If provided, sets the solo state to this value. If not provided, the current solo state is printed. If provided, sets the solo state to this value. If not provided, the current solo state is printed.
""" """
if new_state is None: if new_state is None:
console.out.print(ctx.client.strip[index].solo) app.console.print(ctx.client.strip[index].solo)
return return
ctx.client.strip[index].solo = new_state ctx.client.strip[index].solo = new_state
@ -87,7 +87,7 @@ def mute(
If provided, sets the mute state to this value. If not provided, the current mute state is printed. If provided, sets the mute state to this value. If not provided, the current mute state is printed.
""" """
if new_state is None: if new_state is None:
console.out.print(ctx.client.strip[index].mute) app.console.print(ctx.client.strip[index].mute)
return return
ctx.client.strip[index].mute = new_state ctx.client.strip[index].mute = new_state
@ -107,7 +107,7 @@ def gain(
If provided, sets the gain to this value. If not provided, the current gain is printed. If provided, sets the gain to this value. If not provided, the current gain is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(ctx.client.strip[index].gain) app.console.print(ctx.client.strip[index].gain)
return return
ctx.client.strip[index].gain = new_value ctx.client.strip[index].gain = new_value
@ -127,7 +127,7 @@ def a1(
If provided, sets the A1 state to this value. If not provided, the current A1 state is printed. If provided, sets the A1 state to this value. If not provided, the current A1 state is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(ctx.client.strip[index].A1) app.console.print(ctx.client.strip[index].A1)
return return
ctx.client.strip[index].A1 = new_value ctx.client.strip[index].A1 = new_value
@ -147,7 +147,7 @@ def a2(
If provided, sets the A2 state to this value. If not provided, the current A2 state is printed. If provided, sets the A2 state to this value. If not provided, the current A2 state is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(ctx.client.strip[index].A2) app.console.print(ctx.client.strip[index].A2)
return return
ctx.client.strip[index].A2 = new_value ctx.client.strip[index].A2 = new_value
@ -167,7 +167,7 @@ def a3(
If provided, sets the A3 state to this value. If not provided, the current A3 state is printed. If provided, sets the A3 state to this value. If not provided, the current A3 state is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(ctx.client.strip[index].A3) app.console.print(ctx.client.strip[index].A3)
return return
ctx.client.strip[index].A3 = new_value ctx.client.strip[index].A3 = new_value
@ -187,7 +187,7 @@ def a4(
If provided, sets the A4 state to this value. If not provided, the current A4 state is printed. If provided, sets the A4 state to this value. If not provided, the current A4 state is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(ctx.client.strip[index].A4) app.console.print(ctx.client.strip[index].A4)
return return
ctx.client.strip[index].A4 = new_value ctx.client.strip[index].A4 = new_value
@ -207,7 +207,7 @@ def a5(
If provided, sets the A5 state to this value. If not provided, the current A5 state is printed. If provided, sets the A5 state to this value. If not provided, the current A5 state is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(ctx.client.strip[index].A5) app.console.print(ctx.client.strip[index].A5)
return return
ctx.client.strip[index].A5 = new_value ctx.client.strip[index].A5 = new_value
@ -227,7 +227,7 @@ def b1(
If provided, sets the B1 state to this value. If not provided, the current B1 state is printed. If provided, sets the B1 state to this value. If not provided, the current B1 state is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(ctx.client.strip[index].B1) app.console.print(ctx.client.strip[index].B1)
return return
ctx.client.strip[index].B1 = new_value ctx.client.strip[index].B1 = new_value
@ -247,7 +247,7 @@ def b2(
If provided, sets the B2 state to this value. If not provided, the current B2 state is printed. If provided, sets the B2 state to this value. If not provided, the current B2 state is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(ctx.client.strip[index].B2) app.console.print(ctx.client.strip[index].B2)
return return
ctx.client.strip[index].B2 = new_value ctx.client.strip[index].B2 = new_value
@ -267,6 +267,6 @@ def b3(
If provided, sets the B3 state to this value. If not provided, the current B3 state is printed. If provided, sets the B3 state to this value. If not provided, the current B3 state is printed.
""" """
if new_value is None: if new_value is None:
console.out.print(ctx.client.strip[index].B3) app.console.print(ctx.client.strip[index].B3)
return return
ctx.client.strip[index].B3 = new_value ctx.client.strip[index].B3 = new_value

View File

@ -1,9 +1,11 @@
import re import re
from .error import VbanCLIValidationError
def is_valid_time_string(type_, value: str) -> str: def is_valid_time_string(type_, value: str) -> str:
"""Validate if the given string is a valid time format (HH:MM:SS).""" """Validate if the given string is a valid time format (HH:MM:SS)."""
pattern = r'^(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)$' pattern = r'^(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)$'
if not re.match(pattern, value): if not re.match(pattern, value):
raise ValueError('Invalid time format. Expected HH:MM:SS.') raise VbanCLIValidationError('Invalid time format. Expected HH:MM:SS.')
return value return value