diff --git a/docs/source/examples/basics.rst b/docs/source/examples/basics.rst index b70882430..d86fdbfa3 100644 --- a/docs/source/examples/basics.rst +++ b/docs/source/examples/basics.rst @@ -1323,4 +1323,130 @@ Actually run it: $ python ./15_compact_help.py --host localhost --port 8080 Starting server with config: ServerConfig(host='localhost', port=8080, workers=4, timeout=30, ssl_enabled=False, ssl_cert_path='/etc/ssl/cert.pem', ssl_key_path='/etc/ssl/key.pem', max_request_size=10485760, cors_origins='*', log_level='info', log_file='/var/log/server.log', cache_enabled=True, cache_size=1000, compression_enabled=True, keepalive_timeout=5, db_host='localhost', db_port=5432, db_name='appdb') + +.. _example-16_verbosity: + +Verbosity flags +--------------- + +:class:`tyro.extras.Verbosity` provides standard ``-v``/``--verbose`` and +``-q``/``--quiet`` count flags that map to Python :mod:`logging` levels, +with the two flags being mutually exclusive. + +By default, when ``Verbosity`` is a nested field, long flags carry the field +prefix (``--verbosity.verbose``). Annotating with +:data:`tyro.conf.OmitArgPrefixes` promotes them to the top level +(``--verbose``, ``--quiet``). Short aliases ``-v`` and ``-q`` always work +regardless of nesting. + +Inspired by `clap-verbosity-flag `_ from +the Rust/clap ecosystem. + + +.. code-block:: python + :linenos: + + # 16_verbosity.py + import dataclasses + import logging + from pathlib import Path + + from typing_extensions import Annotated + + import tyro + from tyro.conf import OmitArgPrefixes, Positional + from tyro.extras import Verbosity + + logger = logging.getLogger(__name__) + + @dataclasses.dataclass + class Args: + """Process files with configurable log verbosity.""" + + path: Positional[Path] = dataclasses.field(default_factory=Path.cwd) + """Path to process.""" + + # Log verbosity. OmitArgPrefixes promotes --verbosity.verbose/--verbosity.quiet + # to --verbose/--quiet at the top level. + verbosity: Annotated[Verbosity, OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + if __name__ == "__main__": + args = tyro.cli(Args) + logging.basicConfig(level=args.verbosity.log_level()) + logger.info("path=%s", args.path) + + + + +.. raw:: html + +
+    $ python ./16_verbosity.py --help /home
+    usage: ./16_verbosity.py [-h] [PATH] [-v] [-q]
+    
+    Process files with configurable log verbosity.
+    
+     positional arguments ───────────────────────────────────────────────────────╮
+     [PATH]         Path to process. (default: /home/nobackup/Documents/github.co 
+                    m/jRimbault/tyro/examples/01_basics)                          
+    ──────────────────────────────────────────────────────────────────────────────
+     options ────────────────────────────────────────────────────────────────────╮
+     -h, --help     show this help message and exit                               
+    ──────────────────────────────────────────────────────────────────────────────
+     verbosity ──────────────────────────────────────────────────────────────────╮
+     At most one argument can be overridden.                                      
+     ──────────────────────────────────────────────────────────────────────────── 
+     -v, --verbose  Increase log verbosity. (repeatable)                          
+     -q, --quiet    Decrease log verbosity. (repeatable)                          
+    ──────────────────────────────────────────────────────────────────────────────
+    
+ + + +.. raw:: html + +
+    $ python ./16_verbosity.py -v /home
+    INFO:__main__:path=/home
+    
+ + + +.. raw:: html + +
+    $ python ./16_verbosity.py -vv /home
+    INFO:__main__:path=/home
+    
+ + + +.. raw:: html + +
+    $ python ./16_verbosity.py -q /home
+    
+ + + +.. raw:: html + +
+    $ python ./16_verbosity.py --verbose /home
+    INFO:__main__:path=/home
+    
+ + + +.. raw:: html + +
+    $ python ./16_verbosity.py --quiet --verbose /home
+     Mutually exclusive arguments ────────────────────────────╮
+     Arguments --quiet and --verbose are not allowed together! 
+     ───────────────────────────────────────────────────────── 
+     For full helptext, run ./16_verbosity.py --help           
+    ───────────────────────────────────────────────────────────
     
