`_ 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