\ No newline at end of file diff --git a/examples/01_basics/16_verbosity.py b/examples/01_basics/16_verbosity.py new file mode 100644 index 000000000..9f69515b0 --- /dev/null +++ b/examples/01_basics/16_verbosity.py @@ -0,0 +1,56 @@ +"""Verbosity flags + +:class:`tyro.extras.Verbosity` provides standard ``-v``/``--verbose`` and +``-q``/``--quiet`` count flags that map to Python :mod:`logging` levels, +with the two flags being mutually exclusive. + +By default, when ``Verbosity`` is a nested field, long flags carry the field +prefix (``--verbosity.verbose``). Annotating with +:data:`tyro.conf.OmitArgPrefixes` promotes them to the top level +(``--verbose``, ``--quiet``). Short aliases ``-v`` and ``-q`` always work +regardless of nesting. + +Inspired by `clap-verbosity-flag `_ from +the Rust/clap ecosystem. + +Usage: + + python ./16_verbosity.py --help /home + python ./16_verbosity.py -v /home + python ./16_verbosity.py -vv /home + python ./16_verbosity.py -q /home + python ./16_verbosity.py --verbose /home + python ./16_verbosity.py --quiet --verbose /home +""" + +import dataclasses +import logging +from pathlib import Path + +from typing_extensions import Annotated + +import tyro +from tyro.conf import OmitArgPrefixes, Positional +from tyro.extras import Verbosity + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass +class Args: + """Process files with configurable log verbosity.""" + + path: Positional[Path] = dataclasses.field(default_factory=Path.cwd) + """Path to process.""" + + # Log verbosity. OmitArgPrefixes promotes --verbosity.verbose/--verbosity.quiet + # to --verbose/--quiet at the top level. + verbosity: Annotated[Verbosity, OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + +if __name__ == "__main__": + args = tyro.cli(Args) + logging.basicConfig(level=args.verbosity.log_level()) + logger.info("path=%s", args.path) diff --git a/src/tyro/__init__.py b/src/tyro/__init__.py index 7d944426d..74b3d65e2 100644 --- a/src/tyro/__init__.py +++ b/src/tyro/__init__.py @@ -5,15 +5,40 @@ from . import conf as conf from . import constructors as constructors -from . import extras as extras from ._cli import cli as cli from ._settings import _experimental_options as _experimental_options from ._singleton import MISSING as MISSING from ._singleton import MISSING_NONPROP as MISSING_NONPROP +if TYPE_CHECKING: + from . import extras as extras + # Deprecated interface. if not TYPE_CHECKING: - from ._deprecated import * # noqa from .constructors._primitive_spec import ( UnsupportedTypeAnnotationError as UnsupportedTypeAnnotationError, ) + +_DEPRECATED_LAZY = { + "parse": ("._cli", "cli"), + "from_yaml": (".extras._serialization", "from_yaml"), + "to_yaml": (".extras._serialization", "to_yaml"), +} + + +def __getattr__(name: str): + if name == "extras": + import importlib + + extras = importlib.import_module(".extras", __name__) + globals()["extras"] = extras + return extras + if name in _DEPRECATED_LAZY: + import importlib + + module_path, attr = _DEPRECATED_LAZY[name] + mod = importlib.import_module(module_path, __name__) + val = getattr(mod, attr) + globals()[name] = val + return val + raise AttributeError(f"module 'tyro' has no attribute {name!r}") diff --git a/src/tyro/extras/__init__.py b/src/tyro/extras/__init__.py index 8844e27a9..2aeb9c3a7 100644 --- a/src/tyro/extras/__init__.py +++ b/src/tyro/extras/__init__.py @@ -19,3 +19,4 @@ from ._subcommand_cli_from_dict import ( subcommand_cli_from_dict as subcommand_cli_from_dict, ) +from ._verbosity import Verbosity as Verbosity diff --git a/src/tyro/extras/_verbosity.py b/src/tyro/extras/_verbosity.py new file mode 100644 index 000000000..1f320f1e7 --- /dev/null +++ b/src/tyro/extras/_verbosity.py @@ -0,0 +1,83 @@ +"""Verbosity type for ``-v``/``-q`` count flags with log level computation. + +Inspired by `clap-verbosity-flag `_ from the +Rust/clap ecosystem, which is maintained by the clap maintainers and provides the +same pattern for Rust CLIs. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from typing_extensions import Annotated + +from .. import conf +from ..conf import UseCounterAction + +# Shared mutex group: at most one of --verbose / --quiet can be specified. +_verbosity_mutex: object = conf.create_mutex_group( + required=False, + title="verbosity", +) + + +@dataclass(frozen=True) +class Verbosity: + """Parsed verbosity counters from ``-v``/``-q`` CLI flags. + + Drop into any tyro CLI struct to get standard ``--verbose``/``-v`` and + ``--quiet``/``-q`` count flags that map to Python :mod:`logging` levels. + The two flags are mutually exclusive. + + Example:: + + import logging + import tyro + from dataclasses import dataclass, field + from tyro.extras import Verbosity + + @dataclass + class Args: + verbosity: Verbosity = Verbosity() + + args = tyro.cli(Args) + logging.basicConfig(level=args.verbosity.log_level()) + + Default level mapping (baseline: ``logging.WARNING``): + + .. code-block:: text + + (none) → WARNING (30) + -v → INFO (20) + -vv → DEBUG (10) + -q → ERROR (40) + -qq → CRITICAL (50) + + Values are clamped to the ``DEBUG``..``CRITICAL`` range. + """ + + verbose: Annotated[ + UseCounterAction[int], + conf.arg(aliases=["-v"], help="Increase log verbosity."), + _verbosity_mutex, + ] = 0 + quiet: Annotated[ + UseCounterAction[int], + conf.arg(aliases=["-q"], help="Decrease log verbosity."), + _verbosity_mutex, + ] = 0 + + def log_level(self, *, default: int = logging.WARNING) -> int: + """Compute the effective logging level, clamped to ``DEBUG``..``CRITICAL``. + + Formula: ``default + (quiet - verbose) * 10``. + + Args: + default: Baseline logging level. Defaults to ``logging.WARNING``. + + Returns: + An integer logging level suitable for :func:`logging.basicConfig`. + """ + level = default + (self.quiet - self.verbose) * 10 + return max(logging.DEBUG, min(logging.CRITICAL, level)) diff --git a/tests/test_extras_verbosity.py b/tests/test_extras_verbosity.py new file mode 100644 index 000000000..f6b389018 --- /dev/null +++ b/tests/test_extras_verbosity.py @@ -0,0 +1,162 @@ +# mypy: ignore-errors +import dataclasses +import logging +from pathlib import Path + +import pytest +from typing_extensions import Annotated + +import tyro +from tyro.extras import Verbosity + + +@pytest.mark.parametrize( + "verbose,quiet,expected", + [ + (0, 0, logging.WARNING), + (1, 0, logging.INFO), + (2, 0, logging.DEBUG), + (0, 1, logging.ERROR), + (0, 2, logging.CRITICAL), + (99, 0, logging.DEBUG), + (0, 99, logging.CRITICAL), + ], +) +def test_log_level(verbose: int, quiet: int, expected: int) -> None: + assert Verbosity(verbose=verbose, quiet=quiet).log_level() == expected + + +@pytest.mark.parametrize( + "verbose,quiet,default,expected", + [ + (0, 0, logging.INFO, logging.INFO), + (1, 0, logging.INFO, logging.DEBUG), + ], +) +def test_log_level_custom_default( + verbose: int, quiet: int, default: int, expected: int +) -> None: + assert ( + Verbosity(verbose=verbose, quiet=quiet).log_level(default=default) == expected + ) + + +def test_verbosity_default_is_warning() -> None: + assert Verbosity().log_level() == logging.WARNING + + +def test_verbosity_is_frozen() -> None: + with pytest.raises((AttributeError, dataclasses.FrozenInstanceError)): + Verbosity(verbose=0, quiet=0).verbose = 1 # type: ignore[misc] + + +def test_cli_defaults() -> None: + assert tyro.cli(Verbosity, args=[]) == Verbosity(verbose=0, quiet=0) + + +def test_cli_short_verbose_alias() -> None: + assert tyro.cli(Verbosity, args=["-v"]) == Verbosity(verbose=1, quiet=0) + assert tyro.cli(Verbosity, args=["-vv"]) == Verbosity(verbose=2, quiet=0) + assert tyro.cli(Verbosity, args=["-vvv"]) == Verbosity(verbose=3, quiet=0) + + +def test_cli_short_quiet_alias() -> None: + assert tyro.cli(Verbosity, args=["-q"]) == Verbosity(verbose=0, quiet=1) + assert tyro.cli(Verbosity, args=["-qq"]) == Verbosity(verbose=0, quiet=2) + + +def test_cli_long_verbose_flag() -> None: + assert tyro.cli(Verbosity, args=["--verbose"]) == Verbosity(verbose=1, quiet=0) + assert tyro.cli(Verbosity, args=["--verbose", "--verbose"]) == Verbosity( + verbose=2, quiet=0 + ) + + +def test_cli_long_quiet_flag() -> None: + assert tyro.cli(Verbosity, args=["--quiet"]) == Verbosity(verbose=0, quiet=1) + + +def test_cli_mutually_exclusive() -> None: + """--verbose and --quiet must not be combined.""" + with pytest.raises(SystemExit): + tyro.cli(Verbosity, args=["-v", "-q"]) + + +def test_nested_defaults() -> None: + @dataclasses.dataclass + class App: + path: Path = dataclasses.field(default_factory=Path.cwd) + verbosity: Verbosity = dataclasses.field(default_factory=Verbosity) + + assert tyro.cli(App, args=[]) == App() + + +def test_nested_short_alias() -> None: + @dataclasses.dataclass + class App: + verbosity: Verbosity = dataclasses.field(default_factory=Verbosity) + + assert tyro.cli(App, args=["-vv"]).verbosity == Verbosity(verbose=2, quiet=0) + + +def test_nested_prefixed_long_flag() -> None: + """Without OmitArgPrefixes, long flags carry the field-name prefix.""" + + @dataclasses.dataclass + class App: + verbosity: Verbosity = dataclasses.field(default_factory=Verbosity) + + result = tyro.cli(App, args=["--verbosity.verbose", "--verbosity.verbose"]) + assert result.verbosity == Verbosity(verbose=2, quiet=0) + + +def test_omit_prefixes_long_verbose() -> None: + @dataclasses.dataclass + class App: + verbosity: Annotated[Verbosity, tyro.conf.OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + result = tyro.cli(App, args=["--verbose", "--verbose"]) + assert result.verbosity == Verbosity(verbose=2, quiet=0) + + +def test_omit_prefixes_long_quiet() -> None: + @dataclasses.dataclass + class App: + verbosity: Annotated[Verbosity, tyro.conf.OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + assert tyro.cli(App, args=["--quiet"]).verbosity == Verbosity(verbose=0, quiet=1) + + +def test_omit_prefixes_short_aliases_still_work() -> None: + @dataclasses.dataclass + class App: + verbosity: Annotated[Verbosity, tyro.conf.OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + assert tyro.cli(App, args=["-vvv"]).verbosity == Verbosity(verbose=3, quiet=0) + + +def test_omit_prefixes_mutually_exclusive() -> None: + @dataclasses.dataclass + class App: + verbosity: Annotated[Verbosity, tyro.conf.OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + with pytest.raises(SystemExit): + tyro.cli(App, args=["--verbose", "--quiet"]) + + +def test_omit_prefixes_log_level_roundtrip() -> None: + @dataclasses.dataclass + class App: + verbosity: Annotated[Verbosity, tyro.conf.OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + assert tyro.cli(App, args=["-vv"]).verbosity.log_level() == logging.DEBUG diff --git a/tests/test_py311_generated/test_extras_verbosity_generated.py b/tests/test_py311_generated/test_extras_verbosity_generated.py new file mode 100644 index 000000000..627b2d573 --- /dev/null +++ b/tests/test_py311_generated/test_extras_verbosity_generated.py @@ -0,0 +1,162 @@ +# mypy: ignore-errors +import dataclasses +import logging +from pathlib import Path +from typing import Annotated + +import pytest + +import tyro +from tyro.extras import Verbosity + + +@pytest.mark.parametrize( + "verbose,quiet,expected", + [ + (0, 0, logging.WARNING), + (1, 0, logging.INFO), + (2, 0, logging.DEBUG), + (0, 1, logging.ERROR), + (0, 2, logging.CRITICAL), + (99, 0, logging.DEBUG), + (0, 99, logging.CRITICAL), + ], +) +def test_log_level(verbose: int, quiet: int, expected: int) -> None: + assert Verbosity(verbose=verbose, quiet=quiet).log_level() == expected + + +@pytest.mark.parametrize( + "verbose,quiet,default,expected", + [ + (0, 0, logging.INFO, logging.INFO), + (1, 0, logging.INFO, logging.DEBUG), + ], +) +def test_log_level_custom_default( + verbose: int, quiet: int, default: int, expected: int +) -> None: + assert ( + Verbosity(verbose=verbose, quiet=quiet).log_level(default=default) == expected + ) + + +def test_verbosity_default_is_warning() -> None: + assert Verbosity().log_level() == logging.WARNING + + +def test_verbosity_is_frozen() -> None: + with pytest.raises((AttributeError, dataclasses.FrozenInstanceError)): + Verbosity(verbose=0, quiet=0).verbose = 1 # type: ignore[misc] + + +def test_cli_defaults() -> None: + assert tyro.cli(Verbosity, args=[]) == Verbosity(verbose=0, quiet=0) + + +def test_cli_short_verbose_alias() -> None: + assert tyro.cli(Verbosity, args=["-v"]) == Verbosity(verbose=1, quiet=0) + assert tyro.cli(Verbosity, args=["-vv"]) == Verbosity(verbose=2, quiet=0) + assert tyro.cli(Verbosity, args=["-vvv"]) == Verbosity(verbose=3, quiet=0) + + +def test_cli_short_quiet_alias() -> None: + assert tyro.cli(Verbosity, args=["-q"]) == Verbosity(verbose=0, quiet=1) + assert tyro.cli(Verbosity, args=["-qq"]) == Verbosity(verbose=0, quiet=2) + + +def test_cli_long_verbose_flag() -> None: + assert tyro.cli(Verbosity, args=["--verbose"]) == Verbosity(verbose=1, quiet=0) + assert tyro.cli(Verbosity, args=["--verbose", "--verbose"]) == Verbosity( + verbose=2, quiet=0 + ) + + +def test_cli_long_quiet_flag() -> None: + assert tyro.cli(Verbosity, args=["--quiet"]) == Verbosity(verbose=0, quiet=1) + + +def test_cli_mutually_exclusive() -> None: + """--verbose and --quiet must not be combined.""" + with pytest.raises(SystemExit): + tyro.cli(Verbosity, args=["-v", "-q"]) + + +def test_nested_defaults() -> None: + @dataclasses.dataclass + class App: + path: Path = dataclasses.field(default_factory=Path.cwd) + verbosity: Verbosity = dataclasses.field(default_factory=Verbosity) + + assert tyro.cli(App, args=[]) == App() + + +def test_nested_short_alias() -> None: + @dataclasses.dataclass + class App: + verbosity: Verbosity = dataclasses.field(default_factory=Verbosity) + + assert tyro.cli(App, args=["-vv"]).verbosity == Verbosity(verbose=2, quiet=0) + + +def test_nested_prefixed_long_flag() -> None: + """Without OmitArgPrefixes, long flags carry the field-name prefix.""" + + @dataclasses.dataclass + class App: + verbosity: Verbosity = dataclasses.field(default_factory=Verbosity) + + result = tyro.cli(App, args=["--verbosity.verbose", "--verbosity.verbose"]) + assert result.verbosity == Verbosity(verbose=2, quiet=0) + + +def test_omit_prefixes_long_verbose() -> None: + @dataclasses.dataclass + class App: + verbosity: Annotated[Verbosity, tyro.conf.OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + result = tyro.cli(App, args=["--verbose", "--verbose"]) + assert result.verbosity == Verbosity(verbose=2, quiet=0) + + +def test_omit_prefixes_long_quiet() -> None: + @dataclasses.dataclass + class App: + verbosity: Annotated[Verbosity, tyro.conf.OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + assert tyro.cli(App, args=["--quiet"]).verbosity == Verbosity(verbose=0, quiet=1) + + +def test_omit_prefixes_short_aliases_still_work() -> None: + @dataclasses.dataclass + class App: + verbosity: Annotated[Verbosity, tyro.conf.OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + assert tyro.cli(App, args=["-vvv"]).verbosity == Verbosity(verbose=3, quiet=0) + + +def test_omit_prefixes_mutually_exclusive() -> None: + @dataclasses.dataclass + class App: + verbosity: Annotated[Verbosity, tyro.conf.OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + with pytest.raises(SystemExit): + tyro.cli(App, args=["--verbose", "--quiet"]) + + +def test_omit_prefixes_log_level_roundtrip() -> None: + @dataclasses.dataclass + class App: + verbosity: Annotated[Verbosity, tyro.conf.OmitArgPrefixes] = dataclasses.field( + default_factory=Verbosity + ) + + assert tyro.cli(App, args=["-vv"]).verbosity.log_level() == logging.DEBUG